feat(export): single-file offline keepsake viewer + guest download
Rebuild the guest "keepsake" (offline HTML gallery) so it renders when the extracted index.html is opened over file://. Browsers block external ES-module scripts and fetch() at origin null, so a normal multi-file SvelteKit build shows a blank window. Build the viewer as one self-contained index.html (inlined JS/CSS via vite-plugin-singlefile, standalone non-SvelteKit entry) and inject the data as window.__EXPORT_DATA__; the backend embeds the built viewer via include_dir! and writes the data global into index.html when zipping. Also surface the guest-facing download: a feed banner and the BottomNav Export tab, both shown once the host releases the export. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -899,8 +899,11 @@ async fn run_html_export_inner(
|
||||
let file = tokio::fs::File::create(&tmp_path).await?;
|
||||
let mut zip = ZipFileWriter::with_tokio(file);
|
||||
|
||||
// Write embedded viewer assets (index.html, _app/*, etc.)
|
||||
write_dir_to_zip(&VIEWER_DIR, &mut zip).await?;
|
||||
// Write the embedded single-file viewer, injecting the export data as a
|
||||
// `window.__EXPORT_DATA__` global into index.html. Guests double-click
|
||||
// index.html (file://), where a cross-origin fetch() of a sibling file is
|
||||
// blocked — so the data must be inlined rather than fetched from data.json.
|
||||
write_viewer_with_data(&VIEWER_DIR, &mut zip, &data_json).await?;
|
||||
|
||||
let _ = update_progress(pool, event_id, "html", epoch, 75).await;
|
||||
|
||||
@@ -1301,22 +1304,43 @@ async fn maybe_broadcast_complete(
|
||||
}
|
||||
}
|
||||
|
||||
/// Recursively write all files from an embedded `include_dir::Dir` into a ZIP.
|
||||
async fn write_dir_to_zip(
|
||||
/// Write the embedded viewer into the ZIP, injecting the export data as a
|
||||
/// `window.__EXPORT_DATA__` global into `index.html`. The keepsake is opened by
|
||||
/// double-clicking `index.html` (file://), where browsers block a cross-origin
|
||||
/// `fetch()` of a sibling `data.json` — so the data is inlined into the page.
|
||||
/// (`data.json` is still written separately for the http-served case.)
|
||||
async fn write_viewer_with_data(
|
||||
dir: &include_dir::Dir<'_>,
|
||||
zip: &mut ZipFileWriter<tokio::fs::File>,
|
||||
data_json: &str,
|
||||
) -> Result<()> {
|
||||
for file in dir.files() {
|
||||
let path = file.path().to_string_lossy().to_string();
|
||||
let contents = file.contents();
|
||||
let builder = ZipEntryBuilder::new(path.into(), Compression::Deflate);
|
||||
let mut entry = zip.write_entry_stream(builder).await?;
|
||||
let mut cursor = AllowStdIo::new(std::io::Cursor::new(contents));
|
||||
fcopy(&mut cursor, &mut entry).await?;
|
||||
entry.close().await?;
|
||||
if path == "index.html" {
|
||||
let html = std::str::from_utf8(file.contents())
|
||||
.context("export-viewer index.html is not valid UTF-8")?;
|
||||
// Escape `</` so a caption containing `</script>` can't break out of the tag.
|
||||
let safe = data_json.replace("</", "<\\/");
|
||||
let script = format!("<script>window.__EXPORT_DATA__={safe};</script>");
|
||||
let injected = match html.find("</head>") {
|
||||
Some(idx) => format!("{}{}{}", &html[..idx], script, &html[idx..]),
|
||||
None => format!("{script}{html}"),
|
||||
};
|
||||
let builder = ZipEntryBuilder::new(path.into(), Compression::Deflate);
|
||||
let mut entry = zip.write_entry_stream(builder).await?;
|
||||
let mut cursor = AllowStdIo::new(std::io::Cursor::new(injected.as_bytes()));
|
||||
fcopy(&mut cursor, &mut entry).await?;
|
||||
entry.close().await?;
|
||||
} else {
|
||||
let builder = ZipEntryBuilder::new(path.into(), Compression::Deflate);
|
||||
let mut entry = zip.write_entry_stream(builder).await?;
|
||||
let mut cursor = AllowStdIo::new(std::io::Cursor::new(file.contents()));
|
||||
fcopy(&mut cursor, &mut entry).await?;
|
||||
entry.close().await?;
|
||||
}
|
||||
}
|
||||
for sub_dir in dir.dirs() {
|
||||
Box::pin(write_dir_to_zip(sub_dir, zip)).await?;
|
||||
Box::pin(write_viewer_with_data(sub_dir, zip, data_json)).await?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
@@ -1 +0,0 @@
|
||||
export const env={}
|
||||
File diff suppressed because one or more lines are too long
@@ -1 +0,0 @@
|
||||
import{u as o,n as t,o as c}from"./CcONa1Mr.js";function u(e){throw new Error("https://svelte.dev/e/lifecycle_outside_component")}function r(e){t===null&&u(),o(()=>{const n=c(e);if(typeof n=="function")return n})}export{r as o};
|
||||
@@ -1 +0,0 @@
|
||||
import{f as l,g as o,p as u,i as n,j as d,k as m,h as p,e as _,m as v,l as k}from"./CcONa1Mr.js";class w{anchor;#t=new Map;#s=new Map;#e=new Map;#i=new Set;#f=!0;constructor(t,s=!0){this.anchor=t,this.#f=s}#a=t=>{if(this.#t.has(t)){var s=this.#t.get(t),e=this.#s.get(s);if(e)l(e),this.#i.delete(s);else{var f=this.#e.get(s);f&&(this.#s.set(s,f.effect),this.#e.delete(s),f.fragment.lastChild.remove(),this.anchor.before(f.fragment),e=f.effect)}for(const[i,a]of this.#t){if(this.#t.delete(i),i===t)break;const r=this.#e.get(a);r&&(o(r.effect),this.#e.delete(a))}for(const[i,a]of this.#s){if(i===s||this.#i.has(i))continue;const r=()=>{if(Array.from(this.#t.values()).includes(i)){var c=document.createDocumentFragment();v(a,c),c.append(n()),this.#e.set(i,{effect:a,fragment:c})}else o(a);this.#i.delete(i),this.#s.delete(i)};this.#f||!e?(this.#i.add(i),u(a,r,!1)):r()}}};#r=t=>{this.#t.delete(t);const s=Array.from(this.#t.values());for(const[e,f]of this.#e)s.includes(e)||(o(f.effect),this.#e.delete(e))};ensure(t,s){var e=m,f=k();if(s&&!this.#s.has(t)&&!this.#e.has(t))if(f){var i=document.createDocumentFragment(),a=n();i.append(a),this.#e.set(t,{effect:d(()=>s(a)),fragment:i})}else this.#s.set(t,d(()=>s(this.anchor)));if(this.#t.set(e,t),f){for(const[r,h]of this.#s)r===t?e.unskip_effect(h):e.skip_effect(h);for(const[r,h]of this.#e)r===t?e.unskip_effect(h.effect):e.skip_effect(h.effect);e.oncommit(this.#a),e.ondiscard(this.#r)}else p&&(this.anchor=_),this.#a(e)}}export{w as B};
|
||||
File diff suppressed because one or more lines are too long
@@ -1 +0,0 @@
|
||||
import{b as c,h as o,a as l,E as b,r as p,s as v,c as g,d,e as m}from"./CcONa1Mr.js";import{B as y}from"./BRDva_z9.js";function k(f,h,_=!1){var n;o&&(n=m,l());var s=new y(f),u=_?b:0;function t(a,r){if(o){var e=p(n);if(a!==parseInt(e.substring(1))){var i=v();g(i),s.anchor=i,d(!1),s.ensure(a,r),d(!0);return}}s.ensure(a,r)}c(()=>{var a=!1;h((r,e=0)=>{a=!0,t(e,r)}),a||t(-1,null)},u)}export{k as i};
|
||||
File diff suppressed because one or more lines are too long
@@ -1 +0,0 @@
|
||||
import{A as v,i as d,B as l,C as u,D as T,T as p,F as h,h as i,e as s,R as E,a as y,G as g,c as w,H as N}from"./CcONa1Mr.js";const A=globalThis?.window?.trustedTypes&&globalThis.window.trustedTypes.createPolicy("svelte-trusted-html",{createHTML:t=>t});function M(t){return A?.createHTML(t)??t}function x(t){var r=v("template");return r.innerHTML=M(t.replaceAll("<!>","<!---->")),r.content}function n(t,r){var e=l;e.nodes===null&&(e.nodes={start:t,end:r,a:null,t:null})}function b(t,r){var e=(r&p)!==0,f=(r&h)!==0,a,_=!t.startsWith("<!>");return()=>{if(i)return n(s,null),s;a===void 0&&(a=x(_?t:"<!>"+t),e||(a=u(a)));var o=f||T?document.importNode(a,!0):a.cloneNode(!0);if(e){var c=u(o),m=o.lastChild;n(c,m)}else n(o,o);return o}}function C(t=""){if(!i){var r=d(t+"");return n(r,r),r}var e=s;return e.nodeType!==g?(e.before(e=d()),w(e)):N(e),n(e,e),e}function O(){if(i)return n(s,null),s;var t=document.createDocumentFragment(),r=document.createComment(""),e=d();return t.append(r,e),n(r,e),t}function P(t,r){if(i){var e=l;((e.f&E)===0||e.nodes.end===null)&&(e.nodes.end=s),y();return}t!==null&&t.before(r)}const L="5";typeof window<"u"&&((window.__svelte??={}).v??=new Set).add(L);export{P as a,n as b,O as c,b as f,C as t};
|
||||
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
@@ -1 +0,0 @@
|
||||
import{l as o,a as r}from"../chunks/eAGLaJx1.js";export{o as load_css,r as start};
|
||||
@@ -1 +0,0 @@
|
||||
import{c as s,a as c}from"../chunks/RsTAN2PN.js";import{b as l,E as p,t as i}from"../chunks/CcONa1Mr.js";import{B as m}from"../chunks/BRDva_z9.js";function u(n,r,...e){var o=new m(n);l(()=>{const t=r()??null;o.ensure(t,t&&(a=>t(a,...e)))},p)}const f=!0,_=!1,g=Object.freeze(Object.defineProperty({__proto__:null,prerender:f,ssr:_},Symbol.toStringTag,{value:"Module"}));function h(n,r){var e=s(),o=i(e);u(o,()=>r.children),c(n,e)}export{h as component,g as universal};
|
||||
@@ -1 +0,0 @@
|
||||
import{a as i,f as h}from"../chunks/RsTAN2PN.js";import{q as g,t as v,v as d,w as l,x as s,y as a,z as x}from"../chunks/CcONa1Mr.js";import{s as o}from"../chunks/Bb9JxzU7.js";import{s as _,p}from"../chunks/eAGLaJx1.js";const $={get error(){return p.error},get status(){return p.status}};_.updated.check;const m=$;var k=h("<h1> </h1> <p> </p>",1);function z(c,f){g(f,!0);var t=k(),r=v(t),n=s(r,!0);a(r);var e=x(r,2),u=s(e,!0);a(e),d(()=>{o(n,m.status),o(u,m.error?.message)}),i(c,t),l()}export{z as component};
|
||||
File diff suppressed because one or more lines are too long
@@ -1 +0,0 @@
|
||||
{"version":"1778876725548"}
|
||||
File diff suppressed because one or more lines are too long
Reference in New Issue
Block a user