From 26412758689055eaa555ea21f9dd575a383e8132 Mon Sep 17 00:00:00 2001 From: ruben-cytonic Date: Sat, 21 Feb 2026 10:12:00 +0000 Subject: [PATCH 1/2] =?UTF-8?q?feat:=20v0.1.0=20improvements=20=E2=80=94?= =?UTF-8?q?=20CLI,=20Vite/Nuxt=20support,=20bug=20fixes,=20test=20overhaul?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Add CLI (npx aeo.js generate/init/check) with framework auto-detection - Wire up Vite and Nuxt plugins in package.json exports + tsup build - Fix Astro widget disappearing on View Transitions (use astro:page-load) - Fix empty .md files generated for pages without content - Remove UTF-8 BOM from generated llms.txt / llms-full.txt - Add validateConfig() with warnings for common misconfigurations - Rewrite all 12 test files to match actual API (79/79 tests passing) - Update README with Vite, Nuxt, CLI docs and framework support table Co-Authored-By: Claude Opus 4.6 --- README.md | 118 +++++- package-lock.json | 582 +++++++++++++++++++++++++++++- package.json | 32 ++ src/cli.ts | 235 ++++++++++++ src/core/ai-index.test.ts | 227 ++++++------ src/core/detect.test.ts | 114 +++--- src/core/generate-wrapper.test.ts | 292 ++++++--------- src/core/generate-wrapper.ts | 4 +- src/core/generate.test.ts | 293 +-------------- src/core/llms-full.test.ts | 225 +++++------- src/core/llms-txt.test.ts | 214 +++++------ src/core/manifest.test.ts | 191 +++++----- src/core/raw-markdown.test.ts | 267 +++++++------- src/core/raw-markdown.ts | 7 +- src/core/robots.test.ts | 12 +- src/core/sitemap.test.ts | 12 +- src/core/utils.test.ts | 295 +++++++-------- src/core/utils.ts | 24 +- src/index.ts | 4 +- src/plugins/astro.ts | 29 +- src/widget/core.test.ts | 295 ++++----------- tsup.config.ts | 9 + 22 files changed, 1898 insertions(+), 1583 deletions(-) create mode 100644 src/cli.ts diff --git a/README.md b/README.md index 41625bf..3c551f4 100644 --- a/README.md +++ b/README.md @@ -21,16 +21,18 @@ aeo.js auto-generates the files these engines look for and provides a drop-in wi - **`ai-index.json`** -- AI-optimized content index - **Raw Markdown** -- Per-page `.md` files extracted from your HTML - **Human/AI Widget** -- Drop-in toggle showing the AI-readable version of any page +- **CLI** -- `npx aeo.js generate` to run standalone ## Supported Frameworks | Framework | Status | Import | |-----------|--------|--------| -| Next.js | Stable | `aeo.js/next` | | Astro | Stable | `aeo.js/astro` | +| Next.js | Stable | `aeo.js/next` | +| Vite / React | Stable | `aeo.js/vite` | +| Nuxt | Stable | `aeo.js/nuxt` | | Webpack | Stable | `aeo.js/webpack` | -| Vite | Coming soon | -- | -| Nuxt | Coming soon | -- | +| Any (CLI) | Stable | `npx aeo.js generate` | ## Install @@ -40,6 +42,27 @@ npm install aeo.js ## Quick Start +### Astro + +```js +// astro.config.mjs +import { defineConfig } from 'astro/config'; +import { aeoAstroIntegration } from 'aeo.js/astro'; + +export default defineConfig({ + site: 'https://mysite.com', + integrations: [ + aeoAstroIntegration({ + title: 'My Site', + description: 'A site optimized for AI discovery', + url: 'https://mysite.com', + }), + ], +}); +``` + +The widget is automatically injected and persists across View Transitions. + ### Next.js Wrap your Next.js config with `withAeo`: @@ -67,19 +90,16 @@ After building, run the post-build step to extract content from pre-rendered pag } ``` -### Astro - -Add the integration in your Astro config: +### Vite (React, Vue, Svelte, etc.) ```js -// astro.config.mjs -import { defineConfig } from 'astro/config'; -import { aeoAstroIntegration } from 'aeo.js/astro'; +// vite.config.ts +import { defineConfig } from 'vite'; +import { aeoVitePlugin } from 'aeo.js/vite'; export default defineConfig({ - site: 'https://mysite.com', - integrations: [ - aeoAstroIntegration({ + plugins: [ + aeoVitePlugin({ title: 'My Site', description: 'A site optimized for AI discovery', url: 'https://mysite.com', @@ -88,9 +108,36 @@ export default defineConfig({ }); ``` -### Webpack +The Vite plugin: +- Generates AEO files on `vite dev` and `vite build` +- Injects the widget automatically +- Serves dynamic `.md` files in dev (extracts content from your running app) +- Detects SPA shells and falls back to client-side DOM extraction + +### Nuxt + +Add the module to your Nuxt config: + +```ts +// nuxt.config.ts +export default defineNuxtConfig({ + modules: ['aeo.js/nuxt'], + aeo: { + title: 'My Site', + description: 'A site optimized for AI discovery', + url: 'https://mysite.com', + }, +}); +``` + +The Nuxt module: +- Scans your `pages/` directory for routes +- Generates AEO files during dev and production builds +- Scans pre-rendered HTML from `.output/public/` for full page content +- Injects the widget as a client-side Nuxt plugin +- Adds `` and `` tags for AEO discoverability -Add the plugin to your webpack config: +### Webpack ```js // webpack.config.js @@ -107,8 +154,47 @@ module.exports = { }; ``` +## CLI + +Run aeo.js from the command line without any framework integration: + +```bash +# Generate all AEO files +npx aeo.js generate + +# Generate with options +npx aeo.js generate --url https://mysite.com --title "My Site" --out public + +# Create a config file +npx aeo.js init + +# Check your setup +npx aeo.js check +``` + +### Commands + +| Command | Description | +|---------|-------------| +| `generate` | Generate all AEO files (robots.txt, llms.txt, sitemap.xml, etc.) | +| `init` | Create an `aeo.config.ts` configuration file | +| `check` | Validate your AEO setup and show what would be generated | + +### Options + +| Flag | Description | +|------|-------------| +| `--out ` | Output directory (default: auto-detected) | +| `--url ` | Site URL | +| `--title ` | Site title | +| `--no-widget` | Disable widget generation | +| `--help`, `-h` | Show help | +| `--version`, `-v` | Show version | + ## Configuration +All framework plugins accept the same config object. You can also use `defineConfig` for standalone configs: + ```js import { defineConfig } from 'aeo.js'; @@ -159,7 +245,7 @@ export default defineConfig({ ## Widget -The Human/AI widget is a floating toggle that lets visitors switch between the normal page and its AI-readable markdown version. It's automatically injected by the Astro integration. For Next.js, you can add it manually: +The Human/AI widget is a floating toggle that lets visitors switch between the normal page and its AI-readable markdown version. Framework plugins (Astro, Vite, Nuxt) inject it automatically. For Next.js or manual setups: ```tsx // app/layout.tsx (or any client component) @@ -185,7 +271,7 @@ export function AeoWidgetLoader() { When a visitor clicks **AI**, the widget: 1. Fetches the `.md` file for the current page 2. Falls back to extracting markdown from the live DOM if no `.md` exists -3. Displays the markdown with syntax highlighting +3. Displays the markdown in a slide-out panel 4. Offers copy-to-clipboard and download actions ## Generated Files diff --git a/package-lock.json b/package-lock.json index 68b0b4c..f70d659 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "aeo.js", - "version": "0.0.1", + "version": "0.0.2", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "aeo.js", - "version": "0.0.1", + "version": "0.0.2", "license": "MIT", "dependencies": { "@types/minimatch": "^5.1.2", @@ -15,6 +15,7 @@ "devDependencies": { "@types/node": "^20.0.0", "@types/react": "^18.0.0", + "jsdom": "^28.1.0", "tsup": "^8.0.0", "typescript": "^5.0.0", "vitest": "^1.0.0" @@ -22,10 +23,8 @@ "peerDependencies": { "@astrojs/astro": ">=3.0.0", "@nuxt/kit": ">=3.0.0", - "@sveltejs/kit": ">=1.0.0", "next": ">=13.0.0", "react": ">=17.0.0", - "svelte": ">=3.0.0", "vite": ">=4.0.0", "vue": ">=3.0.0", "webpack": ">=5.0.0" @@ -37,18 +36,12 @@ "@nuxt/kit": { "optional": true }, - "@sveltejs/kit": { - "optional": true - }, "next": { "optional": true }, "react": { "optional": true }, - "svelte": { - "optional": true - }, "vite": { "optional": true }, @@ -60,6 +53,193 @@ } } }, + "node_modules/@acemir/cssom": { + "version": "0.9.31", + "resolved": "https://registry.npmjs.org/@acemir/cssom/-/cssom-0.9.31.tgz", + "integrity": "sha512-ZnR3GSaH+/vJ0YlHau21FjfLYjMpYVIzTD8M8vIEQvIGxeOXyXdzCI140rrCY862p/C/BbzWsjc1dgnM9mkoTA==", + "dev": true, + "license": "MIT" + }, + "node_modules/@asamuzakjp/css-color": { + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/@asamuzakjp/css-color/-/css-color-4.1.2.tgz", + "integrity": "sha512-NfBUvBaYgKIuq6E/RBLY1m0IohzNHAYyaJGuTK79Z23uNwmz2jl1mPsC5ZxCCxylinKhT1Amn5oNTlx1wN8cQg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@csstools/css-calc": "^3.0.0", + "@csstools/css-color-parser": "^4.0.1", + "@csstools/css-parser-algorithms": "^4.0.0", + "@csstools/css-tokenizer": "^4.0.0", + "lru-cache": "^11.2.5" + } + }, + "node_modules/@asamuzakjp/dom-selector": { + "version": "6.8.1", + "resolved": "https://registry.npmjs.org/@asamuzakjp/dom-selector/-/dom-selector-6.8.1.tgz", + "integrity": "sha512-MvRz1nCqW0fsy8Qz4dnLIvhOlMzqDVBabZx6lH+YywFDdjXhMY37SmpV1XFX3JzG5GWHn63j6HX6QPr3lZXHvQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@asamuzakjp/nwsapi": "^2.3.9", + "bidi-js": "^1.0.3", + "css-tree": "^3.1.0", + "is-potential-custom-element-name": "^1.0.1", + "lru-cache": "^11.2.6" + } + }, + "node_modules/@asamuzakjp/nwsapi": { + "version": "2.3.9", + "resolved": "https://registry.npmjs.org/@asamuzakjp/nwsapi/-/nwsapi-2.3.9.tgz", + "integrity": "sha512-n8GuYSrI9bF7FFZ/SjhwevlHc8xaVlb/7HmHelnc/PZXBD2ZR49NnN9sMMuDdEGPeeRQ5d0hqlSlEpgCX3Wl0Q==", + "dev": true, + "license": "MIT" + }, + "node_modules/@bramus/specificity": { + "version": "2.4.2", + "resolved": "https://registry.npmjs.org/@bramus/specificity/-/specificity-2.4.2.tgz", + "integrity": "sha512-ctxtJ/eA+t+6q2++vj5j7FYX3nRu311q1wfYH3xjlLOsczhlhxAg2FWNUXhpGvAw3BWo1xBcvOV6/YLc2r5FJw==", + "dev": true, + "license": "MIT", + "dependencies": { + "css-tree": "^3.0.0" + }, + "bin": { + "specificity": "bin/cli.js" + } + }, + "node_modules/@csstools/color-helpers": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/@csstools/color-helpers/-/color-helpers-6.0.1.tgz", + "integrity": "sha512-NmXRccUJMk2AWA5A7e5a//3bCIMyOu2hAtdRYrhPPHjDxINuCwX1w6rnIZ4xjLcp0ayv6h8Pc3X0eJUGiAAXHQ==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT-0", + "engines": { + "node": ">=20.19.0" + } + }, + "node_modules/@csstools/css-calc": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/@csstools/css-calc/-/css-calc-3.1.1.tgz", + "integrity": "sha512-HJ26Z/vmsZQqs/o3a6bgKslXGFAungXGbinULZO3eMsOyNJHeBBZfup5FiZInOghgoM4Hwnmw+OgbJCNg1wwUQ==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT", + "engines": { + "node": ">=20.19.0" + }, + "peerDependencies": { + "@csstools/css-parser-algorithms": "^4.0.0", + "@csstools/css-tokenizer": "^4.0.0" + } + }, + "node_modules/@csstools/css-color-parser": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/@csstools/css-color-parser/-/css-color-parser-4.0.1.tgz", + "integrity": "sha512-vYwO15eRBEkeF6xjAno/KQ61HacNhfQuuU/eGwH67DplL0zD5ZixUa563phQvUelA07yDczIXdtmYojCphKJcw==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT", + "dependencies": { + "@csstools/color-helpers": "^6.0.1", + "@csstools/css-calc": "^3.0.0" + }, + "engines": { + "node": ">=20.19.0" + }, + "peerDependencies": { + "@csstools/css-parser-algorithms": "^4.0.0", + "@csstools/css-tokenizer": "^4.0.0" + } + }, + "node_modules/@csstools/css-parser-algorithms": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/@csstools/css-parser-algorithms/-/css-parser-algorithms-4.0.0.tgz", + "integrity": "sha512-+B87qS7fIG3L5h3qwJ/IFbjoVoOe/bpOdh9hAjXbvx0o8ImEmUsGXN0inFOnk2ChCFgqkkGFQ+TpM5rbhkKe4w==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT", + "engines": { + "node": ">=20.19.0" + }, + "peerDependencies": { + "@csstools/css-tokenizer": "^4.0.0" + } + }, + "node_modules/@csstools/css-syntax-patches-for-csstree": { + "version": "1.0.27", + "resolved": "https://registry.npmjs.org/@csstools/css-syntax-patches-for-csstree/-/css-syntax-patches-for-csstree-1.0.27.tgz", + "integrity": "sha512-sxP33Jwg1bviSUXAV43cVYdmjt2TLnLXNqCWl9xmxHawWVjGz/kEbdkr7F9pxJNBN2Mh+dq0crgItbW6tQvyow==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT-0" + }, + "node_modules/@csstools/css-tokenizer": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/@csstools/css-tokenizer/-/css-tokenizer-4.0.0.tgz", + "integrity": "sha512-QxULHAm7cNu72w97JUNCBFODFaXpbDg+dP8b/oWFAZ2MTRppA3U00Y2L1HqaS4J6yBqxwa/Y3nMBaxVKbB/NsA==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT", + "engines": { + "node": ">=20.19.0" + } + }, "node_modules/@esbuild/aix-ppc64": { "version": "0.27.3", "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.27.3.tgz", @@ -502,6 +682,24 @@ "node": ">=18" } }, + "node_modules/@exodus/bytes": { + "version": "1.14.1", + "resolved": "https://registry.npmjs.org/@exodus/bytes/-/bytes-1.14.1.tgz", + "integrity": "sha512-OhkBFWI6GcRMUroChZiopRiSp2iAMvEBK47NhJooDqz1RERO4QuZIZnjP63TXX8GAiLABkYmX+fuQsdJ1dd2QQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^20.19.0 || ^22.12.0 || >=24.0.0" + }, + "peerDependencies": { + "@noble/hashes": "^1.8.0 || ^2.0.0" + }, + "peerDependenciesMeta": { + "@noble/hashes": { + "optional": true + } + } + }, "node_modules/@jest/schemas": { "version": "29.6.3", "resolved": "https://registry.npmjs.org/@jest/schemas/-/schemas-29.6.3.tgz", @@ -1041,6 +1239,16 @@ "node": ">=0.4.0" } }, + "node_modules/agent-base": { + "version": "7.1.4", + "resolved": "https://registry.npmjs.org/agent-base/-/agent-base-7.1.4.tgz", + "integrity": "sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 14" + } + }, "node_modules/ansi-styles": { "version": "5.2.0", "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-5.2.0.tgz", @@ -1080,6 +1288,16 @@ "node": "20 || >=22" } }, + "node_modules/bidi-js": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/bidi-js/-/bidi-js-1.0.3.tgz", + "integrity": "sha512-RKshQI1R3YQ+n9YJz2QQ147P66ELpa1FQEg20Dk8oW9t2KgLbpDLLp9aGZ7y8WHSshDknG0bknqGw5/tyCs5tw==", + "dev": true, + "license": "MIT", + "dependencies": { + "require-from-string": "^2.0.2" + } + }, "node_modules/brace-expansion": { "version": "5.0.2", "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.2.tgz", @@ -1208,6 +1426,36 @@ "node": ">= 8" } }, + "node_modules/css-tree": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/css-tree/-/css-tree-3.1.0.tgz", + "integrity": "sha512-0eW44TGN5SQXU1mWSkKwFstI/22X2bG1nYzZTYMAWjylYURhse752YgbE4Cx46AC+bAvI+/dYTPRk1LqSUnu6w==", + "dev": true, + "license": "MIT", + "dependencies": { + "mdn-data": "2.12.2", + "source-map-js": "^1.0.1" + }, + "engines": { + "node": "^10 || ^12.20.0 || ^14.13.0 || >=15.0.0" + } + }, + "node_modules/cssstyle": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/cssstyle/-/cssstyle-6.0.1.tgz", + "integrity": "sha512-IoJs7La+oFp/AB033wBStxNOJt4+9hHMxsXUPANcoXL2b3W4DZKghlJ2cI/eyeRZIQ9ysvYEorVhjrcYctWbog==", + "dev": true, + "license": "MIT", + "dependencies": { + "@asamuzakjp/css-color": "^4.1.2", + "@csstools/css-syntax-patches-for-csstree": "^1.0.26", + "css-tree": "^3.1.0", + "lru-cache": "^11.2.5" + }, + "engines": { + "node": ">=20" + } + }, "node_modules/csstype": { "version": "3.2.3", "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz", @@ -1215,6 +1463,20 @@ "dev": true, "license": "MIT" }, + "node_modules/data-urls": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/data-urls/-/data-urls-7.0.0.tgz", + "integrity": "sha512-23XHcCF+coGYevirZceTVD7NdJOqVn+49IHyxgszm+JIiHLoB2TkmPtsYkNWT1pvRSGkc35L6NHs0yHkN2SumA==", + "dev": true, + "license": "MIT", + "dependencies": { + "whatwg-mimetype": "^5.0.0", + "whatwg-url": "^16.0.0" + }, + "engines": { + "node": "^20.19.0 || ^22.12.0 || >=24.0.0" + } + }, "node_modules/debug": { "version": "4.4.3", "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", @@ -1233,6 +1495,13 @@ } } }, + "node_modules/decimal.js": { + "version": "10.6.0", + "resolved": "https://registry.npmjs.org/decimal.js/-/decimal.js-10.6.0.tgz", + "integrity": "sha512-YpgQiITW3JXGntzdUmyUR1V812Hn8T1YVXhCu+wO3OpS4eU9l4YdD3qjyiKdV6mvV29zapkMeD390UVEf2lkUg==", + "dev": true, + "license": "MIT" + }, "node_modules/deep-eql": { "version": "4.1.4", "resolved": "https://registry.npmjs.org/deep-eql/-/deep-eql-4.1.4.tgz", @@ -1256,6 +1525,19 @@ "node": "^14.15.0 || ^16.10.0 || >=18.0.0" } }, + "node_modules/entities": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/entities/-/entities-6.0.1.tgz", + "integrity": "sha512-aN97NXWF6AWBTahfVOIrB/NShkzi5H7F9r1s9mD3cDj4Ko5f2qhhVoYMibXF7GlLveb/D2ioWay8lxI97Ven3g==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=0.12" + }, + "funding": { + "url": "https://github.com/fb55/entities?sponsor=1" + } + }, "node_modules/esbuild": { "version": "0.27.3", "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.27.3.tgz", @@ -1399,6 +1681,47 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/html-encoding-sniffer": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/html-encoding-sniffer/-/html-encoding-sniffer-6.0.0.tgz", + "integrity": "sha512-CV9TW3Y3f8/wT0BRFc1/KAVQ3TUHiXmaAb6VW9vtiMFf7SLoMd1PdAc4W3KFOFETBJUb90KatHqlsZMWV+R9Gg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@exodus/bytes": "^1.6.0" + }, + "engines": { + "node": "^20.19.0 || ^22.12.0 || >=24.0.0" + } + }, + "node_modules/http-proxy-agent": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/http-proxy-agent/-/http-proxy-agent-7.0.2.tgz", + "integrity": "sha512-T1gkAiYYDWYx3V5Bmyu7HcfcvL7mUrTWiM6yOfa3PIphViJ/gFPbvidQ+veqSOHci/PxBcDabeUNCzpOODJZig==", + "dev": true, + "license": "MIT", + "dependencies": { + "agent-base": "^7.1.0", + "debug": "^4.3.4" + }, + "engines": { + "node": ">= 14" + } + }, + "node_modules/https-proxy-agent": { + "version": "7.0.6", + "resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-7.0.6.tgz", + "integrity": "sha512-vK9P5/iUfdl95AI+JVyUuIcVtd4ofvtrOr3HNtM2yxC9bnMbEdp3x01OhQNnjb8IJYi38VlTE3mBXwcfvywuSw==", + "dev": true, + "license": "MIT", + "dependencies": { + "agent-base": "^7.1.2", + "debug": "4" + }, + "engines": { + "node": ">= 14" + } + }, "node_modules/human-signals": { "version": "5.0.0", "resolved": "https://registry.npmjs.org/human-signals/-/human-signals-5.0.0.tgz", @@ -1409,6 +1732,13 @@ "node": ">=16.17.0" } }, + "node_modules/is-potential-custom-element-name": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/is-potential-custom-element-name/-/is-potential-custom-element-name-1.0.1.tgz", + "integrity": "sha512-bCYeRA2rVibKZd+s2625gGnGF/t7DSqDs4dP7CrLA1m7jKWz6pps0LpYLJN8Q64HtmPKJ1hrN3nzPNKFEKOUiQ==", + "dev": true, + "license": "MIT" + }, "node_modules/is-stream": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/is-stream/-/is-stream-3.0.0.tgz", @@ -1446,6 +1776,47 @@ "dev": true, "license": "MIT" }, + "node_modules/jsdom": { + "version": "28.1.0", + "resolved": "https://registry.npmjs.org/jsdom/-/jsdom-28.1.0.tgz", + "integrity": "sha512-0+MoQNYyr2rBHqO1xilltfDjV9G7ymYGlAUazgcDLQaUf8JDHbuGwsxN6U9qWaElZ4w1B2r7yEGIL3GdeW3Rug==", + "dev": true, + "license": "MIT", + "dependencies": { + "@acemir/cssom": "^0.9.31", + "@asamuzakjp/dom-selector": "^6.8.1", + "@bramus/specificity": "^2.4.2", + "@exodus/bytes": "^1.11.0", + "cssstyle": "^6.0.1", + "data-urls": "^7.0.0", + "decimal.js": "^10.6.0", + "html-encoding-sniffer": "^6.0.0", + "http-proxy-agent": "^7.0.2", + "https-proxy-agent": "^7.0.6", + "is-potential-custom-element-name": "^1.0.1", + "parse5": "^8.0.0", + "saxes": "^6.0.0", + "symbol-tree": "^3.2.4", + "tough-cookie": "^6.0.0", + "undici": "^7.21.0", + "w3c-xmlserializer": "^5.0.0", + "webidl-conversions": "^8.0.1", + "whatwg-mimetype": "^5.0.0", + "whatwg-url": "^16.0.0", + "xml-name-validator": "^5.0.0" + }, + "engines": { + "node": "^20.19.0 || ^22.12.0 || >=24.0.0" + }, + "peerDependencies": { + "canvas": "^3.0.0" + }, + "peerDependenciesMeta": { + "canvas": { + "optional": true + } + } + }, "node_modules/lilconfig": { "version": "3.1.3", "resolved": "https://registry.npmjs.org/lilconfig/-/lilconfig-3.1.3.tgz", @@ -1503,6 +1874,16 @@ "get-func-name": "^2.0.1" } }, + "node_modules/lru-cache": { + "version": "11.2.6", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.2.6.tgz", + "integrity": "sha512-ESL2CrkS/2wTPfuend7Zhkzo2u0daGJ/A2VucJOgQ/C48S/zB8MMeMHSGKYpXhIjbPxfuezITkaBH1wqv00DDQ==", + "dev": true, + "license": "BlueOak-1.0.0", + "engines": { + "node": "20 || >=22" + } + }, "node_modules/magic-string": { "version": "0.30.21", "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz", @@ -1513,6 +1894,13 @@ "@jridgewell/sourcemap-codec": "^1.5.5" } }, + "node_modules/mdn-data": { + "version": "2.12.2", + "resolved": "https://registry.npmjs.org/mdn-data/-/mdn-data-2.12.2.tgz", + "integrity": "sha512-IEn+pegP1aManZuckezWCO+XZQDplx1366JoVhTpMpBB1sPey/SbveZQUosKiKiGYjg1wH4pMlNgXbCiYgihQA==", + "dev": true, + "license": "CC0-1.0" + }, "node_modules/merge-stream": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/merge-stream/-/merge-stream-2.0.0.tgz", @@ -1670,6 +2058,19 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/parse5": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/parse5/-/parse5-8.0.0.tgz", + "integrity": "sha512-9m4m5GSgXjL4AjumKzq1Fgfp3Z8rsvjRNbnkVwfu2ImRqE5D0LnY2QfDen18FSY9C573YU5XxSapdHZTZ2WolA==", + "dev": true, + "license": "MIT", + "dependencies": { + "entities": "^6.0.0" + }, + "funding": { + "url": "https://github.com/inikulin/parse5?sponsor=1" + } + }, "node_modules/path-key": { "version": "3.1.1", "resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz", @@ -1826,6 +2227,16 @@ "node": "^14.15.0 || ^16.10.0 || >=18.0.0" } }, + "node_modules/punycode": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz", + "integrity": "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, "node_modules/react-is": { "version": "18.3.1", "resolved": "https://registry.npmjs.org/react-is/-/react-is-18.3.1.tgz", @@ -1847,6 +2258,16 @@ "url": "https://paulmillr.com/funding/" } }, + "node_modules/require-from-string": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/require-from-string/-/require-from-string-2.0.2.tgz", + "integrity": "sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/resolve-from": { "version": "5.0.0", "resolved": "https://registry.npmjs.org/resolve-from/-/resolve-from-5.0.0.tgz", @@ -1902,6 +2323,19 @@ "fsevents": "~2.3.2" } }, + "node_modules/saxes": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/saxes/-/saxes-6.0.0.tgz", + "integrity": "sha512-xAg7SOnEhrm5zI3puOOKyy1OMcMlIJZYNJY7xLBwSze0UjhPLnWfj2GF2EpT0jmzaJKIWKHLsaSSajf35bcYnA==", + "dev": true, + "license": "ISC", + "dependencies": { + "xmlchars": "^2.2.0" + }, + "engines": { + "node": ">=v12.22.7" + } + }, "node_modules/shebang-command": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz", @@ -2028,6 +2462,13 @@ "node": ">=16 || 14 >=14.17" } }, + "node_modules/symbol-tree": { + "version": "3.2.4", + "resolved": "https://registry.npmjs.org/symbol-tree/-/symbol-tree-3.2.4.tgz", + "integrity": "sha512-9QNk5KwDF+Bvz+PyObkmSYjI5ksVUYtjW7AU22r2NKcfLJcXp96hkDWU3+XndOsUb+AQ9QhfzfCT2O+CNWT5Tw==", + "dev": true, + "license": "MIT" + }, "node_modules/thenify": { "version": "3.3.1", "resolved": "https://registry.npmjs.org/thenify/-/thenify-3.3.1.tgz", @@ -2102,6 +2543,52 @@ "node": ">=14.0.0" } }, + "node_modules/tldts": { + "version": "7.0.23", + "resolved": "https://registry.npmjs.org/tldts/-/tldts-7.0.23.tgz", + "integrity": "sha512-ASdhgQIBSay0R/eXggAkQ53G4nTJqTXqC2kbaBbdDwM7SkjyZyO0OaaN1/FH7U/yCeqOHDwFO5j8+Os/IS1dXw==", + "dev": true, + "license": "MIT", + "dependencies": { + "tldts-core": "^7.0.23" + }, + "bin": { + "tldts": "bin/cli.js" + } + }, + "node_modules/tldts-core": { + "version": "7.0.23", + "resolved": "https://registry.npmjs.org/tldts-core/-/tldts-core-7.0.23.tgz", + "integrity": "sha512-0g9vrtDQLrNIiCj22HSe9d4mLVG3g5ph5DZ8zCKBr4OtrspmNB6ss7hVyzArAeE88ceZocIEGkyW1Ime7fxPtQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/tough-cookie": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/tough-cookie/-/tough-cookie-6.0.0.tgz", + "integrity": "sha512-kXuRi1mtaKMrsLUxz3sQYvVl37B0Ns6MzfrtV5DvJceE9bPyspOqk9xxv7XbZWcfLWbFmm997vl83qUWVJA64w==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "tldts": "^7.0.5" + }, + "engines": { + "node": ">=16" + } + }, + "node_modules/tr46": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/tr46/-/tr46-6.0.0.tgz", + "integrity": "sha512-bLVMLPtstlZ4iMQHpFHTR7GAGj2jxi8Dg0s2h2MafAE4uSWF98FC/3MomU51iQAMf8/qDUbKWf5GxuvvVcXEhw==", + "dev": true, + "license": "MIT", + "dependencies": { + "punycode": "^2.3.1" + }, + "engines": { + "node": ">=20" + } + }, "node_modules/tree-kill": { "version": "1.2.2", "resolved": "https://registry.npmjs.org/tree-kill/-/tree-kill-1.2.2.tgz", @@ -2203,6 +2690,16 @@ "dev": true, "license": "MIT" }, + "node_modules/undici": { + "version": "7.22.0", + "resolved": "https://registry.npmjs.org/undici/-/undici-7.22.0.tgz", + "integrity": "sha512-RqslV2Us5BrllB+JeiZnK4peryVTndy9Dnqq62S3yYRRTj0tFQCwEniUy2167skdGOy3vqRzEvl1Dm4sV2ReDg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=20.18.1" + } + }, "node_modules/undici-types": { "version": "6.21.0", "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz", @@ -2780,6 +3277,54 @@ "dev": true, "license": "MIT" }, + "node_modules/w3c-xmlserializer": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/w3c-xmlserializer/-/w3c-xmlserializer-5.0.0.tgz", + "integrity": "sha512-o8qghlI8NZHU1lLPrpi2+Uq7abh4GGPpYANlalzWxyWteJOCsr/P+oPBA49TOLu5FTZO4d3F9MnWJfiMo4BkmA==", + "dev": true, + "license": "MIT", + "dependencies": { + "xml-name-validator": "^5.0.0" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/webidl-conversions": { + "version": "8.0.1", + "resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-8.0.1.tgz", + "integrity": "sha512-BMhLD/Sw+GbJC21C/UgyaZX41nPt8bUTg+jWyDeg7e7YN4xOM05YPSIXceACnXVtqyEw/LMClUQMtMZ+PGGpqQ==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=20" + } + }, + "node_modules/whatwg-mimetype": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/whatwg-mimetype/-/whatwg-mimetype-5.0.0.tgz", + "integrity": "sha512-sXcNcHOC51uPGF0P/D4NVtrkjSU2fNsm9iog4ZvZJsL3rjoDAzXZhkm2MWt1y+PUdggKAYVoMAIYcs78wJ51Cw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=20" + } + }, + "node_modules/whatwg-url": { + "version": "16.0.1", + "resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-16.0.1.tgz", + "integrity": "sha512-1to4zXBxmXHV3IiSSEInrreIlu02vUOvrhxJJH5vcxYTBDAx51cqZiKdyTxlecdKNSjj8EcxGBxNf6Vg+945gw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@exodus/bytes": "^1.11.0", + "tr46": "^6.0.0", + "webidl-conversions": "^8.0.1" + }, + "engines": { + "node": "^20.19.0 || ^22.12.0 || >=24.0.0" + } + }, "node_modules/which": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", @@ -2813,6 +3358,23 @@ "node": ">=8" } }, + "node_modules/xml-name-validator": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/xml-name-validator/-/xml-name-validator-5.0.0.tgz", + "integrity": "sha512-EvGK8EJ3DhaHfbRlETOWAS5pO9MZITeauHKJyb8wyajUfQUenkIg2MvLDTZ4T/TgIcm3HU0TFBgWWboAZ30UHg==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=18" + } + }, + "node_modules/xmlchars": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/xmlchars/-/xmlchars-2.2.0.tgz", + "integrity": "sha512-JZnDKK8B0RCDw84FNdDAIpZK+JuJw+s7Lz8nksI7SIuU3UXJJslUthsi+uWBUYOwPFwW7W7PRLRfUKpxjtjFCw==", + "dev": true, + "license": "MIT" + }, "node_modules/yocto-queue": { "version": "1.2.2", "resolved": "https://registry.npmjs.org/yocto-queue/-/yocto-queue-1.2.2.tgz", diff --git a/package.json b/package.json index 5c4a9b9..c07497a 100644 --- a/package.json +++ b/package.json @@ -2,6 +2,10 @@ "name": "aeo.js", "version": "0.0.2", "description": "Answer Engine Optimization for the modern web. Make your site discoverable by AI crawlers and LLMs.", + "bin": { + "aeo.js": "./dist/cli.mjs", + "aeojs": "./dist/cli.mjs" + }, "main": "dist/index.js", "module": "dist/index.mjs", "types": "dist/index.d.ts", @@ -11,6 +15,11 @@ "import": "./dist/index.mjs", "require": "./dist/index.js" }, + "./vite": { + "types": "./dist/vite.d.ts", + "import": "./dist/vite.mjs", + "require": "./dist/vite.js" + }, "./next": { "types": "./dist/next.d.ts", "import": "./dist/next.mjs", @@ -21,6 +30,11 @@ "import": "./dist/astro.mjs", "require": "./dist/astro.js" }, + "./nuxt": { + "types": "./dist/nuxt.d.ts", + "import": "./dist/nuxt.mjs", + "require": "./dist/nuxt.js" + }, "./webpack": { "types": "./dist/webpack.d.ts", "import": "./dist/webpack.mjs", @@ -31,6 +45,11 @@ "import": "./dist/react.mjs", "require": "./dist/react.js" }, + "./vue": { + "types": "./dist/vue.d.ts", + "import": "./dist/vue.mjs", + "require": "./dist/vue.js" + }, "./widget": { "types": "./dist/widget.d.ts", "import": "./dist/widget.mjs", @@ -84,26 +103,39 @@ "devDependencies": { "@types/node": "^20.0.0", "@types/react": "^18.0.0", + "jsdom": "^28.1.0", "tsup": "^8.0.0", "typescript": "^5.0.0", "vitest": "^1.0.0" }, "peerDependencies": { "@astrojs/astro": ">=3.0.0", + "@nuxt/kit": ">=3.0.0", "next": ">=13.0.0", "react": ">=17.0.0", + "vite": ">=4.0.0", + "vue": ">=3.0.0", "webpack": ">=5.0.0" }, "peerDependenciesMeta": { "@astrojs/astro": { "optional": true }, + "@nuxt/kit": { + "optional": true + }, "next": { "optional": true }, "react": { "optional": true }, + "vite": { + "optional": true + }, + "vue": { + "optional": true + }, "webpack": { "optional": true } diff --git a/src/cli.ts b/src/cli.ts new file mode 100644 index 0000000..4ef1a40 --- /dev/null +++ b/src/cli.ts @@ -0,0 +1,235 @@ +#!/usr/bin/env node + +import { generateAEOFiles } from './core/generate-wrapper'; +import { resolveConfig } from './core/utils'; +import { detectFramework } from './core/detect'; +import { writeFileSync, existsSync } from 'fs'; +import { join } from 'path'; + +const VERSION = '0.0.2'; + +const HELP = ` +aeo.js v${VERSION} — Answer Engine Optimization for the modern web + +Usage: + npx aeo.js <command> [options] + +Commands: + generate Generate all AEO files (robots.txt, llms.txt, sitemap.xml, etc.) + init Create an aeo.config.ts configuration file + check Validate your AEO setup and show what would be generated + +Options: + --out <dir> Output directory (default: auto-detected) + --url <url> Site URL (default: https://example.com) + --title <title> Site title (default: My Site) + --no-widget Disable widget generation + --help, -h Show this help message + --version, -v Show version + +Examples: + npx aeo.js generate + npx aeo.js generate --url https://mysite.com --title "My Site" + npx aeo.js init + npx aeo.js check +`; + +function parseArgs(args: string[]): { command: string; flags: Record<string, string | boolean> } { + let command = 'help'; + const flags: Record<string, string | boolean> = {}; + + for (let i = 0; i < args.length; i++) { + const arg = args[i]; + if (arg === '--help' || arg === '-h') { + flags.help = true; + } else if (arg === '--version' || arg === '-v') { + flags.version = true; + } else if (arg === '--no-widget') { + flags.noWidget = true; + } else if (arg.startsWith('--') && i + 1 < args.length) { + const key = arg.slice(2); + flags[key] = args[++i]; + } else if (!arg.startsWith('-') && command === 'help') { + command = arg; + } + } + + return { command, flags }; +} + +async function cmdGenerate(flags: Record<string, string | boolean>): Promise<void> { + const framework = detectFramework(); + console.log(`[aeo.js] Detected framework: ${framework.framework}`); + + const config = resolveConfig({ + title: typeof flags.title === 'string' ? flags.title : undefined, + url: typeof flags.url === 'string' ? flags.url : undefined, + outDir: typeof flags.out === 'string' ? flags.out : undefined, + widget: flags.noWidget ? { enabled: false } : undefined, + }); + + console.log(`[aeo.js] Output directory: ${config.outDir}`); + console.log(`[aeo.js] Generating AEO files...`); + + const result = await generateAEOFiles(config); + + if (result.files.length > 0) { + console.log(`[aeo.js] Generated ${result.files.length} files:`); + for (const file of result.files) { + console.log(` - ${file}`); + } + } else { + console.log('[aeo.js] No files generated.'); + } + + if (result.errors.length > 0) { + console.error(`[aeo.js] ${result.errors.length} error(s):`); + for (const error of result.errors) { + console.error(` - ${error}`); + } + process.exit(1); + } +} + +function cmdInit(): void { + const configPath = join(process.cwd(), 'aeo.config.ts'); + + if (existsSync(configPath)) { + console.error('[aeo.js] aeo.config.ts already exists. Remove it first to reinitialize.'); + process.exit(1); + } + + const template = `import { defineConfig } from 'aeo.js'; + +export default defineConfig({ + // Required + title: 'My Site', + url: 'https://example.com', + + // Optional + description: 'A site optimized for AI discovery', + + // Toggle individual generators + generators: { + robotsTxt: true, + llmsTxt: true, + llmsFullTxt: true, + rawMarkdown: true, + manifest: true, + sitemap: true, + aiIndex: true, + }, + + // Customize robots.txt + robots: { + allow: ['/'], + disallow: ['/admin'], + crawlDelay: 0, + }, + + // Widget configuration + widget: { + enabled: true, + position: 'bottom-right', + humanLabel: 'Human', + aiLabel: 'AI', + showBadge: true, + theme: { + background: 'rgba(18, 18, 24, 0.9)', + text: '#C0C0C5', + accent: '#E8E8EA', + badge: '#4ADE80', + }, + }, +}); +`; + + writeFileSync(configPath, template, 'utf-8'); + console.log('[aeo.js] Created aeo.config.ts'); + console.log('[aeo.js] Edit the config and run `npx aeo.js generate` to generate AEO files.'); +} + +function cmdCheck(): void { + const framework = detectFramework(); + const config = resolveConfig(); + + console.log(`[aeo.js] AEO Configuration Check`); + console.log(`${'─'.repeat(40)}`); + console.log(` Framework: ${framework.framework}`); + console.log(` Content dir: ${config.contentDir}`); + console.log(` Output dir: ${config.outDir}`); + console.log(` Title: ${config.title}`); + console.log(` URL: ${config.url}`); + console.log(` Widget: ${config.widget.enabled ? 'enabled' : 'disabled'}`); + console.log(); + console.log(` Generators:`); + + const generators = [ + ['robots.txt', config.generators.robotsTxt], + ['llms.txt', config.generators.llmsTxt], + ['llms-full.txt', config.generators.llmsFullTxt], + ['raw markdown', config.generators.rawMarkdown], + ['docs.json', config.generators.manifest], + ['sitemap.xml', config.generators.sitemap], + ['ai-index.json', config.generators.aiIndex], + ] as const; + + for (const [name, enabled] of generators) { + console.log(` ${enabled ? '+' : '-'} ${name}`); + } + + // Check for config file + const configPath = join(process.cwd(), 'aeo.config.ts'); + const configPathJs = join(process.cwd(), 'aeo.config.js'); + const hasConfig = existsSync(configPath) || existsSync(configPathJs); + + console.log(); + if (hasConfig) { + console.log(` Config file: found`); + } else { + console.log(` Config file: not found (using defaults)`); + console.log(` Run \`npx aeo.js init\` to create one.`); + } + + if (config.url === 'https://example.com') { + console.log(); + console.log(` Warning: Using default URL (https://example.com).`); + console.log(` Set your actual URL in the config or pass --url.`); + } +} + +async function main(): Promise<void> { + const args = process.argv.slice(2); + const { command, flags } = parseArgs(args); + + if (flags.version) { + console.log(VERSION); + return; + } + + if (flags.help || command === 'help') { + console.log(HELP); + return; + } + + switch (command) { + case 'generate': + await cmdGenerate(flags); + break; + case 'init': + cmdInit(); + break; + case 'check': + cmdCheck(); + break; + default: + console.error(`Unknown command: ${command}`); + console.log(HELP); + process.exit(1); + } +} + +main().catch((error) => { + console.error('[aeo.js] Fatal error:', error.message); + process.exit(1); +}); diff --git a/src/core/ai-index.test.ts b/src/core/ai-index.test.ts index 6d076f5..5df4c72 100644 --- a/src/core/ai-index.test.ts +++ b/src/core/ai-index.test.ts @@ -1,135 +1,122 @@ -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) +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { generateAIIndex } from './ai-index'; +import type { ResolvedAeoConfig } from '../types'; + +vi.mock('fs', () => ({ + readdirSync: vi.fn().mockReturnValue([]), + statSync: vi.fn(), + readFileSync: vi.fn().mockReturnValue('# Test\n\nContent'), + existsSync: vi.fn().mockReturnValue(false), })); -describe('generateAiIndex', () => { +const baseConfig: ResolvedAeoConfig = { + url: 'https://example.com', + title: 'Test Site', + description: 'A test site', + contentDir: '/project/content', + outDir: 'public', + pages: [ + { pathname: '/', title: 'Home', description: 'Homepage', content: 'Welcome to our site. We offer great products and services.' }, + { pathname: '/about', title: 'About', description: 'About us', content: 'Learn more about our company and team.' }, + { pathname: '/contact', title: 'Contact' }, + ], + generators: { + robotsTxt: true, + llmsTxt: true, + llmsFullTxt: true, + rawMarkdown: true, + manifest: true, + sitemap: true, + aiIndex: true, + }, + robots: { allow: ['/'], disallow: [], crawlDelay: 0, sitemap: '' }, + widget: { + enabled: true, + position: 'bottom-right', + theme: { background: '#000', text: '#fff', accent: '#eee', badge: '#4ADE80' }, + humanLabel: 'Human', + aiLabel: 'AI', + showBadge: true, + }, +}; + +describe('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(` - <!DOCTYPE html> - <html> - <head><title>Test Page - -

