Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
/.dev-preview
/.idea/
/node_modules
/lua/output
Expand Down
28 changes: 28 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -150,6 +150,34 @@ To test your changes in action, you can run the GitHub Action called "Personal D
To check the workflow progress from the CLI, you can run:
`gh run list --workflow="deploy test.yml"`

#### Local asset preview (CSS/JS)

`npm run dev` opens a browser rendering liquipedia.net with your locally-built
CSS and JS injected, and auto-reloads it whenever you save a `.scss` or `.js`
file. Any editor works — there is no editor integration to configure.

Requirements: a Chromium-family browser (Chrome, Chromium, Brave, or Edge) and
`npm install` already run.

```bash
npm run dev
```

- Edit a `.scss` or `.js` file and save — the browser reloads automatically.
- Press Ctrl-C to stop the proxy, watcher, and browser.

Environment overrides:

- `LP_DEV_BROWSER` — absolute path to a browser binary if auto-detection fails.
- `LP_DEV_PORT` — proxy port (default 8081).
- `npm run dev -- --clean` — wipe the throwaway browser profile before starting.

**This previews CSS/JS only.** Lua modules render server-side and cannot be
previewed locally — use the dev-wiki deploy flow for those. This tool launches a
dedicated, disposable browser profile that ignores certificate errors and proxies
only `*.liquipedia.net`; your normal browser and browsing are untouched. Because
certificate validation is disabled in this profile, avoid browsing other sites in it.

## Support

