diff --git a/apps/chuzz/frontend/src/lib/shortcuts.ts b/apps/chuzz/frontend/src/lib/shortcuts.ts index c53af265..1306ed1a 100644 --- a/apps/chuzz/frontend/src/lib/shortcuts.ts +++ b/apps/chuzz/frontend/src/lib/shortcuts.ts @@ -6,7 +6,8 @@ export type BrowserShortcut = | "reload" | "focus-address" | "back" - | "forward"; + | "forward" + | "view-source"; export interface ShortcutInput { key: string; @@ -47,6 +48,8 @@ export function resolveBrowserShortcut(event: ShortcutInput): BrowserShortcut | if (key === "l" || key === "d" || event.code === "KeyL" || event.code === "KeyD") { return "focus-address"; } + // Cmd-U, the same binding Chrome and Safari use. + if (key === "u" || event.code === "KeyU") return "view-source"; if (key === "[" || event.code === "BracketLeft") return "back"; if (key === "]" || event.code === "BracketRight") return "forward"; return undefined; diff --git a/apps/chuzz/frontend/src/stores/browser.tsx b/apps/chuzz/frontend/src/stores/browser.tsx index ef1e7684..82bc61d7 100644 --- a/apps/chuzz/frontend/src/stores/browser.tsx +++ b/apps/chuzz/frontend/src/stores/browser.tsx @@ -108,6 +108,16 @@ function createBrowserStore() { case "focus-address": focusAddress(); break; + case "view-source": { + const url = state.tabs.find((tab) => tab.id === state.activeTabId)?.url; + // Inert on a source tab rather than recursive. The naive version + // opens `view-source:view-source:https://...`, which the address bar + // happily accepts and nothing can render. + if (url && !url.startsWith("view-source:")) { + void api.openTab(`view-source:${url}`); + } + break; + } case "back": void api.goBack(state.activeTabId); break; diff --git a/apps/chuzz/src/browser.rs b/apps/chuzz/src/browser.rs index 582ceab6..05ab4c07 100644 --- a/apps/chuzz/src/browser.rs +++ b/apps/chuzz/src/browser.rs @@ -127,6 +127,8 @@ enum PageSource { }, /// Built by a WebAssembly guest. Nothing is fetched and no JavaScript runs. Wasm { module: std::path::PathBuf }, + /// The bytes a server sent, shown rather than rendered. + Source { text: String }, } /// A module a page declared, fetched alongside it. @@ -412,6 +414,12 @@ impl Browser { .unwrap_or_default(); (Box::new(page), title) } + PageSource::Source { text } => { + let page = + blitz_html::HtmlDocument::from_html(&source_html(&text), make_config()) + .into_inner(); + (Box::new(page), format!("source of {}", bundle.resolved_url)) + } PageSource::Wasm { module } => match build_wasm_page(&module, make_config()) { // A bare `BaseDocument`, not a `ScriptDocument`. The mount // takes `Box` and `BaseDocument` implements @@ -647,6 +655,24 @@ async fn fetch_page_module( }) } +/// Wrap a server's bytes in the smallest document that shows them verbatim. +/// +/// Escaped and put in a `
`, which is the whole job: the point of view
+/// source is that what you read is what arrived, so nothing here may reformat,
+/// pretty-print or re-serialise it. A document that showed a parsed and
+/// re-emitted tree would be answering a different question, and for a page
+/// whose claim is "there is no script here" it would be the wrong answer.
+fn source_html(text: &str) -> String {
+    let escaped = text
+        .replace('&', "&")
+        .replace('<', "<")
+        .replace('>', ">");
+    format!(
+        r#"Source
+
{escaped}
"# + ) +} + pub(crate) fn page_node(document: &blitz_dom::BaseDocument, tab_id: u64) -> Option { if let Some(node) = document.get_element_by_id(&format!("chuzz-page-{tab_id}")) { return Some(node); @@ -694,6 +720,25 @@ async fn fetch_page( }, }; } + // `view-source:` is answered by fetching what it names and showing the + // bytes instead of rendering them. The prefix stays on the tab's URL, so + // history and the address bar hold it like any other address. + if request.url.scheme() == "view-source" { + let inner = request.url.path().to_owned(); + let source = match Url::parse(&inner) { + Ok(url) => match net.fetch_async(Request::get(url)).await { + Ok((_, bytes)) => decode_body(&bytes), + Err(error) => format!("could not fetch {inner}: {error:?}"), + }, + Err(error) => format!("{inner} is not a URL: {error}"), + }; + return PageBundle { + tab_id, + generation, + resolved_url: request.url.to_string(), + source: PageSource::Source { text: source }, + }; + } if force_revalidate { revalidate(&mut request); } @@ -1169,6 +1214,35 @@ mod tests { ); } + /// What you read has to be what arrived. For a page whose whole claim is + /// "there is no script here", a source view that re-serialised a parsed + /// tree would be answering a different question. + #[test] + fn view_source_shows_the_bytes_verbatim() { + let page = r##"

fallback

+"##; + let shown = source_html(page); + + // The tag survives, escaped, so a reader can see there is exactly one + // script and what type it is. + assert!(shown.contains("<script type=\"application/wasm\"")); + assert!(shown.contains("mount=\"#root\">")); + + // Escaped, not executed or parsed away. + assert!( + !shown.contains("
"), + "the source was interpolated as markup instead of escaped" + ); + assert!(shown.contains("<div id=\"root\">")); + + // Ampersands first, or `<` would come back out as `&lt;`. + assert_eq!( + source_html("a & b < c").matches("&").count(), + 1, + "escaping order mangles ampersands" + ); + } + #[test] fn browser_starts_with_one_blank_tab() { let browser = Browser::new(None);