@open-slide/core 0.0.3 → 0.0.4

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,313 @@
1
+ // Exports a slide as a standalone HTML file (or a .zip bundle if the slide
2
+ // references bundled assets). The export is a static snapshot of each page's
3
+ // post-mount DOM — runtime interactivity (useState click handlers, timers,
4
+ // etc.) is captured at snapshot time only.
5
+
6
+ import { createElement } from 'react';
7
+ import { createRoot } from 'react-dom/client';
8
+ import type { SlideModule } from './sdk';
9
+
10
+ type AssetEntry = { name: string; bytes: Uint8Array };
11
+
12
+ const ASSET_EXT_RE = /\.(?:png|jpe?g|gif|svg|webp|avif|mp4|webm|mov|woff2?|ttf|otf|mp3|wav|ogg)(?:\?[^#]*)?(?:#.*)?$/i;
13
+
14
+ export async function exportSlideAsHtml(slide: SlideModule, slideId: string): Promise<void> {
15
+ const pages = slide.default ?? [];
16
+ if (pages.length === 0) return;
17
+ const title = slide.meta?.title ?? slideId;
18
+
19
+ const pagesHtml = await renderPagesToHtml(pages);
20
+ const bundledCss = collectCss();
21
+ const externalLinks = collectExternalStylesheetLinks();
22
+
23
+ const assets = new Map<string, AssetEntry>();
24
+ const usedNames = new Set<string>();
25
+
26
+ const urls = new Set<string>([
27
+ ...findHtmlAssetUrls(pagesHtml.join('\n')),
28
+ ...findCssAssetUrls(bundledCss),
29
+ ]);
30
+
31
+ for (const url of urls) {
32
+ const absolute = toAbsolute(url);
33
+ if (!absolute) continue;
34
+ try {
35
+ const res = await fetch(absolute);
36
+ if (!res.ok) continue;
37
+ const buf = new Uint8Array(await res.arrayBuffer());
38
+ const name = uniqueAssetName(absolute, usedNames);
39
+ assets.set(url, { name, bytes: buf });
40
+ } catch {
41
+ // ignore unreachable assets
42
+ }
43
+ }
44
+
45
+ const rewrittenPages = pagesHtml.map((html) => rewriteUrls(html, assets, 'html'));
46
+ const rewrittenCss = rewriteUrls(bundledCss, assets, 'css');
47
+
48
+ const html = buildHtml({
49
+ title,
50
+ pagesHtml: rewrittenPages,
51
+ bundledCss: rewrittenCss,
52
+ externalLinks,
53
+ });
54
+
55
+ const htmlBytes = new TextEncoder().encode(html);
56
+
57
+ if (assets.size === 0) {
58
+ downloadBlob(new Blob([htmlBytes as BlobPart], { type: 'text/html' }), `${slideId}.html`);
59
+ return;
60
+ }
61
+
62
+ const { zipSync } = await import('fflate');
63
+ const zipTree: Record<string, Uint8Array | Record<string, Uint8Array>> = {
64
+ [`${slideId}.html`]: htmlBytes,
65
+ assets: {},
66
+ };
67
+ for (const { name, bytes } of assets.values()) {
68
+ (zipTree.assets as Record<string, Uint8Array>)[name] = bytes;
69
+ }
70
+ const zipped = zipSync(zipTree as Parameters<typeof zipSync>[0]);
71
+ downloadBlob(new Blob([zipped as BlobPart], { type: 'application/zip' }), `${slideId}.zip`);
72
+ }
73
+
74
+ async function renderPagesToHtml(pages: NonNullable<SlideModule['default']>): Promise<string[]> {
75
+ const container = document.createElement('div');
76
+ container.setAttribute('aria-hidden', 'true');
77
+ Object.assign(container.style, {
78
+ position: 'fixed',
79
+ left: '-99999px',
80
+ top: '0',
81
+ width: '1920px',
82
+ height: '1080px',
83
+ pointerEvents: 'none',
84
+ });
85
+ document.body.appendChild(container);
86
+
87
+ const result: string[] = [];
88
+ try {
89
+ for (const Page of pages) {
90
+ const host = document.createElement('div');
91
+ host.style.width = '1920px';
92
+ host.style.height = '1080px';
93
+ container.appendChild(host);
94
+ const root = createRoot(host);
95
+ root.render(createElement(Page));
96
+ await nextPaint();
97
+ await nextPaint();
98
+ result.push(host.innerHTML);
99
+ root.unmount();
100
+ container.removeChild(host);
101
+ }
102
+ } finally {
103
+ container.remove();
104
+ }
105
+ return result;
106
+ }
107
+
108
+ function nextPaint(): Promise<void> {
109
+ return new Promise((resolve) => requestAnimationFrame(() => resolve()));
110
+ }
111
+
112
+ function collectCss(): string {
113
+ const chunks: string[] = [];
114
+ for (const sheet of Array.from(document.styleSheets)) {
115
+ let rules: CSSRuleList | null = null;
116
+ try {
117
+ rules = sheet.cssRules;
118
+ } catch {
119
+ continue;
120
+ }
121
+ if (!rules) continue;
122
+ for (const rule of Array.from(rules)) {
123
+ chunks.push(rule.cssText);
124
+ }
125
+ }
126
+ return chunks.join('\n');
127
+ }
128
+
129
+ function collectExternalStylesheetLinks(): string {
130
+ const links: string[] = [];
131
+ for (const sheet of Array.from(document.styleSheets)) {
132
+ try {
133
+ void sheet.cssRules;
134
+ } catch {
135
+ if (sheet.href) {
136
+ links.push(`<link rel="stylesheet" href="${escapeAttr(sheet.href)}">`);
137
+ }
138
+ }
139
+ }
140
+ return links.join('\n');
141
+ }
142
+
143
+ function findHtmlAssetUrls(html: string): string[] {
144
+ const out: string[] = [];
145
+ const attrRe = /\s(?:src|href)="([^"]+)"/g;
146
+ for (const m of html.matchAll(attrRe)) {
147
+ if (looksLikeAsset(m[1])) out.push(m[1]);
148
+ }
149
+ const srcsetRe = /\ssrcset="([^"]+)"/g;
150
+ for (const m of html.matchAll(srcsetRe)) {
151
+ for (const part of m[1].split(',')) {
152
+ const url = part.trim().split(/\s+/)[0];
153
+ if (url && looksLikeAsset(url)) out.push(url);
154
+ }
155
+ }
156
+ return out;
157
+ }
158
+
159
+ function findCssAssetUrls(css: string): string[] {
160
+ const out: string[] = [];
161
+ const re = /url\(\s*(['"]?)([^)'"]+)\1\s*\)/g;
162
+ for (const m of css.matchAll(re)) {
163
+ const url = m[2].trim();
164
+ if (looksLikeAsset(url)) out.push(url);
165
+ }
166
+ return out;
167
+ }
168
+
169
+ function looksLikeAsset(url: string): boolean {
170
+ if (!url) return false;
171
+ if (url.startsWith('data:') || url.startsWith('blob:') || url.startsWith('#')) return false;
172
+ if (url.startsWith('mailto:') || url.startsWith('javascript:')) return false;
173
+ const abs = toAbsolute(url);
174
+ if (!abs) return false;
175
+ // Same-origin only: we can only fetch local assets.
176
+ try {
177
+ const u = new URL(abs);
178
+ if (u.origin !== window.location.origin) return false;
179
+ } catch {
180
+ return false;
181
+ }
182
+ return ASSET_EXT_RE.test(url);
183
+ }
184
+
185
+ function toAbsolute(url: string): string | null {
186
+ try {
187
+ return new URL(url, window.location.href).toString();
188
+ } catch {
189
+ return null;
190
+ }
191
+ }
192
+
193
+ function uniqueAssetName(absoluteUrl: string, used: Set<string>): string {
194
+ let base: string;
195
+ try {
196
+ const u = new URL(absoluteUrl);
197
+ base = u.pathname.split('/').pop() || 'asset';
198
+ } catch {
199
+ base = 'asset';
200
+ }
201
+ if (!used.has(base)) {
202
+ used.add(base);
203
+ return base;
204
+ }
205
+ const hash = shortHash(absoluteUrl);
206
+ const dot = base.lastIndexOf('.');
207
+ const name = dot > 0 ? `${base.slice(0, dot)}-${hash}${base.slice(dot)}` : `${base}-${hash}`;
208
+ used.add(name);
209
+ return name;
210
+ }
211
+
212
+ function shortHash(input: string): string {
213
+ let h = 2166136261;
214
+ for (let i = 0; i < input.length; i++) {
215
+ h ^= input.charCodeAt(i);
216
+ h = Math.imul(h, 16777619);
217
+ }
218
+ return (h >>> 0).toString(36).slice(0, 6);
219
+ }
220
+
221
+ function rewriteUrls(source: string, assets: Map<string, AssetEntry>, kind: 'html' | 'css'): string {
222
+ let out = source;
223
+ for (const [orig, { name }] of assets) {
224
+ const replacement = kind === 'css' ? `./assets/${name}` : `assets/${name}`;
225
+ out = out.split(orig).join(replacement);
226
+ }
227
+ return out;
228
+ }
229
+
230
+ function buildHtml(opts: {
231
+ title: string;
232
+ pagesHtml: string[];
233
+ bundledCss: string;
234
+ externalLinks: string;
235
+ }): string {
236
+ const pagesMarkup = opts.pagesHtml
237
+ .map((page, i) => `<div class="os-page" data-idx="${i}"${i === 0 ? '' : ' hidden'}>${page}</div>`)
238
+ .join('');
239
+
240
+ return `<!doctype html>
241
+ <html lang="en">
242
+ <head>
243
+ <meta charset="utf-8">
244
+ <meta name="viewport" content="width=device-width, initial-scale=1">
245
+ <title>${escapeHtml(opts.title)}</title>
246
+ ${opts.externalLinks}
247
+ <style>
248
+ html, body { margin: 0; height: 100%; background: #000; overflow: hidden; font-family: system-ui, sans-serif; }
249
+ .os-stage { position: fixed; inset: 0; display: flex; align-items: center; justify-content: center; }
250
+ .os-frame { width: 1920px; height: 1080px; background: #fff; color: #000; transform-origin: center center; overflow: hidden; position: relative; }
251
+ .os-page { position: absolute; inset: 0; }
252
+ .os-page[hidden] { display: none !important; }
253
+ .os-counter { position: fixed; bottom: 12px; left: 50%; transform: translateX(-50%); color: #fff; background: rgba(0,0,0,.5); padding: 2px 10px; border-radius: 999px; font-size: 12px; z-index: 10; font-variant-numeric: tabular-nums; }
254
+ </style>
255
+ <style>${opts.bundledCss}</style>
256
+ </head>
257
+ <body>
258
+ <div class="os-stage"><div class="os-frame" id="os-frame">${pagesMarkup}</div></div>
259
+ <div class="os-counter"><span id="os-cur">1</span> / <span id="os-total">${opts.pagesHtml.length}</span></div>
260
+ <script>
261
+ (function () {
262
+ var pages = document.querySelectorAll('.os-page');
263
+ var idx = 0;
264
+ var frame = document.getElementById('os-frame');
265
+ var cur = document.getElementById('os-cur');
266
+ function fit() {
267
+ var s = Math.min(window.innerWidth / 1920, window.innerHeight / 1080);
268
+ frame.style.transform = 'scale(' + s + ')';
269
+ }
270
+ function go(i) {
271
+ idx = Math.max(0, Math.min(pages.length - 1, i));
272
+ pages.forEach(function (p, n) { p.hidden = n !== idx; });
273
+ cur.textContent = String(idx + 1);
274
+ }
275
+ window.addEventListener('resize', fit);
276
+ window.addEventListener('keydown', function (e) {
277
+ if (['ArrowRight','ArrowDown','PageDown',' '].indexOf(e.key) >= 0) { e.preventDefault(); go(idx + 1); }
278
+ else if (['ArrowLeft','ArrowUp','PageUp'].indexOf(e.key) >= 0) { e.preventDefault(); go(idx - 1); }
279
+ else if (e.key === 'Home') { e.preventDefault(); go(0); }
280
+ else if (e.key === 'End') { e.preventDefault(); go(pages.length - 1); }
281
+ });
282
+ fit();
283
+ go(0);
284
+ })();
285
+ </script>
286
+ </body>
287
+ </html>`;
288
+ }
289
+
290
+ function escapeHtml(s: string): string {
291
+ return s
292
+ .replace(/&/g, '&amp;')
293
+ .replace(/</g, '&lt;')
294
+ .replace(/>/g, '&gt;')
295
+ .replace(/"/g, '&quot;')
296
+ .replace(/'/g, '&#39;');
297
+ }
298
+
299
+ function escapeAttr(s: string): string {
300
+ return s.replace(/&/g, '&amp;').replace(/"/g, '&quot;');
301
+ }
302
+
303
+ function downloadBlob(blob: Blob, filename: string): void {
304
+ const url = URL.createObjectURL(blob);
305
+ const a = document.createElement('a');
306
+ a.href = url;
307
+ a.download = filename;
308
+ a.rel = 'noopener';
309
+ document.body.appendChild(a);
310
+ a.click();
311
+ a.remove();
312
+ setTimeout(() => URL.revokeObjectURL(url), 0);
313
+ }
@@ -6,7 +6,25 @@ const EMPTY: FoldersManifest = { folders: [], assignments: {} };
6
6
  async function getManifest(): Promise<FoldersManifest> {
7
7
  const res = await fetch('/__folders');
8
8
  if (!res.ok) throw new Error(`GET /__folders ${res.status}`);
9
- return (await res.json()) as FoldersManifest;
9
+ const raw = (await res.json()) as Partial<FoldersManifest>;
10
+ return {
11
+ folders: raw.folders ?? [],
12
+ assignments: raw.assignments ?? {},
13
+ };
14
+ }
15
+
16
+ async function patchSlideName(slideId: string, name: string): Promise<void> {
17
+ const res = await fetch(`/__slides/${slideId}`, {
18
+ method: 'PATCH',
19
+ headers: { 'content-type': 'application/json' },
20
+ body: JSON.stringify({ name }),
21
+ });
22
+ if (!res.ok) throw new Error(`PATCH /__slides/${slideId} ${res.status}`);
23
+ }
24
+
25
+ async function deleteSlideReq(slideId: string): Promise<void> {
26
+ const res = await fetch(`/__slides/${slideId}`, { method: 'DELETE' });
27
+ if (!res.ok) throw new Error(`DELETE /__slides/${slideId} ${res.status}`);
10
28
  }
11
29
 
12
30
  async function postFolder(name: string, icon: FolderIcon): Promise<Folder> {
@@ -53,6 +71,8 @@ export type UseFoldersResult = {
53
71
  update: (id: string, patch: { name?: string; icon?: FolderIcon }) => Promise<void>;
54
72
  remove: (id: string) => Promise<void>;
55
73
  assign: (slideId: string, folderId: string | null) => Promise<void>;
74
+ renameSlide: (slideId: string, name: string) => Promise<void>;
75
+ deleteSlide: (slideId: string) => Promise<void>;
56
76
  refresh: () => Promise<void>;
57
77
  };
58
78
 
@@ -87,9 +107,9 @@ export function useFolders(): UseFoldersResult {
87
107
  const handler = () => {
88
108
  refresh().catch(() => {});
89
109
  };
90
- import.meta.hot.on('open-slide:folders-changed', handler);
110
+ import.meta.hot.on('open-slide:files-changed', handler);
91
111
  return () => {
92
- import.meta.hot?.off('open-slide:folders-changed', handler);
112
+ import.meta.hot?.off('open-slide:files-changed', handler);
93
113
  };
94
114
  }, [refresh]);
95
115
 
@@ -126,5 +146,21 @@ export function useFolders(): UseFoldersResult {
126
146
  [refresh],
127
147
  );
128
148
 
129
- return { manifest, loading, create, update, remove, assign, refresh };
149
+ const renameSlide = useCallback(
150
+ async (slideId: string, name: string) => {
151
+ await patchSlideName(slideId, name);
152
+ await refresh();
153
+ },
154
+ [refresh],
155
+ );
156
+
157
+ const deleteSlide = useCallback(
158
+ async (slideId: string) => {
159
+ await deleteSlideReq(slideId);
160
+ await refresh();
161
+ },
162
+ [refresh],
163
+ );
164
+
165
+ return { manifest, loading, create, update, remove, assign, renameSlide, deleteSlide, refresh };
130
166
  }
@@ -12,9 +12,7 @@ export type SlideModule = {
12
12
  meta?: SlideMeta;
13
13
  };
14
14
 
15
- export type FolderIcon =
16
- | { type: 'emoji'; value: string }
17
- | { type: 'color'; value: string };
15
+ export type FolderIcon = { type: 'emoji'; value: string } | { type: 'color'; value: string };
18
16
 
19
17
  export type Folder = {
20
18
  id: string;