import path from 'node:path'; import { pathToFileURL } from 'node:url'; import type { FrontendConfig } from '../../types.js'; import { ensureDir, pathExists, readJson, writeJson } from '../../utils/fs.js'; import { FOLDERS } from '../../core/constants.js'; import type { WorkspaceModuleView, WorkspacePackageJson } from '../../config/workspaceManifest.js'; interface ViewDefinitionLike { readonly name?: string; readonly path?: string; readonly renderMode?: 'ssg' | 'ssr' | 'spa'; readonly staticPaths?: readonly string[]; } interface ViewSpecLike { readonly definition?: ViewDefinitionLike; readonly load?: (context: unknown) => unknown | Promise; } interface ModuleDefinitionLike { readonly views?: readonly ViewSpecLike[]; } interface ViewDataEntry { readonly viewName: string; readonly path: string; readonly data: unknown; } export async function generateSsgViewData(config: FrontendConfig): Promise { const workspaceRoot = config.paths.workspace; const pkgPath = path.join(workspaceRoot, 'package.json'); const pkg = await readJson(pkgPath); const moduleConfig = pkg?.webstir?.moduleManifest; const viewMetadata = moduleConfig?.views ?? []; const workspaceMode = pkg?.webstir?.mode; const isSsgWorkspace = typeof workspaceMode === 'string' && workspaceMode.toLowerCase() === 'ssg'; const moduleDefinition = await loadBackendModuleDefinition(workspaceRoot); if (!moduleDefinition?.views || moduleDefinition.views.length === 0) { return; } const perPageData = new Map(); for (const spec of moduleDefinition.views) { const definition = spec.definition ?? {}; const viewName = definition.name ?? ''; const viewPathTemplate = definition.path ?? ''; const meta = findViewMetadata(viewMetadata, viewName, viewPathTemplate); const renderMode = meta?.renderMode ?? definition.renderMode ?? (isSsgWorkspace ? 'ssg' : undefined); if (renderMode !== 'ssg') { continue; } const staticPaths = getEffectiveStaticPaths(meta, definition, isSsgWorkspace); if (!spec.load || !Array.isArray(staticPaths) || staticPaths.length === 0) { continue; } for (const rawPath of staticPaths) { if (typeof rawPath !== 'string' || rawPath.length === 0) { continue; } const normalizedPath = normalizePath(rawPath); const params = deriveRouteParams(viewPathTemplate, normalizedPath); if (!params) { continue; } const ssrContext = createMinimalSsrContext(normalizedPath, params); let data: unknown; try { data = await spec.load(ssrContext); } catch (error) { const viewLabel = viewName || viewPathTemplate || normalizedPath; throw new Error( `[webstir-frontend] failed to load SSG view data for "${viewLabel}" at ${normalizedPath}: ${formatErrorMessage(error)}`, ); } const pageName = normalizedPath === '/' ? FOLDERS.home : (firstPathSegment(normalizedPath) ?? FOLDERS.home); const entries = perPageData.get(pageName) ?? []; entries.push({ viewName: viewName || viewPathTemplate || normalizedPath, path: normalizedPath, // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment data, }); perPageData.set(pageName, entries); } } if (perPageData.size === 0) { return; } const pagesRoot = config.paths.dist.pages; for (const [pageName, entries] of perPageData.entries()) { const pageDir = path.join(pagesRoot, pageName); if (!(await pathExists(pageDir))) { continue; } const dataPath = path.join(pageDir, 'view-data.json'); await ensureDir(pageDir); await writeJson(dataPath, entries); } } function findViewMetadata( views: readonly WorkspaceModuleView[], name: string, templatePath: string, ): WorkspaceModuleView | undefined { return ( views.find( (view) => (view.name && view.name === name) || (view.path && view.path === templatePath), ) ?? views.find((view) => view.path === templatePath) ?? views.find((view) => view.name === name) ); } async function loadBackendModuleDefinition( workspaceRoot: string, ): Promise { const buildRoot = path.join(workspaceRoot, 'build', 'backend'); const candidates = [ path.join(buildRoot, 'module.js'), path.join(buildRoot, 'module.mjs'), path.join(buildRoot, 'module', 'index.js'), path.join(buildRoot, 'module', 'index.mjs'), ]; for (const fullPath of candidates) { if (!(await pathExists(fullPath))) { continue; } try { const url = `${pathToFileURL(fullPath).href}?t=${Date.now()}`; const imported = (await import(url)) as Record; const candidate = extractModuleDefinition(imported); if (candidate) { return candidate; } } catch (error) { throw new Error( `[webstir-frontend] failed to import backend module definition from ${fullPath}: ${formatErrorMessage(error)}`, ); } } return undefined; } function extractModuleDefinition( exports: Record, ): ModuleDefinitionLike | undefined { const keys = ['module', 'moduleDefinition', 'default', 'backendModule']; for (const key of keys) { if (key in exports) { const value = exports[key as keyof typeof exports]; if (value && typeof value === 'object') { return value as ModuleDefinitionLike; } } } return undefined; } function normalizePath(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 formatErrorMessage(error: unknown): string { if (error instanceof Error && error.message) { return error.message; } return String(error); } function firstPathSegment(pathname: string): string | undefined { const [, segment] = pathname.split('/'); if (!segment) { return undefined; } return segment; } function deriveRouteParams(template: string, actual: string): Record | null { if (!template || !actual) { return {}; } const templateSegments = template.split('/').filter(Boolean); const actualSegments = actual.split('/').filter(Boolean); if (templateSegments.length !== actualSegments.length) { return null; } const params: Record = {}; for (let i = 0; i < templateSegments.length; i++) { const templateSegment = templateSegments[i]; const actualSegment = actualSegments[i]; if (templateSegment.startsWith(':')) { const key = templateSegment.slice(1); if (!key) { return null; } params[key] = decodeURIComponent(actualSegment); } else if (templateSegment !== actualSegment) { return null; } } return params; } function createMinimalSsrContext(pathname: string, params: Record): unknown { const url = new URL(`http://localhost${pathname}`); const envAccessor = { get(name: string): string | undefined { return process.env[name]; }, require(name: string): string { const value = process.env[name]; if (value === undefined) { throw new Error(`Missing required env variable ${name} for SSG view rendering.`); } return value; }, entries(): Record { return process.env as Record; }, }; const logger = { level: 'info', log(_level: string, _message: string, _metadata?: Record): void { // no-op for SSG }, debug(_message: string, _metadata?: Record): void { // no-op for SSG }, info(_message: string, _metadata?: Record): void { // no-op for SSG }, warn(_message: string, _metadata?: Record): void { // no-op for SSG }, error(_message: string, _metadata?: Record): void { // no-op for SSG }, with(_bindings: Record) { return this; }, }; return { url, params, cookies: {}, headers: {}, auth: undefined, session: null, env: envAccessor, logger, now: () => new Date(), }; } function getEffectiveStaticPaths( meta: WorkspaceModuleView | undefined, definition: ViewDefinitionLike, isSsgWorkspace: boolean, ): readonly string[] { const explicit = meta?.staticPaths ?? definition.staticPaths ?? []; if (Array.isArray(explicit) && explicit.length > 0) { return explicit; } if (!isSsgWorkspace) { return []; } const candidate = meta?.path ?? definition.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; } if (trimmed.includes(':') || trimmed.includes('*')) { return false; } return true; }