mnfst 0.5.111 → 0.5.113

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.
@@ -910,7 +910,11 @@ function handleVisible(el) {
910
910
  const candidates = document.querySelectorAll(
911
911
  '[x-code]:not([data-code-processed]),' +
912
912
  '[x-code-group]:not([data-group-processed]),' +
913
- 'pre:not([x-code]):not([data-code-processed]) > code[class*="language-"]'
913
+ 'pre:not([x-code]):not([data-code-processed]) > code[class*="language-"],' +
914
+ // Copy-only inline codespans from markdown (`text`{copy}). Skip those
915
+ // that also carry x-code — they're already covered by the first
916
+ // selector — and skip <code copy> inside a <pre> (block-level path).
917
+ 'code[copy]:not([x-code]):not([data-code-processed]):not(pre > code)'
914
918
  );
915
919
  // Always include the triggering element (it's already known to be visible).
916
920
  processOne(el);
@@ -931,6 +935,14 @@ function processOne(el) {
931
935
  } else if (el.matches && el.matches('pre > code[class*="language-"]')) {
932
936
  adoptMarkdownBlocks(el.parentElement);
933
937
  processCodeElement(el.parentElement);
938
+ } else if (el.tagName === 'CODE' && el.hasAttribute('copy')) {
939
+ // Copy-only inline codespan (`text`{copy} in markdown) — no x-code,
940
+ // no highlighting, just wire the click-to-copy handler. Marking it
941
+ // as processed prevents re-discovery on subsequent scans.
942
+ if (el.dataset.codeProcessed !== 'yes') {
943
+ el.dataset.codeProcessed = 'yes';
944
+ setupInlineCopy(el);
945
+ }
934
946
  }
935
947
  }
936
948
 
