diff --git a/app.py b/app.py index 7a29a2f..a990201 100644 --- a/app.py +++ b/app.py @@ -394,18 +394,39 @@ for group in theological_synonym_groups: concept_mappings = json.load(open("data/concept_mappings.json", "r")) concept_mappings_dict = {mapping["query"].lower(): mapping for mapping in concept_mappings} -# Static assets are served with a very long cache lifetime, so their URLs carry a -# hash of their contents ("?v=...") and change whenever the files do. -ASSET_VERSION: str = hashlib.md5( - b"".join( - open(f, "rb").read() - for f in ("styles/page.css", "js/collapsible.js", "js/shortcode.js", "js/verse_menu.js", "js/book_suggest.js") - ) -).hexdigest()[:8] +# Static assets: each file's URL carries a hash of its own contents ("?v=..."). +# A URL whose hash matches is served as immutable for a year; the hash changes +# whenever the file does, so a deploy is picked up immediately and only the +# files that actually changed get downloaded again. Any other URL for the same +# file (no hash, or an old one) is served with "no-cache", so it is revalidated +# every time instead of lingering in a browser for a year. +ASSET_SOURCES = { + "/styles/page.css": ("styles/page.css", "text/css"), + "/js/collapsible.js": ("js/collapsible.js", "application/javascript"), + "/js/shortcode.js": ("js/shortcode.js", "application/javascript"), + "/js/verse_menu.js": ("js/verse_menu.js", "application/javascript"), + "/js/book_suggest.js": ("js/book_suggest.js", "application/javascript"), +} +ASSET_VERSIONS: dict = { + url: hashlib.md5(open(path, "rb").read()).hexdigest()[:8] + for url, (path, _mime) in ASSET_SOURCES.items() +} + + +def asset_url(url: str) -> str: + return f"{url}?v={ASSET_VERSIONS[url]}" + + +app.jinja_env.globals["asset_url"] = asset_url def load_html(path: str) -> str: - return open(path, "r").read().replace("__V__", ASSET_VERSION) + """Read an html file, filling each '/js/x.js?v=__V__' with that file's own version.""" + return re.sub( + r"(/(?:js|styles)/[\w.\-]+)\?v=__V__", + lambda m: asset_url(m.group(1)), + open(path, "r").read(), + ) cached: dict = { @@ -479,41 +500,42 @@ with app.app_context(): template_content = load_html("html/index.html") cached["html_index"] = render_template_string(template_content, testament_data=cached["book_info_for_index"]) -@app.get("/js/shortcode.js") -def serve_shortcode_js(): - response = Response(cached.get("js_shortcode"), mimetype='application/javascript') - response.headers['Cache-Control'] = 'public, max-age=31536000' - return response +def _make_asset_view(url_path: str, mimetype: str): + def view(): + response = Response(cached["assets"][url_path], mimetype=mimetype) + response.add_etag() + if request.args.get("v") == ASSET_VERSIONS[url_path]: + response.headers["Cache-Control"] = "public, max-age=31536000, immutable" + else: + response.headers["Cache-Control"] = "no-cache" + return response.make_conditional(request) + view.__name__ = "asset_" + url_path.strip("/").replace("/", "_").replace(".", "_") + return view -@app.get("/js/book_suggest.js") -def serve_book_suggest_js(): - response = Response(cached.get("js_book_suggest"), mimetype='application/javascript') - response.headers['Cache-Control'] = 'public, max-age=31536000' - return response -@app.get("/js/verse_menu.js") -def serve_verse_menu_js(): - response = Response(cached.get("js_verse_menu"), mimetype='application/javascript') - response.headers['Cache-Control'] = 'public, max-age=31536000' - return response +cached["assets"] = { + "/styles/page.css": cached["css_page"], + "/js/collapsible.js": cached["js_collapsible"], + "/js/shortcode.js": cached["js_shortcode"], + "/js/verse_menu.js": cached["js_verse_menu"], + "/js/book_suggest.js": cached["js_book_suggest"], +} +for _url, (_path, _mime) in ASSET_SOURCES.items(): + app.add_url_rule(_url, view_func=_make_asset_view(_url, _mime), methods=["GET"]) -@app.get("/js/collapsible.js") -def serve_collapsible_js(): - response = Response(cached.get("js_collapsible"), mimetype='application/javascript') - response.headers['Cache-Control'] = 'public, max-age=31536000' - return response -@app.get("/styles/page.css") -def serve_page_css(): - response = Response(cached.get("css_page"), mimetype='text/css') - response.headers['Cache-Control'] = 'public, max-age=31536000' +@app.after_request +def html_pages_are_never_stale(response): + """Pages reference versioned assets, so they must always be revalidated to see new versions.""" + if response.mimetype == "text/html" and "Cache-Control" not in response.headers: + response.headers["Cache-Control"] = "no-cache" return response @app.get("/favicon.ico") @app.get("/favicon.svg") def serve_favicon(): response = Response(cached.get("favicon_svg"), mimetype='image/svg+xml') - response.headers['Cache-Control'] = 'public, max-age=31536000' + response.headers['Cache-Control'] = 'public, max-age=86400' return response def static_page(path: str): @@ -529,7 +551,7 @@ def static_html_doc(title: str, body: str): "
" "" f"Click on the word to get the definition.
Saved in this browser only. Clearing site data or using another device starts fresh.
+Browser storage can be cleared without warning. Download a copy of your notes and saved items, and restore it here or on another device.
++ + +
+ +