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: Usage:
python3 build.py [output.html] python3 build.py [output.html]
python3 build.py --check build to a temp file and only verify
If output is not given, defaults to 'background-standalone.html'.
What gets inlined: What gets inlined:
- config.js, i18n.js (plain <script> swap) - 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) - vendor/stars.json (window.__starsData)
- every theme CSS referenced by window.THEMES - every stylesheet referenced by window.THEMES
(window.__inlineThemeCss, (window.__inlineThemeCss, so
so theme switching works theme switching works offline)
without any files)
The result needs NO sibling files. Note that runtime DATA is still The result needs no sibling files. Runtime DATA is still fetched on load:
fetched when the page loads: basemap/elevation tiles (via the nginx basemap and elevation tiles, Overpass road/settlement cells and the Open-Meteo
CORS proxies of the Docker image), Overpass road + place data and the weather API. Serve the file from the same nginx as the development site (its
Open-Meteo weather API. Serve the standalone file from the same nginx CORS proxies make the tiles reachable) or open it with a warm IndexedDB cache
(or a warm IndexedDB cache = fully offline) for the full experience; for fully offline rendering. Opened via file:// it still runs sky, clouds,
opened via file:// it still runs sky, clouds and the whole UI, just terrain shading and the whole UI, just without the textured ground.
without the textured ground.
The build fails loudly if any external script or stylesheet reference survives.
""" """
import json import json
import os import os
import re import re
import sys import sys
import tempfile
ROOT = os.path.dirname(os.path.abspath(__file__)) 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): def read(path):
with open(os.path.join(ROOT, path), 'r', encoding='utf-8') as f: with open(os.path.join(ROOT, path), 'r', encoding='utf-8') as f:
return f.read() 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): def inline_script(html, tag, body):
old = '<script src="%s"></script>' % tag old = '<script src="%s"></script>' % tag
new = '<script>\n%s\n</script>' % body
if old not in html: if old not in html:
raise SystemExit('build.py: expected tag not found: ' + tag) 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): 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: if not m:
raise SystemExit('build.py: window.THEMES not found in config.js') 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(): def main():
out = sys.argv[1] if len(sys.argv) > 1 else 'background-standalone.html' check = '--check' in sys.argv
html = read('background.html') 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') config_src = read('config.js')
themes = parse_themes(config_src) 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')) stars = json.loads(read('vendor/stars.json'))
theme_css = {name: read(fname) for name, fname in themes.items()} theme_css = {name: read(fname) for name, fname in themes.items()}
embed = ( embed = (
@@ -73,29 +134,42 @@ def main():
'window.__starsData = %s;\n' 'window.__starsData = %s;\n'
'window.__inlineThemeCss = %s;\n' 'window.__inlineThemeCss = %s;\n'
'</script>\n' '</script>\n'
% (json.dumps(stars, separators=(',', ':')), % (json.dumps(stars, separators=(',', ':')), json.dumps(theme_css))
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 = html.replace(anchor, embed + anchor, 1)
html = inline_script(html, 'config.js', read('config.js')) for tag in INLINED_SCRIPTS:
html = inline_script(html, 'i18n.js', read('i18n.js')) html = inline_script(html, tag, read(tag))
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'))
# the default <link id="theme-css" href="ash.css"> stays but is # themes are embedded in window.__inlineThemeCss, so the stylesheet link
# disabled by the inline-theme loader; keep a guarded fallback anyway # is dropped completely (the theme loader guards on getElementById)
html = html.replace('<link id="theme-css"', '<!-- standalone: themes embedded -->\n <link id="theme-css"', 1) 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: with open(os.path.join(ROOT, out), 'w', encoding='utf-8') as f:
f.write(html) f.write(html)
print('Wrote %s (%d bytes)' % (out, len(html))) print('Wrote %s (%d bytes)' % (out, len(html)))
print('Inlined: config.js, i18n.js, suncalc.js, clouds3d.js,') print('Inlined: %s, stars.json (%d entries), themes: %s'
print(' stars.json (%d entries), themes: %s' % (', '.join(INLINED_SCRIPTS), len(stars), ', '.join(sorted(themes))))
% (len(stars), ', '.join(themes)))
if __name__ == '__main__': if __name__ == '__main__':