Files
animated_wallpaper/build.py
T

185 lines
6.3 KiB
Python

#!/usr/bin/env python3
"""
Build a single self-contained HTML file from the source files.
Usage:
python3 build.py [output.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/plane3d.js,
vendor/clouds3d.js (plain <script> swap)
- vendor/stars.json (window.__starsData)
- vendor/moon.jpg (window.__moonTex, data URI)
- every stylesheet referenced by window.THEMES
(window.__inlineThemeCss, so
theme switching works offline)
The result needs no sibling files and no server of ours: basemap tiles, the
elevation tiles and the Overpass mirrors are all CORS-enabled public sources,
so the file runs from any static host or straight from disk (file://). The
bundled nginx is optional - it adds the same-origin proxies and a 30-day
Overpass cache, which is the gentlest way to hit those upstreams.
The build fails loudly if any external script or stylesheet reference survives.
"""
import base64
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 inline_script(html, tag, body):
old = '<script src="%s"></script>' % tag
if old not in html:
raise SystemExit('build.py: expected tag not found: ' + tag)
# 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):
"""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')
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():
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 (must run before the scene scripts)
stars = json.loads(read('vendor/stars.json'))
theme_css = {name: read(fname) for name, fname in themes.items()}
moon_path = os.path.join(ROOT, 'vendor', 'moon.jpg')
moon_uri = ''
if os.path.exists(moon_path):
with open(moon_path, 'rb') as f:
moon_uri = 'data:image/jpeg;base64,' + base64.b64encode(f.read()).decode('ascii')
embed = (
'<script>\n'
'window.__starsData = %s;\n'
'window.__moonTex = "%s";\n'
'window.__inlineThemeCss = %s;\n'
'</script>\n'
% (json.dumps(stars, separators=(',', ':')), moon_uri, json.dumps(theme_css))
)
# 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)
for tag in INLINED_SCRIPTS:
html = inline_script(html, tag, read(tag))
# 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, moon map %d KB)'
% (len(html), len(themes), len(stars), len(moon_uri) // 1024))
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: %s, stars.json (%d entries), moon.jpg (%d KB), themes: %s'
% (', '.join(INLINED_SCRIPTS), len(stars), len(moon_uri) // 1024,
', '.join(sorted(themes))))
if __name__ == '__main__':
main()