Welcome

-

This is the homepage content.

- - - `)); - } - return Promise.resolve(Buffer.from('')); - }); + it('should generate valid JSON index', () => { + const result = generateAIIndex(baseConfig); + const index = JSON.parse(result); - await generateAiIndex('/test/project'); - - expect(mockWriteFile).toHaveBeenCalledWith( - path.join('/test/project', 'public', 'ai-index.json'), - expect.any(String) - ); - - const writtenContent = JSON.parse(mockWriteFile.mock.calls[0][1] as string); - expect(writtenContent).toHaveProperty('version', '1.0.0'); - expect(writtenContent).toHaveProperty('generated'); - expect(writtenContent).toHaveProperty('baseUrl', 'https://example.com'); - expect(writtenContent.pages).toHaveLength(3); - expect(writtenContent.pages[0]).toMatchObject({ - url: 'https://example.com/', - title: 'Home', - description: 'Homepage' + expect(index).toHaveProperty('version', '1.0'); + expect(index).toHaveProperty('generated'); + expect(index.site).toEqual({ + title: 'Test Site', + description: 'A test site', + url: 'https://example.com', }); + expect(index.entries.length).toBeGreaterThan(0); }); - it('should generate AI index without content when extraction fails', async () => { - const mockReadFile = vi.mocked(fs.readFile); - const mockWriteFile = vi.mocked(fs.writeFile); - - mockReadFile.mockRejectedValue(new Error('File not found')); - - await generateAiIndex('/test/project'); - - expect(mockWriteFile).toHaveBeenCalled(); - const writtenContent = JSON.parse(mockWriteFile.mock.calls[0][1] as string); - - expect(writtenContent.pages[0]).toMatchObject({ - url: 'https://example.com/', - title: 'Home', - description: 'Homepage' - }); - expect(writtenContent.pages[0].content).toBeUndefined(); + it('should create entries with required fields', () => { + const result = generateAIIndex(baseConfig); + const index = JSON.parse(result); + + for (const entry of index.entries) { + expect(entry).toHaveProperty('id'); + expect(entry).toHaveProperty('url'); + expect(entry).toHaveProperty('title'); + expect(entry).toHaveProperty('content'); + expect(typeof entry.id).toBe('string'); + expect(entry.id.length).toBe(16); + } }); - it('should handle empty routes gracefully', async () => { - const { resolveConfig } = await import('./utils'); - vi.mocked(resolveConfig).mockResolvedValueOnce({ - routes: [], - baseUrl: 'https://example.com' - }); - - const mockWriteFile = vi.mocked(fs.writeFile); - - await generateAiIndex('/test/project'); - - expect(mockWriteFile).toHaveBeenCalled(); - const writtenContent = JSON.parse(mockWriteFile.mock.calls[0][1] as string); - expect(writtenContent.pages).toEqual([]); + it('should generate unique IDs for each entry', () => { + const result = generateAIIndex(baseConfig); + const index = JSON.parse(result); + const ids = index.entries.map((e: any) => e.id); + const uniqueIds = new Set(ids); + + expect(uniqueIds.size).toBe(ids.length); + }); + + it('should extract keywords from content', () => { + const result = generateAIIndex(baseConfig); + const index = JSON.parse(result); + + const homeEntry = index.entries.find((e: any) => e.url === 'https://example.com'); + expect(homeEntry?.keywords).toBeDefined(); + expect(Array.isArray(homeEntry?.keywords)).toBe(true); }); - it('should extract content from HTML correctly', async () => { - const mockReadFile = vi.mocked(fs.readFile); - const mockWriteFile = vi.mocked(fs.writeFile); - - mockReadFile.mockResolvedValue(Buffer.from(` - - - - Product Page - - - - -
-

