diff --git a/.gitignore b/.gitignore index 4b5b6746381..b43392c4538 100644 --- a/.gitignore +++ b/.gitignore @@ -1,3 +1,4 @@ +/.dev-preview /.idea/ /node_modules /lua/output diff --git a/README.md b/README.md index 74287267068..b9d86ebfcd3 100644 --- a/README.md +++ b/README.md @@ -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. diff --git a/package.json b/package.json index e0130280365..f4f4f9f5090 100644 --- a/package.json +++ b/package.json @@ -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", @@ -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", diff --git a/scripts/dev-preview/browser.js b/scripts/dev-preview/browser.js new file mode 100644 index 00000000000..39dd9ff1f6b --- /dev/null +++ b/scripts/dev-preview/browser.js @@ -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 }; diff --git a/scripts/dev-preview/classify.js b/scripts/dev-preview/classify.js new file mode 100644 index 00000000000..736a8ffe4fa --- /dev/null +++ b/scripts/dev-preview/classify.js @@ -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 }; diff --git a/scripts/dev-preview/constants.js b/scripts/dev-preview/constants.js new file mode 100644 index 00000000000..cd478c7368f --- /dev/null +++ b/scripts/dev-preview/constants.js @@ -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' + ] + } +}; diff --git a/scripts/dev-preview/index.js b/scripts/dev-preview/index.js new file mode 100644 index 00000000000..6fa8b3c25f8 --- /dev/null +++ b/scripts/dev-preview/index.js @@ -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(); diff --git a/scripts/dev-preview/pac.js b/scripts/dev-preview/pac.js new file mode 100644 index 00000000000..93102c042b1 --- /dev/null +++ b/scripts/dev-preview/pac.js @@ -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 }; diff --git a/scripts/dev-preview/proxy.js b/scripts/dev-preview/proxy.js new file mode 100644 index 00000000000..6e0c4057fd6 --- /dev/null +++ b/scripts/dev-preview/proxy.js @@ -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 }; diff --git a/scripts/dev-preview/refresh-client.js b/scripts/dev-preview/refresh-client.js new file mode 100644 index 00000000000..1339d74cfbb --- /dev/null +++ b/scripts/dev-preview/refresh-client.js @@ -0,0 +1,19 @@ +// Appended to the JS bundle we serve on only=scripts. No