If you encounter any issues or have questions, feel free to open an issue on GitHub or reach out to the Liquipedia community for support.
Expand Down
3 changes: 3 additions & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -7,11 +7,13 @@
"@playwright/test": "^1.61.1",
"@stylistic/stylelint-plugin": "^2.1.2",
"@typescript-eslint/eslint-plugin": "^8.56.1",
"chokidar": "^4.0.3",
"eslint": "9.x.x",
"eslint-config-wikimedia": "0.32.3",
"eslint-plugin-jsdoc": "^62.6.0",
"eslint-plugin-vue": "10.6.x",
"globals": "^17.3.0",
"http-mitm-proxy": "^1.1.0",
"jest": "^30.2.0",
"jest-environment-jsdom": "^30.2.0",
"jest-junit": "^16.0.0",
Expand All @@ -33,6 +35,7 @@
]
},
"scripts": {
"dev": "node scripts/dev-preview/index.js",
"lint": "npm run lint:js && npm run lint:scss",
"lint:ci": "npm run lint:js && npm run lint:scss:ci",
"lint:js": "eslint --fix",
Expand Down
38 changes: 38 additions & 0 deletions scripts/dev-preview/browser.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
const { spawn } = require( 'child_process' );
const fs = require( 'fs' );
const { BROWSER_CANDIDATES } = require( './constants.js' );

// Pure: pick the browser binary. LP_DEV_BROWSER wins; otherwise first existing
// candidate for the platform. Returns null if none found.
function detectBrowser( { env, platform, existsSync } ) {
const override = env.LP_DEV_BROWSER;
if ( override ) {
return existsSync( override ) ? override : null;
}
const candidates = BROWSER_CANDIDATES[ platform ] || [];
for ( const candidate of candidates ) {
if ( existsSync( candidate ) ) {
return candidate;
}
}
return null;
}

// Thin shell: spawn the browser detached-but-tracked so we can kill it on teardown.
// --disable-quic is required: HTTP/3 bypasses the HTTP proxy entirely.
function launchBrowser( { browserPath, profileDir, pacUrl, url } ) {
const args = [
`--user-data-dir=${ profileDir }`,
`--proxy-pac-url=${ pacUrl }`,
'--ignore-certificate-errors',
'--test-type',
'--disable-quic',
'--no-first-run',
'--no-default-browser-check',
'--new-window',
url
];
return spawn( browserPath, args, { stdio: 'ignore' } );
}

module.exports = { detectBrowser, launchBrowser, defaultExistsSync: fs.existsSync };
42 changes: 42 additions & 0 deletions scripts/dev-preview/classify.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
// Classify a proxied request URL into the local asset we should serve, or
// 'passthrough' to forward it to the real server untouched.
function classifyRequest( rawUrl ) {
let parsed;
try {
parsed = new URL( rawUrl );
} catch ( e ) {
return 'passthrough';
}

const host = parsed.hostname.toLowerCase();
const isLiquipedia = host === 'liquipedia.net' || host.endsWith( '.liquipedia.net' );
if ( !isLiquipedia || !parsed.pathname.endsWith( '/commons/load.php' ) ) {
return 'passthrough';
}

// The LakesideView skin needs runtime theme vars we cannot compile locally.
if ( ( parsed.searchParams.get( 'skin' ) || '' ) === 'lakesideview' ) {
return 'passthrough';
}

const only = parsed.searchParams.get( 'only' );
if ( only === 'styles' ) {
return 'styles';
}
if ( only === 'scripts' ) {
return 'scripts';
}
return 'passthrough';
}

function contentTypeFor( kind ) {
if ( kind === 'styles' ) {
return 'text/css; charset=utf-8';
}
if ( kind === 'scripts' ) {
return 'text/javascript; charset=utf-8';
}
throw new Error( `No content type for kind: ${ kind }` );
}

module.exports = { classifyRequest, contentTypeFor };
40 changes: 40 additions & 0 deletions scripts/dev-preview/constants.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
const path = require( 'path' );

const repoRoot = path.resolve( __dirname, '..', '..' );

const DEV_DIR = path.join( repoRoot, '.dev-preview' );

module.exports = {
REPO_ROOT: repoRoot,
OUTPUT_CSS: path.join( repoRoot, 'lua', 'output', 'css', 'main.css' ),
OUTPUT_JS: path.join( repoRoot, 'lua', 'output', 'js', 'main.js' ),
DEV_DIR,
PROFILE_DIR: path.join( DEV_DIR, 'profile' ),
CA_DIR: path.join( DEV_DIR, 'ca' ),
DEFAULT_PORT: 8081,
WATCH_GLOBS: [ 'stylesheets/**/*.scss', 'javascript/**/*.js' ],
// First existing entry wins. LP_DEV_BROWSER env overrides all of this.
BROWSER_CANDIDATES: {
linux: [
'/usr/bin/google-chrome',
'/usr/bin/google-chrome-stable',
'/usr/bin/chromium',
'/usr/bin/chromium-browser',
'/usr/bin/brave-browser',
'/opt/brave-bin/brave',
'/usr/bin/microsoft-edge'
],
darwin: [
'/Applications/Google Chrome.app/Contents/MacOS/Google Chrome',
'/Applications/Chromium.app/Contents/MacOS/Chromium',
'/Applications/Brave Browser.app/Contents/MacOS/Brave Browser',
'/Applications/Microsoft Edge.app/Contents/MacOS/Microsoft Edge'
],
win32: [
'C:\\Program Files\\Google\\Chrome\\Application\\chrome.exe',
'C:\\Program Files (x86)\\Google\\Chrome\\Application\\chrome.exe',
'C:\\Program Files\\BraveSoftware\\Brave-Browser\\Application\\brave.exe',
'C:\\Program Files (x86)\\Microsoft\\Edge\\Application\\msedge.exe'
]
}
};
94 changes: 94 additions & 0 deletions scripts/dev-preview/index.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,94 @@
const fs = require( 'fs' );
const { execFileSync } = require( 'child_process' );
const { startProxy } = require( './proxy.js' );
const { startWatcher } = require( './watch.js' );
const { detectBrowser, launchBrowser, defaultExistsSync } = require( './browser.js' );
const { pacDataUrl } = require( './pac.js' );
const {
REPO_ROOT, DEV_DIR, PROFILE_DIR, DEFAULT_PORT
} = require( './constants.js' );

const npmCmd = process.platform === 'win32' ? 'npm.cmd' : 'npm';

function fail( message ) {
console.error( `[dev] ${ message }` );
process.exit( 1 );
}

async function main() {
const args = process.argv.slice( 2 );
const port = Number( process.env.LP_DEV_PORT ) || DEFAULT_PORT;

if ( args.includes( '--clean' ) ) {
fs.rmSync( PROFILE_DIR, { recursive: true, force: true } );
console.log( '[dev] wiped browser profile' );
}
fs.mkdirSync( DEV_DIR, { recursive: true } );

// Fail fast: find a browser before doing anything expensive.
const browserPath = detectBrowser( {
env: process.env,
platform: process.platform,
existsSync: defaultExistsSync
} );
if ( !browserPath ) {
fail( 'No Chromium-family browser found. Set LP_DEV_BROWSER to a chrome/chromium/brave/edge binary.' );
}

// Initial build so assets exist on first page load.
try {
console.log( '[dev] initial build...' );
execFileSync( npmCmd, [ 'run', 'build' ], { cwd: REPO_ROOT, stdio: 'inherit' } );
} catch ( e ) {
fail( 'Initial build failed (see output above).' );
}

const state = { needsReload: false };

let proxy;
try {
proxy = await startProxy( { port, state } );
} catch ( e ) {
fail( `Could not start proxy on port ${ port } (${ e.message }). Set LP_DEV_PORT to another port.` );
}

const watcher = startWatcher( { state } );

const browser = launchBrowser( {
browserPath,
profileDir: PROFILE_DIR,
pacUrl: pacDataUrl( port ),
url: 'https://liquipedia.net'
} );
console.log( `[dev] launched ${ browserPath }` );
console.log( '[dev] edit .scss/.js and the browser reloads. Ctrl-C to stop.' );

let closing = false;
function teardown() {
if ( closing ) {
return;
}
closing = true;
console.log( '\n[dev] shutting down...' );
try {
watcher.stop();
} catch ( e ) { /* ignore */ }
try {
proxy.stop();
} catch ( e ) { /* ignore */ }
try {
browser.kill();
} catch ( e ) { /* ignore */ }
process.exit( 0 );
}

process.on( 'SIGINT', teardown );
process.on( 'SIGTERM', teardown );
browser.on( 'exit', teardown );
browser.on( 'error', ( e ) => {
console.error( `[dev] browser process error: ${ e.message }` );
teardown();
} );
}

main();
22 changes: 22 additions & 0 deletions scripts/dev-preview/pac.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
function buildPac( port ) {
// Apex + subdomains only. shExpMatch( '*.liquipedia.net' ) needs a dot
// before the suffix, so it rejects lookalikes like notliquipedia.net;
// the explicit apex check covers the bare domain. (Do NOT use
// dnsDomainIs( host, 'liquipedia.net' ) — it is a plain suffix match and
// would route notliquipedia.net through the proxy.)
return [
'function FindProxyForURL( url, host ) {',
'\tif ( host === "liquipedia.net" || shExpMatch( host, "*.liquipedia.net" ) ) {',
`\t\treturn "PROXY 127.0.0.1:${ port }";`,
'\t}',
'\treturn "DIRECT";',
'}'
].join( '\n' );
}

function pacDataUrl( port ) {
const encoded = encodeURIComponent( buildPac( port ) );
return `data:application/x-ns-proxy-autoconfig,${ encoded }`;
}

module.exports = { buildPac, pacDataUrl };
81 changes: 81 additions & 0 deletions scripts/dev-preview/proxy.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,81 @@
const fs = require( 'fs' );
const { Proxy } = require( 'http-mitm-proxy' );
const { classifyRequest, contentTypeFor } = require( './classify.js' );
const { REFRESH_CLIENT } = require( './refresh-client.js' );
const { OUTPUT_CSS, OUTPUT_JS, CA_DIR } = require( './constants.js' );

const REBUILD_PATH = '/__lp_dev_rebuild_check__';

function readOrNull( file ) {
try {
return fs.readFileSync( file, 'utf8' );
} catch ( e ) {
return null;
}
}

function serve( res, body, contentType ) {
res.writeHead( 200, {
'Content-Type': contentType,
'Cache-Control': 'no-store'
} );
res.end( body );
}

function startProxy( { port, state } ) {
const proxy = new Proxy();

proxy.onError( ( ctx, err ) => {
console.error( `[proxy] ${ err && err.message ? err.message : err }` );
} );

proxy.onRequest( ( ctx, callback ) => {
const req = ctx.clientToProxyRequest;
const host = req.headers.host || '';
const res = ctx.proxyToClientResponse;

// Rebuild-check endpoint (fetched relative to liquipedia.net origin).
if ( req.url.startsWith( REBUILD_PATH ) ) {
const reload = state.needsReload === true;
state.needsReload = false;
serve( res, JSON.stringify( { reload } ), 'application/json; charset=utf-8' );
return;
}

const kind = classifyRequest( `https://${ host }${ req.url }` );
if ( kind === 'styles' ) {
const css = readOrNull( OUTPUT_CSS );
if ( css !== null ) {
serve( res, css, contentTypeFor( 'styles' ) );
return;
}
} else if ( kind === 'scripts' ) {
const js = readOrNull( OUTPUT_JS );
if ( js !== null ) {
serve( res, js + '\n' + REFRESH_CLIENT, contentTypeFor( 'scripts' ) );
return;
}
}

// passthrough, or local file missing → forward untouched.
callback();
} );

return new Promise( ( resolve, reject ) => {
// Force IPv4 loopback: http-mitm-proxy's internal per-hostname HTTPS
// tunnel dials out via a hardcoded 0.0.0.0 (which the OS resolves as
// 127.0.0.1). If host defaults to 'localhost' and that resolves to
// ::1 first (common on modern Linux), the internal server binds
// IPv6-only and the tunnel connect fails with ECONNREFUSED.
proxy.listen( { host: '127.0.0.1', port, sslCaDir: CA_DIR }, ( err ) => {
if ( err ) {
reject( err );
return;
}
console.log( `[proxy] listening on 127.0.0.1:${ port }` );
resolve( { stop: () => proxy.close() } );
} );
} );
}

module.exports = { startProxy, REBUILD_PATH };
Loading
Loading