diff --git a/README.md b/README.md index cb761f2..186e2a6 100644 --- a/README.md +++ b/README.md @@ -102,6 +102,16 @@ export default defineNuxtConfig({ }); ``` +### SvelteKit + +```json +{ + "scripts": { + "postbuild": "node -e \"import('aeo.js/sveltekit').then(m => m.postBuild({ title: 'My Site', url: 'https://mysite.com' }))\"" + } +} +``` + ### Angular ```json @@ -147,6 +157,7 @@ npx aeo.js check | Next.js | `aeo.js/next` | | Vite | `aeo.js/vite` | | Nuxt | `aeo.js/nuxt` | +| SvelteKit | `aeo.js/sveltekit` | | Angular | `aeo.js/angular` | | Webpack | `aeo.js/webpack` | | CLI | `npx aeo.js generate` | diff --git a/package.json b/package.json index d88346a..52918da 100644 --- a/package.json +++ b/package.json @@ -45,6 +45,11 @@ "import": "./dist/angular.mjs", "require": "./dist/angular.js" }, + "./sveltekit": { + "types": "./dist/sveltekit.d.ts", + "import": "./dist/sveltekit.mjs", + "require": "./dist/sveltekit.js" + }, "./react": { "types": "./dist/react.d.ts", "import": "./dist/react.mjs", diff --git a/src/plugins/sveltekit.test.ts b/src/plugins/sveltekit.test.ts new file mode 100644 index 0000000..2ecbac9 --- /dev/null +++ b/src/plugins/sveltekit.test.ts @@ -0,0 +1,168 @@ +import { describe, it, expect, beforeEach, afterEach } from 'vitest'; +import { postBuild, generate, getWidgetScript } from './sveltekit'; +import { mkdtempSync, mkdirSync, writeFileSync, readFileSync, existsSync, rmSync } from 'fs'; +import { join } from 'path'; +import { tmpdir } from 'os'; + +let projectDir: string; +let originalCwd: string; + +beforeEach(() => { + originalCwd = process.cwd(); + projectDir = mkdtempSync(join(tmpdir(), 'aeo-sveltekit-')); + process.chdir(projectDir); +}); + +afterEach(() => { + process.chdir(originalCwd); + rmSync(projectDir, { recursive: true, force: true }); +}); + +function writePage(relDir: string, html: string): void { + const dir = join(projectDir, relDir); + mkdirSync(dir, { recursive: true }); + writeFileSync(join(dir, 'index.html'), html, 'utf-8'); +} + +const HTML = (title: string) => + `${title}

${title}

Some real content about ${title} for extraction purposes.

`; + +describe('getWidgetScript', () => { + it('returns a module script with the widget config', () => { + const script = getWidgetScript({ title: 'My Site', url: 'https://mysite.com' }); + expect(script).toContain(' sequences in serialized config', () => { + const script = getWidgetScript({ + title: 'Safe must + // not appear there; it would prematurely close the enclosing script element. + const configMatch = script.match(/new AeoWidget\(\{ config: (.+?) \}\)/s); + expect(configMatch).not.toBeNull(); + const jsonPart = configMatch![1]; + expect(jsonPart).not.toContain(''); + // The title value should be present in its unicode-escaped form. + expect(jsonPart).toContain('\\u003c/script\\u003e'); + }); +}); diff --git a/src/plugins/sveltekit.ts b/src/plugins/sveltekit.ts new file mode 100644 index 0000000..665fbf4 --- /dev/null +++ b/src/plugins/sveltekit.ts @@ -0,0 +1,328 @@ +import { generateAEOFiles } from '../core/generate'; +import { resolveConfig } from '../core/utils'; +import type { AeoConfig, PageEntry } from '../types'; +import { extractTextFromHtml, extractTitle, extractDescription } from '../core/html-extract'; +import { join } from 'path'; +import { existsSync, readdirSync, readFileSync, statSync, writeFileSync } from 'fs'; + +/** + * Scan SvelteKit routes from src/routes. + * Directories map to URL segments; (group) segments are transparent and + * dynamic segments ([param], [...rest]) are skipped since they can't be + * enumerated statically. A directory is a page when it contains + * +page.svelte (or +page.md / +page.svx via mdsvex). + */ +function scanSvelteKitRoutes(projectRoot: string): PageEntry[] { + const pages: PageEntry[] = []; + const routesDir = join(projectRoot, 'src', 'routes'); + if (!existsSync(routesDir)) { + pages.push({ pathname: '/' }); + return pages; + } + + const PAGE_FILES = ['+page.svelte', '+page.md', '+page.svx']; + + function walk(dir: string, basePath: string): void { + let entries: string[]; + try { + entries = readdirSync(dir); + } catch { + return; + } + + if (entries.some((e) => PAGE_FILES.includes(e))) { + const pathname = basePath || '/'; + if (!pages.some((p) => p.pathname === pathname)) { + const segment = pathname.split('/').filter(Boolean).pop(); + pages.push({ + pathname, + title: segment ? segment.charAt(0).toUpperCase() + segment.slice(1).replace(/-/g, ' ') : undefined, + }); + } + } + + for (const entry of entries) { + if (entry.startsWith('.') || entry.startsWith('[')) continue; + const fullPath = join(dir, entry); + let stat; + try { + stat = statSync(fullPath); + } catch { + continue; + } + if (!stat.isDirectory()) continue; + // Route groups like (marketing) don't contribute a URL segment + const segment = entry.startsWith('(') && entry.endsWith(')') ? '' : `/${entry}`; + walk(fullPath, `${basePath}${segment}`); + } + } + + walk(routesDir, ''); + + if (!pages.some((p) => p.pathname === '/')) { + pages.unshift({ pathname: '/' }); + } + + return pages; +} + +/** + * Detect the SvelteKit output directory. + * adapter-static writes to build/; other adapters keep prerendered pages + * under .svelte-kit/output and serve static assets from the client dir. + */ +function detectSvelteKitOutputDir(projectRoot: string): string { + const staticBuild = join(projectRoot, 'build'); + if (existsSync(staticBuild)) return staticBuild; + + const clientDir = join(projectRoot, '.svelte-kit', 'output', 'client'); + if (existsSync(clientDir)) return clientDir; + + console.warn( + '[aeo.js] Could not detect SvelteKit output directory (build/ and .svelte-kit/output/client are absent). ' + + 'Pass outDir explicitly or run your build first.', + ); + return staticBuild; +} + +/** Scan a directory tree for prerendered HTML pages. */ +function scanHtmlOutput(outputDir: string): PageEntry[] { + const pages: PageEntry[] = []; + if (!existsSync(outputDir)) return pages; + + function walk(dir: string, basePath: string): void { + let entries: string[]; + try { + entries = readdirSync(dir); + } catch { + return; + } + for (const entry of entries) { + const fullPath = join(dir, entry); + let stat; + try { + stat = statSync(fullPath); + } catch { + continue; + } + if (stat.isDirectory() && !entry.startsWith('.') && entry !== '_app') { + walk(fullPath, `${basePath}/${entry}`); + } else if (entry.endsWith('.html') && entry !== '404.html' && entry !== '500.html') { + try { + const html = readFileSync(fullPath, 'utf-8'); + const pathname = entry === 'index.html' ? basePath || '/' : `${basePath}/${entry.replace('.html', '')}`; + pages.push({ + pathname, + title: extractTitle(html), + description: extractDescription(html), + content: extractTextFromHtml(html), + }); + } catch { + /* skip */ + } + } + } + } + + walk(outputDir, ''); + return pages; +} + +/** + * Generate a widget script tag to inject into prerendered HTML. + */ +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, + }) + .replace(/&/g, '\\u0026') + .replace(//g, '\\u003e'); + + return ``; +} + +/** Inject the widget script into every prerendered HTML page that lacks it. */ +function injectWidgetIntoHtml(outputDir: string, config: AeoConfig): number { + const script = getWidgetScript(config); + if (!script) return 0; + + let injected = 0; + function walk(dir: string): void { + let entries: string[]; + try { + entries = readdirSync(dir); + } catch { + return; + } + for (const entry of entries) { + const fullPath = join(dir, entry); + let stat; + try { + stat = statSync(fullPath); + } catch { + continue; + } + if (stat.isDirectory() && !entry.startsWith('.') && entry !== '_app') { + walk(fullPath); + } else if (entry.endsWith('.html') && entry !== '404.html' && entry !== '500.html') { + try { + const html = readFileSync(fullPath, 'utf-8'); + if (html.includes('aeo.js/widget') || !html.includes('')) continue; + writeFileSync(fullPath, html.replace('', `${script}\n`), 'utf-8'); + injected++; + } catch { + /* skip */ + } + } + } + } + + walk(outputDir); + return injected; +} + +/** + * Post-build function for SvelteKit projects. + * Scans the build output (adapter-static's build/ or .svelte-kit/output) + * for prerendered HTML, generates AEO files alongside it, and optionally + * injects the widget into every prerendered page. + * + * Usage in package.json: + * "postbuild": "node -e \"import('aeo.js/sveltekit').then(m => m.postBuild({ title: 'My Site', url: 'https://mysite.com' }))\"" + */ +export async function postBuild(config: AeoConfig & { injectWidget?: boolean } = {}): Promise { + const projectRoot = process.cwd(); + const outputDir = config.outDir || detectSvelteKitOutputDir(projectRoot); + + console.log(`[aeo.js] Scanning SvelteKit build output: ${outputDir}`); + + const buildPages = scanHtmlOutput(outputDir); + + // Non-static adapters keep prerendered pages in a separate directory. + // Check existence rather than string-matching the path so custom outDir still works. + const prerenderedDir = join(projectRoot, '.svelte-kit', 'output', 'prerendered', 'pages'); + const prerenderedPages = existsSync(prerenderedDir) ? scanHtmlOutput(prerenderedDir) : []; + + const discovered = [...buildPages, ...prerenderedPages]; + if (discovered.length > 0) { + console.log(`[aeo.js] Discovered ${discovered.length} pages from SvelteKit build output`); + } + + const sourcePages = scanSvelteKitRoutes(projectRoot); + + // Merge priority (highest to lowest): + // 1. config.pages (explicit user overrides — always win) + // 2. discovered HTML pages with content + // 3. source-scanned routes (lowest priority) + const pageMap = new Map(); + // Layer in discovered and source pages first (lower priority) + for (const page of [...discovered, ...sourcePages]) { + const existing = pageMap.get(page.pathname); + if (!existing || (page.content && !existing.content)) { + pageMap.set(page.pathname, page); + } + } + // User-provided pages always win — overwrite whatever was discovered + for (const page of config.pages || []) { + pageMap.set(page.pathname, page); + } + + 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); + } + + if (config.injectWidget !== false && resolvedConfig.widget.enabled) { + // For non-static adapters the client dir only holds JS/CSS bundles. + // Prerendered HTML lives under .svelte-kit/output/prerendered/pages. + const widgetDirs: string[] = [outputDir]; + if (existsSync(prerenderedDir)) { + widgetDirs.push(prerenderedDir); + } + let injected = 0; + for (const dir of widgetDirs) { + injected += injectWidgetIntoHtml(dir, config); + } + if (injected > 0) { + console.log(`[aeo.js] Injected widget into ${injected} page(s)`); + } + } +} + +/** + * Generate AEO files for SvelteKit from source routes only (no build output). + * Writes into static/ so the files ship with any adapter. Useful for dev. + */ +export async function generate(config: AeoConfig = {}): Promise { + const projectRoot = process.cwd(); + const discoveredPages = scanSvelteKitRoutes(projectRoot); + + if (discoveredPages.length > 0) { + console.log(`[aeo.js] Discovered ${discoveredPages.length} routes from SvelteKit 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; + } + } + + // Merge with same priority as postBuild: discovered first, then config.pages win. + const pageMap = new Map(); + for (const page of discoveredPages) { + pageMap.set(page.pathname, page); + } + for (const page of config.pages || []) { + pageMap.set(page.pathname, page); + } + + const resolvedConfig = resolveConfig({ + ...config, + outDir: config.outDir || 'static', + 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); + } +} diff --git a/tsup.config.ts b/tsup.config.ts index 16c3e6e..02e06d3 100644 --- a/tsup.config.ts +++ b/tsup.config.ts @@ -10,6 +10,7 @@ export default defineConfig({ astro: 'src/plugins/astro.ts', nuxt: 'src/plugins/nuxt.ts', angular: 'src/plugins/angular.ts', + sveltekit: 'src/plugins/sveltekit.ts', widget: 'src/widget/core.ts', react: 'src/widget/react.tsx', vue: 'src/widget/vue.ts', diff --git a/website/astro.config.mjs b/website/astro.config.mjs index c0fa73b..b629c37 100644 --- a/website/astro.config.mjs +++ b/website/astro.config.mjs @@ -67,6 +67,7 @@ export default defineConfig({ { label: 'Next.js', slug: 'frameworks/nextjs' }, { label: 'Vite', slug: 'frameworks/vite' }, { label: 'Nuxt', slug: 'frameworks/nuxt' }, + { label: 'SvelteKit', slug: 'frameworks/sveltekit' }, { label: 'Angular', slug: 'frameworks/angular' }, { label: 'Webpack', slug: 'frameworks/webpack' }, { label: 'Vanilla JS / Static HTML', slug: 'frameworks/vanilla' }, diff --git a/website/src/content/docs/frameworks/sveltekit.mdx b/website/src/content/docs/frameworks/sveltekit.mdx new file mode 100644 index 0000000..00a73ab --- /dev/null +++ b/website/src/content/docs/frameworks/sveltekit.mdx @@ -0,0 +1,70 @@ +--- +title: SvelteKit +description: Use aeo.js with SvelteKit. +--- + +import { Steps, Aside } from '@astrojs/starlight/components'; + +## Setup + + +1. Install the package: + ```bash + npm install aeo.js + ``` + +2. Add a post-build step to your `package.json`: + ```json + { + "scripts": { + "postbuild": "node -e \"import('aeo.js/sveltekit').then(m => m.postBuild({ title: 'My Site', url: 'https://mysite.com' }))\"" + } + } + ``` + +3. Build your app: + ```bash + npm run build + ``` + + +## How it works + +The SvelteKit plugin: + +- Detects the build output: `build/` for `adapter-static`, or `.svelte-kit/output` for other adapters +- Scans prerendered HTML pages for titles, descriptions, and full text content +- Scans `src/routes` for `+page.svelte` (and mdsvex `+page.md`/`+page.svx`) routes — route groups like `(marketing)` are transparent, dynamic segments like `[slug]` are skipped +- Generates all AEO files (robots.txt, llms.txt, sitemap.xml, ai-index.json, …) into the output directory +- Injects the Human/AI widget into every prerendered page + + + +## Programmatic usage + +Generate AEO files from source routes without a build — files land in `static/` so any adapter ships them: + +```ts +import { generate } from 'aeo.js/sveltekit'; + +await generate({ title: 'My Site', url: 'https://mysite.com' }); +``` + +## Configuration + +Pass any [AeoConfig](/reference/configuration/) options to `postBuild` or `generate`: + +```ts +import { postBuild } from 'aeo.js/sveltekit'; + +await postBuild({ + title: 'My Site', + url: 'https://mysite.com', + description: 'A site optimized for AI discovery', + widget: { enabled: true, position: 'bottom-right' }, + // Skip widget injection into prerendered HTML: + // injectWidget: false, +}); +```