add site-wide JS caching through import maps

This commit is contained in:
liquidex 2024-07-19 20:05:17 +02:00
parent f3aee8f41a
commit 10ccb250c1
15 changed files with 169 additions and 48 deletions

View file

@ -1,5 +1,5 @@
%% title = "back porch" %% title = "back porch"
scripts = ["components/chat.js"] scripts = ["treehouse/components/chat.js"]
styles = ["components/chat.css"] styles = ["components/chat.css"]
% template = true % template = true

View file

@ -1,7 +1,7 @@
%% title = "tairu - an interactive exploration of 2D autotiling techniques" %% title = "tairu - an interactive exploration of 2D autotiling techniques"
scripts = [ scripts = [
"components/literate-programming.js", "treehouse/components/literate-programming.js",
"vendor/codejar.js", "treehouse/vendor/codejar.js",
] ]
styles = ["page/tairu.css"] styles = ["page/tairu.css"]

View file

@ -1,5 +1,5 @@
%% title = "testing ground for (chit)chats" %% title = "testing ground for (chit)chats"
scripts = ["components/chat.js"] scripts = ["treehouse/components/chat.js"]
styles = ["components/chat.css"] styles = ["components/chat.css"]
% id = "01HSR695VNDXRGPC3XCHS3A61V" % id = "01HSR695VNDXRGPC3XCHS3A61V"

View file

@ -1,5 +1,5 @@
%% title = "the treehouse sandbox" %% title = "the treehouse sandbox"
scripts = ["components/literate-programming.js"] scripts = ["treehouse/components/literate-programming.js"]
% id = "01HPWJB4Y5ST6AEK9VDYNS865P" % id = "01HPWJB4Y5ST6AEK9VDYNS865P"
- the sandbox is a framework for playing around with code - the sandbox is a framework for playing around with code

View file