@@ -946,7 +958,11 @@ function observeAll(root = document) {
946
958
  const candidates = [
947
959
  ...root.querySelectorAll('[x-code]:not([data-code-processed])'),
948
960
  ...root.querySelectorAll('[x-code-group]:not([data-group-processed])'),
949
- ...root.querySelectorAll('pre:not([x-code]):not([data-code-processed]) > code[class*="language-"]')
961
+ ...root.querySelectorAll('pre:not([x-code]):not([data-code-processed]) > code[class*="language-"]'),
962
+ // Copy-only inline codespans (no x-code, not inside <pre>) — markdown's
963
+ // `text`{copy} syntax. These need only the copy handler wired, no
964
+ // highlighting work.
965
+ ...root.querySelectorAll('code[copy]:not([x-code]):not([data-code-processed]):not(pre > code)')
950
966
  ];
951
967
  for (const el of candidates) {
952
968
  io.observe(el);
@@ -232,8 +232,17 @@ function initializeExportPlugin() {
232
232
  return !!(node && node.nodeType === 1
233
233
  && node.hasAttribute && node.hasAttribute('data-no-export'));
234
234
  };
235
+ // Default scale = device pixel ratio. On a retina display that's 2,
236
+ // so the export matches what the user actually sees on screen. On a
237
+ // standard 1x display it's 1, so the export is the literal CSS
238
+ // pixel size of the region — no surprise doubling. Callers can
239
+ // explicitly set `resolution: N` to render at N× independent of the
240
+ // host display (useful for "always-2x" archive renders or "1x" thumbs).
241
+ const defaultScale = (typeof window !== 'undefined' && window.devicePixelRatio > 0)
242
+ ? window.devicePixelRatio
243
+ : 1;
235
244
  const out = {
236
- scale: Number(opts.resolution) > 0 ? Number(opts.resolution) : 2,
245
+ scale: Number(opts.resolution) > 0 ? Number(opts.resolution) : defaultScale,
237
246
  ignoreElements,
238
247
  useCORS: true,
239
248
  allowTaint: false,
@@ -249,7 +258,16 @@ function initializeExportPlugin() {
249
258
  scrollX: 0,
250
259
  scrollY: 0,
251
260
  };
252
- if (opts.backgroundColor) out.backgroundColor = opts.backgroundColor;
261
+ // `backgroundColor: 'transparent'` is the explicit opt-out for raster
262
+ // exports — useful for icons and logos that should composite onto an
263
+ // arbitrary surface. We translate it to null because html2canvas-pro
264
+ // uses `null` (not the string 'transparent') to disable its default
265
+ // white fill.
266
+ if (opts.backgroundColor === 'transparent') {
267
+ out.backgroundColor = null;
268
+ } else if (opts.backgroundColor) {
269
+ out.backgroundColor = opts.backgroundColor;
270
+ }
253
271
  if (opts.width) out.width = Number(opts.width);
254
272
  if (opts.height) out.height = Number(opts.height);
255
273
  return out;
@@ -291,7 +309,14 @@ function initializeExportPlugin() {
291
309
  const lib = await loadSnapshotLib();
292
310
  const target = resolveTarget(opts);
293
311
  const so = snapshotOptions(opts);
294
- if ((ext === 'jpeg' || ext === 'webp') && !so.backgroundColor) {
312
+ // Default raster exports to the page's effective background so the
313
+ // snapshot looks like the page itself — a Sales Chart exported from
314
+ // a dark-mode page comes out on a dark background. Callers can
315
+ // explicitly opt into transparency with `backgroundColor: 'transparent'`,
316
+ // which snapshotOptions() translates to the null value html2canvas-pro
317
+ // wants. (Previously only JPEG/WebP got a default fill; PNG defaulted
318
+ // to transparent and rendered white text on a white viewer background.)
319
+ if (so.backgroundColor === undefined) {
295
320
  so.backgroundColor = effectivePageBackground();
296
321
  }
297
322
  await waitForImages(target);
@@ -306,49 +331,122 @@ function initializeExportPlugin() {
306
331
  triggerDownload(dataUrl, filename);
307
332
  }
308
333
 
309
- // Whole-page PDF: route through the browser's native print pipeline.
310
- // It handles multi-page layout, page breaks, vector text, and the page's
311
- // own @media print CSS — far more reliable than rasterizing a long page
312
- // and embedding it as a single image. The user sees the print dialog and
313
- // picks "Save as PDF" (or any installed PDF printer). Element-scoped PDFs
314
- // continue to use html2canvas-pro + jsPDF.
334
+ // PDFs (whole-page or element-scoped) route through the browser's native
335
+ // print pipeline. It handles multi-page layout, page breaks, vector text,
336
+ // and the page's own @media print CSS — far more reliable than
337
+ // rasterizing a long page and embedding it as a single image, and gives
338
+ // selectable / copy-pasteable text in the resulting file. The user sees
339
+ // the print dialog and picks "Save as PDF" (or any installed PDF
340
+ // printer). For element-scoped PDFs, a temporary print stylesheet is
341
+ // injected that hides everything outside the target subtree.
315
342
  async function exportPdf(opts, filename) {
316
- if (!opts.target) {
317
- return printToPdf(filename);
318
- }
319
- const [imgLib, jsPDFCtor] = await Promise.all([loadSnapshotLib(), loadJsPDF()]);
320
- const target = resolveTarget(opts);
321
- const so = snapshotOptions(opts);
322
- if (!so.backgroundColor) so.backgroundColor = effectivePageBackground();
323
- await waitForImages(target);
324
- const canvas = await snapshotToCanvas(imgLib, target, so);
325
- const dataUrl = canvas.toDataURL('image/png');
326
- const img = await loadImage(dataUrl);
327
- const orientation = img.width > img.height ? 'landscape' : 'portrait';
328
- const pdf = new jsPDFCtor({ orientation, unit: 'pt', format: opts.pageSize || 'a4' });
329
- const pageW = pdf.internal.pageSize.getWidth();
330
- const pageH = pdf.internal.pageSize.getHeight();
331
- const ratio = Math.min(pageW / img.width, pageH / img.height);
332
- const renderW = img.width * ratio;
333
- const renderH = img.height * ratio;
334
- const offsetX = (pageW - renderW) / 2;
335
- const offsetY = (pageH - renderH) / 2;
336
- pdf.addImage(dataUrl, 'PNG', offsetX, offsetY, renderW, renderH);
337
- pdf.save(filename);
343
+ // Both whole-page and targeted PDFs route through the browser's print
344
+ // pipeline. That gives us:
345
+ // - Vector text (selectable, copy/pasteable in the PDF)
346
+ // - Real layout (browser's actual rendering of the target's CSS,
347
+ // including cascade layers, custom properties, and computed
348
+ // fonts html2canvas-pro's computed-style walker mis-renders
349
+ // heading classes inside @layer rules)
350
+ // - Multi-page flow for tall content
351
+ // - The page's own @media print rules
352
+ // Whole-page is the default; passing a target adds a temporary
353
+ // @media print stylesheet that hides everything outside that subtree.
354
+ return printToPdf(filename, opts.target ? resolveTarget(opts) : null, opts.pageSize);
338
355
  }
339
356
 
340
- function printToPdf(filename) {
341
- // The browser's "Save as PDF" dialog seeds its default filename from
342
- // document.title. Swap it briefly so the suggested name matches the
343
- // user's intent, then restore after the dialog closes.
357
+ // Trigger the browser's "Save as PDF" dialog. When `target` is provided,
358
+ // the print is scoped to just that element; otherwise the whole page
359
+ // prints. Filename uses the document.title swap trick the print dialog
360
+ // seeds its suggested name from the title.
361
+ //
362
+ // Target-scoping strategy: clone the target into a fresh body-level
363
+ // container and `display: none` every other body child. Cloning (rather
364
+ // than relocating) leaves the live DOM intact — Alpine state, event
365
+ // listeners, and any in-flight reactivity stay where they were. The
366
+ // earlier visibility:hidden approach preserved layout space, so the body
367
+ // still occupied its full natural height during print → blank trailing
368
+ // pages. display:none actually removes that space, so the printed pages
369
+ // fit the target's content with no whitespace tail.
370
+ function printToPdf(filename, target, pageSize) {
344
371
  const original = document.title;
345
372
  const cleaned = String(filename || '').replace(/\.pdf$/i, '') || original;
346
373
  document.title = cleaned;
374
+ // Sanitize pageSize: only allow letters/digits/spaces so an
375
+ // attacker-controlled value can't break out of the @page rule.
376
+ const safePageSize = (typeof pageSize === 'string' && /^[a-zA-Z0-9 ]+$/.test(pageSize))
377
+ ? pageSize.trim()
378
+ : 'a4';
379
+
380
+ let printContainer = null;
381
+ // Always inject a @page rule so the chosen pageSize applies for both
382
+ // whole-page and targeted prints.
383
+ const pageRule = '@page { size: ' + safePageSize + '; margin: 1cm; }';
384
+ const style = document.createElement('style');
385
+ style.setAttribute('data-mnfst-print-scope', '');
386
+
387
+ if (target && target.nodeType === 1) {
388
+ printContainer = document.createElement('div');
389
+ printContainer.setAttribute('data-mnfst-print-target', '');
390
+ // Anchor off-screen so the clone never flashes visibly in normal
391
+ // (non-print) rendering. The print stylesheet below resets these
392
+ // for the print medium only.
393
+ printContainer.style.position = 'absolute';
394
+ printContainer.style.left = '-99999px';
395
+ printContainer.style.top = '0';
396
+ printContainer.appendChild(target.cloneNode(true));
397
+ document.body.appendChild(printContainer);
398
+
399
+ style.textContent =
400
+ pageRule +
401
+ '@media print {' +
402
+ // Hide every original body child — display:none takes them
403
+ // out of layout entirely, so body's height collapses to
404
+ // just the clone's height.
405
+ 'body > *:not([data-mnfst-print-target]) { display: none !important; }' +
406
+ // Restore the clone to normal flow at the top-left of
407
+ // the page, full width.
408
+ 'body > [data-mnfst-print-target] {' +
409
+ 'display: block !important;' +
410
+ 'position: static !important;' +
411
+ 'left: auto !important;' +
412
+ 'top: auto !important;' +
413
+ 'width: 100% !important;' +
414
+ 'max-width: none !important;' +
415
+ 'margin: 0 !important;' +
416
+ 'padding: 0 !important;' +
417
+ '}' +
418
+ '[data-no-export] { display: none !important; }' +
419
+ '}';
420
+ } else {
421
+ // Whole-page print: just honor pageSize and the data-no-export
422
+ // filter; let the page's own print CSS do everything else.
423
+ style.textContent =
424
+ pageRule +
425
+ '@media print {' +
426
+ '[data-no-export] { display: none !important; }' +
427
+ '}';
428
+ }
429
+ document.head.appendChild(style);
430
+
431
+ const cleanup = () => {
432
+ document.title = original;
433
+ style.remove();
434
+ if (printContainer) printContainer.remove();
435
+ window.removeEventListener('afterprint', onAfter);
436
+ };
437
+ const onAfter = () => cleanup();
438
+ window.addEventListener('afterprint', onAfter);
439
+
347
440
  try { window.print(); }
348
- finally {
349
- // Wait one frame so the print dialog reads the swapped title first.
350
- setTimeout(() => { document.title = original; }, 0);
441
+ catch (err) {
442
+ // If print() throws synchronously (rare), tear down immediately.
443
+ cleanup();
444
+ throw err;
351
445
  }
446
+ // Some browsers don't fire afterprint reliably (e.g. when the user
447
+ // cancels with Esc in older Safari). Schedule a fallback teardown
448
+ // a few seconds out so we don't leak the title swap or the style tag.
449
+ setTimeout(cleanup, 30000);
352
450
  }
353
451
 
354
452
  function effectivePageBackground() {
@@ -2,18 +2,18 @@
2
2
  "manifest.appwrite.auth.js": "sha384-Kvv9SjOFBFY2LELQunQpKLr3uDT+supgouo93eahEL4dAHntq0F9u8Hoyx85eQKr",
3
3
  "manifest.appwrite.data.js": "sha384-00ulLT+GAIuPHA/rRT9p98vYlsyDzkyKXtg86BDQ6FGQa5vVVN+W6kuforniBAsz",
4
4
  "manifest.appwrite.presence.js": "sha384-uxRpx9/Jj0kGtklH5QmUlAzD3zdSvFRfK6bcJQqxl+Bsf5tOo4zgwqJTQgtZoHQP",
5
- "manifest.code.js": "sha384-jYW7i5F+K+mL5d/HKpw/Xoo0vOz/pmlvotGd7MUPOu+CB+O28OohqgPAEI4y6bSS",
5
+ "manifest.code.js": "sha384-0uQckfvrEyDVV1Er8SpciZM5egnBzgJG4QFl7ZNdFOWiNLkENeLcRjJKzPLlQO6G",
6
6
  "manifest.color.js": "sha384-Z9G/lzt0vVMxjz4wkPuGG1X9mmQAJR15aOoGX3ephf7r2wnlUWet5GLgkUMtT4vt",
7
7
  "manifest.colorpicker.js": "sha384-0EVn+Ha06h7FIvOxc6WjZYnKYXzi+zba08yKvczSEGTRkWRxyKN2TFrZHI1SDCXu",
8
8
  "manifest.components.js": "sha384-3dCTD5EwCZTiX+1obYtDNM3WWwPh2JDQUQQsdRUUK3gs6FXjse1ShkKaT/2jsNaI",
9
9
  "manifest.data.js": "sha384-pgX6RJRWP7jmWO4ALb+GbS7Gm5JGOrCtxjEOnEcj1aJ8HoGbFjOniyjsntf8IA+B",
10
10
  "manifest.dropdowns.js": "sha384-WMrFoSpKfJuo81dyrwhVrDO8rq+rDwh2x8x4nH01BY5ZHkvjE+/SaT2gWCI0zOn+",
11
- "manifest.export.js": "sha384-qvdGz1TiGEDOeWJ5os1z03RURdKX+ezZEQ1KyV+9iC7X0esLK83mtY87t4MQv45t",
11
+ "manifest.export.js": "sha384-IF2MJHlDpx9hprNtdplNIJJPnU5xE+9/S+LQL3MBG4ERICBtIwOKM5V7RRY/OWzM",
12
12
  "manifest.icons.js": "sha384-uOkboYrovjCpl22eey3Jaxpey+pOnot5NDnRRumcRxiR7IOVaRh1i20gYnWXR5dW",
13
13
  "manifest.localization.js": "sha384-M40EWrbWs2MoJvUiYVQaPxPiOzMYBn/ywuMR02rvoSXG77eIHT/aRoYub9r6+jC+",
14
- "manifest.markdown.js": "sha384-H/Zg0ANrfokWWLR0js9zKd6yasP62sG+aga5r5WnmTd+LPMb93H97JlS8OQV7Ox9",
14
+ "manifest.markdown.js": "sha384-jJFVCg46bYcP/xglX6QguG2qsGi2I/0o9kXLpM2RMq7edbTYpgx6WQ9iNB7kKHmN",
15
15
  "manifest.resize.js": "sha384-Ak5gf44ERfh9pOSAD1qZzJSysslpwBCkevIlz7R3dszTUyzUKGKGF4pn5arOtgG0",
16
- "manifest.router.js": "sha384-n6xmIfWnYzd/0kkVTFuHhFzHuxiDgZ1Lg1W0yB6/w3Myw5pQ6PgE6SJBHfVsO7/D",
16
+ "manifest.router.js": "sha384-/JWAmVtxin3AKAdtIgXLjilMDj9RztBFn1W/ZSu/OPngfj2K8f3oYvz4UKXLwBRm",
17
17
  "manifest.slides.js": "sha384-3uRTkyK9XPLmnxI2+igZlpi4EyPlU/7IHj5j3BZJJ2KN455vXyk99fiXV3feO/XY",
18
18
  "manifest.svg.js": "sha384-nc+3spSGNS2l+82maL/OFz2iOGUhLZ0kqeooj28CEcdElM4OZa34e0tbnokZHVI6",
19
19
  "manifest.tabs.js": "sha384-v6Ti0zHfdLhkFHbTMg0FH6uMrThuBvZrL2PQgVBeeXhDjuN7x4MtoNWogPbAQTaD",
@@ -375,7 +375,16 @@ function applyInlineCodeAttributes(html) {
375
375
  language = tok;
376
376
  }
377
377
  }
378
- let attrs = ` x-code="${escapeForAttribute(language)}"`;
378
+ // Only emit x-code when the author actually requested a language.
379
+ // `inline`{copy} should produce `<code copy>` — copy is a UI flag,
380
+ // not a request for syntax highlighting. Previously we always
381
+ // wrote `x-code=""`, which the code plugin treated as auto-detect
382
+ // and ran hljs.highlightElement on the codespan, colouring
383
+ // identifiers like `x-code` themselves as tokens. Authors who
384
+ // want highlighting opt in explicitly via `code`{bash} or by
385
+ // hand-writing `<code x-code="bash">…</code>`.
386
+ let attrs = '';
387
+ if (language) attrs += ` x-code="${escapeForAttribute(language)}"`;
379
388
  for (const flag of flags) attrs += ` ${flag}`;
380
389
  if (classes.length) attrs += ` class="${escapeForAttribute(classes.join(' '))}"`;
381
390
  for (const [k, v] of kv) attrs += ` ${k}="${escapeForAttribute(v)}"`;
@@ -587,6 +587,20 @@ function interceptLinkClicks() {
587
587
  // Handle pure anchor links normally - don't intercept them
588
588
  if (href.startsWith('#')) return;
589
589
 
590
+ // Don't intercept blob: or data: URLs. The export plugin creates a
591
+ // throwaway <a href="blob:…" download="…"> and programmatically
592
+ // clicks it to trigger a file save; if the router treats it as a
593
+ // SPA link, `new URL("blob:http://localhost/UUID", origin).pathname`
594
+ // resolves to "http://localhost/UUID" which then pushState pushes
595
+ // as a same-origin path, producing /http://localhost/UUID and
596
+ // landing the user on a 404 instead of downloading.
597
+ if (href.startsWith('blob:') || href.startsWith('data:')) return;
598
+
599
+ // Honor `download` — the link is opting out of SPA navigation in
600
+ // favor of letting the browser save its target. Same intent as
601
+ // blob:/data:, just expressed via the standard HTML attribute.
602
+ if (link.hasAttribute('download')) return;
603
+
590
604
  // Check if it's an external link FIRST (before any other processing)
591
605
  let isExternalLink = false;
592
606
  try {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "mnfst",
3
- "version": "0.5.111",
3
+ "version": "0.5.113",
4
4
  "private": false,
5
5
  "workspaces": [
6
6
  "templates/starter",