import { existsSync, readdirSync, renameSync, rmSync } from 'node:fs' import { join, resolve } from 'node:path' import { fileURLToPath } from 'node:url' import { defineConfig } from 'astro/config' // dist 根 stub 清单(astro 5.18.2 content layer 无条件发射的空模块桩——content/consts.js // 的 ASSET_IMPORTS_FILE='content-assets.mjs' / MODULES_IMPORTS_FILE='content-modules.mjs', // 静态构建随 .astro/ 汇入 dist 根;本主题零内容集合,产物纯页面/媒体——桩归属主题清理)。 const DIST_ROOT_STUBS = ['content-assets.mjs', 'content-modules.mjs'] // 字面 .html 路径对齐(URL pathname 保真——快线 Task 4 钉死的映射规则之一): // Astro 页面产物在 build.format 三态下分别是 /index.html(directory——尾斜杠路由 // 的正确形态)或 .html(file/preserve——会给无后缀路由追加后缀)。两者都产不出与 // routes.json targetFile 逐字节一致的 dist/news/x.html 字面文件(file/preserve 会得到 // x.html.html)。此钩子在构建收尾把 .html 后缀路由的包装目录折平为字面文件—— // Task 5 管线的 manifest 对齐(target_file 是唯一权威)因此恒为 no-op。 function literalHtmlPaths() { return { name: 'sf-literal-html-paths', hooks: { 'astro:build:done': async ({ pages, dir, logger }) => { const dist = fileURLToPath(dir) let moved = 0 for (const { pathname } of pages) { // pages 的 pathname 是相对 pageName(directory 格式恒带尾斜杠,如 // 'news/x.html/')——剥尾斜杠后以 .html 后缀判定字面文件路由 const rel = pathname.replace(/\/+$/, '') if (!rel.endsWith('.html')) continue const wrapper = join(dist, rel) const nested = join(wrapper, 'index.html') if (!existsSync(nested)) continue const tmp = join(dist, `.sf-literal-${moved}.tmp`) renameSync(nested, tmp) rmSync(wrapper, { recursive: true }) renameSync(tmp, wrapper) moved++ } if (moved > 0) logger.info(`字面 .html 路径对齐:${moved} 页`) // format 契约自检:x.html.html 残留只在 format 偏离 directory(file/preserve 给 // .html 路由追加后缀)时出现——折平逻辑的前提破裂即明错,不留错形产物 const doubled = readdirSync(dist, { recursive: true }).filter( (f) => typeof f === 'string' && f.replaceAll('\\', '/').endsWith('.html.html'), ) if (doubled.length > 0) { throw new Error( `dist 出现 ${doubled.length} 个 *.html.html 残留(如 ${doubled[0]}):build.format 偏离 'directory'——字面 .html 折平前提破裂(Task 5 target_file 对齐将失真)`, ) } // dist 根 stub 清理(见 DIST_ROOT_STUBS 注记——内容层桩不是站点产物) for (const stub of DIST_ROOT_STUBS) { rmSync(join(dist, stub), { force: true }) } }, }, } } // 纯静态输出(Task 4/5 消费)。页面数据全部来自 SF_BUILD_INPUT 六文件(load-render-data // 单一入口),不设 site——canonical/og 的 origin 是每站点数据(site.json 的 origin), // 不是主题级配置。@sf/contracts 直连源码(与根 tsconfig/vitest 同映射——契约包零依赖 // 纯 TS,vite 直接编译内联,无需预构建 dist)。 // build.format='directory' 显式契约(非缺省依赖):尾斜杠路由 → /index.html 是 // target_file 映射规则的半边——上面的 *.html.html 自检是其守卫。 export default defineConfig({ output: 'static', srcDir: './src', outDir: './dist', build: { format: 'directory' }, integrations: [literalHtmlPaths()], vite: { resolve: { alias: { '@sf/contracts': resolve(import.meta.dirname, '../../packages/contracts/src/index.ts'), }, }, }, })