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:
1 parent
6eb25bdd03
commit
f26ac6c0ce
1 file changed
+116
-42
@@ -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/stars.json (window.__starsData)
|
vendor/clouds3d.js (plain <script> swap)
|
||||||
- every theme CSS referenced by window.THEMES
|
- vendor/stars.json (window.__starsData)
|
||||||
(window.__inlineThemeCss,
|
- every stylesheet referenced by window.THEMES
|
||||||
so theme switching works
|
(window.__inlineThemeCss, so
|
||||||
without any files)
|
theme switching works offline)
|
||||||
|
|
||||||
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__':
|
||||||
|
|||||||
Reference in new issue
Block a user