mnfst 0.5.71 → 0.5.74

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.
@@ -776,6 +776,158 @@ window.ManifestComponentsMutation = {
776
776
  }
777
777
  };
778
778
 
779
+ // Components — route-level prefetch.
780
+ //
781
+ // Two enhancements that run on top of the existing on-encounter loader:
782
+ //
783
+ // 1. Parallel batch on route change. When manifest:route-change fires,
784
+ // scan the [x-route] subtrees that match the new route and call
785
+ // loadComponent() on every <x-*> tag inside them. The loader
786
+ // deduplicates fetches, so calling it for components that the
787
+ // regular swapping logic is already mounting is harmless — but
788
+ // pre-issuing in parallel saves 50–200 ms vs. one-by-one fetches.
789
+ //
790
+ // 2. Prefetch on hover. When the pointer enters an internal <a href>,
791
+ // derive the target pathname, find the [x-route] subtree(s) that
792
+ // would match it, and prefetch their components. By the time the
793
+ // user clicks the link, the components are warm in the loader's
794
+ // cache and navigation feels instant.
795
+ //
796
+ // Both phases require zero author configuration. Manifest auto-discovers
797
+ // what to prefetch from the existing [x-route] DOM structure.
798
+
799
+ (function () {
800
+ 'use strict';
801
+
802
+ // <x-*> tag pattern — lowercase, hyphenated.
803
+ const TAG_RE = /^x-[a-z][a-z0-9-]*$/;
804
+
805
+ // Framework-provided web components (registered by Manifest plugins
806
+ // themselves, not as project components in manifest.json). Skip these
807
+ // when scanning for project components to prefetch.
808
+ const FRAMEWORK_TAGS = new Set(['code', 'code-group']);
809
+
810
+ // Anchors we've already issued a hover-prefetch for. WeakSet so DOM
811
+ // garbage-collects naturally as elements leave the tree.
812
+ const prefetchedAnchors = new WeakSet();
813
+
814
+ function loader() { return window.ManifestComponentsLoader; }
815
+
816
+ // Match a single route pattern against a normalized pathname (no
817
+ // leading/trailing slashes, '/' represented as '/'). Mirrors the
818
+ // router visibility logic so prefetch targets the same subtrees.
819
+ function routeMatches(routeValue, pathname) {
820
+ const pieces = String(routeValue || '').split(',').map((s) => s.trim()).filter(Boolean);
821
+ let matched = false;
822
+ let negated = false;
823
+ for (const piece of pieces) {
824
+ if (piece === '!*') continue; // catch-all only handled by visibility plugin
825
+ if (piece.startsWith('!')) {
826
+ if (piece.slice(1) === pathname) negated = true;
827
+ continue;
828
+ }
829
+ if (piece.startsWith('=')) {
830
+ if (piece.slice(1) === pathname) matched = true;
831
+ continue;
832
+ }
833
+ if (piece.endsWith('/*')) {
834
+ const prefix = piece.slice(0, -2);
835
+ if (pathname === prefix || pathname.startsWith(prefix + '/')) matched = true;
836
+ continue;
837
+ }
838
+ if (piece === pathname) { matched = true; continue; }
839
+ if (pathname.startsWith(piece + '/')) matched = true;
840
+ }
841
+ return matched && !negated;
842
+ }
843
+
844
+ function findRouteSubtrees(pathname) {
845
+ const normalized = (pathname || '/') === '/' ? '/' : pathname.replace(/^\/|\/$/g, '');
846
+ const out = [];
847
+ document.querySelectorAll('[x-route]').forEach((el) => {
848
+ const value = el.getAttribute('x-route') || '';
849
+ if (routeMatches(value, normalized)) out.push(el);
850
+ });
851
+ return out;
852
+ }
853
+
854
+ function discoverComponentNames(root) {
855
+ const names = new Set();
856
+ if (!root || !root.querySelectorAll) return names;
857
+ // querySelectorAll('*') is the fastest path for "every descendant".
858
+ // We filter by tag name in JS — there's no CSS selector for "tag
859
+ // name starts with x-". A page typically has a few thousand nodes,
860
+ // which scans in well under a millisecond.
861
+ root.querySelectorAll('*').forEach((el) => {
862
+ const tag = el.tagName.toLowerCase();
863
+ if (!tag.startsWith('x-') || !TAG_RE.test(tag)) return;
864
+ const name = tag.slice(2);
865
+ if (!FRAMEWORK_TAGS.has(name)) names.add(name);
866
+ });
867
+ return names;
868
+ }
869
+
870
+ function prefetchForRoute(pathname) {
871
+ const L = loader();
872
+ if (!L || typeof L.loadComponent !== 'function') return;
873
+ const subtrees = findRouteSubtrees(pathname);
874
+ if (!subtrees.length) return;
875
+ const names = new Set();
876
+ for (const subtree of subtrees) {
877
+ discoverComponentNames(subtree).forEach((n) => names.add(n));
878
+ }
879
+ names.forEach((name) => {
880
+ try { L.loadComponent(name); } catch { /* swallow — dedup is internal */ }
881
+ });
882
+ }
883
+
884
+ function hrefToPathname(href) {
885
+ if (!href) return null;
886
+ if (/^(#|mailto:|tel:|javascript:)/i.test(href)) return null;
887
+ try {
888
+ const url = new URL(href, window.location.href);
889
+ if (url.origin !== window.location.origin) return null;
890
+ return url.pathname || '/';
891
+ } catch {
892
+ return null;
893
+ }
894
+ }
895
+
896
+ function initialize() {
897
+ // 1) Parallel batch on route change.
898
+ window.addEventListener('manifest:route-change', (event) => {
899
+ const detail = (event && event.detail) || {};
900
+ const path = detail.normalizedPath || detail.to || '/';
901
+ const pathname = String(path).startsWith('/') ? String(path) : '/' + String(path);
902
+ prefetchForRoute(pathname);
903
+ });
904
+
905
+ // 2) Hover prefetch. Use pointerover (bubbles) and check the closest
906
+ // anchor on each event so we get a single trigger per anchor entry
907
+ // without needing pointerenter (which doesn't bubble). Dedup via
908
+ // a WeakSet so repeat moves within the anchor don't re-scan.
909
+ document.addEventListener('pointerover', (e) => {
910
+ if (!e.target || !e.target.closest) return;
911
+ const a = e.target.closest('a[href]');
912
+ if (!a || prefetchedAnchors.has(a)) return;
913
+ // Author opt-out: `data-no-prefetch` skips this anchor.
914
+ if (a.hasAttribute('data-no-prefetch')) return;
915
+ const href = a.getAttribute('href');
916
+ const pathname = hrefToPathname(href);
917
+ if (!pathname) return;
918
+ prefetchedAnchors.add(a);
919
+ prefetchForRoute(pathname);
920
+ });
921
+ }
922
+
923
+ if (document.readyState === 'loading') {
924
+ document.addEventListener('DOMContentLoaded', initialize);
925
+ } else {
926
+ initialize();
927
+ }
928
+ })();
929
+
930
+
779
931
  // Main initialization for Manifest Components
780
932
  function initializeComponents() {
781
933
  if (window.ManifestComponentsRegistry) window.ManifestComponentsRegistry.initialize();
@@ -0,0 +1,453 @@
1
+ /* Manifest Export — runtime download of pages, regions, or data sources.
2
+ *
3
+ * The `x-export` directive turns its host element into a download action.
4
+ * What gets downloaded depends on the host element type:
5
+ *
6
+ * <button x-export> → click downloads the whole page
7
+ * <button x-export="{ target: '#x' }"> → click downloads the element with id="x"
8
+ * <a x-export href="#section"> → click downloads #section (no scroll-to)
9
+ * <a x-export href="/other-page"> → appends ?export=<format> to the href
10
+ * so the destination page auto-exports
11
+ * itself after navigation
12
+ *
13
+ * For the destination of a cross-page export, declare what's exportable:
14
+ *
15
+ * <div x-export="{ trigger: 'url', target: '#report' }"></div>
16
+ *
17
+ * On page load, this checks `?export=<format>` in the URL. If present and a
18
+ * known format, it fires the export against the configured target. If the
19
+ * URL has no param, the page renders normally — random visitors never
20
+ * trigger downloads.
21
+ *
22
+ * For programmatic use, the `$export` magic runs an export from any
23
+ * Alpine expression and returns a promise:
24
+ *
25
+ * <form @submit.prevent="if (valid) await $export({ format: 'csv', source: 'rows' })">
26
+ *
27
+ * Supported formats: pdf (default), png, jpeg, webp, csv, json.
28
+ *
29
+ * Library dependencies (html-to-image, jsPDF) are loaded lazily on first
30
+ * use from jsDelivr; pages that never export pay nothing.
31
+ *
32
+ * Elements with `data-no-export` are excluded from visual snapshots.
33
+ */
34
+
35
+ function initializeExportPlugin() {
36
+
37
+ Alpine.directive('export', (el, { modifiers, expression }, { evaluate, cleanup }) => {
38
+
39
+ const opts = resolveOptions(expression, modifiers, evaluate);
40
+ const format = (opts.format || 'pdf').toLowerCase();
41
+ const isAnchor = el.tagName === 'A';
42
+ const href = isAnchor ? el.getAttribute('href') : null;
43
+
44
+ // ----- URL-trigger destination behavior -----
45
+ // The page declares "I'm exportable; fire if the URL says so." Runs
46
+ // once per page load, regardless of how many elements declare it.
47
+ if (opts.trigger === 'url') {
48
+ if (urlTriggerFired) return;
49
+ const paramName = opts.urlParam || 'export';
50
+ const paramValue = new URLSearchParams(window.location.search).get(paramName);
51
+ if (paramValue) {
52
+ urlTriggerFired = true;
53
+ const fmt = isKnownFormat(paramValue) ? paramValue : format;
54
+ setTimeout(() => {
55
+ runExport(fmt, opts, opts.filename || defaultFilename(fmt))
56
+ .catch((err) => emitError(fmt, err));
57
+ }, Number(opts.delay) > 0 ? Number(opts.delay) : 0);
58
+ }
59
+ return;
60
+ }
61
+
62
+ // ----- Anchor with cross-page href -----
63
+ // Pre-arm the href with ?export=<format> so default browser navigation
64
+ // delivers the user to the destination URL with the export signal.
65
+ // Hover-prefetch, middle-click, right-click-copy all see the same URL.
66
+ if (isAnchor && href && !href.startsWith('#') && !href.startsWith('javascript:')
67
+ && !/^(mailto|tel):/i.test(href)) {
68
+ try {
69
+ const url = new URL(href, window.location.href);
70
+ const paramName = opts.urlParam || 'export';
71
+ url.searchParams.set(paramName, format);
72
+ // Preserve the original href shape: relative stays relative.
73
+ if (url.origin === window.location.origin && !href.startsWith('http')) {
74
+ el.setAttribute('href', url.pathname + url.search + url.hash);
75
+ } else {
76
+ el.setAttribute('href', url.toString());
77
+ }
78
+ } catch (err) {
79
+ console.warn('[x-export] could not parse href for cross-page export:', err.message);
80
+ }
81
+ return;
82
+ }
83
+
84
+ // ----- Anchor with same-page fragment href -----
85
+ // The href IS the target. Click downloads the matched element
86
+ // instead of jumping the page.
87
+ if (isAnchor && href && href.startsWith('#')) {
88
+ const onClick = async (e) => {
89
+ if (e && typeof e.preventDefault === 'function') e.preventDefault();
90
+ const filename = opts.filename || defaultFilename(format);
91
+ try {
92
+ await runExport(format, { ...opts, target: href }, filename);
93
+ } catch (err) {
94
+ emitError(format, err);
95
+ }
96
+ };
97
+ el.addEventListener('click', onClick);
98
+ cleanup(() => el.removeEventListener('click', onClick));
99
+ return;
100
+ }
101
+
102
+ // ----- Default: click anywhere else triggers export -----
103
+ const onClick = async (e) => {
104
+ if (e && typeof e.preventDefault === 'function') e.preventDefault();
105
+ const filename = opts.filename || defaultFilename(format);
106
+ try {
107
+ await runExport(format, opts, filename);
108
+ } catch (err) {
109
+ emitError(format, err);
110
+ }
111
+ };
112
+ el.addEventListener('click', onClick);
113
+ cleanup(() => el.removeEventListener('click', onClick));
114
+ });
115
+
116
+ // ----- $export magic — programmatic trigger from any expression -----
117
+ Alpine.magic('export', () => async (opts = {}) => {
118
+ const format = String(opts.format || 'pdf').toLowerCase();
119
+ const filename = opts.filename || defaultFilename(format);
120
+ return runExport(format, opts, filename);
121
+ });
122
+
123
+ // ------- Options + format helpers -----------------------------------
124
+
125
+ function resolveOptions(expression, modifiers, evaluate) {
126
+ let opts = {};
127
+ if (expression && expression.trim()) {
128
+ try {
129
+ const v = evaluate(expression);
130
+ if (v && typeof v === 'object') opts = { ...v };
131
+ else if (typeof v === 'string') opts.format = v;
132
+ } catch (err) {
133
+ console.warn('[x-export] could not evaluate options expression:', err.message);
134
+ }
135
+ }
136
+ if (!opts.format && Array.isArray(modifiers) && modifiers.length) {
137
+ const found = modifiers.find((m) => isKnownFormat(String(m).toLowerCase()));
138
+ if (found) opts.format = String(found).toLowerCase();
139
+ }
140
+ return opts;
141
+ }
142
+
143
+ function isKnownFormat(f) {
144
+ return f === 'pdf' || f === 'png' || f === 'jpeg' || f === 'jpg' || f === 'webp' || f === 'csv' || f === 'json';
145
+ }
146
+
147
+ function defaultFilename(format) {
148
+ const ext = format === 'jpeg' || format === 'jpg' ? 'jpg' : format;
149
+ const ts = new Date().toISOString().slice(0, 19).replace(/[:T]/g, '-');
150
+ return `export-${ts}.${ext}`;
151
+ }
152
+
153
+ // Transparent 1×1 PNG, used as a fallback when an inline image fails to fetch.
154
+ // html-to-image would otherwise reject the whole snapshot on a single CORS or 404.
155
+ const TRANSPARENT_PIXEL = 'data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNkAAIAAAoAAv/lxKUAAAAASUVORK5CYII=';
156
+
157
+ // html-to-image rejects with the raw `image.onerror` Event when the assembled
158
+ // SVG fails to decode (oversized clones, malformed inline assets, etc.).
159
+ // Translate those into something readable before logging.
160
+ function describeExportError(err) {
161
+ if (err && err.message) return err;
162
+ if (err && typeof Event !== 'undefined' && err instanceof Event) {
163
+ const tag = err.target && err.target.tagName ? err.target.tagName.toLowerCase() : 'image';
164
+ return new Error(
165
+ `failed to render ${tag} during export. ` +
166
+ `Common causes: cross-origin images without CORS headers, ` +
167
+ `an oversized target element, or a no-target snapshot of a complex page. ` +
168
+ `Pass a "target" option to scope the snapshot.`
169
+ );
170
+ }
171
+ return new Error(String(err));
172
+ }
173
+
174
+ function emitError(format, err) {
175
+ const e = describeExportError(err);
176
+ console.error('[x-export] export failed:', e.message);
177
+ try {
178
+ window.dispatchEvent(new CustomEvent('manifest:export-error', {
179
+ detail: { format, error: e.message }
180
+ }));
181
+ } catch { /* ignore */ }
182
+ }
183
+
184
+ async function runExport(format, opts, filename) {
185
+ switch (format) {
186
+ case 'pdf': return exportPdf(opts, filename);
187
+ case 'png': return exportImage(opts, filename, 'png');
188
+ case 'jpeg':
189
+ case 'jpg': return exportImage(opts, filename, 'jpeg');
190
+ case 'webp': return exportImage(opts, filename, 'webp');
191
+ case 'csv': return exportCsv(opts, filename);
192
+ case 'json': return exportJson(opts, filename);
193
+ default: throw new Error(`Unknown format "${format}". Supported: pdf, png, jpeg, webp, csv, json.`);
194
+ }
195
+ }
196
+
197
+ // ------- Visual exports (PDF, PNG, JPEG, WebP) ----------------------
198
+
199
+ function resolveTarget(opts) {
200
+ if (opts.target) {
201
+ const t = typeof opts.target === 'string'
202
+ ? document.querySelector(opts.target)
203
+ : opts.target;
204
+ if (!t) throw new Error(`target "${opts.target}" matched no element`);
205
+ return t;
206
+ }
207
+ return document.body;
208
+ }
209
+
210
+ function snapshotOptions(opts) {
211
+ // Filter callback for html-to-image — exclude opt-out elements.
212
+ const filter = (node) => {
213
+ if (!node || node.nodeType !== 1) return true;
214
+ return !(node.hasAttribute && node.hasAttribute('data-no-export'));
215
+ };
216
+ const out = {
217
+ pixelRatio: Number(opts.resolution) > 0 ? Number(opts.resolution) : 2,
218
+ cacheBust: true,
219
+ // A single cross-origin image without CORS headers would otherwise
220
+ // reject the whole snapshot. Substitute a transparent pixel so the
221
+ // export still produces a usable file.
222
+ imagePlaceholder: TRANSPARENT_PIXEL,
223
+ filter
224
+ };
225
+ if (opts.backgroundColor) out.backgroundColor = opts.backgroundColor;
226
+ if (opts.width) out.canvasWidth = Number(opts.width);
227
+ if (opts.height) out.canvasHeight = Number(opts.height);
228
+ return out;
229
+ }
230
+
231
+ // Run an html-to-image call once. If it rejects with a raw Event (the SVG
232
+ // failed to decode — usually a font or oversized clone issue), retry once
233
+ // with safer options before propagating the error. Most real-world failures
234
+ // recover on the second attempt.
235
+ async function snapshotWithFallback(lib, fn, target, so) {
236
+ try {
237
+ return await lib[fn](target, so);
238
+ } catch (err) {
239
+ if (err && err.message) throw err; // a real Error — don't mask it
240
+ const safer = { ...so, skipFonts: true, pixelRatio: Math.min(so.pixelRatio || 2, 1) };
241
+ return await lib[fn](target, safer);
242
+ }
243
+ }
244
+
245
+ async function exportImage(opts, filename, ext) {
246
+ const lib = await loadHtmlToImage();
247
+ const target = resolveTarget(opts);
248
+ const so = snapshotOptions(opts);
249
+ let dataUrl;
250
+ if (ext === 'png') dataUrl = await snapshotWithFallback(lib, 'toPng', target, so);
251
+ else if (ext === 'jpeg') {
252
+ if (!so.backgroundColor) so.backgroundColor = effectivePageBackground();
253
+ so.quality = Number(opts.quality) > 0 && Number(opts.quality) <= 1
254
+ ? Number(opts.quality)
255
+ : 0.95;
256
+ dataUrl = await snapshotWithFallback(lib, 'toJpeg', target, so);
257
+ } else if (ext === 'webp') {
258
+ const webpOpts = { ...so, type: 'image/webp', quality: opts.quality || 0.95 };
259
+ const blob = await snapshotWithFallback(lib, 'toBlob', target, webpOpts);
260
+ dataUrl = URL.createObjectURL(blob);
261
+ }
262
+ triggerDownload(dataUrl, filename);
263
+ }
264
+
265
+ async function exportPdf(opts, filename) {
266
+ const [imgLib, jsPDFCtor] = await Promise.all([loadHtmlToImage(), loadJsPDF()]);
267
+ const target = resolveTarget(opts);
268
+ const so = snapshotOptions(opts);
269
+ if (!so.backgroundColor) so.backgroundColor = effectivePageBackground();
270
+ const dataUrl = await snapshotWithFallback(imgLib, 'toPng', target, so);
271
+ const img = await loadImage(dataUrl);
272
+ const orientation = img.width > img.height ? 'landscape' : 'portrait';
273
+ const pdf = new jsPDFCtor({ orientation, unit: 'pt', format: opts.pageSize || 'a4' });
274
+ const pageW = pdf.internal.pageSize.getWidth();
275
+ const pageH = pdf.internal.pageSize.getHeight();
276
+ const ratio = Math.min(pageW / img.width, pageH / img.height);
277
+ const renderW = img.width * ratio;
278
+ const renderH = img.height * ratio;
279
+ const offsetX = (pageW - renderW) / 2;
280
+ const offsetY = (pageH - renderH) / 2;
281
+ pdf.addImage(dataUrl, 'PNG', offsetX, offsetY, renderW, renderH);
282
+ pdf.save(filename);
283
+ }
284
+
285
+ function effectivePageBackground() {
286
+ let el = document.body;
287
+ while (el && el !== document.documentElement.parentElement) {
288
+ const cs = getComputedStyle(el);
289
+ const bg = cs.backgroundColor;
290
+ if (bg && bg !== 'rgba(0, 0, 0, 0)' && bg !== 'transparent') return bg;
291
+ el = el.parentElement;
292
+ }
293
+ return '#ffffff';
294
+ }
295
+
296
+ function loadImage(dataUrl) {
297
+ return new Promise((resolve, reject) => {
298
+ const img = new Image();
299
+ img.onload = () => resolve(img);
300
+ img.onerror = reject;
301
+ img.src = dataUrl;
302
+ });
303
+ }
304
+
305
+ // ------- Tabular exports (CSV, JSON) --------------------------------
306
+
307
+ function resolveDataset(opts) {
308
+ if (Array.isArray(opts.data)) return opts.data;
309
+ if (opts.data && typeof opts.data === 'object') return opts.data;
310
+ if (opts.source) {
311
+ const x = window.$x || (window.Alpine && window.Alpine.magic && window.Alpine.magic('x'));
312
+ if (x && x[opts.source] != null) return x[opts.source];
313
+ }
314
+ throw new Error('csv/json export needs `source: "<name>"` or `data: <value>`');
315
+ }
316
+
317
+ function exportCsv(opts, filename) {
318
+ const data = resolveDataset(opts);
319
+ if (!Array.isArray(data)) throw new Error('csv export expects an array data source');
320
+ if (data.length === 0) {
321
+ triggerDownload(blobUrl('', 'text/csv'), filename);
322
+ return;
323
+ }
324
+ const headers = [];
325
+ const seen = new Set();
326
+ for (const row of data) {
327
+ if (!row || typeof row !== 'object') continue;
328
+ for (const k of Object.keys(row)) {
329
+ if (!seen.has(k)) { seen.add(k); headers.push(k); }
330
+ }
331
+ }
332
+ const lines = [headers.map(csvCell).join(',')];
333
+ for (const row of data) {
334
+ lines.push(headers.map((h) => csvCell(row && row[h])).join(','));
335
+ }
336
+ triggerDownload(blobUrl(lines.join('\n') + '\n', 'text/csv;charset=utf-8'), filename);
337
+ }
338
+
339
+ function exportJson(opts, filename) {
340
+ const data = resolveDataset(opts);
341
+ const serializable = sanitize(data);
342
+ triggerDownload(blobUrl(JSON.stringify(serializable, null, 2), 'application/json'), filename);
343
+ }
344
+
345
+ function sanitize(value) {
346
+ if (Array.isArray(value)) return value.map(sanitize);
347
+ if (value && typeof value === 'object') {
348
+ const out = {};
349
+ for (const k of Object.keys(value)) {
350
+ if (k.startsWith('$') || k === '_loading' || k === '_error') continue;
351
+ out[k] = sanitize(value[k]);
352
+ }
353
+ return out;
354
+ }
355
+ return value;
356
+ }
357
+
358
+ function csvCell(v) {
359
+ if (v == null) return '';
360
+ const s = typeof v === 'object' ? JSON.stringify(v) : String(v);
361
+ return /[",\n\r]/.test(s) ? `"${s.replace(/"/g, '""')}"` : s;
362
+ }
363
+
364
+ // ------- Helpers ----------------------------------------------------
365
+
366
+ function blobUrl(content, type) {
367
+ return URL.createObjectURL(new Blob([content], { type }));
368
+ }
369
+
370
+ function triggerDownload(url, filename) {
371
+ const a = document.createElement('a');
372
+ a.href = url;
373
+ a.download = filename;
374
+ document.body.appendChild(a);
375
+ a.click();
376
+ a.remove();
377
+ if (url.startsWith('blob:')) setTimeout(() => { try { URL.revokeObjectURL(url); } catch { /* ignore */ } }, 2000);
378
+ }
379
+
380
+ // Lazy library loaders — cached promises.
381
+
382
+ let htmlToImagePromise = null;
383
+ function loadHtmlToImage() {
384
+ if (htmlToImagePromise) return htmlToImagePromise;
385
+ htmlToImagePromise = loadScript('https://cdn.jsdelivr.net/npm/html-to-image@1.11.13/dist/html-to-image.min.js')
386
+ .then(() => {
387
+ if (!window.htmlToImage) throw new Error('html-to-image failed to load');
388
+ return window.htmlToImage;
389
+ });
390
+ return htmlToImagePromise;
391
+ }
392
+
393
+ let jsPDFPromise = null;
394
+ function loadJsPDF() {
395
+ if (jsPDFPromise) return jsPDFPromise;
396
+ jsPDFPromise = loadScript('https://cdn.jsdelivr.net/npm/jspdf@2.5.2/dist/jspdf.umd.min.js')
397
+ .then(() => {
398
+ const ctor = window.jspdf && window.jspdf.jsPDF;
399
+ if (!ctor) throw new Error('jsPDF failed to load');
400
+ return ctor;
401
+ });
402
+ return jsPDFPromise;
403
+ }
404
+
405
+ function loadScript(src) {
406
+ return new Promise((resolve, reject) => {
407
+ const existing = document.querySelector(`script[src="${src}"]`);
408
+ if (existing) {
409
+ if (existing._loaded) resolve();
410
+ else {
411
+ existing.addEventListener('load', () => resolve());
412
+ existing.addEventListener('error', reject);
413
+ }
414
+ return;
415
+ }
416
+ const s = document.createElement('script');
417
+ s.src = src;
418
+ s.async = true;
419
+ s.addEventListener('load', () => { s._loaded = true; resolve(); });
420
+ s.addEventListener('error', () => reject(new Error('Failed to load ' + src)));
421
+ document.head.appendChild(s);
422
+ });
423
+ }
424
+ }
425
+
426
+ // Module-level guard: URL-triggered exports fire at most once per page load,
427
+ // regardless of how many elements declare `trigger: 'url'`.
428
+ let urlTriggerFired = false;
429
+
430
+ // Standard plugin init lifecycle.
431
+ let exportPluginInitialized = false;
432
+ function ensureExportPluginInitialized() {
433
+ if (exportPluginInitialized) return;
434
+ if (!window.Alpine || typeof window.Alpine.directive !== 'function') return;
435
+ exportPluginInitialized = true;
436
+ initializeExportPlugin();
437
+ }
438
+ window.ensureExportPluginInitialized = ensureExportPluginInitialized;
439
+ if (document.readyState === 'loading') {
440
+ document.addEventListener('DOMContentLoaded', ensureExportPluginInitialized);
441
+ }
442
+ document.addEventListener('alpine:init', ensureExportPluginInitialized);
443
+ if (window.Alpine && typeof window.Alpine.directive === 'function') {
444
+ setTimeout(ensureExportPluginInitialized, 0);
445
+ } else {
446
+ const checkAlpine = setInterval(() => {
447
+ if (window.Alpine && typeof window.Alpine.directive === 'function') {
448
+ clearInterval(checkAlpine);
449
+ ensureExportPluginInitialized();
450
+ }
451
+ }, 10);
452
+ setTimeout(() => clearInterval(checkAlpine), 5000);
453
+ }
package/lib/manifest.js CHANGED
@@ -199,7 +199,9 @@
199
199
  'slides',
200
200
  'resize',
201
201
  'colorpicker',
202
- 'url-parameters'
202
+ 'url-parameters',
203
+ 'virtual',
204
+ 'export'
203
205
  ];
204
206
 
205
207
  // Appwrite integration plugins (opt-in only, never auto-loaded)
@@ -0,0 +1,319 @@
1
+ /* Manifest Virtual — variable-height list virtualization for Alpine.
2
+ *
3
+ * Renders only the rows visible in the scroll viewport (plus an overscan
4
+ * buffer), so a list of tens of thousands of rows can scroll smoothly with
5
+ * a low DOM count. Row heights are measured on render and the spacer
6
+ * recalculates, so authors aren't bound to a fixed row height.
7
+ *
8
+ * Usage — wrap an x-for template with x-virtual on the scrolling container:
9
+ *
10
+ * <div x-virtual style="height: 600px; overflow: auto">
11
+ * <template x-for="row in $x.customers" :key="row.id">
12
+ * <div class="row">
13
+ * <span x-text="row.name"></span>
14
+ * </div>
15
+ * </template>
16
+ * </div>
17
+ *
18
+ * Options (object expression on the directive):
19
+ *
20
+ * <div x-virtual="{ estimate: 48, overscan: 5 }" style="height: 600px">
21
+ *
22
+ * estimate Initial per-row height in px (default 50). Used for rows
23
+ * that haven't been measured yet. Closer estimates mean
24
+ * less scroll-position drift on first render.
25
+ * overscan Rows to render above/below the visible window (default 3).
26
+ * Higher = smoother scroll, more DOM.
27
+ *
28
+ * Notes:
29
+ *
30
+ * - Only one template child is supported. It must have x-for and :key.
31
+ * - The container element must have a bounded height (CSS height /
32
+ * max-height) and scroll. The plugin sets overflow: auto + position:
33
+ * relative if not already set.
34
+ * - Heights are remeasured automatically if a row's content changes.
35
+ */
36
+
37
+ function initializeVirtualPlugin() {
38
+
39
+ Alpine.directive('virtual', (el, { expression }, { effect, evaluate, evaluateLater, cleanup }) => {
40
+
41
+ // --- Find and parse the template ---
42
+ const template = el.querySelector(':scope > template');
43
+ if (!template) {
44
+ console.warn('[x-virtual] expects a child <template> with x-for, e.g. <template x-for="row in $x.items" :key="row.id">…');
45
+ return;
46
+ }
47
+ const forExpr = template.getAttribute('x-for');
48
+ if (!forExpr) {
49
+ console.warn('[x-virtual] child <template> must have x-for');
50
+ return;
51
+ }
52
+ const m = /^\s*(\S+|\(\s*\S+\s*,\s*\S+\s*\))\s+(?:in|of)\s+(.+?)\s*$/.exec(forExpr);
53
+ if (!m) {
54
+ console.warn('[x-virtual] could not parse x-for expression: ' + forExpr);
55
+ return;
56
+ }
57
+ const itemName = m[1].trim();
58
+ const sourceExpr = m[2].trim();
59
+ const keyExpr =
60
+ template.getAttribute(':key') ||
61
+ template.getAttribute('x-bind:key') ||
62
+ `${itemName}.id`;
63
+
64
+ // Remove x-for/:key so Alpine doesn't try to render the full list, but
65
+ // KEEP the template in the DOM as our render source. We'll clone its
66
+ // contents per visible row.
67
+ template.removeAttribute('x-for');
68
+ template.removeAttribute(':key');
69
+ template.removeAttribute('x-bind:key');
70
+
71
+ // --- Options ---
72
+ const options = expression ? evaluate(expression) || {} : {};
73
+ const initialEstimate = Number(options.estimate) > 0 ? Number(options.estimate) : 50;
74
+ const overscan = Number.isFinite(options.overscan) && options.overscan >= 0 ? Number(options.overscan) : 3;
75
+
76
+ // --- Container setup ---
77
+ const cs = getComputedStyle(el);
78
+ if (cs.overflow === 'visible' && cs.overflowY === 'visible') el.style.overflow = 'auto';
79
+ if (cs.position === 'static') el.style.position = 'relative';
80
+
81
+ // The spacer holds the rendered (absolutely positioned) rows and sizes
82
+ // itself to the total virtual height so the scrollbar is correct.
83
+ const spacer = document.createElement('div');
84
+ spacer.dataset.virtualSpacer = '';
85
+ spacer.style.position = 'relative';
86
+ spacer.style.width = '100%';
87
+ spacer.style.height = '0px';
88
+ el.appendChild(spacer);
89
+
90
+ // --- State ---
91
+ // heights: key -> measured pixel height (only for rows that have been
92
+ // mounted at least once and measured).
93
+ const heights = new Map();
94
+ let measuredSum = 0;
95
+ let measuredCount = 0;
96
+ // rendered: key -> wrapper element currently in the DOM
97
+ const rendered = new Map();
98
+ // data: latest snapshot of the source array
99
+ let data = [];
100
+ // Cached cumulative offsets — index i holds the sum of heights of rows
101
+ // 0..(i-1). Length is data.length + 1; final entry is total height.
102
+ let cumulative = new Float64Array(1);
103
+
104
+ const getAvg = () => (measuredCount > 0 ? measuredSum / measuredCount : initialEstimate);
105
+ const rowHeightFor = (key) => heights.get(key) ?? getAvg();
106
+
107
+ // Evaluate the key expression against an item without going through
108
+ // Alpine — `new Function` is fast and isolates from the surrounding
109
+ // scope. Expression usually looks like `row.id` or `row.$id`.
110
+ const keyFn = buildKeyFn(itemName, keyExpr);
111
+
112
+ function rebuildCumulative() {
113
+ const n = data.length;
114
+ cumulative = new Float64Array(n + 1);
115
+ let y = 0;
116
+ for (let i = 0; i < n; i++) {
117
+ cumulative[i] = y;
118
+ const k = keyFn(data[i]);
119
+ y += rowHeightFor(k);
120
+ }
121
+ cumulative[n] = y;
122
+ spacer.style.height = y + 'px';
123
+ }
124
+
125
+ // Find the first index whose offset is >= scrollTop. Cumulative is
126
+ // monotonic so binary search works.
127
+ function findStartIndex(scrollTop) {
128
+ let lo = 0, hi = data.length;
129
+ while (lo < hi) {
130
+ const mid = (lo + hi) >>> 1;
131
+ if (cumulative[mid + 1] <= scrollTop) lo = mid + 1;
132
+ else hi = mid;
133
+ }
134
+ return Math.max(0, lo - overscan);
135
+ }
136
+
137
+ function findEndIndex(scrollBottom, startHint) {
138
+ let i = startHint;
139
+ const n = data.length;
140
+ while (i < n && cumulative[i] < scrollBottom) i++;
141
+ return Math.min(n, i + overscan);
142
+ }
143
+
144
+ function renderVisible() {
145
+ if (!data.length) {
146
+ for (const [, node] of rendered) node.remove();
147
+ rendered.clear();
148
+ return;
149
+ }
150
+ const scrollTop = el.scrollTop;
151
+ const viewportHeight = el.clientHeight;
152
+ const start = findStartIndex(scrollTop);
153
+ const end = findEndIndex(scrollTop + viewportHeight, start);
154
+
155
+ // Track which keys remain visible
156
+ const stillVisible = new Set();
157
+ for (let i = start; i < end; i++) {
158
+ const item = data[i];
159
+ if (item == null) continue;
160
+ const key = keyFn(item);
161
+ if (key == null) continue; // skip un-keyable rows
162
+ stillVisible.add(key);
163
+
164
+ let node = rendered.get(key);
165
+ if (!node) {
166
+ node = mountRow(i);
167
+ if (!node) continue;
168
+ rendered.set(key, node);
169
+ spacer.appendChild(node);
170
+ // x-data on the row needs the parent scope (where the
171
+ // source array lives) to resolve, so we MUST init after
172
+ // append, not before.
173
+ Alpine.initTree(node);
174
+ // Measure on next frame so Alpine has bound everything.
175
+ requestAnimationFrame(() => measureRow(key, node));
176
+ }
177
+ node.style.top = cumulative[i] + 'px';
178
+ node.dataset.virtualIndex = i;
179
+ }
180
+
181
+ // Remove rows no longer in the window
182
+ for (const [key, node] of rendered) {
183
+ if (!stillVisible.has(key)) {
184
+ node.remove();
185
+ rendered.delete(key);
186
+ }
187
+ }
188
+ }
189
+
190
+ function mountRow(index) {
191
+ const tplChild = template.content.firstElementChild;
192
+ if (!tplChild) return null;
193
+ const node = tplChild.cloneNode(true);
194
+ node.style.position = 'absolute';
195
+ node.style.left = '0';
196
+ node.style.right = '0';
197
+ // Inject a per-row Alpine scope. Because we reference the source
198
+ // expression with the index baked in via a getter, Alpine tracks
199
+ // the dependency and re-renders this row when its data updates.
200
+ const scopeExpr = `{ get ${itemName}() { return (${sourceExpr})[${index}]; } }`;
201
+ // Merge with any existing x-data on the cloned root.
202
+ const existing = node.getAttribute('x-data');
203
+ node.setAttribute('x-data', existing ? `Object.assign({}, ${scopeExpr}, ${existing})` : scopeExpr);
204
+ // Note: caller must Alpine.initTree(node) AFTER appending to the
205
+ // DOM, otherwise the scope can't resolve identifiers (e.g. the
206
+ // source array) from outer x-data contexts.
207
+ return node;
208
+ }
209
+
210
+ function measureRow(key, node) {
211
+ if (!node.isConnected) return;
212
+ const h = node.offsetHeight;
213
+ if (!h) return;
214
+ const prev = heights.get(key);
215
+ if (prev === h) return;
216
+ if (prev !== undefined) measuredSum -= prev;
217
+ else measuredCount++;
218
+ measuredSum += h;
219
+ heights.set(key, h);
220
+ // Recompute cumulative offsets and re-render so positions reflect
221
+ // the new heights AND any rows now in/out of the visible window.
222
+ rebuildCumulative();
223
+ renderVisible();
224
+ }
225
+
226
+ // --- Reactive data source subscription ---
227
+ const sourceGetter = evaluateLater(sourceExpr);
228
+ effect(() => {
229
+ sourceGetter((value) => {
230
+ data = Array.isArray(value) ? value : (value ? Array.from(value) : []);
231
+ // When the data identity or length changes, drop any rendered
232
+ // rows whose keys no longer exist in the new data.
233
+ const validKeys = new Set();
234
+ for (const item of data) {
235
+ if (item != null) validKeys.add(keyFn(item));
236
+ }
237
+ for (const [key, node] of rendered) {
238
+ if (!validKeys.has(key)) {
239
+ node.remove();
240
+ rendered.delete(key);
241
+ }
242
+ }
243
+ rebuildCumulative();
244
+ renderVisible();
245
+ });
246
+ });
247
+
248
+ // --- Scroll + resize handlers ---
249
+ let scrollScheduled = false;
250
+ const onScroll = () => {
251
+ if (scrollScheduled) return;
252
+ scrollScheduled = true;
253
+ requestAnimationFrame(() => {
254
+ scrollScheduled = false;
255
+ renderVisible();
256
+ });
257
+ };
258
+ el.addEventListener('scroll', onScroll, { passive: true });
259
+
260
+ const ro = new ResizeObserver(() => renderVisible());
261
+ ro.observe(el);
262
+
263
+ cleanup(() => {
264
+ el.removeEventListener('scroll', onScroll);
265
+ ro.disconnect();
266
+ for (const [, node] of rendered) node.remove();
267
+ rendered.clear();
268
+ spacer.remove();
269
+ });
270
+ });
271
+
272
+ }
273
+
274
+ // Build a key-evaluator function for a given itemName + keyExpr.
275
+ // `keyFn(item)` returns the row's key. Falls back to identity if it fails.
276
+ function buildKeyFn(itemName, keyExpr) {
277
+ try {
278
+ // eslint-disable-next-line no-new-func
279
+ const fn = new Function(itemName, `return (${keyExpr});`);
280
+ return (item) => {
281
+ try { return fn(item); } catch { return item; }
282
+ };
283
+ } catch {
284
+ return (item) => item;
285
+ }
286
+ }
287
+
288
+ // Track initialization to prevent duplicates
289
+ let virtualPluginInitialized = false;
290
+
291
+ function ensureVirtualPluginInitialized() {
292
+ if (virtualPluginInitialized) return;
293
+ if (!window.Alpine || typeof window.Alpine.directive !== 'function') return;
294
+ virtualPluginInitialized = true;
295
+ initializeVirtualPlugin();
296
+ }
297
+
298
+ // Expose on window for loader to call if needed
299
+ window.ensureVirtualPluginInitialized = ensureVirtualPluginInitialized;
300
+
301
+ // Handle both DOMContentLoaded and alpine:init
302
+ if (document.readyState === 'loading') {
303
+ document.addEventListener('DOMContentLoaded', ensureVirtualPluginInitialized);
304
+ }
305
+
306
+ document.addEventListener('alpine:init', ensureVirtualPluginInitialized);
307
+
308
+ // If Alpine is already initialized when this script loads, initialize immediately
309
+ if (window.Alpine && typeof window.Alpine.directive === 'function') {
310
+ setTimeout(ensureVirtualPluginInitialized, 0);
311
+ } else {
312
+ const checkAlpine = setInterval(() => {
313
+ if (window.Alpine && typeof window.Alpine.directive === 'function') {
314
+ clearInterval(checkAlpine);
315
+ ensureVirtualPluginInitialized();
316
+ }
317
+ }, 10);
318
+ setTimeout(() => clearInterval(checkAlpine), 5000);
319
+ }
package/package.json CHANGED
@@ -1,12 +1,13 @@
1
1
  {
2
2
  "name": "mnfst",
3
- "version": "0.5.71",
3
+ "version": "0.5.74",
4
4
  "private": false,
5
5
  "workspaces": [
6
6
  "templates/starter",
7
7
  "packages/render",
8
8
  "packages/types",
9
- "packages/test"
9
+ "packages/test",
10
+ "packages/export"
10
11
  ],
11
12
  "main": "lib/manifest.js",
12
13
  "style": "lib/manifest.css",
@@ -32,8 +33,9 @@
32
33
  "release:render": "cd packages/render && npm version patch --no-git-tag-version && npm publish --auth-type=web",
33
34
  "release:types": "cd packages/types && npm version patch --no-git-tag-version && npm publish --auth-type=web",
34
35
  "release:test": "cd packages/test && npm version patch --no-git-tag-version && npm publish --auth-type=web",
36
+ "release:export": "cd packages/export && npm version patch --no-git-tag-version && npm publish --auth-type=web",
35
37
  "release:starter": "cd packages/create-starter && npm version patch --no-git-tag-version && npm publish --auth-type=web",
36
- "release:all": "npm run release:run && npm run release:render && npm run release:types && npm run release:test && npm run release:starter && npm run release",
38
+ "release:all": "npm run release:run && npm run release:render && npm run release:types && npm run release:test && npm run release:export && npm run release:starter && npm run release",
37
39
  "prepublishOnly": "npm run build",
38
40
  "test": "vitest run",
39
41
  "lint": "echo 'No linting configured'"