use std::fs;
use std::path::Path;
use anyhow::{Context, Result};
pub const RUNTIME_IMPORT_URL: &str = "/static/islands-core/islands_core.js";
pub const PATCH_TOKEN: &str = "Object.assign(Object.create(null), import1)";
const NEEDLE: &str = "\"/static/islands-core/islands_core.js\": import1,";
const PATCHED: &str =
"\"/static/islands-core/islands_core.js\": Object.assign(Object.create(null), import1),";
pub fn patch_page_js(js_path: &Path) -> Result<bool> {
let content =
fs::read_to_string(js_path).with_context(|| format!("reading {}", js_path.display()))?;
if !content.contains(NEEDLE) {
return Ok(false);
}
let new_content = content.replacen(NEEDLE, PATCHED, 1);
fs::write(js_path, new_content)
.with_context(|| format!("writing patched {}", js_path.display()))?;
Ok(true)
}
pub fn patch_page_source(source: &str) -> String {
if !source.contains(NEEDLE) {
return source.to_owned();
}
source.replacen(NEEDLE, PATCHED, 1)
}
pub fn assert_patched(js_path: &Path) -> Result<()> {
let content =
fs::read_to_string(js_path).with_context(|| format!("reading {}", js_path.display()))?;
if content.contains(PATCH_TOKEN) {
return Ok(());
}
anyhow::bail!(
"V8 namespace patch missing from {} — wasm-bindgen's emitted import shape changed. \
Neither the patch token (`{PATCH_TOKEN}`) nor the expected needle (`{NEEDLE}`) is \
present. Update islands_build::patch::NEEDLE to match the new wasm-bindgen output.",
js_path.display()
)
}
pub fn rewrite_basenames_in_js(source: &str, rename_map: &[(String, String)]) -> String {
let mut content = source.to_owned();
for (original, hashed) in rename_map {
content = replace_whole_basename(&content, original, hashed);
}
for (original, hashed) in rename_map {
if !original.contains("islands_core.js") {
continue;
}
let rewritten_key_line = format!(
r#""/static/islands-core/{hashed}": Object.assign(Object.create(null), import1)"#
);
let original_key_line = format!(
r#""/static/islands-core/{original}": Object.assign(Object.create(null), import1)"#
);
content = content.replace(&rewritten_key_line, &original_key_line);
}
content
}
fn is_filename_char(c: char) -> bool {
c.is_ascii_alphanumeric() || matches!(c, '_' | '-' | '.')
}
fn replace_whole_basename(content: &str, original: &str, hashed: &str) -> String {
if original.is_empty() {
return content.to_owned();
}
let mut result = String::with_capacity(content.len());
let mut cursor = 0;
while let Some(offset) = content[cursor..].find(original) {
let start = cursor + offset;
let end = start + original.len();
let preceded = content[..start]
.chars()
.next_back()
.is_some_and(is_filename_char);
let followed = content[end..].chars().next().is_some_and(is_filename_char);
result.push_str(&content[cursor..start]);
result.push_str(if preceded || followed { original } else { hashed });
cursor = end;
}
result.push_str(&content[cursor..]);
result
}
pub fn patch_runtime_snippets(js_path: &Path) -> Result<bool> {
let content =
fs::read_to_string(js_path).with_context(|| format!("reading {}", js_path.display()))?;
let patched = wrap_snippet_namespace_imports(&content);
if patched == content {
return Ok(false);
}
fs::write(js_path, &patched)
.with_context(|| format!("writing patched {}", js_path.display()))?;
Ok(true)
}
pub fn wrap_snippet_namespace_imports(source: &str) -> String {
let mut changed = false;
let mut lines: Vec<String> = Vec::with_capacity(source.lines().count());
for line in source.lines() {
match wrap_snippet_import_value(line) {
Some(wrapped) => {
lines.push(wrapped);
changed = true;
}
None => lines.push(line.to_owned()),
}
}
if !changed {
return source.to_owned();
}
let mut output = lines.join("\n");
if source.ends_with('\n') {
output.push('\n');
}
output
}
fn wrap_snippet_import_value(line: &str) -> Option<String> {
if !line.contains("/snippets/") {
return None;
}
let separator = "\": ";
let separator_index = line.find(separator)?;
let key_through_separator = &line[..separator_index + separator.len()];
let value_part = line[separator_index + separator.len()..].trim_end();
let identifier = value_part.strip_suffix(',')?;
let digits = identifier.strip_prefix("import")?;
if digits.is_empty() || !digits.bytes().all(|byte| byte.is_ascii_digit()) {
return None;
}
Some(format!(
"{key_through_separator}Object.assign(Object.create(null), {identifier}),"
))
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn patches_the_page_core_import_value() {
let source = format!(" {NEEDLE}\n");
let patched = patch_page_source(&source);
assert!(patched.contains(PATCH_TOKEN), "got: {patched}");
}
#[test]
fn page_patch_is_idempotent_in_memory() {
let once = patch_page_source(&format!(" {NEEDLE}\n"));
let twice = patch_page_source(&once);
assert_eq!(once, twice);
}
#[test]
fn wraps_snippet_importobject_namespace_value() {
let source = " \"./snippets/islands-runtime-abc/inline0.js\": import1,\n";
let output = wrap_snippet_namespace_imports(source);
assert!(
output.contains(
"\"./snippets/islands-runtime-abc/inline0.js\": Object.assign(Object.create(null), import1),"
),
"snippet importObject value must be namespace-wrapped; got: {output}"
);
}
#[test]
fn leaves_import_statement_and_non_snippet_lines_untouched() {
let source = concat!(
"import * as import1 from \"./snippets/islands-runtime-abc/inline0.js\";\n",
" \"__wbindgen_placeholder__\": import1,\n",
);
assert_eq!(wrap_snippet_namespace_imports(source), source);
}
#[test]
fn snippet_wrap_is_idempotent() {
let source = " \"./snippets/x/inline0.js\": import2,\n";
let once = wrap_snippet_namespace_imports(source);
let twice = wrap_snippet_namespace_imports(&once);
assert_eq!(once, twice, "re-running the wrap must be a no-op");
}
#[test]
fn page_patch_token_survives_basename_rewrite() {
let patched = patch_page_source(&format!(" {NEEDLE}\n"));
let rename_map = vec![(
"islands_core.js".to_owned(),
"islands_core.deadbeef.js".to_owned(),
)];
let after = rewrite_basenames_in_js(&patched, &rename_map);
assert!(after.contains(PATCH_TOKEN), "patch token lost: {after}");
assert!(
after.contains("/static/islands-core/islands_core.js\":"),
"runtime importObject key must be restored unhashed: {after}"
);
}
#[test]
fn rewrite_is_deterministic_for_substring_basenames() {
let source = "import init, { start } from \"./pasifico_worker.js\";\n";
let worker = ("worker.js".to_owned(), "worker.aaaaaaaa.js".to_owned());
let glue = (
"pasifico_worker.js".to_owned(),
"pasifico_worker.bbbbbbbb.js".to_owned(),
);
let forward = rewrite_basenames_in_js(source, &[worker.clone(), glue.clone()]);
let reverse = rewrite_basenames_in_js(source, &[glue, worker]);
let expected = "import init, { start } from \"./pasifico_worker.bbbbbbbb.js\";\n";
assert_eq!(
forward, expected,
"shorter-first order corrupted the longer basename reference"
);
assert_eq!(reverse, expected, "longer-first order changed the result");
assert_eq!(forward, reverse, "rewrite must be order-independent");
}
#[test]
fn snippet_key_stays_unhashed_through_rewrite() {
let wrapped = wrap_snippet_namespace_imports(" \"./snippets/r/inline0.js\": import1,\n");
let rename_map = vec![(
"islands_core.js".to_owned(),
"islands_core.deadbeef.js".to_owned(),
)];
let after = rewrite_basenames_in_js(&wrapped, &rename_map);
assert!(
after.contains(
"\"./snippets/r/inline0.js\": Object.assign(Object.create(null), import1),"
),
"snippet key must stay unhashed and the wrap must survive; got: {after}"
);
}
}