diff --git a/.github/workflows/start-gitlab.yml b/.github/workflows/start-gitlab.yml
deleted file mode 100644
index 462e725d..00000000
--- a/.github/workflows/start-gitlab.yml
+++ /dev/null
@@ -1,55 +0,0 @@
-name: Start GitLab CI
-on:
- # Use pull_request_target to run the workflow from the base branch (e.g., main)
- # This ensures the trusted workflow logic executes, even for PRs from forks.
- # It also grants access to secrets needed for the trigger.
- pull_request_target:
- types: [opened, synchronize, reopened]
-jobs:
- trigger-gitlab:
- runs-on: ubuntu-latest
- steps:
- - name: Start Gitlab Pipeline
- env:
- # Get trigger config from secrets
- GL_TRIGGER_TOKEN: ${{ secrets.GL_TRIGGER_TOKEN }}
- GL_TRIGGER_URL: ${{ secrets.GL_TRIGGER_URL }}
- # Use a specific ref from secrets if provided, otherwise default to the PR's head branch name
- GL_TRIGGER_REF: ${{ secrets.GL_TRIGGER_REF || github.event.pull_request.head.ref }}
- # --- Variables to pass to GitLab ---
- # The commit SHA in the GitHub PR
- GITHUB_PR_SHA: ${{ github.event.pull_request.head.sha }}
- # The ref (branch name) of the PR head
- GITHUB_PR_REF: ${{ github.event.pull_request.head.ref }}
- # The repository name (e.g., 'your-org/your-repo')
- GITHUB_REPO: ${{ github.repository }}
- # The GitHub token for reporting status back
- GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
- GITHUB_REPO_URL: ${{ github.event.pull_request.head.repo.clone_url }}
- run: |
- # --- Safety Checks ---
- # Ensure critical secrets are actually available (they should be with pull_request_target)
- if [ -z "$GL_TRIGGER_TOKEN" ]; then
- echo "::error::GL_TRIGGER_TOKEN secret is missing or unavailable!"
- exit 1
- fi
- if [ -z "$GITHUB_TOKEN" ]; then
- echo "::error::GITHUB_TOKEN is empty. Secrets may not be properly accessed."
- exit 1
- fi
- # Ensure URL is set
- if [ -z "$GL_TRIGGER_URL" ]; then
- echo "::error::GL_TRIGGER_URL secret is missing or unavailable!"
- exit 1
- fi
-
- echo "Triggering GitLab pipeline for SHA: ${GITHUB_PR_SHA}"
- curl --fail --silent --show-error --request POST \
- --form token="${GL_TRIGGER_TOKEN}" \
- --form ref="${GL_TRIGGER_REF}" \
- --form "variables[GITHUB_PR_SHA]=${GITHUB_PR_SHA}" \
- --form "variables[GITHUB_PR_REF]=${GITHUB_PR_REF}" \
- --form "variables[GITHUB_REPO]=${GITHUB_REPO}" \
- --form "variables[GITHUB_REPO_URL]=${GITHUB_REPO_URL}" \
- "${GL_TRIGGER_URL}" > /dev/null
- echo "GitLab pipeline triggered."
diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml
new file mode 100644
index 00000000..fc951c74
--- /dev/null
+++ b/.github/workflows/test.yml
@@ -0,0 +1,95 @@
+name: Test
+
+# Runs on pull requests (including forks) and on pushes to master.
+# Uses `pull_request`, not `pull_request_target`: fork code runs with a
+# read-only token and no access to repository secrets. No secrets are used.
+on:
+ pull_request:
+ push:
+ branches: [master]
+
+permissions:
+ contents: read
+
+concurrency:
+ group: test-${{ github.event.pull_request.number || github.ref }}
+ cancel-in-progress: true
+
+jobs:
+ unit:
+ name: Unit tests and build
+ runs-on: ubuntu-24.04
+ timeout-minutes: 20
+ steps:
+ - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
+ with:
+ persist-credentials: false
+
+ - uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0
+ with:
+ node-version: 18.20.4 # same as contrib/Dockerfile.base
+
+ - name: Install
+ run: npm install --no-audit --no-fund
+
+ - name: Test
+ run: npm test
+
+ - name: Build (bitcoin-mainnet, liquid-mainnet)
+ run: |
+ DEST=dist/bitcoin-mainnet npm run dist -- bitcoin-mainnet
+ DEST=dist/liquid-mainnet npm run dist -- liquid-mainnet
+
+ regtest:
+ # Same check the old GitLab job ran: build the images and run the regtest
+ # explorer from contrib/docker-compose.yml until its API reports block 100.
+ name: Docker regtest (${{ matrix.arch }})
+ runs-on: ${{ matrix.runner }}
+ timeout-minutes: 120
+ strategy:
+ fail-fast: false
+ matrix:
+ include:
+ - arch: amd64
+ runner: ubuntu-24.04
+ - arch: arm64
+ runner: ubuntu-24.04-arm
+ steps:
+ - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
+ with:
+ persist-credentials: false
+
+ - name: Build base image
+ # The published base image is only a cache source (public pull, no login).
+ run: |
+ docker pull "blockstream/esplora-base:latest-${{ matrix.arch }}" || true
+ docker build \
+ -f contrib/Dockerfile.base \
+ --build-arg BUILDKIT_INLINE_CACHE=1 \
+ --cache-from "blockstream/esplora-base:latest-${{ matrix.arch }}" \
+ -t blockstream/esplora-base:latest .
+
+ - name: Build and start the regtest explorer
+ run: docker compose -f contrib/docker-compose.yml up --build -d
+
+ - name: Wait for block height 100
+ run: |
+ for i in $(seq 1 60); do
+ height=$(curl --fail --silent --max-time 10 http://localhost/regtest/api/blocks/tip/height || true)
+ if [ "$height" = "100" ]; then
+ echo "Success: block height is 100."
+ exit 0
+ fi
+ echo "Attempt $i: height '${height}', waiting..."
+ sleep 5
+ done
+ echo "Failure: block height did not reach 100."
+ exit 1
+
+ - name: Show container logs
+ if: failure()
+ run: docker compose -f contrib/docker-compose.yml logs --tail 200
+
+ - name: Stop
+ if: always()
+ run: docker compose -f contrib/docker-compose.yml down
diff --git a/README.md b/README.md
index 8ad9790f..e9f2a020 100644
--- a/README.md
+++ b/README.md
@@ -94,6 +94,25 @@ and run `$ npm run dist`. The files will be created under `dist/`.
Because Esplora is a single-page app, the HTTP server needs to be configured to serve the `index.html` file in reply to missing pages.
See [`contrib/nginx.conf.in`](contrib/nginx.conf.in) for example nginx configuration (TL;DR: `try_files $uri /index.html`).
+### Content-hashed frontend assets
+
+Production builds create content-hashed copies of the app, stylesheets, lazy-loaded
+scripts and fonts referenced directly by the stylesheets by default. Original
+filenames are retained for rollback and cached HTML. `asset-manifest.json` in the
+output directory (`dist/`, or `DEST`) maps original relative paths to hashed paths.
+Set `HASH_ASSETS=0` to disable hashing and use plain filenames without a manifest.
+
+Both server-side renderers accept `ASSET_MANIFEST` pointing to this file (default:
+`DEST/asset-manifest.json`, or the project's `dist/asset-manifest.json`). Missing
+manifests fall back to plain filenames; `STATIC_ROOT` is applied to either form.
+
+Deployment must upload new hashed files with
+`Cache-Control: public, max-age=31536000, immutable` **before** updating HTML, and
+must not delete old hashed files immediately after deployment (cached HTML and
+open tabs still reference them). If the server falls back to `index.html` for
+unknown paths, hashed-asset misses must return a real 404 instead, so a missing
+file is never cached as HTML.
+
## Pre-rendering server (noscript)
To start a pre-rendering server that generates static HTML replies suitable for noscript users, run:
diff --git a/build.sh b/build.sh
index 3bb37a98..fb9d671d 100755
--- a/build.sh
+++ b/build.sh
@@ -31,9 +31,6 @@ cp -RL www/* $CUSTOM_ASSETS $DEST/
# Assemble base CSS modules followed by flavor customizations
node scripts/assemble-css.js "$DEST/style.css" $CUSTOM_CSS
-# Index HTML
-pug client/index.pug -o $DEST
-
# Open search (requires absolute CANONICAL_URL)
if [ -n "$CANONICAL_URL" ]; then
pug client/opensearch.pug -E xml -o $DEST
@@ -42,11 +39,26 @@ fi
# RTLify CSS
cat $DEST/style.css | node -p "require('cssjanus').transform(fs.readFileSync('/dev/stdin').toString(), false, true)" > $DEST/style-rtl.css
+# Resolve lazy-loaded scripts before envify bakes their filenames into app.js.
+export ASSET_MANIFEST="$DEST/asset-manifest.json"
+unset INSTASCAN_ASSET INFINITE_SCROLL_ASSET
+if [[ "$HASH_ASSETS" != "0" ]]; then
+ node scripts/hash-assets.js "$DEST" --scripts-only
+ export INSTASCAN_ASSET=$(node -p "require('./scripts/asset-manifest').loadAssetManifest()['instascan.min.js']")
+ export INFINITE_SCROLL_ASSET=$(node -p "require('./scripts/asset-manifest').loadAssetManifest()['js/infinite-scroll.js']")
+fi
+
# Browserify bundle
# --no-dedupe needed due to https://github.com/substack/bundle-collapser/issues/20 https://github.com/browserify/browserify/issues/1450
(cd client && browserify --no-dedupe -p bundle-collapser/plugin src/run-browser.js \
| ( [[ "$NODE_ENV" != "development" ]] && uglifyjs -cm || cat ) ) \
> $DEST/app.js
+# Fonts are rewritten before CSS hashing; HTML is rendered only after app.js is hashed.
+if [[ "$HASH_ASSETS" != "0" ]]; then
+ node scripts/hash-assets.js "$DEST"
+fi
+pug client/index.pug -o $DEST -O "$(node scripts/asset-manifest.js)"
+
# Pre-render notfound.html
babel-node render-view.js '{"view":"error","error":"Page Not Found"}' > $DEST/notfound.html
diff --git a/client/index.pug b/client/index.pug
index a40194f7..e4aae793 100644
--- a/client/index.pug
+++ b/client/index.pug
@@ -4,7 +4,10 @@ doctype html
- staticRoot = process.env.STATIC_ROOT || ''
- pageTitle = prerender_title ? `${prerender_title} ยท ${baseTitle}` : baseTitle
-- cssPath = t ? t`style.css` : 'style.css'
+- assets = typeof assetManifest === 'object' && assetManifest || {}
+- ltrPath = staticRoot + (assets['style.css'] || 'style.css')
+- rtlPath = staticRoot + (assets['style-rtl.css'] || 'style-rtl.css')
+- cssPath = t && t`style.css` === 'style-rtl.css' ? rtlPath : ltrPath
- lang = t ? t.lang_id : 'en'
- dir = t ? t`ltr` : 'ltr'
@@ -29,7 +32,7 @@ head
meta(name='viewport', content='width=device-width, initial-scale=1')
link(rel='shortcut icon', type='image/png', href=staticRoot+'img/favicon.png')
- link(rel='stylesheet', href=staticRoot+cssPath)
+ link(rel='stylesheet', href=cssPath, data-ltr=ltrPath, data-rtl=rtlPath)
if process.env.CANONICAL_URL
//- open search requires the absolute URL of the explorer
@@ -53,5 +56,5 @@ body(lang=lang, dir=dir)
.nojs-shim
p This block explorer needs JavaScript to run. Please enable it and reload the page.
- script(src=staticRoot+'app.js', async)
+ script(src=staticRoot+(assets['app.js'] || 'app.js'), async)
!= process.env.FOOT_HTML
diff --git a/client/src/app.js b/client/src/app.js
index a5ff5f08..1ea9f3e3 100644
--- a/client/src/app.js
+++ b/client/src/app.js
@@ -799,9 +799,14 @@ export default function main(
// Click-to-copy
if (navigator.clipboard) copy$.subscribe(text => navigator.clipboard.writeText(text))
- // Switch stylesheet based on current language
- const stylesheet = document.querySelector('link[href="style.css"]')
- t$.map(t => t`style.css`).distinctUntilChanged().subscribe(styleSrc =>
+ // Keep the existing translation's RTL marker, but resolve URLs from the rendered link.
+ const stylesheet = document.querySelector('link[rel="stylesheet"][data-ltr]')
+ || document.querySelector('link[rel="stylesheet"]') // HTML cached before hashing was introduced
+ t$.map(t => {
+ const rtl = t`style.css` === 'style-rtl.css'
+ return stylesheet.getAttribute(rtl ? 'data-rtl' : 'data-ltr')
+ || (process.env.STATIC_ROOT || '') + (rtl ? 'style-rtl.css' : 'style.css')
+ }).distinctUntilChanged().subscribe(styleSrc =>
stylesheet.getAttribute('href') != styleSrc && (stylesheet.href = styleSrc))
// Apply language and text direction to root element
diff --git a/client/src/driver/instascan.js b/client/src/driver/instascan.js
index e946696e..63112690 100644
--- a/client/src/driver/instascan.js
+++ b/client/src/driver/instascan.js
@@ -1,6 +1,7 @@
import { Observable as O } from '../rxjs'
const staticRoot = process.env.STATIC_ROOT || ''
+ , instascanAsset = process.env.INSTASCAN_ASSET || 'instascan.min.js'
// check for WebRTC camera support
if (navigator.mediaDevices && navigator.mediaDevices.getUserMedia) {
@@ -11,11 +12,11 @@ if (navigator.mediaDevices && navigator.mediaDevices.getUserMedia) {
if (loaded) return;
loaded = true;
const script = document.createElement('script')
- script.src = `${staticRoot}instascan.min.js`
+ script.src = `${staticRoot}${instascanAsset}`
document.body.appendChild(script)
}
- const Instascan$ = O.fromEvent(document.body, 'load', true).filter(e => e.target?.src?.endsWith('/instascan.min.js')).map(_ => window.Instascan).share()
+ const Instascan$ = O.fromEvent(document.body, 'load', true).filter(e => e.target?.src?.endsWith(`/${instascanAsset}`)).map(_ => window.Instascan).share()
, Scanner$ = Instascan$.map(Instascan => Instascan.Scanner)
, Camera$ = Instascan$.map(Instascan => Instascan.Camera)
diff --git a/client/src/views/lander.js b/client/src/views/lander.js
index da0d390f..19c7a1ba 100644
--- a/client/src/views/lander.js
+++ b/client/src/views/lander.js
@@ -16,7 +16,8 @@ const LandingPage = ({ t, ...S }) => {
const logos = logoSources
return layout(
-
+ {/* Read at render time: the prerender server sets it from the manifest at startup. */}
+
diff --git a/contrib/runits/prerenderer.runit b/contrib/runits/prerenderer.runit
index ceed22c1..841b7b77 100755
--- a/contrib/runits/prerenderer.runit
+++ b/contrib/runits/prerenderer.runit
@@ -6,6 +6,8 @@ export NODE_ENV=${NODE_ENV:-production}
export API_URL=http://127.0.0.1/{NGINX_PATH}api
export TZ=UTC
export SOCKET_PATH=/var/prerender-http.sock
+# Content-hashed asset names for this flavor's build (see README).
+export ASSET_MANIFEST={STATIC_DIR}/asset-manifest.json
exec 2>&1
exec /bin/bash -c 'source /root/.nvm/nvm.sh && cd /srv/explorer/source && npm run prerender-server -- --cluster {FLAVOR}'
diff --git a/gitlab/test.yml b/gitlab/test.yml
deleted file mode 100644
index 2f6361bc..00000000
--- a/gitlab/test.yml
+++ /dev/null
@@ -1,105 +0,0 @@
-.report_github_status:
- stage: build
- variables:
- GIT_STRATEGY: none
- rules:
- # Only run this job if:
- # 1. The pipeline was started by an API trigger (`CI_PIPELINE_SOURCE == "trigger"`)
- # 2. The required variables from GitHub Actions are present.
- - if: '$CI_PIPELINE_SOURCE == "trigger" && $GITHUB_PR_SHA && $GH_STATUS_TOKEN && $GITHUB_REPO && $GITHUB_PR_REF && $GITHUB_REPO_URL'
- before_script:
- - echo "$DOCKERHUB_PASSWORD" | docker login -u $DOCKERHUB_USER --password-stdin
- # Make sure curl and git are available
- - apk add --no-cache curl git
- # Report "pending" status to GitHub as soon as the job starts
- - |
- echo "Reporting pending status to GitHub commit $GITHUB_PR_SHA"
- curl --fail --request POST \
- --url "https://api.github.com/repos/${GITHUB_REPO}/statuses/${GITHUB_PR_SHA}" \
- --header "Authorization: Bearer ${GH_STATUS_TOKEN}" \
- --header "Accept: application/vnd.github.v3+json" \
- --header "Content-Type: application/json" \
- --data @- <
{
if (err) throw err
console.log(html)
diff --git a/scripts/asset-manifest.js b/scripts/asset-manifest.js
new file mode 100644
index 00000000..2b20eafe
--- /dev/null
+++ b/scripts/asset-manifest.js
@@ -0,0 +1,35 @@
+const fs = require('fs')
+const path = require('path')
+
+// Shared by the static build and both server-side renderers. Dev needs no manifest.
+function loadAssetManifest() {
+ if (process.env.HASH_ASSETS === '0') return {}
+ const filename = process.env.ASSET_MANIFEST
+ || path.resolve(__dirname, '..', process.env.DEST || 'dist', 'asset-manifest.json')
+ try {
+ return JSON.parse(fs.readFileSync(filename, 'utf8'))
+ } catch (error) {
+ if (error.code === 'ENOENT') return {}
+ throw error
+ }
+}
+
+// Lazy-loaded scripts are referenced from views through env vars. The browser bundle gets
+// them from envify at build time; server-side renderers set them from the manifest.
+const lazyAssetEnv = {
+ INSTASCAN_ASSET: 'instascan.min.js',
+ INFINITE_SCROLL_ASSET: 'js/infinite-scroll.js',
+}
+
+function exportAssetEnv(manifest, env=process.env) {
+ for (const [ name, original ] of Object.entries(lazyAssetEnv)) {
+ if (!env[name] && manifest[original]) env[name] = manifest[original]
+ }
+ return env
+}
+
+module.exports = { loadAssetManifest, exportAssetEnv }
+
+if (require.main === module) {
+ console.log(JSON.stringify({ assetManifest: loadAssetManifest() }))
+}
diff --git a/scripts/hash-assets.js b/scripts/hash-assets.js
new file mode 100644
index 00000000..41630df0
--- /dev/null
+++ b/scripts/hash-assets.js
@@ -0,0 +1,65 @@
+const fs = require('fs')
+const path = require('path')
+const { createHash } = require('crypto')
+
+const lazyScripts = [ 'instascan.min.js', 'js/infinite-scroll.js' ]
+const stylesheets = [ 'style.css', 'style-rtl.css' ]
+
+function hashAssets(destination, scriptsOnly=false) {
+ const root = path.resolve(destination)
+ , manifestPath = path.join(root, 'asset-manifest.json')
+ , previous = fs.existsSync(manifestPath) ? JSON.parse(fs.readFileSync(manifestPath, 'utf8')) : {}
+ , originals = Object.fromEntries(Object.entries(previous).map(([ original, hashed ]) => [ hashed, original ]))
+ , manifest = {}
+
+ function copyHashed(relative) {
+ if (manifest[relative]) return manifest[relative]
+ const content = fs.readFileSync(path.join(root, relative))
+ , hash = createHash('sha256').update(content).digest('hex').slice(0, 12)
+ , extension = path.extname(relative)
+ , hashed = `${relative.slice(0, -extension.length)}.${hash}${extension}`
+ fs.writeFileSync(path.join(root, hashed), content)
+ manifest[relative] = hashed
+ return hashed
+ }
+
+ lazyScripts.forEach(copyHashed)
+
+ if (!scriptsOnly) {
+ for (const stylesheet of stylesheets) {
+ const filename = path.join(root, stylesheet)
+ , css = fs.readFileSync(filename, 'utf8')
+ , rewritten = css.replace(/url\(\s*(['"]?)([^'"\)]+?)\1\s*\)/gi, (match, quote, url) => {
+ // Only local font URLs belong to phase 1. Preserve query strings/fragments.
+ if (/^(?:[a-z][a-z\d+.-]*:|\/\/)/i.test(url)) return match
+ const [ , pathname, suffix ] = url.match(/^([^?#]+)([?#].*)?$/) || []
+ if (!pathname || !/\.(woff2?|ttf)$/i.test(pathname)) return match
+ const absolute = pathname.startsWith('/')
+ , relative = path.posix.normalize(absolute ? pathname.slice(1) : path.posix.join(path.posix.dirname(stylesheet), pathname))
+ if (relative.startsWith('../')) throw new Error(`Font URL escapes output directory: ${url}`)
+ // Re-running the script must not hash an already hashed filename again.
+ const original = originals[relative] || relative
+ , hashed = copyHashed(original)
+ , rewrittenUrl = absolute ? '/' + hashed : path.posix.relative(path.posix.dirname(stylesheet), hashed)
+ return `url(${quote}${rewrittenUrl}${suffix || ''}${quote})`
+ })
+ fs.writeFileSync(filename, rewritten)
+ copyHashed(stylesheet)
+ }
+ copyHashed('app.js')
+ }
+
+ const sorted = Object.fromEntries(Object.entries(manifest).sort(([ a ], [ b ]) => a.localeCompare(b, 'en')))
+ fs.writeFileSync(manifestPath, JSON.stringify(sorted, null, 2) + '\n')
+ return sorted
+}
+
+module.exports = { hashAssets }
+
+if (require.main === module && process.env.HASH_ASSETS !== '0') {
+ const [ destination, mode ] = process.argv.slice(2)
+ if (!destination || (mode && mode !== '--scripts-only')) {
+ throw new Error('Usage: node scripts/hash-assets.js [--scripts-only]')
+ }
+ hashAssets(destination, mode === '--scripts-only')
+}
diff --git a/test/hash-assets.test.js b/test/hash-assets.test.js
new file mode 100644
index 00000000..e249b598
--- /dev/null
+++ b/test/hash-assets.test.js
@@ -0,0 +1,153 @@
+const test = require('node:test')
+const assert = require('node:assert/strict')
+const fs = require('fs')
+const path = require('path')
+const { createHash } = require('crypto')
+const { execFileSync } = require('child_process')
+const pug = require('pug')
+const { hashAssets } = require('../scripts/hash-assets')
+const l10n = require('../client/src/l10n').default
+
+const root = path.resolve(__dirname, '..')
+const digest = content => createHash('sha256').update(content).digest('hex').slice(0, 12)
+
+function fixture(t) {
+ fs.mkdirSync(path.join(root, 'dist'), { recursive: true })
+ const directory = fs.mkdtempSync(path.join(root, 'dist', 'hash-test-'))
+ t.after(() => fs.rmSync(directory, { recursive: true, force: true }))
+ const files = {
+ 'app.js': 'app',
+ 'instascan.min.js': 'scanner',
+ 'js/infinite-scroll.js': 'scroll',
+ 'font/example.woff2': 'font two',
+ 'font/example.woff': 'font one',
+ 'font/example.ttf': 'font ttf',
+ 'font/unused.ttf': 'unused',
+ 'style.css': 'a{src:url("font/example.woff2?#v1")}b{src:url(./font/example.woff)}c{src:url(/font/example.ttf)}',
+ 'style-rtl.css': "a{src:url('font/example.ttf')}b{src:url(font/example.woff2)}",
+ 'img/example.svg': 'image',
+ 'libwally/wallycore.js': 'wally',
+ 'index.html': 'html',
+ 'robots.txt': 'robots',
+ 'opensearch.xml': 'search',
+ 'favicon.ico': 'icon',
+ }
+ for (const [ filename, content ] of Object.entries(files)) {
+ fs.mkdirSync(path.dirname(path.join(directory, filename)), { recursive: true })
+ fs.writeFileSync(path.join(directory, filename), content)
+ }
+ return { directory, files }
+}
+
+test('hashes only phase 1 assets, retains originals, and hashes final CSS bytes', t => {
+ const { directory, files } = fixture(t)
+ const prepared = hashAssets(directory, true)
+ assert.deepEqual(Object.keys(prepared), ['instascan.min.js', 'js/infinite-scroll.js'])
+ assert.equal(fs.readFileSync(path.join(directory, 'style.css'), 'utf8'), files['style.css'])
+ const manifest = hashAssets(directory)
+ assert.equal(Object.keys(manifest).length, 8)
+ for (const [ original, hashed ] of Object.entries(manifest)) {
+ const content = fs.readFileSync(path.join(directory, original))
+ assert.deepEqual(content, fs.readFileSync(path.join(directory, hashed)))
+ assert.ok(hashed.endsWith(`.${digest(content)}${path.extname(original)}`))
+ }
+ for (const filename of Object.keys(files).filter(filename => !manifest[filename])) {
+ assert.equal(fs.readFileSync(path.join(directory, filename), 'utf8'), files[filename])
+ }
+ const css = fs.readFileSync(path.join(directory, manifest['style.css']), 'utf8')
+ assert.ok(css.includes(`url("${manifest['font/example.woff2']}?#v1")`))
+ assert.ok(css.includes(`url(${manifest['font/example.woff']})`))
+ assert.ok(css.includes(`url(/${manifest['font/example.ttf']})`))
+ assert.deepEqual(hashAssets(directory), manifest, 'repeated hashing is idempotent')
+
+ fs.appendFileSync(path.join(directory, 'font/example.woff2'), ' changed')
+ const changed = hashAssets(directory)
+ assert.notEqual(changed['font/example.woff2'], manifest['font/example.woff2'])
+ assert.notEqual(changed['style.css'], manifest['style.css'])
+ assert.notEqual(changed['style-rtl.css'], manifest['style-rtl.css'])
+ assert.equal(changed['app.js'], manifest['app.js'])
+})
+
+test('leaves external URLs unchanged and fails on missing local fonts', t => {
+ const { directory } = fixture(t)
+ const css = 'a{src:url(https://example.com/font.woff2)}b{src:url(//example.com/font.ttf)}c{src:url(data:font/woff;base64,AA)}'
+ fs.writeFileSync(path.join(directory, 'style.css'), css)
+ hashAssets(directory)
+ assert.equal(fs.readFileSync(path.join(directory, 'style.css'), 'utf8'), css)
+ fs.writeFileSync(path.join(directory, 'style.css'), 'a{src:url(font/missing.ttf)}')
+ assert.throws(() => hashAssets(directory), /ENOENT/)
+})
+
+test('HASH_ASSETS=0 skips hashing entirely and renderer loader falls back without a manifest', t => {
+ const { directory, files } = fixture(t)
+ execFileSync(process.execPath, ['scripts/hash-assets.js', directory], {
+ cwd: root, env: { ...process.env, HASH_ASSETS: '0' }
+ })
+ assert.equal(fs.existsSync(path.join(directory, 'asset-manifest.json')), false)
+ for (const [ filename, content ] of Object.entries(files)) {
+ assert.equal(fs.readFileSync(path.join(directory, filename), 'utf8'), content)
+ }
+ const load = env => JSON.parse(execFileSync(process.execPath, ['scripts/asset-manifest.js'], {
+ cwd: root, env: { ...process.env, HASH_ASSETS: '1', ASSET_MANIFEST: path.join(directory, 'asset-manifest.json'), ...env }
+ }))
+ assert.deepEqual(load(), { assetManifest: {} })
+ const manifest = hashAssets(directory)
+ assert.deepEqual(load(), { assetManifest: manifest })
+ assert.deepEqual(load({ HASH_ASSETS: '0' }), { assetManifest: {} })
+ assert.deepEqual(load({ ASSET_MANIFEST: '', DEST: directory }), { assetManifest: manifest })
+ fs.writeFileSync(path.join(directory, 'asset-manifest.json'), '{broken')
+ assert.throws(() => load(), /SyntaxError/)
+})
+
+test('template resolves both directions and STATIC_ROOT consistently for every language', t => {
+ const previous = process.env.STATIC_ROOT
+ process.env.STATIC_ROOT = '/preview/assets/'
+ t.after(() => previous === undefined ? delete process.env.STATIC_ROOT : process.env.STATIC_ROOT = previous)
+ for (const assetManifest of [undefined, {
+ 'style.css': 'style.0123456789ab.css',
+ 'style-rtl.css': 'style-rtl.abcdef012345.css',
+ 'app.js': 'app.123456789abc.js'
+ }]) {
+ for (const [ lang, translate ] of Object.entries(l10n)) {
+ const html = pug.renderFile(path.join(root, 'client/index.pug'), { assetManifest, t: translate })
+ , ltr = assetManifest ? assetManifest['style.css'] : 'style.css'
+ , rtl = assetManifest ? assetManifest['style-rtl.css'] : 'style-rtl.css'
+ , app = assetManifest ? assetManifest['app.js'] : 'app.js'
+ assert.ok(html.includes(`href="/preview/assets/${lang === 'he' ? rtl : ltr}"`), lang)
+ assert.ok(html.includes(`data-ltr="/preview/assets/${ltr}" data-rtl="/preview/assets/${rtl}"`))
+ assert.ok(html.includes(`src="/preview/assets/${app}"`))
+ }
+ }
+})
+
+test('exportAssetEnv sets lazy-script names from the manifest without overriding', () => {
+ const { exportAssetEnv } = require('../scripts/asset-manifest')
+ const manifest = {
+ 'instascan.min.js': 'instascan.min.0123456789ab.js',
+ 'js/infinite-scroll.js': 'js/infinite-scroll.ba9876543210.js',
+ }
+ assert.deepEqual(exportAssetEnv(manifest, {}), {
+ INSTASCAN_ASSET: 'instascan.min.0123456789ab.js',
+ INFINITE_SCROLL_ASSET: 'js/infinite-scroll.ba9876543210.js',
+ })
+ assert.equal(exportAssetEnv(manifest, { INSTASCAN_ASSET: 'given.js' }).INSTASCAN_ASSET, 'given.js')
+ assert.deepEqual(exportAssetEnv({}, {}), {})
+})
+
+test('server-rendered landing page reads the hashed infinite-scroll name at render time', t => {
+ const render = require('snabbdom-to-html')
+ const LandingPage = require('../client/src/views/lander').default
+ const previous = process.env.INFINITE_SCROLL_ASSET
+ t.after(() => {
+ if (previous === undefined) delete process.env.INFINITE_SCROLL_ASSET
+ else process.env.INFINITE_SCROLL_ASSET = previous
+ })
+ const state = { t: l10n.en, page: { pathname: '/', query: {} } }
+ const scriptSrc = () => (render(LandingPage(state)).match(/