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/
/cookies
/node_modules
Expand Down
39 changes: 39 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -150,6 +150,45 @@ 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 picks up changes whenever you save a `.scss` or `.js`
file. Any editor works — there is no editor integration to configure.

It is a batteries-included wrapper around the same `scripts/proxy_lp.py`
interception addon you can run by hand as `mitmproxy -s scripts/proxy_lp.py`,
adding a throwaway browser profile (so no CA certificate has to be installed
anywhere), a file watcher, and live updates.

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

```bash
npm run dev
```

- Save a `.scss` file — the new stylesheet is swapped in without reloading, so
scroll position, open dialogs, and half-written edits survive.
- Save a `.js` file — the page reloads, since new script has to be re-executed.
Reloads are held back while an edit form or preview is open, so an in-progress
wiki preview is never clobbered; refresh manually when you are ready.
- 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_MITMDUMP` — path to the `mitmdump` binary if it is not on `PATH`.
- `LP_DEV_PORT` — proxy port (default 8081).
- `npm run dev -- --clean` — wipe the throwaway browser profile before starting.
- `npm run dev -- --no-reload` — rebuild on save but never touch the page.

**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
2 changes: 2 additions & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@
"@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",
Expand All @@ -33,6 +34,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
41 changes: 41 additions & 0 deletions scripts/dev-preview/browser.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
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 in our own process group — deliberately not
// detached — so Ctrl-C reaches it, and index.js also kills it explicitly and
// tears down when it exits. Detaching would take it out of the group, leaving an
// orphaned browser holding the profile lock whenever teardown does not run.
// --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 };
46 changes: 46 additions & 0 deletions scripts/dev-preview/constants.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,46 @@
const path = require( 'path' );

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

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

module.exports = {
REPO_ROOT: repoRoot,
DEV_DIR,
PROFILE_DIR: path.join( DEV_DIR, 'profile' ),
// mitmdump keeps its generated CA here rather than in ~/.mitmproxy, so it is
// disposable along with the rest of .dev-preview. Nothing is ever installed
// into a trust store — the throwaway browser profile ignores cert errors.
MITM_CONFDIR: path.join( DEV_DIR, 'mitmproxy' ),
// Watcher → proxy handshake. Content is "<buildId> <kinds>", e.g.
// "1754400000000 css,js". Written atomically here, read (never consumed) by
// scripts/proxy_lp.py.
MARKER_FILE: path.join( DEV_DIR, 'rebuild_marker' ),
PROXY_SCRIPT: path.join( repoRoot, 'scripts', 'proxy_lp.py' ),
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'
]
}
};
102 changes: 102 additions & 0 deletions scripts/dev-preview/index.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,102 @@
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, MARKER_FILE, 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;
// Opt out when a reload would get in the way — long-lived wiki previews,
// sandbox pages, anything holding state you do not want disturbed.
const reload = !args.includes( '--no-reload' );

if ( args.includes( '--clean' ) ) {
fs.rmSync( PROFILE_DIR, { recursive: true, force: true } );
console.log( '[dev] wiped browser profile' );
}
fs.mkdirSync( DEV_DIR, { recursive: true } );
// Start from a clean slate so a marker from a previous session cannot be
// mistaken for a rebuild in this one.
fs.rmSync( MARKER_FILE, { force: 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).' );
}

let proxy;
try {
proxy = await startProxy( { port, reload } );
} catch ( e ) {
fail( e.message );
}

const watcher = startWatcher();

const browser = launchBrowser( {
browserPath,
profileDir: PROFILE_DIR,
pacUrl: pacDataUrl( port ),
url: 'https://liquipedia.net'
} );
console.log( `[dev] launched ${ browserPath }` );
if ( reload ) {
console.log( '[dev] edit .scss for an in-place style swap, .js to reload. Ctrl-C to stop.' );
} else {
console.log( '[dev] reload disabled — edits are rebuilt, refresh to see them. 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 };
92 changes: 92 additions & 0 deletions scripts/dev-preview/proxy.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,92 @@
const { spawn, spawnSync } = require( 'child_process' );
const { MITM_CONFDIR, PROXY_SCRIPT, REPO_ROOT } = require( './constants.js' );

const MITMDUMP = process.env.LP_DEV_MITMDUMP || 'mitmdump';

// How long to wait for mitmdump to prove it stayed up. It exits within
// milliseconds on the common failures (port taken, bad addon, missing dep), so
// this only has to outlast process spawn.
const STARTUP_GRACE_MS = 1500;

const INSTALL_HINT = `Could not run "${ MITMDUMP }". Install it with:\n` +
' pip install mitmproxy\n' +
'Or point LP_DEV_MITMDUMP at the binary.';

// Cheap pre-flight so a missing mitmproxy fails with an install hint rather
// than an ENOENT stack trace after we have already run a build.
function checkAvailable() {
const probe = spawnSync( MITMDUMP, [ '--version' ], { stdio: 'ignore' } );
return !probe.error;
}

/**
* Spawn mitmdump with scripts/proxy_lp.py as its addon. The addon owns all
* request handling (interception, the rebuild-check endpoint, reload-client
* injection); this module only owns the process lifecycle.
*
* @param {Object} options
* @param {number} options.port Port to listen on, loopback only.
* @param {boolean} options.reload Whether the addon should inject the reload client.
* @return {Promise<{stop: Function}>}
*/
function startProxy( { port, reload } ) {
if ( !checkAvailable() ) {
return Promise.reject( new Error( INSTALL_HINT ) );
}

const args = [
'--listen-host', '127.0.0.1',
'--listen-port', String( port ),
'--set', `confdir=${ MITM_CONFDIR }`,
// Drop the per-request flow log; startup errors and the addon's own
// output still come through. Do not turn termlog_verbosity down to do
// this — that hides addon warnings too.
'--flow-detail', '0',
'-s', PROXY_SCRIPT
];

const child = spawn( MITMDUMP, args, {
cwd: REPO_ROOT,
stdio: [ 'ignore', 'inherit', 'inherit' ],
env: Object.assign( {}, process.env, { LP_DEV_RELOAD: reload ? '1' : '0' } )
} );

return new Promise( ( resolve, reject ) => {
let settled = false;

child.on( 'error', ( err ) => {
if ( settled ) {
return;
}
settled = true;
reject( new Error( `${ INSTALL_HINT }\n(${ err.message })` ) );
} );

// Exiting during the grace period means it never got listening — surface
// that as a startup failure instead of a silently dead proxy.
child.on( 'exit', ( code ) => {
if ( settled ) {
console.error( `[proxy] mitmdump exited (code ${ code })` );
return;
}
settled = true;
reject( new Error(
`mitmdump exited immediately (code ${ code }); see output above. ` +
`If port ${ port } is taken, set LP_DEV_PORT to another one.`
) );
} );

setTimeout( () => {
if ( settled ) {
return;
}
settled = true;
console.log( `[proxy] mitmdump listening on 127.0.0.1:${ port }` );
resolve( {
stop: () => child.kill( 'SIGTERM' )
} );
}, STARTUP_GRACE_MS );
} );
}

module.exports = { startProxy };
Loading
Loading