Our Products

-

We offer amazing solutions.

-
-

Product A

-

Description of Product A with features.

-
-
-
Footer content
- - - `)); - - 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'); + it('should handle pages without content', () => { + const result = generateAIIndex(baseConfig); + const index = JSON.parse(result); + + const contactEntry = index.entries.find((e: any) => e.url.includes('/contact')); + expect(contactEntry).toBeDefined(); + expect(contactEntry?.title).toBe('Contact'); + }); + + it('should handle empty pages', () => { + const config: ResolvedAeoConfig = { ...baseConfig, pages: [] }; + const result = generateAIIndex(config); + const index = JSON.parse(result); + + expect(index.entries).toEqual([]); + expect(index.metadata.totalEntries).toBe(0); + }); + + it('should include metadata with embedding recommendations', () => { + const result = generateAIIndex(baseConfig); + const index = JSON.parse(result); + + expect(index.metadata).toMatchObject({ + generator: 'aeo.js', + generatorUrl: 'https://aeojs.org', + }); + expect(index.metadata.embedding).toBeDefined(); }); -}); \ No newline at end of file +}); diff --git a/src/core/detect.test.ts b/src/core/detect.test.ts index 74a63de..6d9e1b1 100644 --- a/src/core/detect.test.ts +++ b/src/core/detect.test.ts @@ -1,143 +1,139 @@ -import { describe, it, expect, vi, beforeEach } from 'vitest' -import { detectFramework } from './detect' -import * as utils from './utils' +import { describe, it, expect, vi } from 'vitest'; +import { detectFramework } from './detect'; +import * as utils from './utils'; -vi.mock('./utils') +vi.mock('./utils'); describe('detectFramework', () => { - beforeEach(() => { - vi.clearAllMocks() - }) - it('should detect Next.js project', () => { vi.spyOn(utils, 'readPackageJson').mockReturnValue({ - dependencies: { next: '13.0.0' } - }) + dependencies: { next: '13.0.0' }, + }); - const result = detectFramework('/project') + const result = detectFramework('/project'); expect(result).toEqual({ framework: 'next', contentDir: 'app', outDir: 'out', - }) - }) + }); + }); it('should detect Vite project', () => { vi.spyOn(utils, 'readPackageJson').mockReturnValue({ - devDependencies: { vite: '4.0.0' } - }) + devDependencies: { vite: '4.0.0' }, + }); - const result = detectFramework('/project') + const result = detectFramework('/project'); expect(result).toEqual({ framework: 'vite', contentDir: 'src', outDir: 'dist', - }) - }) + }); + }); it('should detect Astro project', () => { vi.spyOn(utils, 'readPackageJson').mockReturnValue({ - dependencies: { astro: '3.0.0' } - }) + dependencies: { astro: '3.0.0' }, + }); - const result = detectFramework('/project') + const result = detectFramework('/project'); expect(result).toEqual({ framework: 'astro', contentDir: 'src/content', outDir: 'dist', - }) - }) + }); + }); it('should detect Nuxt project', () => { vi.spyOn(utils, 'readPackageJson').mockReturnValue({ - devDependencies: { nuxt: '3.0.0' } - }) + devDependencies: { nuxt: '3.0.0' }, + }); - const result = detectFramework('/project') + const result = detectFramework('/project'); expect(result).toEqual({ framework: 'nuxt', contentDir: 'content', outDir: '.output/public', - }) - }) + }); + }); it('should detect Remix project', () => { vi.spyOn(utils, 'readPackageJson').mockReturnValue({ - devDependencies: { '@remix-run/dev': '2.0.0' } - }) + devDependencies: { '@remix-run/dev': '2.0.0' }, + }); - const result = detectFramework('/project') + const result = detectFramework('/project'); expect(result).toEqual({ framework: 'remix', contentDir: 'app', outDir: 'build/client', - }) - }) + }); + }); it('should detect SvelteKit project', () => { vi.spyOn(utils, 'readPackageJson').mockReturnValue({ - devDependencies: { '@sveltejs/kit': '1.0.0' } - }) + devDependencies: { '@sveltejs/kit': '1.0.0' }, + }); - const result = detectFramework('/project') + const result = detectFramework('/project'); expect(result).toEqual({ framework: 'sveltekit', contentDir: 'src', outDir: 'build', - }) - }) + }); + }); it('should detect Angular project', () => { vi.spyOn(utils, 'readPackageJson').mockReturnValue({ - dependencies: { '@angular/core': '17.0.0' } - }) + dependencies: { '@angular/core': '17.0.0' }, + }); - const result = detectFramework('/project') + const result = detectFramework('/project'); expect(result).toEqual({ framework: 'angular', contentDir: 'src', outDir: 'dist', - }) - }) + }); + }); it('should detect Docusaurus project', () => { vi.spyOn(utils, 'readPackageJson').mockReturnValue({ - dependencies: { '@docusaurus/core': '3.0.0' } - }) + dependencies: { '@docusaurus/core': '3.0.0' }, + }); - const result = detectFramework('/project') + const result = detectFramework('/project'); expect(result).toEqual({ framework: 'docusaurus', contentDir: 'docs', outDir: 'build', - }) - }) + }); + }); it('should return unknown when no framework is detected', () => { vi.spyOn(utils, 'readPackageJson').mockReturnValue({ dependencies: {}, - devDependencies: {} - }) + devDependencies: {}, + }); - const result = detectFramework('/project') + const result = detectFramework('/project'); expect(result).toEqual({ framework: 'unknown', contentDir: 'src', outDir: 'dist', - }) - }) + }); + }); it('should handle combined dependencies and devDependencies', () => { vi.spyOn(utils, 'readPackageJson').mockReturnValue({ dependencies: { react: '18.0.0' }, - devDependencies: { next: '13.0.0' } - }) + devDependencies: { next: '13.0.0' }, + }); - const result = detectFramework('/project') + const result = detectFramework('/project'); expect(result).toEqual({ framework: 'next', contentDir: 'app', outDir: 'out', - }) - }) -}) \ No newline at end of file + }); + }); +}); diff --git a/src/core/generate-wrapper.test.ts b/src/core/generate-wrapper.test.ts index 3b12385..b8c4ae8 100644 --- a/src/core/generate-wrapper.test.ts +++ b/src/core/generate-wrapper.test.ts @@ -1,198 +1,146 @@ 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 - } - }) +import { generateAEOFiles } from './generate-wrapper'; +import * as robots from './robots'; +import * as llmsTxt from './llms-txt'; +import * as llmsFull from './llms-full'; +import * as rawMarkdown from './raw-markdown'; +import * as manifest from './manifest'; +import * as sitemap from './sitemap'; +import * as aiIndex from './ai-index'; +import type { ResolvedAeoConfig } from '../types'; + +vi.mock('fs', () => ({ + mkdirSync: vi.fn(), + writeFileSync: vi.fn(), + existsSync: vi.fn().mockReturnValue(true), + readFileSync: vi.fn(), + readdirSync: vi.fn().mockReturnValue([]), + statSync: vi.fn(), + copyFileSync: vi.fn(), })); -describe('generateAllWrapper', () => { +const baseConfig: ResolvedAeoConfig = { + title: 'Test Site', + description: 'Test description', + url: 'https://example.com', + contentDir: 'content', + outDir: '/tmp/test-out', + pages: [], + generators: { + robotsTxt: true, + llmsTxt: true, + llmsFullTxt: true, + rawMarkdown: true, + manifest: true, + sitemap: true, + aiIndex: true, + }, + robots: { allow: ['/'], disallow: [], crawlDelay: 0, sitemap: '' }, + widget: { + enabled: true, + position: 'bottom-right', + theme: { background: '#000', text: '#fff', accent: '#eee', badge: '#4ADE80' }, + humanLabel: 'Human', + aiLabel: 'AI', + showBadge: true, + }, +}; + +describe('generateAEOFiles', () => { beforeEach(() => { vi.clearAllMocks(); - vi.spyOn(console, 'log').mockImplementation(() => {}); - vi.spyOn(console, 'error').mockImplementation(() => {}); + vi.spyOn(robots, 'generateRobotsTxt').mockReturnValue('User-agent: *\nAllow: /'); + vi.spyOn(llmsTxt, 'generateLlmsTxt').mockReturnValue('# LLMs.txt'); + vi.spyOn(llmsFull, 'generateLlmsFullTxt').mockReturnValue('# Full LLMs'); + vi.spyOn(manifest, 'generateManifest').mockReturnValue('{"docs":[]}'); + vi.spyOn(sitemap, 'generateSitemap').mockReturnValue(''); + vi.spyOn(aiIndex, 'generateAIIndex').mockReturnValue('{"index":[]}'); + vi.spyOn(rawMarkdown, 'copyMarkdownFiles').mockReturnValue([]); + vi.spyOn(rawMarkdown, 'generatePageMarkdownFiles').mockReturnValue([]); }); - 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 call all enabled generators and return file list', async () => { + const result = await generateAEOFiles(baseConfig); + + expect(robots.generateRobotsTxt).toHaveBeenCalledWith(baseConfig); + expect(llmsTxt.generateLlmsTxt).toHaveBeenCalledWith(baseConfig); + expect(llmsFull.generateLlmsFullTxt).toHaveBeenCalledWith(baseConfig); + expect(manifest.generateManifest).toHaveBeenCalledWith(baseConfig); + expect(sitemap.generateSitemap).toHaveBeenCalledWith(baseConfig); + expect(aiIndex.generateAIIndex).toHaveBeenCalledWith(baseConfig); + + expect(result.files).toContain('robots.txt'); + expect(result.files).toContain('llms.txt'); + expect(result.files).toContain('llms-full.txt'); + expect(result.files).toContain('docs.json'); + expect(result.files).toContain('sitemap.xml'); + expect(result.files).toContain('ai-index.json'); + expect(result.errors).toEqual([]); }); it('should skip disabled generators', async () => { - const { resolveConfig } = await import('./utils'); - vi.mocked(resolveConfig).mockResolvedValueOnce({ - routes: [], - baseUrl: 'https://example.com', + const config: ResolvedAeoConfig = { + ...baseConfig, generators: { - robots: true, - sitemap: false, - llmsTxt: true, - llmsFull: false, + robotsTxt: true, + llmsTxt: false, + llmsFullTxt: false, + rawMarkdown: false, manifest: true, + sitemap: false, 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(); - }); + const result = await generateAEOFiles(config); - 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(); - }); + expect(robots.generateRobotsTxt).toHaveBeenCalled(); + expect(llmsTxt.generateLlmsTxt).not.toHaveBeenCalled(); + expect(llmsFull.generateLlmsFullTxt).not.toHaveBeenCalled(); + expect(manifest.generateManifest).toHaveBeenCalled(); + expect(sitemap.generateSitemap).not.toHaveBeenCalled(); + expect(aiIndex.generateAIIndex).not.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); + expect(result.files).toContain('robots.txt'); + expect(result.files).toContain('docs.json'); + expect(result.files).not.toContain('llms.txt'); }); - it('should use default config when generators not specified', async () => { - const { resolveConfig } = await import('./utils'); - vi.mocked(resolveConfig).mockResolvedValueOnce({ - routes: [], - baseUrl: 'https://example.com' + it('should handle generator errors gracefully', async () => { + vi.spyOn(robots, 'generateRobotsTxt').mockImplementation(() => { + throw new Error('Robots generation failed'); }); - const { generateRobots } = await import('./robots'); - const { generateSitemap } = await import('./sitemap'); - - await generateAllWrapper('/test/project'); + const result = await generateAEOFiles(baseConfig); - // Should call all generators by default - expect(generateRobots).toHaveBeenCalled(); - expect(generateSitemap).toHaveBeenCalled(); + expect(result.errors).toHaveLength(1); + expect(result.errors[0]).toContain('robots.txt'); + expect(result.files).toContain('llms.txt'); + expect(result.files).toContain('sitemap.xml'); }); - 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'); + it('should complete even if all generators fail', async () => { + vi.spyOn(robots, 'generateRobotsTxt').mockImplementation(() => { throw new Error('fail'); }); + vi.spyOn(llmsTxt, 'generateLlmsTxt').mockImplementation(() => { throw new Error('fail'); }); + vi.spyOn(llmsFull, 'generateLlmsFullTxt').mockImplementation(() => { throw new Error('fail'); }); + vi.spyOn(manifest, 'generateManifest').mockImplementation(() => { throw new Error('fail'); }); + vi.spyOn(sitemap, 'generateSitemap').mockImplementation(() => { throw new Error('fail'); }); + vi.spyOn(aiIndex, 'generateAIIndex').mockImplementation(() => { throw new Error('fail'); }); + vi.spyOn(rawMarkdown, 'generatePageMarkdownFiles').mockImplementation(() => { throw new Error('fail'); }); + vi.spyOn(rawMarkdown, 'copyMarkdownFiles').mockImplementation(() => { throw new Error('fail'); }); + + const result = await generateAEOFiles(baseConfig); + + expect(result.files).toEqual([]); + expect(result.errors.length).toBeGreaterThan(0); + }); - await generateAllWrapper(projectRoot); + it('should return result shape with files and errors arrays', async () => { + const result = await generateAEOFiles(baseConfig); - expect(generateRobots).toHaveBeenCalledWith(projectRoot); - expect(generateSitemap).toHaveBeenCalledWith(projectRoot); - expect(generateLlmsTxt).toHaveBeenCalledWith(projectRoot); + expect(result).toHaveProperty('files'); + expect(result).toHaveProperty('errors'); + expect(Array.isArray(result.files)).toBe(true); + expect(Array.isArray(result.errors)).toBe(true); }); -}); \ No newline at end of file +}); diff --git a/src/core/generate-wrapper.ts b/src/core/generate-wrapper.ts index 36c93ab..bc4d5d2 100644 --- a/src/core/generate-wrapper.ts +++ b/src/core/generate-wrapper.ts @@ -55,7 +55,7 @@ export async function generateAEOFiles( if (config.generators.llmsTxt) { try { const content = genLlms(config); - writeFileSync(join(outDir, 'llms.txt'), '\uFEFF' + content, 'utf-8'); + writeFileSync(join(outDir, 'llms.txt'), content, 'utf-8'); files.push('llms.txt'); } catch (e: any) { errors.push(`llms.txt: ${e.message}`); @@ -65,7 +65,7 @@ export async function generateAEOFiles( if (config.generators.llmsFullTxt) { try { const content = genLlmsFull(config); - writeFileSync(join(outDir, 'llms-full.txt'), '\uFEFF' + content, 'utf-8'); + writeFileSync(join(outDir, 'llms-full.txt'), content, 'utf-8'); files.push('llms-full.txt'); } catch (e: any) { errors.push(`llms-full.txt: ${e.message}`); diff --git a/src/core/generate.test.ts b/src/core/generate.test.ts index 6c6b3c4..07fb27a 100644 --- a/src/core/generate.test.ts +++ b/src/core/generate.test.ts @@ -1,285 +1,8 @@ -import { describe, it, expect, vi, beforeEach } from 'vitest' -import { generateAEOFiles } from './generate' -import * as generateWrapper from './generate-wrapper' -import * as detect from './detect' -import * as utils from './utils' -import * as robots from './robots' -import * as llmsTxt from './llms-txt' -import * as llmsFull from './llms-full' -import * as rawMarkdown from './raw-markdown' -import * as manifest from './manifest' -import * as sitemap from './sitemap' -import * as aiIndex from './ai-index' -import { mkdirSync, writeFileSync } from 'fs' - -vi.mock('./detect') -vi.mock('./utils') -vi.mock('./robots') -vi.mock('./llms-txt') -vi.mock('./llms-full') -vi.mock('./raw-markdown') -vi.mock('./manifest') -vi.mock('./sitemap') -vi.mock('./ai-index') -vi.mock('fs', () => ({ - mkdirSync: vi.fn(), - writeFileSync: vi.fn(), - readFileSync: vi.fn(), - existsSync: vi.fn(), - readdirSync: vi.fn(), - statSync: vi.fn(), - copyFileSync: vi.fn() -})) - -describe('generateAEOFiles', () => { - beforeEach(() => { - vi.clearAllMocks() - - // Setup default mocks - vi.mocked(utils.resolveConfig).mockReturnValue({ - output: 'public/aeo', - baseUrl: 'https://example.com', - include: ['**/*.md'], - exclude: ['**/node_modules/**'], - generateRobots: true, - generateLLMs: true, - generateManifest: true, - generateSitemap: true, - generateAIIndex: true, - generateRawMarkdown: true, - title: 'Test Site', - description: 'Test Description', - url: 'https://example.com', - contentDir: 'content', - outDir: 'public/aeo', - generators: { - robotsTxt: true, - llmsTxt: true, - llmsFullTxt: true, - rawMarkdown: true, - manifest: true, - sitemap: true, - aiIndex: true - }, - widget: { - enabled: true, - position: 'bottom-right', - theme: { - background: 'rgba(18, 18, 24, 0.9)', - text: '#C0C0C5', - accent: '#E8E8EA', - badge: '#4ADE80' - }, - humanLabel: 'Human', - aiLabel: 'AI', - showBadge: true - } - }) - - vi.mocked(utils.getAllMarkdownFiles).mockReturnValue([ - '/project/README.md', - '/project/docs/guide.md' - ]) - - vi.mocked(detect.detectFramework).mockReturnValue({ - name: 'next', - version: '13.0.0', - configFile: 'next.config.js', - contentDir: 'content', - outDir: 'public' - }) - - // Mock generator functions to return sample content - vi.mocked(robots.generateRobotsTxt).mockReturnValue('User-agent: *\nAllow: /') - vi.mocked(llmsTxt.generateLlmsTxt).mockReturnValue('# LLMs.txt') - vi.mocked(llmsFull.generateLlmsFullTxt).mockReturnValue('# Full LLMs') - vi.mocked(manifest.generateManifest).mockReturnValue('{"docs":[]}') - vi.mocked(sitemap.generateSitemap).mockReturnValue('') - vi.mocked(aiIndex.generateAIIndex).mockReturnValue('{"index":[]}') - vi.mocked(rawMarkdown.copyMarkdownFiles).mockReturnValue([]) - }) - - it('should generate all AEO files when all options enabled', async () => { - const config = { - generateRobots: true, - generateLLMs: true, - generateManifest: true, - generateSitemap: true, - generateAIIndex: true, - generateRawMarkdown: true - } - - await generateAEOFiles('/project', config) - - // Check that files were written - expect(mkdirSync).toHaveBeenCalled() - expect(writeFileSync).toHaveBeenCalledWith( - expect.stringContaining('robots.txt'), - expect.any(String), - 'utf-8' - ) - expect(writeFileSync).toHaveBeenCalledWith( - expect.stringContaining('llms.txt'), - expect.any(String), - 'utf-8' - ) - expect(writeFileSync).toHaveBeenCalledWith( - expect.stringContaining('llms-full.txt'), - expect.any(String), - 'utf-8' - ) - expect(writeFileSync).toHaveBeenCalledWith( - expect.stringContaining('docs.json'), - expect.any(String), - 'utf-8' - ) - expect(writeFileSync).toHaveBeenCalledWith( - expect.stringContaining('sitemap.xml'), - expect.any(String), - 'utf-8' - ) - expect(writeFileSync).toHaveBeenCalledWith( - expect.stringContaining('ai-index.json'), - expect.any(String), - 'utf-8' - ) - expect(rawMarkdown.copyMarkdownFiles).toHaveBeenCalled() - }) - - it('should only generate specified files', async () => { - const config = { - generateRobots: true, - generateLLMs: false, - generateManifest: true, - generateSitemap: false, - generateAIIndex: false, - generateRawMarkdown: false - } - - vi.mocked(utils.resolveConfig).mockReturnValue({ - output: 'public/aeo', - baseUrl: 'https://example.com', - include: ['**/*.md'], - exclude: ['**/node_modules/**'], - generateRobots: false, - generateLLMs: false, - generateManifest: false, - generateSitemap: false, - generateAIIndex: false, - generateRawMarkdown: false, - title: 'Test Site', - description: 'Test Description', - url: 'https://example.com', - contentDir: 'content', - outDir: 'public/aeo', - generators: { - robotsTxt: false, - llmsTxt: false, - llmsFullTxt: false, - rawMarkdown: false, - manifest: false, - sitemap: false, - aiIndex: false - }, - widget: { - enabled: true, - position: 'bottom-right', - theme: { - background: 'rgba(18, 18, 24, 0.9)', - text: '#C0C0C5', - accent: '#E8E8EA', - badge: '#4ADE80' - }, - humanLabel: 'Human', - aiLabel: 'AI', - showBadge: true - } - }) - - await generateAEOFiles('/project', config) - - // Check that only specified files were generated - expect(writeFileSync).toHaveBeenCalledWith( - expect.stringContaining('robots.txt'), - expect.any(String), - 'utf-8' - ) - expect(writeFileSync).not.toHaveBeenCalledWith( - expect.stringContaining('llms.txt'), - expect.any(String), - 'utf-8' - ) - expect(writeFileSync).not.toHaveBeenCalledWith( - expect.stringContaining('llms-full.txt'), - expect.any(String), - 'utf-8' - ) - expect(writeFileSync).toHaveBeenCalledWith( - expect.stringContaining('docs.json'), - expect.any(String), - 'utf-8' - ) - expect(writeFileSync).not.toHaveBeenCalledWith( - expect.stringContaining('sitemap.xml'), - expect.any(String), - 'utf-8' - ) - expect(writeFileSync).not.toHaveBeenCalledWith( - expect.stringContaining('ai-index.json'), - expect.any(String), - 'utf-8' - ) - expect(rawMarkdown.copyMarkdownFiles).not.toHaveBeenCalled() - }) - - it('should use default project root when not specified', async () => { - await generateAEOFiles() - - expect(utils.resolveConfig).toHaveBeenCalled() - expect(utils.getAllMarkdownFiles).toHaveBeenCalledWith( - process.cwd(), - expect.any(Array), - expect.any(Array) - ) - }) - - it('should pass detected framework to generators', async () => { - await generateAEOFiles('/project') - - expect(detect.detectFramework).toHaveBeenCalledWith('/project') - }) - - it('should handle errors gracefully', async () => { - vi.mocked(utils.getAllMarkdownFiles).mockImplementation(() => { - throw new Error('File system error') - }) - - await expect(generateAEOFiles('/project')).rejects.toThrow('File system error') - }) - - it('should return summary of generated files', async () => { - const result = await generateAEOFiles('/project') - - expect(result).toEqual({ - markdownFiles: ['/project/README.md', '/project/docs/guide.md'], - framework: { - name: 'next', - version: '13.0.0', - configFile: 'next.config.js', - contentDir: 'content', - outDir: 'public' - }, - totalFiles: 2, - outputPath: 'public/aeo' - }) - }) - - it('should handle no markdown files found', async () => { - vi.mocked(utils.getAllMarkdownFiles).mockReturnValue([]) - - const result = await generateAEOFiles('/project') - - expect(result.totalFiles).toBe(0) - expect(result.markdownFiles).toEqual([]) - }) -}) \ No newline at end of file +import { describe, it, expect } from 'vitest'; +import { generateAEOFiles } from './generate'; + +describe('generate re-export', () => { + it('should export generateAEOFiles', () => { + expect(typeof generateAEOFiles).toBe('function'); + }); +}); diff --git a/src/core/llms-full.test.ts b/src/core/llms-full.test.ts index 5f6a062..3c66a7b 100644 --- a/src/core/llms-full.test.ts +++ b/src/core/llms-full.test.ts @@ -1,148 +1,107 @@ -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) +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { generateLlmsFullTxt } from './llms-full'; +import type { ResolvedAeoConfig } from '../types'; + +vi.mock('fs', () => ({ + readdirSync: vi.fn().mockReturnValue([]), + statSync: vi.fn(), + readFileSync: vi.fn().mockReturnValue('# Test\n\nContent'), + existsSync: vi.fn().mockReturnValue(false), })); -describe('generateLlmsFull', () => { +const baseConfig: ResolvedAeoConfig = { + url: 'https://example.com', + title: 'Test Project', + description: 'A test project description', + contentDir: '/project/content', + outDir: 'public', + pages: [], + generators: { + robotsTxt: true, + llmsTxt: true, + llmsFullTxt: true, + rawMarkdown: true, + manifest: true, + sitemap: true, + aiIndex: true, + }, + robots: { allow: ['/'], disallow: [], crawlDelay: 0, sitemap: '' }, + widget: { + enabled: true, + position: 'bottom-right', + theme: { background: '#000', text: '#fff', accent: '#eee', badge: '#4ADE80' }, + humanLabel: 'Human', + aiLabel: 'AI', + showBadge: true, + }, +}; + +describe('generateLlmsFullTxt', () => { 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 generate complete documentation header', () => { + const result = generateLlmsFullTxt(baseConfig); + + expect(result).toContain('# Test Project - Complete Documentation'); + expect(result).toContain('> A test project description'); }); - 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 include table of contents section', () => { + const result = generateLlmsFullTxt(baseConfig); + + expect(result).toContain('## Table of Contents'); + expect(result).toContain('Each section is separated by a horizontal rule'); }); - 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 page content when pages have content', () => { + const config: ResolvedAeoConfig = { + ...baseConfig, + pages: [ + { pathname: '/', title: 'Home', content: 'Welcome to our site' }, + { pathname: '/about', title: 'About', description: 'About us', content: 'We are great' }, + ], + }; + + const result = generateLlmsFullTxt(config); + + expect(result).toContain('# Home'); + expect(result).toContain('Welcome to our site'); + expect(result).toContain('# About'); + expect(result).toContain('We are great'); + expect(result).toContain('URL: https://example.com'); + expect(result).toContain('URL: https://example.com/about'); }); - it('should include separator between pages', async () => { - const mockWriteFile = vi.mocked(fs.writeFile); - vi.mocked(fs.readFile).mockResolvedValue(Buffer.from('Content')); + it('should include separator between pages', () => { + const config: ResolvedAeoConfig = { + ...baseConfig, + pages: [ + { pathname: '/', title: 'Home', content: 'Home content' }, + { pathname: '/about', title: 'About', content: 'About content' }, + ], + }; + + const result = generateLlmsFullTxt(config); + const separatorCount = (result.match(/^---$/gm) || []).length; - await generateLlmsFull('/test/project'); - - const content = mockWriteFile.mock.calls[0][1] as string; - const separatorCount = (content.match(/---\n\n/g) || []).length; - expect(separatorCount).toBeGreaterThan(0); }); -}); \ No newline at end of file + + it('should include footer', () => { + const result = generateLlmsFullTxt(baseConfig); + + expect(result).toContain('## About This Document'); + expect(result).toContain('Generated by aeo.js'); + expect(result).toContain('https://aeojs.org'); + }); + + it('should include fallback content when no pages exist', () => { + const result = generateLlmsFullTxt(baseConfig); + + // Should still have the site title in the content + expect(result).toContain('# Test Project'); + expect(result).toContain('URL: https://example.com'); + }); +}); diff --git a/src/core/llms-txt.test.ts b/src/core/llms-txt.test.ts index 3596965..cbebc26 100644 --- a/src/core/llms-txt.test.ts +++ b/src/core/llms-txt.test.ts @@ -1,142 +1,108 @@ -import { describe, it, expect, vi, beforeEach } from 'vitest' -import { generateLlmsTxt } from './llms-txt' -import fs from 'fs' -import path from 'path' -import type { ResolvedAeoConfig } from '../types' +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { generateLlmsTxt } from './llms-txt'; +import type { ResolvedAeoConfig } from '../types'; -vi.mock('fs') -vi.mock('path') +vi.mock('fs', () => ({ + readdirSync: vi.fn().mockReturnValue([]), + statSync: vi.fn(), + readFileSync: vi.fn().mockReturnValue('# Test\n\nContent'), + existsSync: vi.fn().mockReturnValue(false), +})); -describe('generateLlmsTxt', () => { - const mockFs = fs as any - const mockPath = path as any +const baseConfig: ResolvedAeoConfig = { + url: 'https://example.com', + title: 'Test Project', + description: 'A test project description', + contentDir: '/project/content', + outDir: 'public', + pages: [], + generators: { + robotsTxt: true, + llmsTxt: true, + llmsFullTxt: true, + rawMarkdown: true, + manifest: true, + sitemap: true, + aiIndex: true, + }, + robots: { allow: ['/'], disallow: [], crawlDelay: 0, sitemap: '' }, + widget: { + enabled: true, + position: 'bottom-right', + theme: { background: '#000', text: '#fff', accent: '#eee', badge: '#4ADE80' }, + humanLabel: 'Human', + aiLabel: 'AI', + showBadge: true, + }, +}; +describe('generateLlmsTxt', () => { beforeEach(() => { - vi.clearAllMocks() - mockPath.join.mockImplementation((...args: string[]) => args.join('/')) - mockPath.relative.mockImplementation((from: string, to: string) => - to.replace(from + '/', '') - ) - mockPath.extname.mockImplementation((file: string) => { - if (file.endsWith('.md')) return '.md' - if (file.endsWith('.mdx')) return '.mdx' - return '' - }) - - mockFs.readdirSync.mockReturnValue(['README.md', 'guide.md']) - mockFs.statSync.mockReturnValue({ - isDirectory: () => false, - isFile: () => true - }) - mockFs.readFileSync.mockReturnValue('# Test Title\n\nTest content\n\n## Section\n\nMore content') - }) - - const baseConfig: ResolvedAeoConfig = { - url: 'https://example.com', - title: 'Test Project', - description: 'A test project description', - contentDir: '/project/content', - outDir: 'public', - generators: { - robotsTxt: true, - llmsTxt: true, - llmsFullTxt: true, - rawMarkdown: true, - manifest: true, - sitemap: true, - aiIndex: true, - } - } + vi.clearAllMocks(); + }); it('should generate llms.txt with project title and description', () => { - const result = generateLlmsTxt(baseConfig) - - expect(result).toContain('# Test Project') - expect(result).toContain('> A test project description') - }) + const result = generateLlmsTxt(baseConfig); + + expect(result).toContain('# Test Project'); + expect(result).toContain('> A test project description'); + }); it('should include about section', () => { - const result = generateLlmsTxt(baseConfig) - - expect(result).toContain('## About') - expect(result).toContain('This file provides a structured overview') - expect(result).toContain('optimized for consumption by Large Language Models') - }) - - it('should include documentation structure', () => { - const result = generateLlmsTxt(baseConfig) - - expect(result).toContain('## Documentation Structure') - }) + const result = generateLlmsTxt(baseConfig); + + expect(result).toContain('## About'); + expect(result).toContain('This file provides a structured overview'); + expect(result).toContain('optimized for consumption by Large Language Models'); + }); it('should include quick links', () => { - const result = generateLlmsTxt(baseConfig) - - expect(result).toContain('## Quick Links') - expect(result).toContain('Full Documentation: https://example.com/llms-full.txt') - expect(result).toContain('Documentation Manifest: https://example.com/docs.json') - expect(result).toContain('AI-Optimized Index: https://example.com/ai-index.json') - expect(result).toContain('Sitemap: https://example.com/sitemap.xml') - }) + const result = generateLlmsTxt(baseConfig); + + expect(result).toContain('## Quick Links'); + expect(result).toContain('Full Documentation: https://example.com/llms-full.txt'); + expect(result).toContain('Documentation Manifest: https://example.com/docs.json'); + expect(result).toContain('AI-Optimized Index: https://example.com/ai-index.json'); + expect(result).toContain('Sitemap: https://example.com/sitemap.xml'); + }); it('should include LLM instructions', () => { - const result = generateLlmsTxt(baseConfig) - - expect(result).toContain('## For LLMs') - expect(result).toContain('To get the complete documentation in a single file') - expect(result).toContain('https://example.com/llms-full.txt') - expect(result).toContain('For structured access to individual pages') - expect(result).toContain('https://example.com/docs.json') - expect(result).toContain('For RAG (Retrieval Augmented Generation) systems') - expect(result).toContain('https://example.com/ai-index.json') - }) + const result = generateLlmsTxt(baseConfig); + + expect(result).toContain('## For LLMs'); + expect(result).toContain('https://example.com/llms-full.txt'); + expect(result).toContain('https://example.com/docs.json'); + expect(result).toContain('https://example.com/ai-index.json'); + }); it('should include footer', () => { - const result = generateLlmsTxt(baseConfig) - - expect(result).toContain('Generated by aeo.js') - expect(result).toContain('Learn more at https://aeojs.org') - }) + const result = generateLlmsTxt(baseConfig); + + expect(result).toContain('Generated by aeo.js'); + expect(result).toContain('Learn more at https://aeojs.org'); + }); it('should handle missing description', () => { + const config: ResolvedAeoConfig = { ...baseConfig, description: '' }; + const result = generateLlmsTxt(config); + + expect(result).toContain('# Test Project'); + // Should not have empty blockquote + expect(result).not.toContain('> \n'); + }); + + it('should include pages section when pages exist', () => { const config: ResolvedAeoConfig = { ...baseConfig, - description: undefined as any, - } - - const result = generateLlmsTxt(config) - - expect(result).not.toContain('> ') - expect(result).toContain('# Test Project') - }) - - it('should handle nested directory structure', () => { - mockFs.readdirSync.mockImplementation((dir: string) => { - if (dir === '/project/content') return ['docs', 'README.md'] - if (dir.includes('docs')) return ['guide.md', 'api.md'] - return [] - }) - mockFs.statSync.mockImplementation((path: string) => ({ - isDirectory: () => path.includes('docs') && !path.includes('.md'), - isFile: () => path.includes('.md') - })) - - const result = generateLlmsTxt(baseConfig) - - expect(result).toContain('### Main Documentation') - expect(result).toContain('### docs') - }) - - it('should extract title from markdown content', () => { - mockFs.readFileSync.mockImplementation((file: string) => { - if (file.includes('README')) { - return '---\ntitle: Custom Title\ndescription: Custom desc\n---\n# Content Title\n\nContent' - } - return '# Regular Title\n\nContent' - }) - - const result = generateLlmsTxt(baseConfig) - - expect(result).toContain('[Custom Title]') - }) -}) \ No newline at end of file + pages: [ + { pathname: '/', title: 'Home' }, + { pathname: '/about', title: 'About Us' }, + ], + }; + const result = generateLlmsTxt(config); + + expect(result).toContain('## Pages'); + expect(result).toContain('Home'); + expect(result).toContain('About Us'); + }); +}); diff --git a/src/core/manifest.test.ts b/src/core/manifest.test.ts index 56bc75c..8e9590f 100644 --- a/src/core/manifest.test.ts +++ b/src/core/manifest.test.ts @@ -1,140 +1,121 @@ -import { describe, it, expect, beforeEach, vi } from 'vitest'; +import { describe, it, expect, vi, beforeEach } from 'vitest'; import { generateManifest } from './manifest'; -import * as fs from 'fs/promises'; -import * as path from 'path'; - -vi.mock('fs/promises'); -vi.mock('./utils', () => ({ - resolveConfig: vi.fn().mockResolvedValue({ - routes: [ - { path: '/', title: 'Home', description: 'Homepage', keywords: ['home', 'main'] }, - { path: '/about', title: 'About Us', description: 'Learn about our company' }, - { path: '/products', title: 'Products' }, - { path: '/contact', title: 'Contact', description: 'Get in touch', keywords: ['contact', 'email', 'phone'] } - ], - baseUrl: 'https://example.com', - name: 'Example Site', - description: 'An example website' - }), - ensureDir: vi.fn().mockResolvedValue(undefined) +import type { ResolvedAeoConfig } from '../types'; + +vi.mock('fs', () => ({ + readdirSync: vi.fn().mockReturnValue([]), + statSync: vi.fn(), + readFileSync: vi.fn().mockReturnValue('# Test\n\nContent'), + existsSync: vi.fn().mockReturnValue(false), })); +const baseConfig: ResolvedAeoConfig = { + url: 'https://example.com', + title: 'Example Site', + description: 'An example website', + contentDir: '/project/content', + outDir: 'public', + pages: [ + { pathname: '/', title: 'Home', description: 'Homepage' }, + { pathname: '/about', title: 'About Us', description: 'Learn about our company' }, + { pathname: '/products', title: 'Products' }, + { pathname: '/contact', title: 'Contact', description: 'Get in touch' }, + ], + generators: { + robotsTxt: true, + llmsTxt: true, + llmsFullTxt: true, + rawMarkdown: true, + manifest: true, + sitemap: true, + aiIndex: true, + }, + robots: { allow: ['/'], disallow: [], crawlDelay: 0, sitemap: '' }, + widget: { + enabled: true, + position: 'bottom-right', + theme: { background: '#000', text: '#fff', accent: '#eee', badge: '#4ADE80' }, + humanLabel: 'Human', + aiLabel: 'AI', + showBadge: true, + }, +}; + describe('generateManifest', () => { beforeEach(() => { vi.clearAllMocks(); }); - it('should generate docs.json manifest with all routes', async () => { - const mockWriteFile = vi.mocked(fs.writeFile); - - await generateManifest('/test/project'); - - expect(mockWriteFile).toHaveBeenCalledWith( - path.join('/test/project', 'public', 'docs.json'), - expect.any(String) - ); - - const manifest = JSON.parse(mockWriteFile.mock.calls[0][1] as string); - + it('should generate valid JSON manifest', () => { + const result = generateManifest(baseConfig); + const manifest = JSON.parse(result); + expect(manifest).toHaveProperty('version', '1.0'); - expect(manifest).toHaveProperty('name', 'Example Site'); - expect(manifest).toHaveProperty('description', 'An example website'); - expect(manifest).toHaveProperty('baseUrl', 'https://example.com'); expect(manifest).toHaveProperty('generated'); + expect(manifest.site).toEqual({ + title: 'Example Site', + description: 'An example website', + url: 'https://example.com', + }); expect(manifest.documents).toHaveLength(4); }); - it('should format document entries correctly', async () => { - const mockWriteFile = vi.mocked(fs.writeFile); - - await generateManifest('/test/project'); - - const manifest = JSON.parse(mockWriteFile.mock.calls[0][1] as string); - - const homeDoc = manifest.documents[0]; + it('should format document entries correctly', () => { + const result = generateManifest(baseConfig); + const manifest = JSON.parse(result); + + const homeDoc = manifest.documents.find((d: any) => d.url === 'https://example.com'); expect(homeDoc).toMatchObject({ - id: expect.stringMatching(/^[a-f0-9]{8}$/), - url: 'https://example.com/', + url: 'https://example.com', title: 'Home', description: 'Homepage', - keywords: ['home', 'main'] }); - - const aboutDoc = manifest.documents[1]; + + const aboutDoc = manifest.documents.find((d: any) => d.url.includes('/about')); expect(aboutDoc).toMatchObject({ - id: expect.stringMatching(/^[a-f0-9]{8}$/), url: 'https://example.com/about', title: 'About Us', description: 'Learn about our company', - keywords: [] - }); - - const productsDoc = manifest.documents[2]; - expect(productsDoc).toMatchObject({ - id: expect.stringMatching(/^[a-f0-9]{8}$/), - url: 'https://example.com/products', - title: 'Products', - description: '', - keywords: [] }); }); - it('should generate unique IDs for each document', async () => { - const mockWriteFile = vi.mocked(fs.writeFile); - - await generateManifest('/test/project'); - - const manifest = JSON.parse(mockWriteFile.mock.calls[0][1] as string); - const ids = manifest.documents.map((doc: any) => doc.id); - const uniqueIds = new Set(ids); - - expect(uniqueIds.size).toBe(ids.length); - }); + it('should handle empty pages array', () => { + const config: ResolvedAeoConfig = { ...baseConfig, pages: [] }; + const result = generateManifest(config); + const manifest = JSON.parse(result); - it('should handle empty routes array', async () => { - const { resolveConfig } = await import('./utils'); - vi.mocked(resolveConfig).mockResolvedValueOnce({ - routes: [], - baseUrl: 'https://example.com', - name: 'Empty Site', - description: 'A site with no pages' - }); - - const mockWriteFile = vi.mocked(fs.writeFile); - - await generateManifest('/test/project'); - - const manifest = JSON.parse(mockWriteFile.mock.calls[0][1] as string); expect(manifest.documents).toEqual([]); + expect(manifest.metadata.totalDocuments).toBe(0); }); - it('should use defaults for missing config fields', async () => { - const { resolveConfig } = await import('./utils'); - vi.mocked(resolveConfig).mockResolvedValueOnce({ - routes: [{ path: '/', title: 'Home' }], - baseUrl: 'https://example.com' + it('should include metadata', () => { + const result = generateManifest(baseConfig); + const manifest = JSON.parse(result); + + expect(manifest.metadata).toMatchObject({ + totalDocuments: 4, + generator: 'aeo.js', + generatorUrl: 'https://aeojs.org', }); - - const mockWriteFile = vi.mocked(fs.writeFile); - - await generateManifest('/test/project'); - - const manifest = JSON.parse(mockWriteFile.mock.calls[0][1] as string); - expect(manifest.name).toBe(''); - expect(manifest.description).toBe(''); }); - it('should include timestamp in generated field', async () => { - const mockWriteFile = vi.mocked(fs.writeFile); + it('should include timestamp in generated field', () => { const beforeTime = new Date().toISOString(); - - await generateManifest('/test/project'); - - const manifest = JSON.parse(mockWriteFile.mock.calls[0][1] as string); + const result = generateManifest(baseConfig); + const manifest = JSON.parse(result); const afterTime = new Date().toISOString(); - + expect(manifest.generated).toBeDefined(); - expect(new Date(manifest.generated).toISOString()).toBeGreaterThanOrEqual(beforeTime); - expect(new Date(manifest.generated).toISOString()).toBeLessThanOrEqual(afterTime); + expect(manifest.generated >= beforeTime).toBe(true); + expect(manifest.generated <= afterTime).toBe(true); + }); + + it('should sort documents by URL', () => { + const result = generateManifest(baseConfig); + const manifest = JSON.parse(result); + const urls = manifest.documents.map((d: any) => d.url); + + const sorted = [...urls].sort(); + expect(urls).toEqual(sorted); }); -}); \ No newline at end of file +}); diff --git a/src/core/raw-markdown.test.ts b/src/core/raw-markdown.test.ts index 4e38de9..4519165 100644 --- a/src/core/raw-markdown.test.ts +++ b/src/core/raw-markdown.test.ts @@ -1,194 +1,185 @@ import { describe, it, expect, beforeEach, vi } from 'vitest'; -import { copyRawMarkdown } from './raw-markdown'; -import { readdirSync, statSync, copyFileSync, mkdirSync } from 'fs'; -import * as path from 'path'; +import { copyRawMarkdown, generatePageMarkdownFiles } from './raw-markdown'; +import { readdirSync, statSync, copyFileSync, mkdirSync, writeFileSync } from 'fs'; import type { ResolvedAeoConfig } from '../types'; -vi.mock('fs'); +vi.mock('fs', () => ({ + readdirSync: vi.fn(), + statSync: vi.fn(), + copyFileSync: vi.fn(), + mkdirSync: vi.fn(), + writeFileSync: vi.fn(), + existsSync: vi.fn().mockReturnValue(true), + readFileSync: vi.fn(), +})); + vi.mock('path', async () => { const actual = await vi.importActual('path'); return { ...actual, - join: vi.fn((...args) => args.filter(Boolean).join('/')), - relative: vi.fn((from, to) => to.replace(from + '/', '')), - extname: vi.fn((file) => { + join: vi.fn((...args: string[]) => args.filter(Boolean).join('/')), + relative: vi.fn((from: string, to: string) => to.replace(from + '/', '')), + extname: vi.fn((file: string) => { const match = file.match(/\.[^.]+$/); return match ? match[0] : ''; }), - dirname: vi.fn((file) => { + dirname: vi.fn((file: string) => { const parts = file.split('/'); parts.pop(); return parts.join('/'); - }) + }), }; }); +const createConfig = (overrides = {}): ResolvedAeoConfig => ({ + url: 'https://example.com', + title: 'Test Site', + description: 'Test description', + contentDir: 'content', + outDir: 'public/aeo', + pages: [], + generators: { + robotsTxt: true, + llmsTxt: true, + llmsFullTxt: true, + rawMarkdown: true, + manifest: true, + sitemap: true, + aiIndex: true, + }, + robots: { allow: ['/'], disallow: [], crawlDelay: 0, sitemap: '' }, + widget: { + enabled: true, + position: 'bottom-right', + theme: { background: '#000', text: '#fff', accent: '#eee', badge: '#4ADE80' }, + humanLabel: 'Human', + aiLabel: 'AI', + showBadge: true, + }, + ...overrides, +}); + describe('copyRawMarkdown', () => { const mockReaddirSync = vi.mocked(readdirSync); const mockStatSync = vi.mocked(statSync); const mockCopyFileSync = vi.mocked(copyFileSync); - const mockMkdirSync = vi.mocked(mkdirSync); beforeEach(() => { vi.clearAllMocks(); }); - const createConfig = (overrides = {}): ResolvedAeoConfig => ({ - url: 'https://example.com', - title: 'Test Site', - description: 'Test description', - contentDir: 'content', - outDir: 'public/aeo', - generators: { - robotsTxt: true, - llmsTxt: true, - llmsFullTxt: true, - rawMarkdown: true, - manifest: true, - sitemap: true, - aiIndex: true, - }, - ...overrides - }); + it('should copy markdown files from source to output directory', () => { + mockReaddirSync.mockReturnValue(['page1.md', 'page2.md', 'image.png'] as any); + mockStatSync.mockReturnValue({ isFile: () => true, isDirectory: () => false } as any); - it('should copy markdown files from source to public directory', () => { - mockReaddirSync.mockReturnValue(['page1.md', 'page2.md', 'image.png', 'subfolder'] as any); - - mockStatSync.mockImplementation((path) => { - const pathStr = path.toString(); - if (pathStr.endsWith('subfolder')) { - return { isFile: () => false, isDirectory: () => true } as any; - } - return { isFile: () => true, isDirectory: () => false } as any; - }); + const result = copyRawMarkdown(createConfig()); - const config = createConfig(); - const result = copyRawMarkdown(config); - expect(mockCopyFileSync).toHaveBeenCalledTimes(2); - expect(mockCopyFileSync).toHaveBeenCalledWith( - 'content/page1.md', - 'public/aeo/page1.md' - ); - expect(mockCopyFileSync).toHaveBeenCalledWith( - 'content/page2.md', - 'public/aeo/page2.md' - ); expect(result).toHaveLength(2); }); - it('should recursively copy markdown files from subdirectories', () => { + it('should skip non-markdown files', () => { + mockReaddirSync.mockReturnValue(['doc.md', 'script.js', 'style.css'] as any); + mockStatSync.mockReturnValue({ isFile: () => true, isDirectory: () => false } as any); + + const result = copyRawMarkdown(createConfig()); + + expect(mockCopyFileSync).toHaveBeenCalledTimes(1); + expect(result).toHaveLength(1); + }); + + it('should handle missing directory gracefully', () => { + mockReaddirSync.mockImplementation(() => { throw new Error('ENOENT'); }); + + const result = copyRawMarkdown(createConfig()); + + expect(result).toEqual([]); + expect(mockCopyFileSync).not.toHaveBeenCalled(); + }); + + it('should recursively copy from subdirectories', () => { mockReaddirSync.mockImplementation((dirPath) => { const pathStr = dirPath.toString(); - if (pathStr === 'content') { - return ['docs', 'root.md'] as any; - } else if (pathStr.endsWith('docs')) { - return ['guide.md', 'api'] as any; - } else if (pathStr.endsWith('api')) { - return ['reference.md'] as any; - } + if (pathStr === 'content') return ['docs', 'root.md'] as any; + if (pathStr.endsWith('docs')) return ['guide.md'] as any; return []; }); - mockStatSync.mockImplementation((path) => { const pathStr = path.toString(); if (pathStr.includes('docs') && !pathStr.includes('.md')) { return { isFile: () => false, isDirectory: () => true } as any; } - if (pathStr.includes('api') && !pathStr.includes('.md')) { - return { isFile: () => false, isDirectory: () => true } as any; - } return { isFile: () => true, isDirectory: () => false } as any; }); - const config = createConfig(); - const result = copyRawMarkdown(config); - - expect(mockCopyFileSync).toHaveBeenCalledWith( - 'content/root.md', - 'public/aeo/root.md' - ); - expect(mockCopyFileSync).toHaveBeenCalledWith( - 'content/docs/guide.md', - 'public/aeo/docs/guide.md' - ); - expect(mockCopyFileSync).toHaveBeenCalledWith( - 'content/docs/api/reference.md', - 'public/aeo/docs/api/reference.md' - ); - - expect(mockMkdirSync).toHaveBeenCalledWith('public/aeo', expect.any(Object)); - expect(mockMkdirSync).toHaveBeenCalledWith('public/aeo/docs', expect.any(Object)); - expect(mockMkdirSync).toHaveBeenCalledWith('public/aeo/docs/api', expect.any(Object)); - expect(result).toHaveLength(3); + const result = copyRawMarkdown(createConfig()); + + expect(mockCopyFileSync).toHaveBeenCalledTimes(2); + expect(result).toHaveLength(2); }); - it('should skip non-markdown files', () => { - mockReaddirSync.mockReturnValue(['document.md', 'script.js', 'style.css', 'data.json'] as any); - - mockStatSync.mockReturnValue({ - isFile: () => true, - isDirectory: () => false - } as any); - - const config = createConfig(); - const result = copyRawMarkdown(config); - - expect(mockCopyFileSync).toHaveBeenCalledTimes(1); - expect(mockCopyFileSync).toHaveBeenCalledWith( - 'content/document.md', - 'public/aeo/document.md' - ); + it('should handle copy errors for individual files', () => { + mockReaddirSync.mockReturnValue(['file1.md', 'file2.md'] as any); + mockStatSync.mockReturnValue({ isFile: () => true, isDirectory: () => false } as any); + mockCopyFileSync + .mockImplementationOnce(() => { throw new Error('Permission denied'); }) + .mockImplementationOnce(() => undefined); + + const result = copyRawMarkdown(createConfig()); + + expect(mockCopyFileSync).toHaveBeenCalledTimes(2); expect(result).toHaveLength(1); }); +}); + +describe('generatePageMarkdownFiles', () => { + beforeEach(() => { + vi.clearAllMocks(); + }); - it('should handle missing markdown directory gracefully', () => { - mockReaddirSync.mockImplementation(() => { - throw new Error('ENOENT: Directory not found'); + it('should skip pages without content', () => { + const config = createConfig({ + pages: [ + { pathname: '/', title: 'Home' }, + { pathname: '/about', title: 'About' }, + ], }); - const config = createConfig(); - const result = copyRawMarkdown(config); - - expect(result).toEqual([]); - expect(mockCopyFileSync).not.toHaveBeenCalled(); + const result = generatePageMarkdownFiles(config); + + expect(result).toHaveLength(0); + expect(writeFileSync).not.toHaveBeenCalled(); }); - it('should use custom markdown directory from config', () => { - mockReaddirSync.mockReturnValue(['test.md'] as any); - - mockStatSync.mockReturnValue({ - isFile: () => true, - isDirectory: () => false - } as any); - - const config = createConfig({ contentDir: 'custom-docs' }); - const result = copyRawMarkdown(config); - - expect(mockReaddirSync).toHaveBeenCalledWith('custom-docs'); - expect(mockCopyFileSync).toHaveBeenCalledWith( - 'custom-docs/test.md', - 'public/aeo/test.md' - ); - expect(result).toHaveLength(1); + it('should generate .md files for pages with content', () => { + const config = createConfig({ + pages: [ + { pathname: '/', title: 'Home', content: 'Welcome to our site' }, + { pathname: '/about', title: 'About', description: 'About us', content: 'We are great' }, + ], + }); + + const result = generatePageMarkdownFiles(config); + + expect(result).toHaveLength(2); + expect(writeFileSync).toHaveBeenCalledTimes(2); }); - it('should handle copy errors for individual files', () => { - mockReaddirSync.mockReturnValue(['file1.md', 'file2.md'] as any); - - mockStatSync.mockReturnValue({ - isFile: () => true, - isDirectory: () => false - } as any); - - mockCopyFileSync - .mockImplementationOnce(() => { throw new Error('Permission denied'); }) - .mockImplementationOnce(() => undefined); + it('should include frontmatter in generated files', () => { + const config = createConfig({ + pages: [ + { pathname: '/about', title: 'About', description: 'About us', content: 'Content here' }, + ], + }); - const config = createConfig(); - const result = copyRawMarkdown(config); - - expect(mockCopyFileSync).toHaveBeenCalledTimes(2); - expect(result).toHaveLength(1); + generatePageMarkdownFiles(config); + + const writtenContent = vi.mocked(writeFileSync).mock.calls[0][1] as string; + expect(writtenContent).toContain('---'); + expect(writtenContent).toContain('title: "About"'); + expect(writtenContent).toContain('description: "About us"'); + expect(writtenContent).toContain('generated_by: aeo.js'); + expect(writtenContent).toContain('# About'); + expect(writtenContent).toContain('Content here'); }); -}); \ No newline at end of file +}); diff --git a/src/core/raw-markdown.ts b/src/core/raw-markdown.ts index 59e1f9d..9d2524a 100644 --- a/src/core/raw-markdown.ts +++ b/src/core/raw-markdown.ts @@ -64,9 +64,12 @@ export function generatePageMarkdownFiles(config: ResolvedAeoConfig): GeneratedM const pages = config.pages || []; for (const page of pages) { - // Use site title as fallback for pages without a title + // Only generate .md files for pages that have actual content. + // Pages discovered from filenames (dev mode) only have pathname/title + // but no body content — those are useless as standalone markdown files. + if (!page.content) continue; + const pageTitle = page.title || (page.pathname === '/' ? config.title : undefined); - if (!page.content && !pageTitle) continue; let filename: string; if (page.pathname === '/') { diff --git a/src/core/robots.test.ts b/src/core/robots.test.ts index 9c67690..ac182b5 100644 --- a/src/core/robots.test.ts +++ b/src/core/robots.test.ts @@ -9,6 +9,7 @@ describe('generateRobotsTxt', () => { description: 'Test description', contentDir: 'content', outDir: 'public', + pages: [], generators: { robotsTxt: true, llmsTxt: true, @@ -17,7 +18,16 @@ describe('generateRobotsTxt', () => { manifest: true, sitemap: true, aiIndex: true, - } + }, + robots: { allow: ['/'], disallow: [], crawlDelay: 0, sitemap: '' }, + widget: { + enabled: true, + position: 'bottom-right', + theme: { background: '#000', text: '#fff', accent: '#eee', badge: '#4ADE80' }, + humanLabel: 'Human', + aiLabel: 'AI', + showBadge: true, + }, } it('should generate robots.txt with AI crawler rules', () => { diff --git a/src/core/sitemap.test.ts b/src/core/sitemap.test.ts index 713bf31..565a8ec 100644 --- a/src/core/sitemap.test.ts +++ b/src/core/sitemap.test.ts @@ -38,6 +38,7 @@ describe('generateSitemap', () => { description: 'Test description', contentDir: '/test/content', outDir: 'public', + pages: [], generators: { robotsTxt: true, llmsTxt: true, @@ -46,7 +47,16 @@ describe('generateSitemap', () => { manifest: true, sitemap: true, aiIndex: true, - } + }, + robots: { allow: ['/'], disallow: [], crawlDelay: 0, sitemap: '' }, + widget: { + enabled: true, + position: 'bottom-right', + theme: { background: '#000', text: '#fff', accent: '#eee', badge: '#4ADE80' }, + humanLabel: 'Human', + aiLabel: 'AI', + showBadge: true, + }, }; it('should generate sitemap.xml with all routes', () => { diff --git a/src/core/utils.test.ts b/src/core/utils.test.ts index b3439a5..62c1a48 100644 --- a/src/core/utils.test.ts +++ b/src/core/utils.test.ts @@ -1,176 +1,125 @@ -import { describe, it, expect, vi, beforeEach } from 'vitest' -import { - resolveConfig, - getAllMarkdownFiles, - getProjectStructure, - generateHash, - ensureDirectoryExists -} from './utils' -import fs from 'fs' -import path from 'path' -import crypto from 'crypto' - -vi.mock('fs') -vi.mock('path') -vi.mock('crypto') +import { describe, it, expect, vi } from 'vitest'; +import { + resolveConfig, + parseFrontmatter, + bumpHeadings, + extractTitle, +} from './utils'; + +vi.mock('./detect', () => ({ + detectFramework: vi.fn().mockReturnValue({ + framework: 'unknown', + contentDir: 'src', + outDir: 'dist', + }), +})); describe('utils', () => { - const mockFs = fs as any - const mockPath = path as any - const mockCrypto = crypto as any - - beforeEach(() => { - vi.clearAllMocks() - mockPath.join.mockImplementation((...args: string[]) => args.join('/')) - mockPath.resolve.mockImplementation((p: string) => `/absolute${p}`) - mockPath.relative.mockImplementation((from: string, to: string) => to.replace(from, '')) - mockPath.dirname.mockImplementation((p: string) => { - const parts = p.split('/') - parts.pop() - return parts.join('/') - }) - }) - describe('resolveConfig', () => { - it('should merge default config with user config', () => { - const userConfig = { - output: 'custom-output', - baseUrl: 'https://custom.com', - include: ['*.mdx'] - } - - const result = resolveConfig(userConfig) - - expect(result.output).toBe('custom-output') - expect(result.baseUrl).toBe('https://custom.com') - expect(result.include).toContain('*.mdx') - expect(result.include).toContain('**/*.md') - expect(result.exclude).toContain('**/node_modules/**') - }) - - it('should use default config when no user config provided', () => { - const result = resolveConfig() - - expect(result.output).toBe('public/aeo') - expect(result.baseUrl).toBe('') - expect(result.include).toEqual(['**/*.md', '**/*.mdx']) - expect(result.exclude).toContain('**/node_modules/**') - }) - - it('should handle partial user config', () => { - const userConfig = { - baseUrl: 'https://example.com' - } - - const result = resolveConfig(userConfig) - - expect(result.output).toBe('public/aeo') - expect(result.baseUrl).toBe('https://example.com') - }) - }) - - describe('getAllMarkdownFiles', () => { - it('should find all markdown files matching patterns', () => { - mockFs.readdirSync.mockReturnValue(['file1.md', 'file2.mdx', 'file3.txt']) - mockFs.statSync.mockImplementation((path: string) => ({ - isDirectory: () => path.includes('subdir'), - isFile: () => !path.includes('subdir') - })) - mockFs.existsSync.mockReturnValue(true) - - const files = getAllMarkdownFiles('/root', ['*.md', '*.mdx'], []) - - expect(files.length).toBeGreaterThan(0) - }) - - it('should exclude files matching exclude patterns', () => { - mockFs.readdirSync.mockReturnValue(['file1.md', 'node_modules', 'file2.md']) - mockFs.statSync.mockImplementation((path: string) => ({ - isDirectory: () => path.includes('node_modules'), - isFile: () => !path.includes('node_modules') - })) - - const files = getAllMarkdownFiles('/root', ['**/*.md'], ['**/node_modules/**']) - - expect(files.every(f => !f.includes('node_modules'))).toBe(true) - }) - }) - - describe('getProjectStructure', () => { - it('should generate project structure tree', () => { - mockFs.readdirSync.mockImplementation((dir: string) => { - if (dir === '/root') return ['src', 'package.json'] - if (dir.includes('src')) return ['index.js', 'utils.js'] - return [] - }) - mockFs.statSync.mockImplementation((path: string) => ({ - isDirectory: () => path.includes('src') && !path.includes('.'), - isFile: () => path.includes('.') - })) - - const structure = getProjectStructure('/root') - - expect(structure).toContain('src/') - expect(structure).toContain('package.json') - }) - - it('should respect max depth limit', () => { - mockFs.readdirSync.mockReturnValue(['deep']) - mockFs.statSync.mockReturnValue({ isDirectory: () => true, isFile: () => false }) - - const structure = getProjectStructure('/root', 2) - const lines = structure.split('\n') - - expect(lines.length).toBeLessThanOrEqual(10) - }) - }) - - describe('generateHash', () => { - it('should generate consistent hash for same content', () => { - const mockHash = { - update: vi.fn().mockReturnThis(), - digest: vi.fn().mockReturnValue('abc123') - } - mockCrypto.createHash.mockReturnValue(mockHash) - - const hash1 = generateHash('test content') - const hash2 = generateHash('test content') - - expect(hash1).toBe(hash2) - expect(hash1).toBe('abc123') - }) - - it('should generate different hash for different content', () => { - let callCount = 0 - const mockHash = { - update: vi.fn().mockReturnThis(), - digest: vi.fn(() => `hash${++callCount}`) - } - mockCrypto.createHash.mockReturnValue(mockHash) - - const hash1 = generateHash('content1') - const hash2 = generateHash('content2') - - expect(hash1).not.toBe(hash2) - }) - }) - - describe('ensureDirectoryExists', () => { - it('should create directory if it does not exist', () => { - mockFs.existsSync.mockReturnValue(false) - mockFs.mkdirSync.mockReturnValue(undefined) - - ensureDirectoryExists('/new/path') - - expect(mockFs.mkdirSync).toHaveBeenCalledWith('/new/path', { recursive: true }) - }) - - it('should not create directory if it already exists', () => { - mockFs.existsSync.mockReturnValue(true) - - ensureDirectoryExists('/existing/path') - - expect(mockFs.mkdirSync).not.toHaveBeenCalled() - }) - }) -}) \ No newline at end of file + it('should return default config when no user config provided', () => { + const result = resolveConfig(); + + expect(result.title).toBe('My Site'); + expect(result.description).toBe(''); + expect(result.url).toBe('https://example.com'); + expect(result.generators.robotsTxt).toBe(true); + expect(result.generators.llmsTxt).toBe(true); + expect(result.widget.enabled).toBe(true); + expect(result.widget.position).toBe('bottom-right'); + }); + + it('should merge user config with defaults', () => { + const result = resolveConfig({ + title: 'Custom Title', + url: 'https://custom.com', + generators: { sitemap: false }, + }); + + expect(result.title).toBe('Custom Title'); + expect(result.url).toBe('https://custom.com'); + expect(result.generators.sitemap).toBe(false); + expect(result.generators.robotsTxt).toBe(true); + }); + + it('should handle partial widget config', () => { + const result = resolveConfig({ + widget: { + position: 'top-left', + theme: { accent: '#FF0000' }, + }, + }); + + expect(result.widget.position).toBe('top-left'); + expect(result.widget.theme.accent).toBe('#FF0000'); + expect(result.widget.theme.background).toBe('rgba(18, 18, 24, 0.9)'); + }); + + it('should resolve robots config', () => { + const result = resolveConfig({ + robots: { disallow: ['/admin'], crawlDelay: 5 }, + }); + + expect(result.robots.disallow).toEqual(['/admin']); + expect(result.robots.crawlDelay).toBe(5); + expect(result.robots.allow).toEqual(['/']); + }); + }); + + describe('parseFrontmatter', () => { + it('should extract frontmatter from markdown', () => { + const input = '---\ntitle: My Title\ndescription: My Desc\n---\n# Content'; + const result = parseFrontmatter(input); + + expect(result.frontmatter.title).toBe('My Title'); + expect(result.frontmatter.description).toBe('My Desc'); + expect(result.content).toContain('# Content'); + }); + + it('should return empty frontmatter when none exists', () => { + const input = '# Just Content\nNo frontmatter here'; + const result = parseFrontmatter(input); + + expect(result.frontmatter).toEqual({}); + expect(result.content).toBe(input); + }); + + it('should handle quoted values', () => { + const input = '---\ntitle: "Quoted Title"\n---\nContent'; + const result = parseFrontmatter(input); + + expect(result.frontmatter.title).toBe('Quoted Title'); + }); + }); + + describe('bumpHeadings', () => { + it('should increase heading levels by specified amount', () => { + const input = '# H1\n## H2\n### H3'; + const result = bumpHeadings(input, 1); + + expect(result).toContain('## H1'); + expect(result).toContain('### H2'); + expect(result).toContain('#### H3'); + }); + + it('should cap at h6', () => { + const input = '###### H6'; + const result = bumpHeadings(input, 1); + + expect(result).toContain('###### H6'); + }); + }); + + describe('extractTitle', () => { + it('should extract h1 title', () => { + expect(extractTitle('# My Title\nContent')).toBe('My Title'); + }); + + it('should fall back to h2', () => { + expect(extractTitle('## Sub Title\nContent')).toBe('Sub Title'); + }); + + it('should fall back to first line', () => { + expect(extractTitle('Some text\nMore text')).toBe('Some text'); + }); + }); +}); diff --git a/src/core/utils.ts b/src/core/utils.ts index 2f014dc..e339894 100644 --- a/src/core/utils.ts +++ b/src/core/utils.ts @@ -4,9 +4,31 @@ import type { AeoConfig, ResolvedAeoConfig, MarkdownFile } from '../types'; import { detectFramework } from './detect'; import { minimatch } from 'minimatch'; +export function validateConfig(config: AeoConfig): string[] { + const warnings: string[] = []; + + if (config.url && !/^https?:\/\//.test(config.url)) { + warnings.push(`url "${config.url}" should start with http:// or https://`); + } + + if (config.url === 'https://example.com') { + warnings.push('url is set to the default "https://example.com" — set your actual site URL'); + } + + if (!config.title) { + warnings.push('title is not set — your generated files will use "My Site"'); + } + + if (config.robots?.crawlDelay && config.robots.crawlDelay < 0) { + warnings.push('robots.crawlDelay should be a positive number'); + } + + return warnings; +} + export function resolveConfig(config: AeoConfig = {}): ResolvedAeoConfig { const frameworkInfo = detectFramework(); - + return { title: config.title || 'My Site', description: config.description || '', diff --git a/src/index.ts b/src/index.ts index edd5215..6f30b58 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,6 +1,6 @@ import type { AeoConfig } from './types'; -export const VERSION = '0.0.1'; +export const VERSION = '0.0.2'; export function defineConfig(config: AeoConfig): AeoConfig { return config; @@ -23,4 +23,4 @@ export type { // Export core functions export { detectFramework } from './core/detect'; export { generateAEOFiles as generateAll, generateAEOFiles } from './core/generate'; -export { resolveConfig } from './core/utils'; \ No newline at end of file +export { resolveConfig, validateConfig } from './core/utils'; \ No newline at end of file diff --git a/src/plugins/astro.ts b/src/plugins/astro.ts index b25e8ae..35bee0f 100644 --- a/src/plugins/astro.ts +++ b/src/plugins/astro.ts @@ -206,14 +206,33 @@ export function aeoAstroIntegration(options: AeoConfig = {}): any { } if (resolvedConfig.widget.enabled && injectScript) { - const widgetOpts = JSON.stringify(resolvedConfig.widget); + const widgetConfig = JSON.stringify({ + title: resolvedConfig.title, + description: resolvedConfig.description, + url: resolvedConfig.url, + widget: resolvedConfig.widget, + }); injectScript( 'page', `import { AeoWidget } from 'aeo.js/widget'; -if (document.readyState === 'loading') { - document.addEventListener('DOMContentLoaded', () => { new AeoWidget(${widgetOpts}); }); -} else { - new AeoWidget(${widgetOpts}); +let __aeoWidget; +function __initAeoWidget() { + if (__aeoWidget) __aeoWidget.destroy(); + try { + __aeoWidget = new AeoWidget({ config: ${widgetConfig} }); + } catch (e) { + console.warn('[aeo.js] Widget initialization failed:', e); + } +} +// astro:page-load fires on initial load AND after every View Transition navigation +document.addEventListener('astro:page-load', __initAeoWidget); +// Fallback for Astro sites without View Transitions +if (!document.querySelector('meta[name="astro-view-transitions-enabled"]')) { + if (document.readyState === 'loading') { + document.addEventListener('DOMContentLoaded', __initAeoWidget); + } else { + __initAeoWidget(); + } }` ); } diff --git a/src/widget/core.test.ts b/src/widget/core.test.ts index 7177e7b..cb21c6d 100644 --- a/src/widget/core.test.ts +++ b/src/widget/core.test.ts @@ -1,248 +1,75 @@ -import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest' -import { AEOWidget } from './core' +/** + * @vitest-environment jsdom + */ +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; +import { AeoWidget } from './core'; -describe('AEOWidget', () => { - let widget: AEOWidget - let container: HTMLDivElement +describe('AeoWidget', () => { + let widget: AeoWidget | null = null; beforeEach(() => { - document.body.innerHTML = '' - container = document.createElement('div') - container.id = 'test-container' - document.body.appendChild(container) - - global.fetch = vi.fn() - }) + document.body.innerHTML = ''; + global.fetch = vi.fn().mockResolvedValue({ + ok: false, + text: () => Promise.resolve(''), + }); + }); afterEach(() => { if (widget) { - widget.destroy() + widget.destroy(); + widget = null; } - document.body.innerHTML = '' - vi.clearAllMocks() - }) + document.body.innerHTML = ''; + vi.clearAllMocks(); + }); describe('initialization', () => { - it('should create widget with default options', () => { - widget = new AEOWidget() - - expect(widget).toBeDefined() - expect(document.querySelector('.aeo-widget')).toBeDefined() - }) + it('should create widget and inject into DOM', () => { + widget = new AeoWidget({ + config: { + title: 'Test', + url: 'https://test.com', + widget: { enabled: true, position: 'bottom-right' }, + }, + }); + + expect(widget).toBeDefined(); + const toggle = document.querySelector('.aeo-toggle'); + expect(toggle).not.toBeNull(); + }); - it('should apply custom theme', () => { - widget = new AEOWidget({ - theme: 'light' - }) - - const widgetEl = document.querySelector('.aeo-widget') - expect(widgetEl?.getAttribute('data-theme')).toBe('light') - }) - - it('should position widget correctly', () => { - widget = new AEOWidget({ - position: 'top-right' - }) - - const widgetEl = document.querySelector('.aeo-widget') as HTMLElement - expect(widgetEl?.style.top).toBeDefined() - expect(widgetEl?.style.right).toBeDefined() - }) - - it('should start expanded if specified', () => { - widget = new AEOWidget({ - startExpanded: true - }) - - const content = document.querySelector('.aeo-widget-content') as HTMLElement - expect(content?.style.display).not.toBe('none') - }) - }) - - describe('data loading', () => { - it('should fetch llms.txt by default', async () => { - const mockResponse = { - ok: true, - text: () => Promise.resolve('# Test Content') - } - - ;(global.fetch as any).mockResolvedValue(mockResponse) - - widget = new AEOWidget() - await new Promise(resolve => setTimeout(resolve, 100)) - - expect(global.fetch).toHaveBeenCalledWith('/aeo/llms.txt') - }) - - it('should fetch from custom endpoint', async () => { - const mockResponse = { - ok: true, - text: () => Promise.resolve('Custom content') - } - - ;(global.fetch as any).mockResolvedValue(mockResponse) - - widget = new AEOWidget({ - endpoint: 'https://api.example.com/aeo' - }) - await new Promise(resolve => setTimeout(resolve, 100)) - - expect(global.fetch).toHaveBeenCalledWith('https://api.example.com/aeo') - }) - - it('should handle fetch errors gracefully', async () => { - ;(global.fetch as any).mockRejectedValue(new Error('Network error')) - - widget = new AEOWidget() - await new Promise(resolve => setTimeout(resolve, 100)) - - const content = document.querySelector('.aeo-widget-content') - expect(content?.textContent).toContain('Error') - }) - - it('should use fallback extractor when fetch fails', async () => { - ;(global.fetch as any).mockRejectedValue(new Error('Network error')) - - document.body.innerHTML = ` -

