From 83d6a12fe2129723b7e76ae984c043112c23d9e5 Mon Sep 17 00:00:00 2001 From: "Murilo A." Date: Wed, 10 Jun 2026 13:20:18 -0300 Subject: [PATCH 1/2] fix(astro): normalize file URL paths on Windows --- src/plugins/astro.test.ts | 31 +++++++++++++++++++++++++++++++ src/plugins/astro.ts | 15 ++++++++++++--- 2 files changed, 43 insertions(+), 3 deletions(-) create mode 100644 src/plugins/astro.test.ts diff --git a/src/plugins/astro.test.ts b/src/plugins/astro.test.ts new file mode 100644 index 0000000..5781e2d --- /dev/null +++ b/src/plugins/astro.test.ts @@ -0,0 +1,31 @@ +import { fileURLToPath } from 'url'; +import { describe, expect, it, vi } 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', () => { + 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 }); + }); +}); diff --git a/src/plugins/astro.ts b/src/plugins/astro.ts index 05daf59..86e7686 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,14 @@ function escapeAttr(str: string): string { return str.replace(/&/g, '&').replace(/"/g, '"').replace(//g, '>'); } +function toFileSystemPath(pathOrUrl: string | URL): string { + if (pathOrUrl instanceof URL) { + 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 +190,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 +237,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); From 8eae12b23a8d25cc3621c161a597d416e5ecd3f8 Mon Sep 17 00:00:00 2001 From: ruben-cytonic Date: Tue, 16 Jun 2026 23:08:22 +0100 Subject: [PATCH 2/2] fix(astro): add protocol guard to toFileSystemPath + expand Windows test coverage Guard toFileSystemPath against non-file: URL objects by throwing a descriptive TypeError before delegating to fileURLToPath. Add test cases for the build command path in astro:config:setup and the astro:build:done hook with Windows-style file:// URLs so regressions in those paths are caught by CI. --- src/plugins/astro.test.ts | 70 ++++++++++++++++++++++++++++++++++++++- src/plugins/astro.ts | 5 +++ 2 files changed, 74 insertions(+), 1 deletion(-) diff --git a/src/plugins/astro.test.ts b/src/plugins/astro.test.ts index 5781e2d..e6b3567 100644 --- a/src/plugins/astro.test.ts +++ b/src/plugins/astro.test.ts @@ -1,5 +1,5 @@ import { fileURLToPath } from 'url'; -import { describe, expect, it, vi } from 'vitest'; +import { describe, expect, it, vi, beforeEach } from 'vitest'; import { aeoAstroIntegration } from './astro'; const fsMocks = vi.hoisted(() => ({ @@ -14,6 +14,10 @@ const fsMocks = vi.hoisted(() => ({ vi.mock('fs', () => fsMocks); describe('aeoAstroIntegration', () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + it('uses native filesystem paths for Astro file URLs in dev', () => { fsMocks.existsSync.mockReturnValue(false); @@ -28,4 +32,68 @@ describe('aeoAstroIntegration', () => { 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 86e7686..45c00d5 100644 --- a/src/plugins/astro.ts +++ b/src/plugins/astro.ts @@ -99,6 +99,11 @@ function escapeAttr(str: string): string { 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); }