diff --git a/src/plugins/astro.test.ts b/src/plugins/astro.test.ts new file mode 100644 index 0000000..e6b3567 --- /dev/null +++ b/src/plugins/astro.test.ts @@ -0,0 +1,99 @@ +import { fileURLToPath } from 'url'; +import { describe, expect, it, vi, beforeEach } from 'vitest'; +import { aeoAstroIntegration } from './astro'; + +const fsMocks = vi.hoisted(() => ({ + existsSync: vi.fn(), + mkdirSync: vi.fn(), + readFileSync: vi.fn(), + readdirSync: vi.fn(), + statSync: vi.fn(), + writeFileSync: vi.fn(), +})); + +vi.mock('fs', () => fsMocks); + +describe('aeoAstroIntegration', () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it('uses native filesystem paths for Astro file URLs in dev', () => { + fsMocks.existsSync.mockReturnValue(false); + + const publicDir = new URL('file:///C:/mock/project/public'); + const outDir = new URL('file:///C:/mock/project/dist'); + const integration = aeoAstroIntegration({ widget: { enabled: false } }); + + integration.hooks['astro:config:setup']({ + config: { publicDir, outDir }, + command: 'dev', + }); + + expect(fsMocks.mkdirSync).toHaveBeenCalledWith(fileURLToPath(publicDir), { recursive: true }); + }); + + it('uses native filesystem paths for Astro file URLs in build (astro:config:setup)', () => { + fsMocks.existsSync.mockReturnValue(false); + + const publicDir = new URL('file:///C:/mock/project/public'); + const outDir = new URL('file:///C:/mock/project/dist'); + const integration = aeoAstroIntegration({ widget: { enabled: false } }); + + // In build mode, outDir is used (not publicDir for mkdirSync) + // The key thing is that toFileSystemPath is called on outDir without throwing + expect(() => { + integration.hooks['astro:config:setup']({ + config: { publicDir, outDir }, + command: 'build', + }); + }).not.toThrow(); + }); + + it('uses native filesystem paths for Windows file URLs in astro:build:done hook', async () => { + // Simulate an empty build output directory so scanBuiltPages returns no pages + fsMocks.readdirSync.mockReturnValue([]); + fsMocks.existsSync.mockReturnValue(false); + + const publicDir = new URL('file:///C:/mock/project/public'); + const outDir = new URL('file:///C:/mock/project/dist'); + const dir = new URL('file:///C:/mock/project/dist/'); + const integration = aeoAstroIntegration({ url: 'https://example.com', widget: { enabled: false } }); + + const logger = { + fork: () => ({ + info: vi.fn(), + debug: vi.fn(), + error: vi.fn(), + }), + }; + + // astro:build:done uses astroConfig.site which is set during astro:config:setup + integration.hooks['astro:config:setup']({ + config: { publicDir, outDir, site: 'https://example.com' }, + command: 'build', + }); + + // Should resolve the Windows file URL to a filesystem path without throwing + await expect( + integration.hooks['astro:build:done']({ dir, logger }) + ).resolves.not.toThrow(); + + // readdirSync should have been called with the converted Windows path + expect(fsMocks.readdirSync).toHaveBeenCalledWith(fileURLToPath(dir)); + }); + + it('toFileSystemPath throws a descriptive error for non-file: URLs', () => { + // Access the integration to trigger the module load; the guard is tested via + // astro:config:setup with a non-file URL object passed as outDir + const integration = aeoAstroIntegration({ widget: { enabled: false } }); + + const nonFileUrl = new URL('https://example.com/dist'); + expect(() => { + integration.hooks['astro:config:setup']({ + config: { publicDir: nonFileUrl, outDir: nonFileUrl }, + command: 'build', + }); + }).toThrow(/expected a file: URL/); + }); +}); diff --git a/src/plugins/astro.ts b/src/plugins/astro.ts index 05daf59..45c00d5 100644 --- a/src/plugins/astro.ts +++ b/src/plugins/astro.ts @@ -3,6 +3,7 @@ import { resolveConfig } from '../core/utils'; import type { AeoConfig, PageEntry, ResolvedAeoConfig } from '../types'; import { join, resolve, sep } from 'path'; import { existsSync, mkdirSync, readFileSync, readdirSync, statSync, writeFileSync } from 'fs'; +import { fileURLToPath } from 'url'; import { extractTextFromHtml, extractTitle, extractDescription, htmlToMarkdown } from '../core/html-extract'; import { generateSiteSchemas, generatePageSchemas, generateJsonLdScript } from '../core/schema'; import { generateOGTagsHtml } from '../core/opengraph'; @@ -96,6 +97,19 @@ function escapeAttr(str: string): string { return str.replace(/&/g, '&').replace(/"/g, '"').replace(//g, '>'); } +function toFileSystemPath(pathOrUrl: string | URL): string { + if (pathOrUrl instanceof URL) { + if (pathOrUrl.protocol !== 'file:') { + throw new TypeError( + `toFileSystemPath: expected a file: URL but received "${pathOrUrl.protocol}" — cannot convert to a filesystem path` + ); + } + return fileURLToPath(pathOrUrl); + } + + return pathOrUrl.replace(/^\/([A-Za-z]:)(?=\/|\\)/, '$1'); +} + /** * Inject meta description, canonical URL, OG tags, and JSON-LD into each built HTML page's . * Skips tags that already exist in the page. @@ -181,11 +195,11 @@ export function aeoAstroIntegration(options: AeoConfig = {}): any { resolvedConfig = resolveConfig({ ...options, contentDir: options.contentDir || 'src/content', - outDir: options.outDir || (command === 'build' ? config.outDir.pathname : config.publicDir.pathname), + outDir: options.outDir || (command === 'build' ? toFileSystemPath(config.outDir) : toFileSystemPath(config.publicDir)), }); if (command === 'dev') { - const publicPath = config.publicDir.pathname; + const publicPath = toFileSystemPath(config.publicDir); if (!existsSync(publicPath)) { mkdirSync(publicPath, { recursive: true }); } @@ -228,7 +242,7 @@ if (!document.querySelector('meta[name="astro-view-transitions-enabled"]')) { const buildLogger = logger.fork('aeo.js'); buildLogger.info('Generating AEO files...'); - const outPath = dir instanceof URL ? dir.pathname : (dir || astroConfig.outDir.pathname); + const outPath = toFileSystemPath(dir || astroConfig.outDir); const siteUrl = options.url || astroConfig.site || 'https://example.com'; const discoveredPages = scanBuiltPages(outPath, siteUrl);