Page Title

-

Some content

- ` - - widget = new AEOWidget({ - useFallback: true - }) - await new Promise(resolve => setTimeout(resolve, 100)) - - const content = document.querySelector('.aeo-widget-content') - expect(content?.textContent).toContain('Page Title') - }) - }) - - describe('user interactions', () => { - it('should toggle expansion on button click', () => { - widget = new AEOWidget() - - const button = document.querySelector('.aeo-widget-toggle') as HTMLButtonElement - const content = document.querySelector('.aeo-widget-content') as HTMLElement - - expect(content.style.display).toBe('none') - - button.click() - expect(content.style.display).toBe('block') - - button.click() - expect(content.style.display).toBe('none') - }) - - it('should copy content to clipboard', async () => { - const mockClipboard = { - writeText: vi.fn().mockResolvedValue(undefined) - } - Object.assign(navigator, { - clipboard: mockClipboard - }) - - widget = new AEOWidget() - widget['content'] = 'Test content to copy' - - const copyButton = document.querySelector('.aeo-widget-copy') as HTMLButtonElement - copyButton.click() - - expect(mockClipboard.writeText).toHaveBeenCalledWith('Test content to copy') - }) - - it('should call onToggle callback', () => { - const onToggle = vi.fn() - widget = new AEOWidget({ - onToggle - }) - - const button = document.querySelector('.aeo-widget-toggle') as HTMLButtonElement - button.click() - - expect(onToggle).toHaveBeenCalledWith(true) - - button.click() - expect(onToggle).toHaveBeenCalledWith(false) - }) - }) - - describe('styling', () => { it('should inject styles into head', () => { - widget = new AEOWidget() - - const styles = document.querySelector('style[data-aeo-widget]') - expect(styles).toBeDefined() - expect(styles?.textContent).toContain('.aeo-widget') - }) - - it('should apply custom colors', () => { - widget = new AEOWidget({ - primaryColor: '#FF0000', - backgroundColor: '#00FF00' - }) - - const widgetEl = document.querySelector('.aeo-widget') as HTMLElement - expect(widgetEl?.style.getPropertyValue('--aeo-primary')).toBe('#FF0000') - expect(widgetEl?.style.getPropertyValue('--aeo-background')).toBe('#00FF00') - }) - }) + widget = new AeoWidget({ + config: { + title: 'Test', + url: 'https://test.com', + widget: { enabled: true }, + }, + }); + + const style = document.querySelector('style'); + expect(style).not.toBeNull(); + expect(style?.textContent).toContain('.aeo-toggle'); + }); + }); describe('destroy', () => { it('should remove widget from DOM', () => { - widget = new AEOWidget() - - expect(document.querySelector('.aeo-widget')).toBeDefined() - - widget.destroy() - - expect(document.querySelector('.aeo-widget')).toBeNull() - }) - - it('should remove injected styles', () => { - widget = new AEOWidget() - - expect(document.querySelector('style[data-aeo-widget]')).toBeDefined() - - widget.destroy() - - expect(document.querySelector('style[data-aeo-widget]')).toBeNull() - }) - }) - - describe('update', () => { - it('should update widget options', () => { - widget = new AEOWidget({ - theme: 'dark' - }) - - widget.update({ - theme: 'light' - }) - - const widgetEl = document.querySelector('.aeo-widget') - expect(widgetEl?.getAttribute('data-theme')).toBe('light') - }) - - it('should refetch data on endpoint change', async () => { - const mockResponse = { - ok: true, - text: () => Promise.resolve('New content') - } - - ;(global.fetch as any).mockResolvedValue(mockResponse) - - widget = new AEOWidget({ - endpoint: '/old-endpoint' - }) - - widget.update({ - endpoint: '/new-endpoint' - }) - - await new Promise(resolve => setTimeout(resolve, 100)) - - expect(global.fetch).toHaveBeenCalledWith('/new-endpoint') - }) - }) -}) \ No newline at end of file + widget = new AeoWidget({ + config: { + title: 'Test', + url: 'https://test.com', + widget: { enabled: true }, + }, + }); + + expect(document.querySelector('.aeo-toggle')).not.toBeNull(); + + widget.destroy(); + widget = null; + + expect(document.querySelector('.aeo-toggle')).toBeNull(); + }); + }); +}); diff --git a/tsup.config.ts b/tsup.config.ts index 944c7ea..e3e4a2d 100644 --- a/tsup.config.ts +++ b/tsup.config.ts @@ -3,11 +3,15 @@ import { defineConfig } from 'tsup' export default defineConfig({ entry: { index: 'src/index.ts', + cli: 'src/cli.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: ['esm', 'cjs'], dts: true, @@ -16,9 +20,14 @@ export default defineConfig({ clean: true, external: [ 'react', + 'vue', + 'svelte', + 'vite', 'webpack', 'next', '@astrojs/astro', + '@nuxt/kit', + '@sveltejs/kit', ], treeshake: true, minify: process.env.NODE_ENV === 'production', From a58227a16b1fef7b84355d5081ecb58858e6d798 Mon Sep 17 00:00:00 2001 From: ruben-cytonic Date: Sat, 21 Feb 2026 10:22:05 +0000 Subject: [PATCH 2/2] feat: add Angular plugin with route scanning and widget injection - Add aeo.js/angular export with postBuild() and generate() functions - Reads angular.json to auto-detect output directory (v17+ browser/ path) - Scans *.routes.ts files and component directories for route discovery - Scans pre-rendered HTML from build output for full page content - Auto-injects widget into index.html during postBuild - Add 8 tests covering postBuild, generate, widget injection, route scanning - Update README with Angular quick start and framework table Co-Authored-By: Claude Opus 4.6 --- README.md | 29 ++- package.json | 5 + src/plugins/angular.test.ts | 268 +++++++++++++++++++++++++++ src/plugins/angular.ts | 356 ++++++++++++++++++++++++++++++++++++ tsup.config.ts | 1 + 5 files changed, 658 insertions(+), 1 deletion(-) create mode 100644 src/plugins/angular.test.ts create mode 100644 src/plugins/angular.ts diff --git a/README.md b/README.md index 3c551f4..6a1b73a 100644 --- a/README.md +++ b/README.md @@ -31,6 +31,7 @@ aeo.js auto-generates the files these engines look for and provides a drop-in wi | Next.js | Stable | `aeo.js/next` | | Vite / React | Stable | `aeo.js/vite` | | Nuxt | Stable | `aeo.js/nuxt` | +| Angular | Stable | `aeo.js/angular` | | Webpack | Stable | `aeo.js/webpack` | | Any (CLI) | Stable | `npx aeo.js generate` | @@ -137,6 +138,32 @@ The Nuxt module: - Injects the widget as a client-side Nuxt plugin - Adds `` and `` tags for AEO discoverability +### Angular + +Add a post-build step to your `package.json`: + +```json +{ + "scripts": { + "postbuild": "node -e \"import('aeo.js/angular').then(m => m.postBuild({ title: 'My App', url: 'https://myapp.com' }))\"" + } +} +``` + +The Angular plugin: +- Reads `angular.json` to auto-detect the output directory (`dist//browser/`) +- Scans route config files (`*.routes.ts`) and component directories for routes +- Scans pre-rendered HTML from the build output for full page content +- Injects the widget into `index.html` automatically + +You can also generate AEO files from source routes without building: + +```ts +import { generate } from 'aeo.js/angular'; + +await generate({ title: 'My App', url: 'https://myapp.com' }); +``` + ### Webpack ```js @@ -245,7 +272,7 @@ export default defineConfig({ ## Widget -The Human/AI widget is a floating toggle that lets visitors switch between the normal page and its AI-readable markdown version. Framework plugins (Astro, Vite, Nuxt) inject it automatically. For Next.js or manual setups: +The Human/AI widget is a floating toggle that lets visitors switch between the normal page and its AI-readable markdown version. Framework plugins (Astro, Vite, Nuxt, Angular) inject it automatically. For Next.js or manual setups: ```tsx // app/layout.tsx (or any client component) diff --git a/package.json b/package.json index c07497a..3c5f070 100644 --- a/package.json +++ b/package.json @@ -40,6 +40,11 @@ "import": "./dist/webpack.mjs", "require": "./dist/webpack.js" }, + "./angular": { + "types": "./dist/angular.d.ts", + "import": "./dist/angular.mjs", + "require": "./dist/angular.js" + }, "./react": { "types": "./dist/react.d.ts", "import": "./dist/react.mjs", diff --git a/src/plugins/angular.test.ts b/src/plugins/angular.test.ts new file mode 100644 index 0000000..2feccea --- /dev/null +++ b/src/plugins/angular.test.ts @@ -0,0 +1,268 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { postBuild, generate, getWidgetScript } from './angular'; +import { existsSync, readdirSync, readFileSync, statSync, writeFileSync, mkdirSync } from 'fs'; + +vi.mock('fs', () => ({ + existsSync: vi.fn(), + readdirSync: vi.fn().mockReturnValue([]), + readFileSync: vi.fn().mockReturnValue(''), + statSync: vi.fn(), + writeFileSync: vi.fn(), + mkdirSync: vi.fn(), +})); + +vi.mock('../core/generate', () => ({ + generateAEOFiles: vi.fn().mockResolvedValue({ files: ['robots.txt'], errors: [] }), +})); + +const mockExistsSync = vi.mocked(existsSync); +const mockReaddirSync = vi.mocked(readdirSync); +const mockReadFileSync = vi.mocked(readFileSync); +const mockStatSync = vi.mocked(statSync); +const mockWriteFileSync = vi.mocked(writeFileSync); + +describe('Angular plugin', () => { + beforeEach(() => { + vi.clearAllMocks(); + mockExistsSync.mockReturnValue(false); + mockReaddirSync.mockReturnValue([]); + }); + + describe('getWidgetScript', () => { + it('should return script tag with widget config', () => { + const script = getWidgetScript({ + title: 'My App', + url: 'https://myapp.com', + }); + + expect(script).toContain(''; + } + return ''; + }); + + mockReaddirSync.mockReturnValue([]); + + await postBuild({ + title: 'My App', + url: 'https://myapp.com', + outDir: '/project/dist', + }); + + // writeFileSync should NOT be called for index.html since widget is already present + const indexWriteCalls = mockWriteFileSync.mock.calls.filter( + call => call[0].toString().endsWith('index.html'), + ); + expect(indexWriteCalls).toHaveLength(0); + }); + }); + + describe('generate', () => { + it('should scan Angular routes from source', async () => { + const { generateAEOFiles } = await import('../core/generate'); + + mockExistsSync.mockImplementation((p) => { + const path = p.toString(); + if (path.endsWith('src/app')) return true; + return false; + }); + + mockReaddirSync.mockImplementation((p) => { + const path = p.toString(); + if (path.endsWith('src/app')) return ['about', 'contact', 'app.routes.ts'] as any; + if (path.endsWith('about')) return ['about.component.ts'] as any; + if (path.endsWith('contact')) return ['contact.component.ts'] as any; + return []; + }); + + mockStatSync.mockImplementation((p) => { + const path = p.toString(); + const isDir = path.endsWith('about') || path.endsWith('contact') || path.endsWith('src/app'); + return { + isFile: () => !isDir, + isDirectory: () => isDir, + } as any; + }); + + mockReadFileSync.mockImplementation((p) => { + const path = p.toString(); + if (path.endsWith('app.routes.ts')) { + return `export const routes: Routes = [ + { path: '', component: HomeComponent }, + { path: 'about', component: AboutComponent }, + { path: 'contact', component: ContactComponent }, + ];`; + } + return ''; + }); + + await generate({ title: 'My App', url: 'https://myapp.com' }); + + expect(generateAEOFiles).toHaveBeenCalledWith( + expect.objectContaining({ + pages: expect.arrayContaining([ + expect.objectContaining({ pathname: '/' }), + expect.objectContaining({ pathname: '/about' }), + expect.objectContaining({ pathname: '/contact' }), + ]), + }), + ); + }); + + it('should always include root page', async () => { + const { generateAEOFiles } = await import('../core/generate'); + + mockExistsSync.mockReturnValue(false); + + await generate({ title: 'My App', url: 'https://myapp.com' }); + + expect(generateAEOFiles).toHaveBeenCalledWith( + expect.objectContaining({ + pages: expect.arrayContaining([ + expect.objectContaining({ pathname: '/' }), + ]), + }), + ); + }); + }); + + describe('detectAngularOutputDir', () => { + it('should read outputPath from angular.json', async () => { + const { generateAEOFiles } = await import('../core/generate'); + + mockExistsSync.mockImplementation((p) => { + const path = p.toString(); + if (path.endsWith('angular.json')) return true; + // The detected output dir should contain 'browser' for v17+ + if (path.includes('my-app') && path.includes('browser')) return true; + return false; + }); + + mockReadFileSync.mockImplementation((p) => { + const path = p.toString(); + if (path.endsWith('angular.json')) { + return JSON.stringify({ + projects: { + 'my-app': { + architect: { + build: { + options: { + outputPath: { base: 'dist/my-app' }, + }, + }, + }, + }, + }, + }); + } + return ''; + }); + + mockReaddirSync.mockReturnValue([]); + + await postBuild({ title: 'My App', url: 'https://myapp.com' }); + + // Should have used the detected output dir from angular.json + expect(generateAEOFiles).toHaveBeenCalledWith( + expect.objectContaining({ + outDir: expect.stringContaining('my-app'), + }), + ); + }); + }); +}); diff --git a/src/plugins/angular.ts b/src/plugins/angular.ts new file mode 100644 index 0000000..ec053ae --- /dev/null +++ b/src/plugins/angular.ts @@ -0,0 +1,356 @@ +import { generateAEOFiles } from '../core/generate'; +import { resolveConfig } from '../core/utils'; +import type { AeoConfig, PageEntry } from '../types'; +import { join } from 'path'; +import { existsSync, readdirSync, readFileSync, statSync } from 'fs'; + +/** + * Scan Angular route components from app/ directory. + * Looks for standalone components that represent pages (files matching *.component.ts + * inside folders that follow Angular routing conventions). + */ +function scanAngularRoutes(projectRoot: string): PageEntry[] { + const pages: PageEntry[] = []; + const srcDir = join(projectRoot, 'src', 'app'); + if (!existsSync(srcDir)) { + // Always include root even when no source directory found + pages.push({ pathname: '/' }); + return pages; + } + + function walk(dir: string, basePath: string = ''): void { + try { + const entries = readdirSync(dir); + for (const entry of entries) { + const fullPath = join(dir, entry); + const stat = statSync(fullPath); + if (stat.isDirectory() && !entry.startsWith('.') && !entry.startsWith('_') && entry !== 'node_modules') { + walk(fullPath, `${basePath}/${entry}`); + } + } + + // Check for route config files that define paths + for (const entry of entries) { + if (entry.match(/\.routes\.ts$/)) { + try { + const content = readFileSync(join(dir, entry), 'utf-8'); + // Extract path values from route configs: { path: 'about', ... } + const pathMatches = content.matchAll(/path:\s*['"]([^'"]*)['"]/g); + for (const match of pathMatches) { + const routePath = match[1]; + if (routePath === '**' || routePath.startsWith(':')) continue; + const pathname = routePath === '' ? '/' : `${basePath}/${routePath}`.replace(/\/+/g, '/'); + const name = routePath || 'home'; + if (!pages.some(p => p.pathname === pathname)) { + pages.push({ + pathname, + title: name === 'home' ? undefined : name.charAt(0).toUpperCase() + name.slice(1).replace(/-/g, ' '), + }); + } + } + } catch { /* skip */ } + } + } + + // Fallback: infer routes from component directories with component files + for (const entry of entries) { + const fullPath = join(dir, entry); + const stat = statSync(fullPath); + if (stat.isDirectory() && !entry.startsWith('.')) { + const componentFile = readdirSync(fullPath).find(f => f.match(/\.component\.ts$/)); + if (componentFile && entry !== 'app' && entry !== 'shared' && entry !== 'core' && entry !== 'components' && entry !== 'services' && entry !== 'models' && entry !== 'guards' && entry !== 'interceptors' && entry !== 'pipes' && entry !== 'directives') { + const pathname = `${basePath}/${entry}`.replace(/\/+/g, '/'); + if (!pages.some(p => p.pathname === pathname)) { + pages.push({ + pathname, + title: entry.charAt(0).toUpperCase() + entry.slice(1).replace(/-/g, ' '), + }); + } + } + } + } + } catch { /* skip */ } + } + + walk(srcDir); + + // Always include root + if (!pages.some(p => p.pathname === '/')) { + pages.unshift({ pathname: '/' }); + } + + return pages; +} + +function extractText(html: string): string { + let text = html; + text = text.replace(//gi, ''); + text = text.replace(//gi, ''); + text = text.replace(//gi, ''); + const mainMatch = text.match(/]*>([\s\S]*)<\/main>/i); + if (mainMatch) { + text = mainMatch[1]; + } else { + // Angular uses and — extract from the app root + const appRootMatch = text.match(/]*>([\s\S]*)<\/app-root>/i); + if (appRootMatch) { + text = appRootMatch[1]; + } + text = text.replace(//gi, ''); + text = text.replace(//gi, ''); + text = text.replace(//gi, ''); + } + text = text.replace(/]+href=["']([^"']*)["'][^>]*>([\s\S]*?)<\/a>/gi, (_, url, inner) => { + if (/<(?:h[1-6]|div|p|section)[^>]*>/i.test(inner)) { + const cleanInner = inner.replace(/<[^>]+>/g, ' ').replace(/\s+/g, ' ').trim(); + return `\n[${cleanInner.slice(0, 120).trim()}](${url})\n`; + } + return `[${inner}](${url})`; + }); + text = text.replace(/]*>([\s\S]*?)<\/h1>/gi, '\n\n## $1\n\n'); + text = text.replace(/]*>([\s\S]*?)<\/h2>/gi, '\n\n## $1\n\n'); + text = text.replace(/]*>([\s\S]*?)<\/h3>/gi, '\n\n### $1\n\n'); + text = text.replace(/]*>([\s\S]*?)<\/h4>/gi, '\n\n#### $1\n\n'); + text = text.replace(/]*>([\s\S]*?)<\/h5>/gi, '\n\n##### $1\n\n'); + text = text.replace(/]*>([\s\S]*?)<\/h6>/gi, '\n\n###### $1\n\n'); + text = text.replace(/]+href=["']([^"']*)["'][^>]*>([\s\S]*?)<\/a>/gi, '[$2]($1)'); + text = text.replace(/<(?:strong|b)[^>]*>([\s\S]*?)<\/(?:strong|b)>/gi, '**$1**'); + text = text.replace(/<(?:em|i)[^>]*>([\s\S]*?)<\/(?:em|i)>/gi, '*$1*'); + text = text.replace(/]*>([\s\S]*?)<\/li>/gi, '\n- $1'); + text = text.replace(/]*>([\s\S]*?)<\/blockquote>/gi, '\n\n> $1\n\n'); + text = text.replace(/]*\/?>/gi, '\n\n---\n\n'); + text = text.replace(/]*\/?>/gi, '\n'); + text = text.replace(/<\/p>/gi, '\n\n'); + text = text.replace(/]*>/gi, ''); + text = text.replace(/<\/?(?:div|section|article|header|main|aside|figure|figcaption|table|thead|tbody|tr|td|th|ul|ol|dl|dt|dd)[^>]*>/gi, '\n'); + text = text.replace(/<[^>]+>/g, ''); + text = text.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"').replace(/'/g, "'").replace(/ /g, ' ').replace(/©/g, '(c)'); + text = text.replace(/[\u{1F1E0}-\u{1FAFF}\u{2600}-\u{27BF}\u{FE00}-\u{FE0F}\u{200D}\u{20E3}]/gu, ''); + text = text.split('\n').map(l => l.replace(/\s+/g, ' ').trim()).join('\n'); + text = text.replace(/\n{3,}/g, '\n\n'); + text = text.replace(/\[[\s\n]+/g, '[').replace(/[\s\n]+\]/g, ']'); + text = text.replace(/(#{2,6})\s*\n+\s*/g, '$1 '); + text = text.replace(/^#{2,6}\s*$/gm, ''); + text = text.replace(/\n{3,}/g, '\n\n'); + return text.trim().slice(0, 8000); +} + +/** + * Detect Angular output directory by reading angular.json. + * Angular v17+ outputs to dist//browser/, older versions to dist//. + */ +function detectAngularOutputDir(projectRoot: string): string { + const angularJsonPath = join(projectRoot, 'angular.json'); + if (!existsSync(angularJsonPath)) return join(projectRoot, 'dist'); + + try { + const angularJson = JSON.parse(readFileSync(angularJsonPath, 'utf-8')); + const defaultProject = angularJson.defaultProject || Object.keys(angularJson.projects || {})[0]; + if (!defaultProject) return join(projectRoot, 'dist'); + + const project = angularJson.projects[defaultProject]; + const buildTarget = project?.architect?.build || project?.targets?.build; + + // Check outputPath from build options + if (buildTarget?.options?.outputPath) { + const outputPath = buildTarget.options.outputPath; + // Angular v17+ uses object form: { base: "dist/my-app" } + if (typeof outputPath === 'object' && outputPath.base) { + return join(projectRoot, outputPath.base, 'browser'); + } + return join(projectRoot, outputPath); + } + + return join(projectRoot, 'dist', defaultProject); + } catch { + return join(projectRoot, 'dist'); + } +} + +/** + * Scan Angular build output for pre-rendered HTML pages. + */ +function scanAngularBuildOutput(outputDir: string): PageEntry[] { + const pages: PageEntry[] = []; + if (!existsSync(outputDir)) return pages; + + function walk(dir: string, basePath: string = ''): void { + try { + const entries = readdirSync(dir); + for (const entry of entries) { + const fullPath = join(dir, entry); + const stat = statSync(fullPath); + if (stat.isDirectory() && !entry.startsWith('.') && entry !== 'assets' && entry !== 'media') { + walk(fullPath, `${basePath}/${entry}`); + } else if (entry.endsWith('.html') && entry !== '404.html' && entry !== '500.html') { + try { + const html = readFileSync(fullPath, 'utf-8'); + const titleMatch = html.match(/([^<]*)<\/title>/i); + const descMatch = html.match(/<meta\s+name=["']description["']\s+content=["']([^"']*)["']/i); + const textContent = extractText(html); + + let pathname: string; + if (entry === 'index.html') { + pathname = basePath || '/'; + } else { + pathname = `${basePath}/${entry.replace('.html', '')}`; + } + + pages.push({ + pathname, + title: titleMatch?.[1]?.split('|')[0]?.trim(), + description: descMatch?.[1], + content: textContent, + }); + } catch { /* skip */ } + } + } + } catch { /* skip */ } + } + + walk(outputDir); + return pages; +} + +/** + * Generate a widget script tag to inject into Angular's index.html. + * Returns HTML string to add before </body>. + */ +export function getWidgetScript(config: AeoConfig = {}): string { + const resolvedConfig = resolveConfig(config); + if (!resolvedConfig.widget.enabled) return ''; + + const widgetConfig = JSON.stringify({ + title: resolvedConfig.title, + description: resolvedConfig.description, + url: resolvedConfig.url, + widget: resolvedConfig.widget, + }); + + return `<script type="module"> +import('aeo.js/widget').then(({ AeoWidget }) => { + try { + new AeoWidget({ config: ${widgetConfig} }); + } catch (e) { + console.warn('[aeo.js] Widget initialization failed:', e); + } +}).catch(() => {}); +</script>`; +} + +/** + * Post-build function for Angular projects. + * Scans the Angular build output directory for pre-rendered HTML, + * generates AEO files alongside the build output, and optionally + * injects the widget into index.html. + * + * Usage in package.json: + * "postbuild": "node -e \"import('aeo.js/angular').then(m => m.postBuild({ title: 'My App', url: 'https://mysite.com' }))\"" + * + * Or with Angular SSR prerender: + * "postbuild": "node -e \"import('aeo.js/angular').then(m => m.postBuild({ title: 'My App', url: 'https://mysite.com', injectWidget: true }))\"" + */ +export async function postBuild(config: AeoConfig & { injectWidget?: boolean } = {}): Promise<void> { + const projectRoot = process.cwd(); + const outputDir = config.outDir || detectAngularOutputDir(projectRoot); + + console.log(`[aeo.js] Scanning Angular build output: ${outputDir}`); + + // Discover pages from build output HTML + const buildPages = scanAngularBuildOutput(outputDir); + if (buildPages.length > 0) { + console.log(`[aeo.js] Discovered ${buildPages.length} pages from Angular build output`); + } + + // Discover routes from source + const sourcePages = scanAngularRoutes(projectRoot); + + // Merge: build output (with content) takes priority over source-scanned routes + const allPages = [...buildPages, ...sourcePages, ...(config.pages || [])]; + const pageMap = new Map<string, PageEntry>(); + for (const page of allPages) { + const existing = pageMap.get(page.pathname); + if (!existing || (page.content && !existing.content)) { + pageMap.set(page.pathname, page); + } + } + + // Apply defaults + for (const page of pageMap.values()) { + if (page.pathname === '/' && !page.title && config.title) { + page.title = config.title; + } + if (!page.description && config.description) { + page.description = config.description; + } + } + + const resolvedConfig = resolveConfig({ + ...config, + outDir: outputDir, + pages: Array.from(pageMap.values()), + }); + + const result = await generateAEOFiles(resolvedConfig); + if (result.files.length > 0) { + console.log(`[aeo.js] Generated ${result.files.length} files`); + } + if (result.errors.length > 0) { + console.error('[aeo.js] Errors:', result.errors); + } + + // Optionally inject widget into index.html + if (config.injectWidget !== false && resolvedConfig.widget.enabled) { + const indexPath = join(outputDir, 'index.html'); + if (existsSync(indexPath)) { + try { + let html = readFileSync(indexPath, 'utf-8'); + if (!html.includes('aeo.js/widget')) { + const script = getWidgetScript(config); + html = html.replace('</body>', `${script}\n</body>`); + const { writeFileSync } = await import('fs'); + writeFileSync(indexPath, html, 'utf-8'); + console.log('[aeo.js] Injected widget into index.html'); + } + } catch (error) { + console.warn('[aeo.js] Could not inject widget into index.html:', error); + } + } + } +} + +/** + * Generate AEO files for Angular from source routes only (no build output). + * Useful for dev/CI environments where build output isn't available. + */ +export async function generate(config: AeoConfig = {}): Promise<void> { + const projectRoot = process.cwd(); + const discoveredPages = scanAngularRoutes(projectRoot); + + if (discoveredPages.length > 0) { + console.log(`[aeo.js] Discovered ${discoveredPages.length} routes from Angular source`); + } + + for (const page of discoveredPages) { + if (page.pathname === '/' && !page.title && config.title) { + page.title = config.title; + } + if (!page.description && config.description) { + page.description = config.description; + } + } + + const resolvedConfig = resolveConfig({ + ...config, + outDir: config.outDir || 'public', + pages: [...(config.pages || []), ...discoveredPages], + }); + + const result = await generateAEOFiles(resolvedConfig); + if (result.files.length > 0) { + console.log(`[aeo.js] Generated ${result.files.length} files`); + } + if (result.errors.length > 0) { + console.error('[aeo.js] Errors:', result.errors); + } +} diff --git a/tsup.config.ts b/tsup.config.ts index e3e4a2d..16c3e6e 100644 --- a/tsup.config.ts +++ b/tsup.config.ts @@ -9,6 +9,7 @@ export default defineConfig({ webpack: 'src/plugins/webpack.ts', astro: 'src/plugins/astro.ts', nuxt: 'src/plugins/nuxt.ts', + angular: 'src/plugins/angular.ts', widget: 'src/widget/core.ts', react: 'src/widget/react.tsx', vue: 'src/widget/vue.ts',