@ -25,6 +25,8 @@ use crate::{
navmap::{build_navigation_map, NavigationMap}, navmap::{build_navigation_map, NavigationMap},
tree::branches_to_html, tree::branches_to_html,
}, },
import_map::ImportMap,
include_static::IncludeStatic,
state::Source, state::Source,
static_urls::StaticUrls, static_urls::StaticUrls,
tree::SemaRoots, tree::SemaRoots,
@ -216,7 +218,8 @@ impl Generator {
let mut config_derived_data = ConfigDerivedData { let mut config_derived_data = ConfigDerivedData {
image_sizes: Default::default(), image_sizes: Default::default(),
static_urls: StaticUrls::new( static_urls: StaticUrls::new(
paths.static_dir.to_owned(), // NOTE: Allow referring to generated static assets here.
paths.target_dir.join("static"),
format!("{}/static", config.site), format!("{}/static", config.site),
), ),
}; };
@ -227,10 +230,19 @@ impl Generator {
handlebars.register_helper( handlebars.register_helper(
"asset", "asset",
Box::new(StaticUrls::new( Box::new(StaticUrls::new(
paths.static_dir.to_owned(), paths.target_dir.join("static"),
format!("{}/static", config.site), format!("{}/static", config.site),
)), )),
); );
handlebars.register_helper(
"include_static",
Box::new(IncludeStatic {
// NOTE: Again, allow referring to generated static assets.
// This is necessary for import maps, for whom the <src> attribute is not
// currently supported.
base_dir: paths.target_dir.join("static"),
}),
);
let mut template_file_ids = HashMap::new(); let mut template_file_ids = HashMap::new();
for entry in WalkDir::new(paths.template_dir) { for entry in WalkDir::new(paths.template_dir) {
@ -389,11 +401,18 @@ pub fn generate(paths: &Paths<'_>) -> anyhow::Result<(Config, Treehouse)> {
info!("copying static directory to target directory"); info!("copying static directory to target directory");
copy_dir(paths.static_dir, paths.target_dir.join("static"))?; copy_dir(paths.static_dir, paths.target_dir.join("static"))?;
info!("creating static/generated directory");
std::fs::create_dir_all(paths.target_dir.join("static/generated"))?;
info!("parsing tree"); info!("parsing tree");
let mut generator = Generator::default(); let mut generator = Generator::default();
generator.add_directory_rec(paths.content_dir)?; generator.add_directory_rec(paths.content_dir)?;
let (mut treehouse, parsed_trees) = generator.parse_trees(&config, paths)?; let (mut treehouse, parsed_trees) = generator.parse_trees(&config, paths)?;
// NOTE: The navigation map is a legacy feature that is lazy-loaded when fragment-based
// navigation is used.
// I couldn't be bothered with adding it to the import map since fragment-based navigation is
// only used on very old links. Adding caching to the navigation map is probably not worth it.
info!("generating navigation map"); info!("generating navigation map");
let navigation_map = build_navigation_map(&treehouse, "index"); let navigation_map = build_navigation_map(&treehouse, "index");
std::fs::write( std::fs::write(
@ -401,6 +420,13 @@ pub fn generate(paths: &Paths<'_>) -> anyhow::Result<(Config, Treehouse)> {
navigation_map.to_javascript(), navigation_map.to_javascript(),
)?; )?;
info!("generating import map");
let import_map = ImportMap::generate(config.site.clone(), &config.javascript.import_roots);
std::fs::write(
paths.target_dir.join("static/generated/import-map.json"),
serde_json::to_string_pretty(&import_map).context("could not serialize import map")?,
)?;
info!("generating standalone pages"); info!("generating standalone pages");
generator.generate_all_files( generator.generate_all_files(
&mut treehouse, &mut treehouse,

View file

@ -11,6 +11,7 @@ use crate::{
compiled::{compile_syntax, CompiledSyntax}, compiled::{compile_syntax, CompiledSyntax},
Syntax, Syntax,
}, },
import_map::ImportRoot,
static_urls::StaticUrls, static_urls::StaticUrls,
}; };
@ -47,6 +48,9 @@ pub struct Config {
/// ``` /// ```
pub redirects: Redirects, pub redirects: Redirects,
/// JavaScript configuration.
pub javascript: JavaScript,
/// Overrides for emoji filenames. Useful for setting up aliases. /// Overrides for emoji filenames. Useful for setting up aliases.
/// ///
/// On top of this, emojis are autodiscovered by walking the `static/emoji` directory. /// On top of this, emojis are autodiscovered by walking the `static/emoji` directory.
@ -74,6 +78,12 @@ pub struct Redirects {
pub page: HashMap<String, String>, pub page: HashMap<String, String>,
} }
#[derive(Debug, Clone, Deserialize, Serialize)]
pub struct JavaScript {
/// Import roots to generate in the project's import map.
pub import_roots: Vec<ImportRoot>,
}
impl Config { impl Config {
pub fn load(path: &Path) -> anyhow::Result<Self> { pub fn load(path: &Path) -> anyhow::Result<Self> {
let string = std::fs::read_to_string(path).context("cannot read config file")?; let string = std::fs::read_to_string(path).context("cannot read config file")?;

View file

@ -0,0 +1,64 @@
use std::{collections::HashMap, ffi::OsStr, path::PathBuf};
use log::warn;
use serde::{Deserialize, Serialize};
use walkdir::WalkDir;
use crate::static_urls::StaticUrls;
#[derive(Debug, Clone, Serialize)]
pub struct ImportMap {
pub imports: HashMap<String, String>,
}
#[derive(Debug, Clone, Deserialize, Serialize)]
pub struct ImportRoot {
pub name: String,
pub path: String,
}
impl ImportMap {
pub fn generate(base_url: String, import_roots: &[ImportRoot]) -> Self {
let mut import_map = ImportMap {
imports: HashMap::new(),
};
for root in import_roots {
let static_urls = StaticUrls::new(
PathBuf::from(&root.path),
format!("{base_url}/{}", root.path),
);
for entry in WalkDir::new(&root.path) {
let entry = match entry {
Ok(entry) => entry,
Err(error) => {
warn!("directory walk failed: {error}");
continue;
}
};
if !entry.file_type().is_dir() && entry.path().extension() == Some(OsStr::new("js"))
{
let normalized_path = entry
.path()
.strip_prefix(&root.path)
.unwrap_or(entry.path())
.to_string_lossy()
.replace('\\', "/");
match static_urls.get(&normalized_path) {
Ok(url) => {
import_map
.imports
.insert(format!("{}/{normalized_path}", root.name), url);
}
Err(error) => {
warn!("could not get static url for {normalized_path}: {error}")
}
}
}
}
}
import_map
}
}

View file

@ -0,0 +1,28 @@
use std::path::PathBuf;
use handlebars::{Context, Handlebars, Helper, HelperDef, RenderContext, RenderError, ScopedJson};
use serde_json::Value;
pub struct IncludeStatic {
pub base_dir: PathBuf,
}
impl HelperDef for IncludeStatic {
fn call_inner<'reg: 'rc, 'rc>(
&self,
helper: &Helper<'reg, 'rc>,
_: &'reg Handlebars<'reg>,
_: &'rc Context,
_: &mut RenderContext<'reg, 'rc>,
) -> Result<ScopedJson<'reg, 'rc>, RenderError> {
if let Some(param) = helper.param(0).and_then(|v| v.value().as_str()) {
return Ok(ScopedJson::Derived(Value::String(
std::fs::read_to_string(self.base_dir.join(param)).map_err(|error| {
RenderError::new(format!("cannot read static asset {param}: {error}"))
})?,
)));
}
Err(RenderError::new("asset path must be provided"))
}
}

View file

@ -14,6 +14,8 @@ mod cli;
mod config; mod config;
mod fun; mod fun;
mod html; mod html;
mod import_map;
mod include_static;
mod paths; mod paths;
mod state; mod state;
mod static_urls; mod static_urls;

View file

@ -17,21 +17,6 @@ export const domConsole = {
} }
}; };
async function withTemporaryGlobalScope(callback) {
let state = {
oldValues: {},
set(key, value) {
this.oldValues[key] = globalThis[key];
globalThis[key] = value;
}
};
await callback(state);
jsConsole.trace(state.oldValues, "bringing back old state");
for (let key in state.oldValues) {
globalThis[key] = state.oldValues[key];
}
}
let evaluationComplete = null; let evaluationComplete = null;
export async function evaluate(commands, { error, newOutput }) { export async function evaluate(commands, { error, newOutput }) {

View file

@ -1,7 +1,6 @@
// This is definitely not a three.js ripoff. // This is definitely not a three.js ripoff.
import { addSpell } from "treehouse/spells.js"; import { addSpell } from "treehouse/spells.js";
import { navigationMap } from "/navmap.js";
import * as ulid from "treehouse/ulid.js"; import * as ulid from "treehouse/ulid.js";
/* Branch persistence */ /* Branch persistence */
@ -128,7 +127,8 @@ class LinkedBranch extends Branch {
// No need to await for the import because we don't use the resulting module. // No need to await for the import because we don't use the resulting module.
// Just fire and forger 💀 // Just fire and forger 💀
// and let them run in parallel. // and let them run in parallel.
import(script.src); let url = URL.createObjectURL(new Blob([script.textContent], { type: "text/javascript" }))
import(url);
} }
} catch (error) { } catch (error) {
this.loadingText.innerText = error.toString(); this.loadingText.innerText = error.toString();
@ -177,6 +177,8 @@ function navigateToPage(page) {
async function navigateToBranch(fragment) { async function navigateToBranch(fragment) {
if (fragment.length == 0) return; if (fragment.length == 0) return;
let { navigationMap } = await import("/navmap.js");
let element = document.getElementById(fragment); let element = document.getElementById(fragment);
if (element !== null) { if (element !== null) {
// If the element is already loaded on the page, we're good. // If the element is already loaded on the page, we're good.

View file

@ -9,24 +9,24 @@
<link rel="stylesheet" href="{{ asset 'css/main.css' }}"> <link rel="stylesheet" href="{{ asset 'css/main.css' }}">
<link rel="stylesheet" href="{{ asset 'css/tree.css' }}"> <link rel="stylesheet" href="{{ asset 'css/tree.css' }}">
<script type="importmap">{ {{!-- Import maps currently don't support the src="" attribute. Unless we come up with something
"imports": { clever to do while browser vendors figure that out, we'll just have to do a cache-busting include_static. --}}
"treehouse/": "{{ config.site }}/static/js/" {{!-- <script type="importmap" src="{{ asset 'generated/import-map.json' }}"></script> --}}
} <script type="importmap">{{{ include_static 'generated/import-map.json' }}}</script>
}</script>
<script> <script>
const TREEHOUSE_SITE = `{{ config.site }}`; const TREEHOUSE_SITE = `{{ config.site }}`;
const TREEHOUSE_NEWS_COUNT = {{ len feeds.news.branches }}; const TREEHOUSE_NEWS_COUNT = {{ len feeds.news.branches }};
</script> </script>
<script type="module" src="{{ config.site }}/navmap.js"></script> <script type="module">
<script type="module" src="{{ config.site }}/static/js/spells.js"></script> import "treehouse/spells.js";
<script type="module" src="{{ config.site }}/static/js/ulid.js"></script> import "treehouse/ulid.js";
<script type="module" src="{{ config.site }}/static/js/usability.js"></script> import "treehouse/usability.js";
<script type="module" src="{{ config.site }}/static/js/settings.js"></script> import "treehouse/settings.js";
<script type="module" src="{{ config.site }}/static/js/tree.js"></script> import "treehouse/tree.js";
<script type="module" src="{{ config.site }}/static/js/emoji.js"></script> import "treehouse/emoji.js";
<script type="module" src="{{ config.site }}/static/js/news.js"></script> import "treehouse/news.js";
</script>
<meta property="og:site_name" content="{{ config.user.title }}"> <meta property="og:site_name" content="{{ config.user.title }}">
<meta property="og:title" content="{{ page.title }}"> <meta property="og:title" content="{{ page.title }}">

View file

@ -4,12 +4,15 @@
extracting them way more painful than it needs to be. --}} extracting them way more painful than it needs to be. --}}
{{#each page.styles}} {{#each page.styles}}
<link rel="stylesheet" href="{{ ../config.site }}/static/css/{{ this }}"> <link rel="stylesheet" href="{{ asset (cat 'css/' this) }}">
{{/each}} {{/each}}
<script type="module">
{{!-- Go through the import map for each script. --}}
{{#each page.scripts}} {{#each page.scripts}}
<script type="module" src="{{ ../config.site }}/static/js/{{ this }}"></script> import "{{ this }}";
{{/each}} {{/each}}
</script>
{{{ page.tree }}} {{{ page.tree }}}
</main> </main>

View file

@ -5,7 +5,7 @@
<head> <head>
<title>treehouse iframe sandbox</title> <title>treehouse iframe sandbox</title>
<link rel="stylesheet" href="{{ config.site }}/static/css/main.css"> <link rel="stylesheet" href="{{ asset 'css/main.css' }}">
<style> <style>
body { body {
@ -21,12 +21,7 @@
} }
</style> </style>
<script type="importmap">{ <script type="importmap">{{{ include_static 'generated/import-map.json' }}}</script>
"imports": {
"treehouse/": "{{ config.site }}/static/js/",
"tairu/": "{{ config.site }}/static/js/components/tairu/"
}
}</script>
<script type="module"> <script type="module">
import { evaluate, domConsole, jsConsole } from "treehouse/components/literate-programming/eval.js"; import { evaluate, domConsole, jsConsole } from "treehouse/components/literate-programming/eval.js";

View file

@ -46,3 +46,9 @@ description = "a place on the Internet I like to call home"
[emoji] [emoji]
[pics] [pics]
[javascript]
import_roots = [
{ name = "treehouse", path = "static/js" },
{ name = "tairu", path = "static/js/components/tairu" },
]