import fs from 'node:fs'; import path from 'node:path'; const ASSET_MARKER = ''; const DEFAULT_ENTRY = 'ui/frontend/js/frontend.js'; const DEFAULT_TEMPLATES = [ { source: 'ui/frontend/masterpage_template.tpl', output: 'ui/frontend/masterpage.tpl', }, { source: 'ui/frontend/errors_template.tpl', output: 'ui/frontend/errors.tpl', }, ]; /** * Normalizes a path for comparisons with Vite manifest keys. * * @param {string} filePath Path to normalize. * @returns {string} A slash-separated path. * @sideEffects None. */ function normalizeManifestPath(filePath) { return filePath.replaceAll(path.sep, '/'); } /** * Resolves a path relative to the Vite project root. * * @param {string} projectRoot Vite project root. * @param {string} filePath Absolute or project-relative path. * @returns {string} An absolute path. * @sideEffects None. */ function resolveProjectPath(projectRoot, filePath) { return path.resolve(projectRoot, filePath); } /** * Finds the manifest entry for the configured JavaScript entry. * * @param {Record} manifest Parsed Vite manifest. * @param {string} projectRoot Vite project root. * @param {string} entryPath Configured JavaScript entry path. * @returns {object} The matching Vite manifest entry. * @sideEffects None. */ function findEntry(manifest, projectRoot, entryPath) { const relativeEntry = normalizeManifestPath(path.relative(projectRoot, entryPath)); const match = Object.entries(manifest).find(([source, asset]) => asset.isEntry && (normalizeManifestPath(source) === relativeEntry || asset.src === relativeEntry), ); if (!match) { throw new Error(`Vite manifest entry not found for ${relativeEntry}.`); } return match[1]; } /** * Creates an asset URL from Vite's configured public base. * * @param {string} baseUrl Vite public base URL. * @param {string} assetPath Manifest asset path. * @returns {string} The public asset URL. * @sideEffects None. */ function createAssetUrl(baseUrl, assetPath) { return `${baseUrl.replace(/\/?$/, '/')}${assetPath.replace(/^\/+/, '')}`; } /** * Builds script and stylesheet tags from a Vite manifest entry. * * @param {object} entry Vite manifest entry. * @param {string} baseUrl Vite public base URL. * @returns {string} HTML asset tags. * @sideEffects None. */ function createAssetTags(entry, baseUrl) { const cssTags = (entry.css ?? []).map( assetPath => `\t`, ); const scriptTag = `\t`; return [...cssTags, scriptTag].join('\n'); } /** * Replaces the asset marker in a source template and writes its generated output. * * @param {string} projectRoot Vite project root. * @param {{source: string, output: string}} template Template source and output paths. * @param {string} assetTags Generated Vite asset tags. * @returns {void} * @sideEffects Reads and writes template files. */ function generateTemplate(projectRoot, template, assetTags) { const sourcePath = resolveProjectPath(projectRoot, template.source); const outputPath = resolveProjectPath(projectRoot, template.output); const source = fs.readFileSync(sourcePath, 'utf8'); const markerCount = source.split(ASSET_MARKER).length - 1; if (markerCount !== 1) { throw new Error(`${sourcePath} must contain exactly one ${ASSET_MARKER} marker.`); } fs.mkdirSync(path.dirname(outputPath), { recursive: true }); fs.writeFileSync(outputPath, source.replace(ASSET_MARKER, assetTags), 'utf8'); } /** * Creates a Vite plugin that generates F3 templates from the build manifest. * * @param {{entry?: string, templates?: Array<{source: string, output: string}>}} options Plugin configuration. * @returns {import('vite').Plugin} Vite plugin instance. * @sideEffects Writes generated templates after a successful build. */ export default function viteTemplatePlugin({ entry = DEFAULT_ENTRY, templates = DEFAULT_TEMPLATES, } = {}) { let resolvedConfig; return { name: 'vite-template-plugin', apply: 'build', /** * Stores the resolved Vite configuration for post-build path resolution. * * @param {import('vite').ResolvedConfig} config Resolved Vite configuration. * @returns {void} * @sideEffects Updates plugin state. */ configResolved(config) { resolvedConfig = config; }, /** * Generates F3 templates after Vite has written the manifest. * * @returns {void} * @sideEffects Reads the manifest and writes generated template files. */ closeBundle() { const manifestName = resolvedConfig.build.manifest === true ? '.vite/manifest.json' : resolvedConfig.build.manifest; const manifestPath = resolveProjectPath(resolvedConfig.build.outDir, manifestName); const manifest = JSON.parse(fs.readFileSync(manifestPath, 'utf8')); const entryPath = resolveProjectPath(resolvedConfig.root, entry); const manifestEntry = findEntry(manifest, resolvedConfig.root, entryPath); const assetTags = createAssetTags(manifestEntry, resolvedConfig.base); templates.forEach(template => generateTemplate(resolvedConfig.root, template, assetTags)); }, }; }