import path from 'node:path'; import { FOLDERS, FILES } from '../../core/constants.js'; import type { FrontendConfig } from '../../types.js'; import { copy, ensureDir, pathExists, readJson } from '../../utils/fs.js'; import { scanGlob } from '../../utils/glob.js'; import { getPageDirectories } from '../../core/pages.js'; import { assertNoSsgRoutesInModuleConfig } from './validation.js'; import type { WorkspaceModuleView, WorkspacePackageJson } from '../../config/workspaceManifest.js'; import { runSsgSeo } from './seo.js'; export async function applySsgRouting(config: FrontendConfig): Promise { const distRoot = config.paths.dist.frontend; const distPagesRoot = config.paths.dist.pages; const isRootLayout = path.resolve(distRoot) === path.resolve(distPagesRoot); const pages = await getPageDirectories(distPagesRoot); const pageIndexMap = new Map(); const rootIndexPath = path.join(distRoot, FILES.indexHtml); for (const page of pages) { const sourceIndex = path.join(page.directory, FILES.indexHtml); if (!(await pathExists(sourceIndex))) { continue; } pageIndexMap.set(page.name, sourceIndex); if (isRootLayout) { continue; } // For each page, create a //index.html alias to its main HTML file when available. const targetDir = path.join(distRoot, page.name); await ensureDir(targetDir); const targetIndex = path.join(targetDir, FILES.indexHtml); await copy(sourceIndex, targetIndex); } if (isRootLayout) { if (await pathExists(rootIndexPath)) { pageIndexMap.set(FOLDERS.home, rootIndexPath); } } else { // Ensure a root index.html that aliases the home page when present. const homeIndexPath = path.join(distPagesRoot, FOLDERS.home, FILES.indexHtml); if (await pathExists(homeIndexPath)) { await ensureDir(path.dirname(rootIndexPath)); await copy(homeIndexPath, rootIndexPath); } await applyContentAliases(config, distRoot, distPagesRoot); } await applyStaticPathAliases(config, distRoot, distPagesRoot, pageIndexMap); const seoOptions = await resolveWorkspaceSeoOptions(config.paths.workspace); await runSsgSeo(distRoot, seoOptions); if (seoOptions.trailingSlash === false) { await applyNoTrailingSlashAliases(distRoot); } } async function applyContentAliases( config: FrontendConfig, distRoot: string, distPagesRoot: string, ): Promise { if (path.resolve(distRoot) === path.resolve(distPagesRoot)) { return; } const contentRelativeRoot = config.content.basePath.slice(1, -1); const contentRoot = path.join(distPagesRoot, contentRelativeRoot); if (!(await pathExists(contentRoot))) { return; } const indexes = await scanGlob(`${contentRelativeRoot}/**/index.html`, { cwd: distPagesRoot }); for (const relativeIndex of indexes) { const sourceIndex = path.join(distPagesRoot, relativeIndex); if (!(await pathExists(sourceIndex))) { continue; } const targetIndex = path.join(distRoot, relativeIndex); await ensureDir(path.dirname(targetIndex)); await copy(sourceIndex, targetIndex); } } async function applyNoTrailingSlashAliases(distRoot: string): Promise { const indexes = (await scanGlob('**/index.html', { cwd: distRoot })).filter( (relative) => !relative.split(path.sep).join('/').startsWith('pages/'), ); for (const relativeIndex of indexes) { const normalized = relativeIndex.split(path.sep).join('/'); if (normalized === FILES.indexHtml) { continue; } const withoutIndex = normalized.slice(0, -`/${FILES.indexHtml}`.length); const sourceIndex = path.join(distRoot, relativeIndex); const targetHtml = path.join(distRoot, `${withoutIndex}${path.extname(FILES.indexHtml)}`); if (path.resolve(sourceIndex) === path.resolve(targetHtml)) { continue; } await ensureDir(path.dirname(targetHtml)); await copy(sourceIndex, targetHtml); } } async function resolveWorkspaceSeoOptions( workspaceRoot: string, ): Promise<{ siteUrl?: string; trailingSlash?: boolean }> { const fromEnv = process.env.WEBSTIR_SITE_URL?.trim(); const pkgPath = path.join(workspaceRoot, 'package.json'); const pkg = await readJson>(pkgPath); const webstir = pkg?.webstir; if (!webstir || typeof webstir !== 'object') { return fromEnv ? { siteUrl: fromEnv } : {}; } const container = webstir as Record; const candidate = container.siteUrl; const siteUrl = fromEnv ? fromEnv : typeof candidate === 'string' && candidate.trim() ? candidate.trim() : undefined; const trailingSlash = typeof container.trailingSlash === 'boolean' ? container.trailingSlash : undefined; return { siteUrl, trailingSlash }; } async function applyStaticPathAliases( config: FrontendConfig, distRoot: string, distPagesRoot: string, pageIndexMap: Map, ): Promise { if (pageIndexMap.size === 0) { return; } const workspaceRoot = config.paths.workspace; const pkgPath = path.join(workspaceRoot, 'package.json'); const pkg = await readJson(pkgPath); const workspaceMode = pkg?.webstir?.mode; const isSsgWorkspace = typeof workspaceMode === 'string' && workspaceMode.toLowerCase() === 'ssg'; const moduleConfig = pkg?.webstir?.moduleManifest; assertNoSsgRoutesInModuleConfig(moduleConfig); const views = moduleConfig?.views ?? []; if (views.length === 0) { return; } for (const view of views) { const renderMode = view.renderMode ?? (isSsgWorkspace ? 'ssg' : undefined); if (renderMode !== 'ssg') { continue; } const paths = getEffectiveStaticPaths(view, isSsgWorkspace); for (const raw of paths) { if (typeof raw !== 'string' || raw.length === 0) { continue; } const normalized = normalizeStaticPath(raw); let sourceIndex: string | undefined; if (normalized === '/') { sourceIndex = pageIndexMap.get(FOLDERS.home); } else { const relativePath = normalized.replace(/^\/+/, ''); const candidate = path.join(distPagesRoot, relativePath, FILES.indexHtml); if (await pathExists(candidate)) { sourceIndex = candidate; } else { const pageName = firstPathSegment(normalized); if (!pageName) { continue; } sourceIndex = pageIndexMap.get(pageName); } } if (!sourceIndex) { continue; } const targetIndex = normalized === '/' ? path.join(distRoot, FILES.indexHtml) : path.join(distRoot, normalized.replace(/^\/+/, ''), FILES.indexHtml); if (path.resolve(sourceIndex) === path.resolve(targetIndex)) { continue; } await ensureDir(path.dirname(targetIndex)); await copy(sourceIndex, targetIndex); } } } function normalizeStaticPath(value: string): string { let s = value.trim(); if (!s.startsWith('/')) { s = `/${s}`; } if (s.length > 1 && s.endsWith('/')) { s = s.slice(0, -1); } return s; } function firstPathSegment(pathname: string): string | undefined { const [, segment] = pathname.split('/'); if (!segment) { return undefined; } return segment; } function getEffectiveStaticPaths( view: WorkspaceModuleView, isSsgWorkspace: boolean, ): readonly string[] { const explicitPaths = view.staticPaths ?? []; if (explicitPaths.length > 0) { return explicitPaths; } if (!isSsgWorkspace) { return []; } const candidate = view.path ?? ''; if (!isDefaultStaticPathCandidate(candidate)) { return []; } return [candidate]; } function isDefaultStaticPathCandidate(template: string): boolean { if (typeof template !== 'string') { return false; } const trimmed = template.trim(); if (!trimmed.startsWith('/')) { return false; } // Avoid treating parameterized or wildcard templates as a single concrete path. if (trimmed.includes(':') || trimmed.includes('*')) { return false; } return true; }