/**
* PromptJS v1.0.0 — Project Builder (Wave J + K + SPA + Adapters)
* ============================================================================
*
* Multi-file project builder. Compiles all .pjs files in src/pages/.
*
* v0.4.0 (MPA mode): Separate HTML files per route, bundled JS/CSS.
* v0.6.0 (SPA mode): Single HTML, client-side router, factory functions.
* v0.8.0 (Adapters): Plugin hooks, config loading, adapter post-processing.
*
* SPA mode is activated when any page has `router: benar` in front-matter.
* Without it, output is identical to v0.4.0 (zero regression).
*
* Usage:
* const Builder = require('./builder');
* const result = Builder.buildProject({ rootDir: 'src', outDir: 'dist' });
*/
'use strict';
const fs = require('fs');
const path = require('path');
const Engine = require('./promptjs');
const { ROUTER_RUNTIME } = require('./router-runtime');
const Plugins = require('./plugins');
const { findPjsFiles: findPjsFilesCore } = require('../cli/utils');
// Default ignore-set khusus build proyek: selain node_modules/.git/dist, juga
// lewati direktori dokumentasi & test agar tidak ikut ter-build.
const BUILDER_IGNORE_DIRS = ['node_modules', '.git', 'dist', 'doc-dev', 'tests'];
/**
* Find all .pjs files recursively in a directory.
*
* Delegasi tipis ke sumber kebenaran tunggal di `src/cli/utils.js` dengan
* ignore-set & pengurutan khusus builder. (Sebelumnya fungsi ini diduplikasi.)
*
* @param {string} dir - Root directory
* @param {string[]} [ignore] - Directories to ignore (default: BUILDER_IGNORE_DIRS)
* @returns {string[]} Array of absolute paths (sorted)
*/
function findPjsFiles(dir, ignore) {
return findPjsFilesCore(dir, {
ignoreDirs: ignore || BUILDER_IGNORE_DIRS,
sort: true,
});
}
/**
* Generate route path from file path.
*
* @param {string} filePath - Full file path
* @param {string} pagesDir - Pages directory path
* @returns {string} Route path (e.g. "/about")
*/
function fileToRoute(filePath, pagesDir) {
const rel = path.relative(pagesDir, filePath);
const withoutExt = rel.replace(/\.pjs$/, '');
const parts = withoutExt.split(path.sep);
// [slug].pjs → :slug
const routeParts = parts.map((p) => {
const dynMatch = p.match(/^\[(.+)\]$/);
if (dynMatch) return ':' + dynMatch[1];
return p;
});
let route = '/' + routeParts.join('/');
// index → /
if (route.endsWith('/index')) {
route = route.slice(0, -'index'.length) || '/';
}
// Remove trailing slash (except root)
if (route.length > 1 && route.endsWith('/')) {
route = route.slice(0, -1);
}
return route;
}
/**
* Generate output HTML filename from route.
*
* @param {string} route - Route path
* @returns {string} HTML filename relative to dist/
*/
function routeToHtmlFile(route) {
if (route === '/' || route === '') return 'index.html';
const clean = route.replace(/^\//, '');
return clean + '.html';
}
/**
* Generate a safe JavaScript identifier from a route path.
*
* / → index
* /about → about
* /blog/:slug → blog_slug
*
* @param {string} route - Route path
* @returns {string} Safe identifier
*/
function routeToPageName(route) {
if (route === '/' || route === '') return 'index';
return route
.replace(/^\//, '')
.replace(/:/g, '_')
.replace(/\//g, '_')
.replace(/[^a-zA-Z0-9_]/g, '_');
}
/**
* Build a single page: compile .pjs → extract JS + CSS.
*
* @param {string} filePath - Path to .pjs file
* @param {Object} opts - Build options
* @param {boolean} [opts.spa] - Compile in SPA mode (factory function)
* @returns {{ route: string, pageName: string, htmlFile: string, js: string, css: string, errors: Object[], success: boolean, isSPA: boolean }}
*/
function buildPage(filePath, opts) {
const engine = new Engine.PromptJSEngine();
const route = fileToRoute(filePath, opts.pagesDir);
const pageName = routeToPageName(route);
const result = engine.compileFile(filePath, {
source: path.basename(filePath),
dataDir: path.dirname(filePath),
loadDataFiles: !opts.dev,
scope: opts.scope,
// v0.6: Pass SPA options
pageName: pageName,
pageRoute: route,
});
return {
route,
pageName,
htmlFile: routeToHtmlFile(route),
js: result.js || '',
css: result.css || '',
errors: result.errors,
warnings: result.warnings,
success: result.success,
isSPA: !!result.isSPA,
};
}
/**
* Generate HTML wrapper for a page (MPA mode).
*
* @param {string} pageTitle - Page title
* @param {Object} opts - Build options
* @returns {string} HTML string
*/
function generatePageHTML(pageTitle, opts) {
const cssLink = opts.hasCss ? '\n ' : '';
const jsScript = opts.hasJs ? '' : '';
return `
${pageTitle}
${cssLink}
${jsScript}
`;
}
/**
* Generate SPA HTML shell (single page with router).
*
* @param {string} title - Site title
* @param {Object} opts - Build options
* @returns {string} HTML string
*/
function generateSPAHTML(title, opts) {
const cssLink = opts.hasCss ? '\n ' : '';
return `
${title}
${cssLink}
`;
}
/**
* Build entire project.
*
* @param {Object} opts - Build options
* @param {string} [opts.rootDir='src'] - Source root directory
* @param {string} [opts.outDir='dist'] - Output directory
* @param {string} [opts.pagesDir='pages'] - Pages subdirectory (relative to rootDir)
* @param {boolean} [opts.dev=false] - Dev mode
* @param {string} [opts.adapter=null] - Adapter name: 'static', 'node', 'vercel', or null
* @param {Object[]} [opts.plugins=[]] - Loaded plugins for transform hooks
* @param {Object} [opts.meta={}] - Meta tags for static adapter
* @param {string} [opts.siteUrl=''] - Site URL for sitemap/canonical
* @param {string} [opts.apiUrl=''] - API URL for Node adapter proxy
* @param {boolean} [opts.csp=false] - Enable CSP nonce injection (v1.0.1)
* @returns {{ pages: Object[], js: string, css: string, errors: Object[], isSPA: boolean, adapter: Object|null }}
*/
function buildProject(opts) {
opts = opts || {};
const rootDir = path.resolve(opts.rootDir || 'src');
const outDir = path.resolve(opts.outDir || 'dist');
const pagesDir = path.join(rootDir, opts.pagesDir || 'pages');
const dev = !!opts.dev;
const plugins = opts.plugins || [];
const adapter = opts.adapter || null;
const errors = [];
const pages = [];
let allCss = '';
// Find all .pjs page files
const pageFiles = findPjsFiles(pagesDir);
if (pageFiles.length === 0) {
errors.push({
code: 'E0000',
severity: 'error',
message: `No .pjs files found in ${pagesDir}`,
suggestion: 'Buat file .pjs di direktori pages/',
});
return { pages, js: '', css: '', errors, isSPA: false };
}
// First pass: compile all pages and detect SPA mode
let isSPA = false;
const pageResults = [];
for (const filePath of pageFiles) {
const pageResult = buildPage(filePath, {
pagesDir,
dev,
scope: path.basename(filePath, '.pjs'),
});
pageResults.push(pageResult);
if (pageResult.isSPA) isSPA = true;
if (!pageResult.success) errors.push(...pageResult.errors);
// Note: transformJS and transformCSS hooks are already applied inside
// Engine.compile() (see promptjs.js lines 314-316). Do NOT re-apply them
// here — doing so would cause plugins to run twice on the same content,
// which breaks non-idempotent transforms (BUG-8 fix).
}
// Collect CSS from all pages
for (const pr of pageResults) {
if (pr.css) {
allCss += `\n/* === Page: ${pr.route} === */\n`;
allCss += pr.css + '\n';
}
}
let adapterResult = null;
// Write output files
try {
// Clean dist
if (fs.existsSync(outDir)) {
fs.rmSync(outDir, { recursive: true, force: true });
}
fs.mkdirSync(outDir, { recursive: true });
if (isSPA) {
// ═══ SPA MODE ═══
// Compile all pages as factory functions, generate route table + router
let spaJs = '';
spaJs += '// PromptJS v1.0.0 — SPA Bundle\n';
spaJs += '// Auto-generated. Do not edit.\n\n';
// Emit each page as a named factory function.
// Compiler SPA output has structure:
// - Header comments (// Generated by...)
// - Runtime helpers (tree-shaken)
// - "// === User Code ===" marker
// - var __cleanupFns = []; etc.
// - ... page body ...
// - return { el, mount, unmount };
//
// For bundling: extract runtime helpers from the FIRST page only
// (they're identical across pages), then wrap each page's user code
// in a named factory function.
let runtimeExtracted = false;
for (const pr of pageResults) {
if (!pr.js) continue;
const lines = pr.js.split('\n');
const userCodeIdx = lines.findIndex((l) => l.includes('// === User Code ==='));
if (!runtimeExtracted && userCodeIdx > 0) {
// Extract header + runtime helpers (everything before user code)
for (let i = 0; i < userCodeIdx; i++) {
spaJs += lines[i] + '\n';
}
spaJs += '\n';
runtimeExtracted = true;
}
// Extract user code (everything from "// === User Code ===" onward)
const userLines = userCodeIdx >= 0 ? lines.slice(userCodeIdx + 1) : lines;
spaJs += `// === Page: ${pr.route} (${pr.pageName}) ===\n`;
spaJs += `function __page_${pr.pageName}(__parent) {\n`;
for (const line of userLines) {
if (line.trim()) {
spaJs += ' ' + line + '\n';
} else {
spaJs += '\n';
}
}
spaJs += '\n}\n\n';
}
// Build route table
spaJs += '// === Route Table ===\n';
spaJs += 'var __PJS_ROUTES = {\n';
for (const pr of pageResults) {
if (pr.js) {
const routeKey = pr.route.replace(/\\/g, '\\\\').replace(/'/g, "\\'");
spaJs += ` "${routeKey}": __page_${pr.pageName},\n`;
}
}
spaJs += '};\n\n';
// Embed router runtime
spaJs += '// === Router Runtime ===\n';
spaJs += ROUTER_RUNTIME + '\n\n';
// Initialize router
spaJs += '// === Initialize ===\n';
spaJs += '__pjsRouter(__PJS_ROUTES);\n';
fs.writeFileSync(path.join(outDir, 'prompt.js'), spaJs, 'utf-8');
// Write single SPA HTML
const html = generateSPAHTML('PromptJS SPA', { hasCss: !!allCss });
fs.writeFileSync(path.join(outDir, 'index.html'), html, 'utf-8');
} else {
// ═══ MPA MODE (v0.4 behavior, zero regression) ═══
let allJs = '';
for (const pr of pageResults) {
if (pr.js) {
const routeVar = JSON.stringify(pr.route);
allJs += `\n// === Page: ${pr.route} (${pr.pageName}) ===\n`;
allJs += `if (window.__PJS_ROUTE__ === ${routeVar} || window.location.pathname === ${routeVar}) {\n`;
allJs += pr.js + '\n';
allJs += '}\n';
}
// Generate per-page HTML
const pageTitle = path.basename(pr.route.replace(/^\//, ''), '.pjs') || 'PromptJS';
const html = generatePageHTML(pageTitle, { hasCss: !!allCss, hasJs: !!allJs });
pages.push({
route: pr.route,
htmlFile: pr.htmlFile,
html,
js: pr.js,
css: pr.css,
success: pr.success,
errors: pr.errors,
});
}
if (allJs) {
const jsOutput =
'// PromptJS v1.0.0 — Bundled JavaScript\n' +
'// Auto-generated. Do not edit.\n' +
'window.__PJS_ROUTE__ = window.location.pathname;\n' +
allJs;
fs.writeFileSync(path.join(outDir, 'prompt.js'), jsOutput, 'utf-8');
}
// Write HTML files
for (const page of pages) {
const htmlPath = path.join(outDir, page.htmlFile);
fs.mkdirSync(path.dirname(htmlPath), { recursive: true });
fs.writeFileSync(htmlPath, page.html, 'utf-8');
}
}
// Write CSS
if (allCss) {
const cssOutput =
'/* PromptJS v1.0.0 — Bundled CSS */\n' + '/* Auto-generated. Do not edit. */\n' + allCss;
fs.writeFileSync(path.join(outDir, 'prompt.css'), cssOutput, 'utf-8');
}
// Copy static assets if they exist
const assetsDir = path.join(rootDir, 'assets');
if (fs.existsSync(assetsDir)) {
copyDirRecursive(assetsDir, path.join(outDir, 'assets'));
}
// v0.8: Apply adapter post-processing
if (adapter) {
const routes = pageResults
.filter(function (pr) {
return pr.success;
})
.map(function (pr) {
return pr.route;
});
try {
adapterResult = runAdapter(adapter, {
outDir: outDir,
isSPA: isSPA,
routes: routes,
meta: opts.meta || {},
siteUrl: opts.siteUrl || '',
apiUrl: opts.apiUrl || '',
csp: !!opts.csp,
});
if (adapterResult.errors && adapterResult.errors.length > 0) {
errors.push(...adapterResult.errors);
}
} catch (adapterErr) {
errors.push({
code: 'E0000',
severity: 'error',
message: 'Adapter error: ' + adapterErr.message,
suggestion: 'Check adapter name: ' + adapter,
});
}
}
// v0.8: Apply transformHTML hooks on all generated HTML files
applyHtmlPlugins(outDir, plugins);
} catch (e) {
errors.push({
code: 'E0000',
severity: 'error',
message: `Build write error: ${e.message}`,
suggestion: 'Periksa permission folder dist/',
});
}
return { pages, js: '', css: allCss, errors, isSPA, adapter: adapterResult };
}
/**
* Copy directory recursively.
*
* @param {string} src - Source directory
* @param {string} dest - Destination directory
*/
function copyDirRecursive(src, dest) {
fs.mkdirSync(dest, { recursive: true });
const entries = fs.readdirSync(src, { withFileTypes: true });
for (const entry of entries) {
const srcPath = path.join(src, entry.name);
const destPath = path.join(dest, entry.name);
if (entry.isDirectory()) {
copyDirRecursive(srcPath, destPath);
} else {
fs.copyFileSync(srcPath, destPath);
}
}
}
/**
* Run an adapter by name.
*
* @param {string} name - Adapter name ('static', 'node', 'vercel')
* @param {Object} opts - Adapter options
* @returns {Object} Adapter result
*/
function runAdapter(name, opts) {
const AdapterStatic = require('./adapters/static');
const AdapterNode = require('./adapters/node');
const AdapterVercel = require('./adapters/vercel');
switch (name) {
case 'static':
return AdapterStatic.runStaticAdapter(opts);
case 'node':
return AdapterNode.runNodeAdapter(opts);
case 'vercel':
return AdapterVercel.runVercelAdapter(opts);
default:
return {
errors: [
{
code: 'E0000',
severity: 'error',
message: 'Unknown adapter: ' + name,
suggestion: 'Use static, node, or vercel',
},
],
};
}
}
/**
* Apply transformHTML plugin hooks to all .html files in a directory.
*
* @param {string} dir - Directory to process
* @param {Object[]} plugins - Loaded plugins
*/
function applyHtmlPlugins(dir, plugins) {
if (!plugins || plugins.length === 0) return;
const htmlFiles = findHtmlFiles(dir);
for (let i = 0; i < htmlFiles.length; i++) {
const htmlPath = htmlFiles[i];
let html = fs.readFileSync(htmlPath, 'utf-8');
html = Plugins.transformHTML(plugins, html, path.basename(htmlPath));
fs.writeFileSync(htmlPath, html, 'utf-8');
}
}
/**
* Find all .html files recursively.
*
* @param {string} dir - Directory
* @returns {string[]} HTML file paths
*/
function findHtmlFiles(dir) {
let results = [];
try {
const entries = fs.readdirSync(dir, { withFileTypes: true });
for (let i = 0; i < entries.length; i++) {
const entry = entries[i];
const fullPath = path.join(dir, entry.name);
if (entry.isDirectory() && entry.name !== 'node_modules') {
const sub = findHtmlFiles(fullPath);
results = results.concat(sub);
} else if (entry.name.endsWith('.html')) {
results.push(fullPath);
}
}
} catch {}
return results;
}
module.exports = {
findPjsFiles,
fileToRoute,
routeToHtmlFile,
routeToPageName,
buildPage,
generatePageHTML,
generateSPAHTML,
buildProject,
copyDirRecursive,
runAdapter,
};