The frontend build uses Webpack with Babel, SCSS compilation, and JavaScript obfuscation in production. Assets are compiled to public/build/ and served statically. In production, a manifest.json maps logical asset names to content-hashed filenames; the manifest_asset() PHP helper reads this manifest so templates always reference the correct file.
Frontend dependencies are managed via npm.
Install dependencies:
npm install
The JavaScript entry point is src/index.js:
// src/index.js
import './style/_app.scss';
document.addEventListener( 'DOMContentLoaded', async () => {
// your application code
} );
All JavaScript and SCSS flows through this single entry point. Webpack resolves imports from here and bundles everything into public/build/.
Styles live in src/style/. The main file is _app.scss:
// src/style/_app.scss
@charset "UTF-8";
// import partials
@import 'variables';
@import 'layout';
@import 'components';
// etc.
The SCSS pipeline uses sass-loader → css-loader → style-loader.
webpack.config.js exports a function that receives the env object from CLI arguments — mode and cache flags drive build behaviour:
module.exports = ( env ) => {
const isProduction = env.mode === 'production';
return {
mode: env.mode,
entry: { app: './src/index.js' }, // named entry → output is app.[hash].js
cache: Boolean( env.cache ),
devtool: isProduction ? false : 'inline-source-map',
output: {
filename: isProduction ? '[name].[contenthash].js' : 'app.js',
path: path.resolve( __dirname, 'public/build' ),
library: 'app',
},
// ...
};
};
| Mode | Filename | Source map |
|---|---|---|
| Development | app.js (fixed, no hash) |
Inline (embedded in file) |
| Production | app.[contenthash].js (cache-busting hash) |
None — never emitted |
Source maps are disabled in production (devtool: false). Emitting a .map file would expose the pre-obfuscated source in browser DevTools, completely defeating the obfuscator.
The entry is declared as { app: './src/index.js' } (a named entry object) so webpack uses app as [name], producing consistent app.[hash].js filenames rather than the default main.[hash].js.
In development the output is always public/build/app.js. In production the content hash changes whenever the file content changes, ensuring browsers pick up the new file. The manifest_asset() PHP helper resolves the correct filename automatically — see Asset helpers below.
# Development build with file watching
npm run dev
# Production build
npm run build
Under the hood:
{
"scripts": {
"dev": "webpack --watch --env mode=development --env cache=0 --progress",
"build": "webpack --env mode=production --env cache=1 --progress"
}
}
npm run dev watches for file changes and rebuilds automatically. Use this during active frontend development. The --env cache=0 flag disables Webpack's persistent cache in dev mode so changes are always reflected immediately.
npm run build produces a minified, obfuscated production bundle with content-hashed filenames. No source maps are emitted.
Three production-only plugins run during npm run build:
new TerserPlugin( {
parallel: true,
terserOptions: {
format: {
comments: false, // strip all comments
},
compress: {
drop_console: true, // remove console.* calls
},
},
extractComments: false,
} )
All console.log(), console.warn(), console.error() calls are stripped from the production bundle. Add any debugging console calls freely during development — they won't make it to production.
new WebpackManifestPlugin( { publicPath: '/build/' } )
Emits public/build/manifest.json mapping logical entry names to their hashed filenames:
{
"app.js": "/build/app.dc2b151aac9bb7095ff3.js"
}
The PHP manifest_asset() helper reads this file so templates can reference app.js by logical name without hardcoding the hash. Only emitted in production — in development the filename is always app.js so no manifest is needed.
The obfuscator runs after minification and applies layered protections:
new JavaScriptObfuscator( {
// --- domain lock ---
domainLock: [ 'localhost', '127.0.0.1:7000', 'nations-original.com' ],
// --- devtools disruption ---
debugProtection: true,
debugProtectionInterval: 4000, // re-fires debugger every 4 s while DevTools is open
disableConsoleOutput: true,
// --- structural obfuscation ---
compact: true,
selfDefending: true,
controlFlowFlattening: true,
controlFlowFlatteningThreshold: 0.75,
deadCodeInjection: true,
deadCodeInjectionThreshold: 0.4,
numbersToExpressions: true,
// --- identifier renaming ---
identifierNamesGenerator: 'hexadecimal',
renameGlobals: false,
simplify: true,
// --- string obfuscation ---
splitStrings: true,
splitStringsChunkLength: 5,
stringArray: true,
stringArrayEncoding: [ 'rc4' ],
stringArrayThreshold: 0.75,
stringArrayCallsTransform: true,
stringArrayCallsTransformThreshold: 0.75,
stringArrayIndexShift: true,
stringArrayRotate: true,
stringArrayShuffle: true,
stringArrayWrappersCount: 2,
stringArrayWrappersChainedCalls: true,
stringArrayWrappersParametersMaxCount: 4,
stringArrayWrappersType: 'function',
unicodeEscapeSequence: false,
log: false,
} )
What each layer does:
Domain lock — the bundle only executes on the listed domains. If someone downloads the JS and runs it on a different origin, it fails silently. Update before deploying to a new domain:
domainLock: [
'localhost',
'127.0.0.1:7000',
'your-domain.com',
'www.your-domain.com',
],
localhost and 127.0.0.1:* should never reach production, but they're included here to allow the production bundle to run in development without modification, just don't forget to update the list before deploying.
debugProtectionInterval: 4000 — re-fires a debugger statement every 4 seconds while DevTools is open. The browser freezes in the breakpoint continuously, making step-through inspection essentially unusable. (0 = one-time trigger only, which is easy to dismiss.)
controlFlowFlattening — replaces readable if/for/while blocks with a switch dispatch loop driven by an opaque state variable. Even after deobfuscation, the control flow is unreadable.
deadCodeInjection — injects fake but convincing unreachable code paths (~40% of nodes). Static analysis tools and humans waste time tracing paths that never execute.
numbersToExpressions — 42 becomes something like 0x1f + 0xb. Literal constant searches stop working.
splitStrings + stringArrayEncoding: ['rc4'] — strings are split into 5-character chunks, extracted into an array, and RC4-encrypted at rest; decoded only at runtime. They never appear as readable literals. RC4 is considerably harder to reverse by hand than the default 'base64'.
stringArrayWrappersType: 'function' + wrappersCount: 2 — two layers of function indirection wrap every string lookup. Tracing a string to its value requires following multiple call chains (previously: variable aliases, one layer).
Self defending — if the bundle is formatted or modified (e.g. run through a beautifier), the code detects the change and breaks itself.
new CleanWebpackPlugin( {
cleanStaleWebpackAssets: false,
} )
Cleans public/build/ before each build.
Never put secrets in variables you expose to the frontend — anything in the bundle is visible to anyone who downloads it regardless of obfuscation.
The output is configured with library: 'app':
output: {
library: 'app',
// ...
}
This exposes the bundle as a global window.app object in the browser. Exported functions and objects from src/index.js are accessible as app.something from inline scripts in templates.
The header template sets the current route name on the app object after the bundle loads:
// In head.php
<script>
app.router.setCurrentRouteName( '<?= Router::$currentRoute->name ?>' );
</script>
This bridges the PHP router and the JavaScript layer — client-side navigation and AJAX calls can reference the current route by name.
asset()Validates that a file exists in public/ before returning its public URL:
function asset( string $path ): string
{
if ( file_exists( sprintf( '%s/public/%s', project_dir(), $path ) ) === false )
throw new FileNotFoundException( "Asset not found: $path" );
return "/$path";
}
If the file doesn't exist — build hasn't run, wrong path, file was cleaned — asset() throws FileNotFoundException immediately rather than rendering a broken script tag that silently fails. Use this for static assets whose filenames never change (jQuery, Bootstrap, images, etc.).
manifest_asset()Resolves a logical asset name through public/build/manifest.json to the correct content-hashed URL, then delegates to asset() for existence validation:
function manifest_asset( string $filename ): string
{
static $manifest = null;
if ( $manifest === null ) {
$manifestPath = project_dir() . '/public/build/manifest.json';
$manifest = file_exists( $manifestPath )
? json_decode( file_get_contents( $manifestPath ), true )
: [];
}
$resolved = $manifest[ $filename ] ?? "build/$filename";
return asset( ltrim( $resolved, '/' ) );
}
| Mode | manifest.json exists? |
Resolution |
|---|---|---|
| Production | Yes | Reads manifest.json, returns hashed URL (e.g. /build/app.dc2b15....js) |
| Development | No | Falls back to build/$filename — matches the fixed app.js dev output |
Use manifest_asset() for the main application bundle. Use asset() for everything else.
Usage in templates:
<script src="<?= manifest_asset( 'app.js' ) ?>"></script>
<script src="<?= asset( 'js/jquery-3.6.0.min.js' ) ?>"></script>
<script src="<?= asset( 'js/bootstrap.min.js' ) ?>"></script>
The startup script launches the development server:
#!/usr/bin/env bash
port=${1:-7000}
# Use Symfony CLI if available (HTTPS), fall back to PHP built-in server
if ! [ -x "$(command -v symfony)" ]; then
use_symfony_cli=false
else
use_symfony_cli=true
fi
# Open browser
if [ "$use_symfony_cli" = true ]; then
open_browser "https://127.0.0.1:$port"
symfony serve --port="$port"
else
open_browser "http://127.0.0.1:$port"
php -S 127.0.0.1:"$port" -t public
fi
# Start on default port 7000
./run.sh
# Start on a custom port
./run.sh 8080
Symfony CLI is used when available because it provides HTTPS via a self-signed certificate, which is required by the acceptance test suite (acceptInsecureCerts: true in Acceptance.suite.yml). The PHP built-in server is the fallback for environments without Symfony CLI — HTTP only, port 7000.
The script attempts to open the browser automatically using gio open, xdg-open, or open depending on the OS. If none of these are available it prints the URL to the console.
The template project includes CKEditor as a separate frontend build in public/CKEditor/:
# Install CKEditor dependencies
cd public/CKEditor
npm install
# Build CKEditor
npm run build
CKEditor is activated via the blank middleware pattern — Kernel::setEditorStatus(true) is called from the editor_component view, and footer.php checks Kernel::isEditorActivated() to conditionally import the CKEditor_activator component:
// In footer.php
<?php if ( Kernel::isEditorActivated() ) : ?>
<?php $this->import( CKEditor_activator::class ) ?>
<?php endif ?>
This means the CKEditor scripts are only loaded on pages that explicitly include the editor_component view — no unnecessary script loading on pages that don't need it.
The editor converts its Markdown output to HTML via Showdown before form submission:
// In CKEditor_activator.php
$( 'form#editor' ).on( 'submit', ( event ) => {
event.preventDefault();
$( '#editor_data' )[0].value = converter.makeHtml( editor.getData() );
event.currentTarget.submit();
} );
Forgetting to update domain lock before deploying — the production bundle only executes on domains listed in domainLock. If you add a new domain or change your production URL and forget to update this list and rebuild, the JavaScript silently fails on the new domain with no error message. Update domainLock and run npm run build as part of every domain change.
Using asset() for the main bundle — in production the main bundle has a content-hash suffix (app.abc123.js). Always use manifest_asset('app.js') for the main bundle, not asset('build/app.js'). The latter throws FileNotFoundException in production because the fixed filename doesn't exist.
Running npm run build in development — production builds apply obfuscation which makes debugging impossible. Use npm run dev during development and only run npm run build when preparing a production deployment.
Putting sensitive env vars in JavaScript — dotenv-webpack bundles env vars into the JavaScript output. Even with obfuscation, determined attackers can extract values from the bundle. Never put API secrets, database credentials, or private keys in variables accessed from JavaScript. Use them server-side only.