build.py: actually apply the inlined scripts, accept window.THEMES objects, drop the theme stylesheet link, verify only real script/link tags

This commit is contained in:
Richard Zink committed 2026-10-11 15:39:42 +02:00
1 parent 6eb25bdd03
commit f26ac6c0ce
1 file changed
+114 -40
+114 -40
View File
@@ -4,68 +4,129 @@ Build a single self-contained HTML file from the source files.
Usage:
python3 build.py [output.html]
If output is not given, defaults to 'background-standalone.html'.
python3 build.py --check build to a temp file and only verify
What gets inlined:
- config.js, i18n.js (plain <script> swap)
- vendor/suncalc.js, vendor/clouds3d.js (plain <script> swap)
- vendor/suncalc.js, vendor/plane3d.js,
vendor/clouds3d.js (plain <script> swap)
- vendor/stars.json (window.__starsData)
- every theme CSS referenced by window.THEMES
(window.__inlineThemeCss,
so theme switching works
without any files)
- every stylesheet referenced by window.THEMES
(window.__inlineThemeCss, so
theme switching works offline)
The result needs NO sibling files. Note that runtime DATA is still
fetched when the page loads: basemap/elevation tiles (via the nginx
CORS proxies of the Docker image), Overpass road + place data and the
Open-Meteo weather API. Serve the standalone file from the same nginx
(or a warm IndexedDB cache = fully offline) for the full experience;
opened via file:// it still runs sky, clouds and the whole UI, just
without the textured ground.
The result needs no sibling files. Runtime DATA is still fetched on load:
basemap and elevation tiles, Overpass road/settlement cells and the Open-Meteo
weather API. Serve the file from the same nginx as the development site (its
CORS proxies make the tiles reachable) or open it with a warm IndexedDB cache
for fully offline rendering. Opened via file:// it still runs sky, clouds,
terrain shading and the whole UI, just without the textured ground.
The build fails loudly if any external script or stylesheet reference survives.
"""
import json
import os
import re
import sys
import tempfile
ROOT = os.path.dirname(os.path.abspath(__file__))
INLINED_SCRIPTS = [
'config.js',
'i18n.js',
'vendor/suncalc.js',
'vendor/plane3d.js',
'vendor/clouds3d.js',
]
def read(path):
with open(os.path.join(ROOT, path), 'r', encoding='utf-8') as f:
return f.read()
def guard(s):
# a literal </script> inside an inline block would end it early
return s.replace('</', '<\\/')
def inline_script(html, tag, body):
old = '<script src="%s"></script>' % tag
new = '<script>\n%s\n</script>' % body
if old not in html:
raise SystemExit('build.py: expected tag not found: ' + tag)
return html.replace(old, new, 1)
# a literal </script> inside an inline block would end it early
return html.replace(old, '<script>\n%s\n</script>' % body.replace('</', '<\\/'), 1)
def parse_themes(config_src):
m = re.search(r'window\.THEMES\s*=\s*\{(.*?)\}', config_src, re.S)
"""Return {themeId: cssFileName} from window.THEMES.
Accepts both shapes:
'ash': 'ash.css'
'ash': { css: 'ash.css', lighting: { ... } }
"""
m = re.search(r'window\.THEMES\s*=\s*\{', config_src)
if not m:
raise SystemExit('build.py: window.THEMES not found in config.js')
return dict(re.findall(r"'([^']+)'\s*:\s*'([^']+\.css)'", m.group(1)))
i = m.end()
depth = 1
block_start = i
while i < len(config_src) and depth:
c = config_src[i]
if c == '{':
depth += 1
elif c == '}':
depth -= 1
i += 1
block = config_src[block_start:i - 1]
themes = {}
# top level entries only: split on commas that sit at brace depth 0
parts, buf, depth = [], '', 0
for c in block:
if c == '{':
depth += 1
elif c == '}':
depth -= 1
if c == ',' and depth == 0:
parts.append(buf)
buf = ''
else:
buf += c
parts.append(buf)
for part in parts:
entry = re.match(r"\s*['\"]?([A-Za-z0-9_-]+)['\"]?\s*:\s*(.*)", part, re.S)
if not entry:
continue
name, value = entry.group(1), entry.group(2)
css = re.search(r"['\"]([^'\"]+\.css)['\"]", value)
if not css:
raise SystemExit('build.py: theme %r has no .css reference' % name)
themes[name] = css.group(1)
if not themes:
raise SystemExit('build.py: window.THEMES is empty')
return themes
def verify(html):
"""Fail if anything still points at a sibling file."""
leftovers = re.findall(r'<script[^>]*\bsrc="([^"]+)"', html)
leftovers += [re.search(r'\bhref="([^"]+)"', tag).group(1)
for tag in re.findall(r'<link[^>]*>', html)
if re.search(r'\bhref="[^"]+\.css"', tag)]
if leftovers:
raise SystemExit('build.py: not self-contained, still referencing: ' +
', '.join(sorted(set(leftovers))))
def main():
out = sys.argv[1] if len(sys.argv) > 1 else 'background-standalone.html'
html = read('background.html')
check = '--check' in sys.argv
args = [a for a in sys.argv[1:] if not a.startswith('--')]
out = args[0] if args else 'background-standalone.html'
html = read('background.html')
config_src = read('config.js')
themes = parse_themes(config_src)
# embedded data preamble (before clouds3d's own scripts run)
# embedded data preamble (must run before the scene scripts)
stars = json.loads(read('vendor/stars.json'))
theme_css = {name: read(fname) for name, fname in themes.items()}
embed = (
@@ -73,29 +134,42 @@ def main():
'window.__starsData = %s;\n'
'window.__inlineThemeCss = %s;\n'
'</script>\n'
% (json.dumps(stars, separators=(',', ':')),
json.dumps(theme_css))
% (json.dumps(stars, separators=(',', ':')), json.dumps(theme_css))
)
anchor = '<script src="vendor/suncalc.js"></script>'
# must come before config.js so the early theme loader sees the CSS
anchor = '<script src="config.js"></script>'
if anchor not in html:
raise SystemExit('build.py: anchor script tag not found in background.html')
html = html.replace(anchor, embed + anchor, 1)
html = inline_script(html, 'config.js', read('config.js'))
html = inline_script(html, 'i18n.js', read('i18n.js'))
html = inline_script(html, 'vendor/suncalc.js', read('vendor/suncalc.js'))
html = inline_script(html, 'vendor/plane3d.js', read('vendor/plane3d.js'))
html = inline_script(html, 'vendor/clouds3d.js', read('vendor/clouds3d.js'))
for tag in INLINED_SCRIPTS:
html = inline_script(html, tag, read(tag))
# the default <link id="theme-css" href="ash.css"> stays but is
# disabled by the inline-theme loader; keep a guarded fallback anyway
html = html.replace('<link id="theme-css"', '<!-- standalone: themes embedded -->\n <link id="theme-css"', 1)
# themes are embedded in window.__inlineThemeCss, so the stylesheet link
# is dropped completely (the theme loader guards on getElementById)
rep_link = re.search(r'\n[ \t]*<link id="theme-css"[^>]*>', html)
if not rep_link:
raise SystemExit('build.py: theme-css link not found in background.html')
html = html[:rep_link.start()] + '\n <!-- standalone build: themes are embedded -->' + \
html[rep_link.end():]
verify(html)
if check:
with tempfile.NamedTemporaryFile('w', suffix='.html', delete=False) as f:
f.write(html)
tmp = f.name
print('OK: self-contained (%d bytes, %d themes, %d stars)'
% (len(html), len(themes), len(stars)))
os.unlink(tmp)
return
with open(os.path.join(ROOT, out), 'w', encoding='utf-8') as f:
f.write(html)
print('Wrote %s (%d bytes)' % (out, len(html)))
print('Inlined: config.js, i18n.js, suncalc.js, clouds3d.js,')
print(' stars.json (%d entries), themes: %s'
% (len(stars), ', '.join(themes)))
print('Inlined: %s, stars.json (%d entries), themes: %s'
% (', '.join(INLINED_SCRIPTS), len(stars), ', '.join(sorted(themes))))
if __name__ == '__main__':