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) => + `
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('