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 yarn (v4).
Install dependencies:
yarn install
The JavaScript entry point is src/index.js (the project is JavaScript, not TypeScript — find returns zero .ts files under src/):
// 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 MiniCssExtractPlugin.loader → css-loader → sass-loader. CSS is extracted into a separate file (not inlined via 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
yarn dev
# Production build
yarn build
Under the hood:
{
"scripts": {
"dev": "webpack --watch --env mode=development --env cache=0 --progress",
"build": "webpack --env mode=production --env cache=1 --progress"
}
}
yarn 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.
yarn build produces a minified, obfuscated production bundle with content-hashed filenames. No source maps are emitted.
Three production-only plugins run during yarn 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.
Production builds run PurgeCSSPlugin (isProduction && new PurgeCSSPlugin(...)) over the templates and JS source to strip unused CSS classes:
new PurgeCSSPlugin( {
paths: glob.sync(
[
`${__dirname}/templates/**/*.php`,
`${__dirname}/templates_twig/**/*.twig`,
`${__dirname}/src/**/*.js`,
],
{ nodir: true }
),
safelist: {
standard: [ "show", "fade", "collapsing", "modal-backdrop", "offcanvas-backdrop" ],
greedy: [ /^bs-/, /^data-bs-/ ],
},
} )
standard keeps individual class names Bootstrap's JS toggles at runtime; greedy keeps every class matching the regex (anything starting with bs- or data-bs-, which covers all Bootstrap data attributes). Add new entries to standard if you toggle a class by name from JS; add a greedy regex for any pattern Bootstrap uses that PurgeCSS would otherwise strip.
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 in production builds (isProduction && new JavaScriptObfuscator(...)). The full config:
new JavaScriptObfuscator( {
domainLock: [
"localhost",
"127.0.0.1:7000",
"nations-original.com",
],
debugProtection: true,
debugProtectionInterval: 4000,
disableConsoleOutput: true,
compact: true,
controlFlowFlattening: true,
controlFlowFlatteningThreshold: 0.75,
deadCodeInjection: true,
deadCodeInjectionThreshold: 0.4,
identifierNamesGenerator: "hexadecimal",
log: false,
numbersToExpressions: true,
renameGlobals: false,
selfDefending: true,
simplify: true,
splitStrings: true,
splitStringsChunkLength: 5,
stringArray: true,
stringArrayCallsTransform: true,
stringArrayCallsTransformThreshold: 0.75,
stringArrayEncoding: [ "rc4" ],
stringArrayIndexShift: true,
stringArrayRotate: true,
stringArrayShuffle: true,
stringArrayWrappersCount: 2,
stringArrayWrappersChainedCalls: true,
stringArrayWrappersParametersMaxCount: 4,
stringArrayWrappersType: "function",
unicodeEscapeSequence: false,
}, [ "vendor.*.js" ] )
Domain lock — the bundle only executes on the hard-coded list ["localhost", "127.0.0.1:7000", "nations-original.com"]. There is no buildDomainLock() function or env-var reader — to add a new domain, edit the array directly. If someone downloads the JS and runs it on a different origin, it fails silently.
debugProtection: true + debugProtectionInterval: 4000 — the bundle fires debugger statements every 4 seconds and tries to freeze DevTools, making interactive debugging extremely painful. This is on by default for the production build.
controlFlowFlattening: true — the readable if/for/while structure is flattened into a state machine with a switch dispatcher. Reverse-engineering tools struggle to reconstruct the original control flow.
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-encoded at rest; decoded only at runtime. They never appear as readable literals in the bundle source.
stringArrayWrappersType: 'function' + wrappersCount: 2 — two layers of function indirection wrap every string lookup. Tracing a string to its value requires following multiple call chains.
selfDefending: true — the bundle detects formatting or modification and re-evaluates to a broken state if tampered with.
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.).
build_manifest()Internal shared loader for the manifest JSON. Reads public/build/manifest.json once and caches the result in a static variable for the lifetime of the request:
function build_manifest(): array
{
static $manifest = null;
if ( $manifest === null ) {
$manifestPath = project_dir() . '/public/build/manifest.json';
$manifest = file_exists( $manifestPath )
? json_decode( file_get_contents( $manifestPath ), true )
: [];
}
return $manifest;
}
Most applications won't call this directly — manifest_asset() and manifest_has() both delegate to it. It is available as a public function if you need to inspect the full manifest contents (e.g. for custom asset loading logic).
manifest_has()Returns true if a given logical filename is present in the manifest and the resolved file physically exists on disk:
function manifest_has( string $filename ): bool
Use this to conditionally include optional build chunks that may or may not be present in a given build (for example a lazily-split vendor chunk):
// In head.php — only include the vendor chunk if it was emitted in this build
<?php if ( manifest_has( 'vendor.js' ) ) : ?>
<script src="<?= manifest_asset( 'vendor.js' ) ?>"></script>
<?php endif ?>
<script src="<?= manifest_asset( 'app.js' ) ?>"></script>
manifest_has() resolves the filename through the manifest first (falling back to build/$filename in development), then calls file_exists() on the resolved path. It does not throw — it returns false for any missing file.
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
Delegates to build_manifest() for the manifest lookup (shared static cache).
| 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. Use manifest_has() to check whether an optional chunk was emitted before including it.
Usage in templates:
<?php if ( manifest_has( 'vendor.js' ) ) : ?>
<script src="<?= manifest_asset( 'vendor.js' ) ?>"></script>
<?php endif ?>
<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 marked and sanitizes it via DOMPurify before form submission. The relevant scripts are loaded from public/CKEditor/build/marked.js and public/CKEditor/build/purify.js:
// In CKEditor_activator.php
$( 'form#editor' ).on( 'submit', ( event ) => {
event.preventDefault();
$( '#editor_data' )[0].value = DOMPurify.sanitize( marked.parse( editor.getData() ) );
event.currentTarget.submit();
} );
Forgetting to update domain lock before deploying — the production bundle only executes on domains returned by buildDomainLock(). If you add a new domain or change your production URL and forget to update the DOMAIN_LOCK env var and rebuild, the JavaScript silently fails on the new domain with no error message. Update DOMAIN_LOCK and run yarn 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 yarn build in development — production builds apply obfuscation which makes debugging impossible. Use yarn dev during development and only run yarn 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.