mnfst 0.5.111 → 0.5.112

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);
@@ -249,7 +249,16 @@ function initializeExportPlugin() {
249
249
  scrollX: 0,
250
250
  scrollY: 0,
251
251
  };
252
- if (opts.backgroundColor) out.backgroundColor = opts.backgroundColor;
252
+ // `backgroundColor: 'transparent'` is the explicit opt-out for raster
253
+ // exports — useful for icons and logos that should composite onto an
254
+ // arbitrary surface. We translate it to null because html2canvas-pro
255
+ // uses `null` (not the string 'transparent') to disable its default
256
+ // white fill.
257
+ if (opts.backgroundColor === 'transparent') {
258
+ out.backgroundColor = null;
259
+ } else if (opts.backgroundColor) {
260
+ out.backgroundColor = opts.backgroundColor;
261
+ }
253
262
  if (opts.width) out.width = Number(opts.width);
254
263
  if (opts.height) out.height = Number(opts.height);
255
264
  return out;
@@ -291,7 +300,14 @@ function initializeExportPlugin() {
291
300
  const lib = await loadSnapshotLib();
292
301
  const target = resolveTarget(opts);
293
302
  const so = snapshotOptions(opts);
294
- if ((ext === 'jpeg' || ext === 'webp') && !so.backgroundColor) {
303
+ // Default raster exports to the page's effective background so the
304
+ // snapshot looks like the page itself — a Sales Chart exported from
305
+ // a dark-mode page comes out on a dark background. Callers can
306
+ // explicitly opt into transparency with `backgroundColor: 'transparent'`,
307
+ // which snapshotOptions() translates to the null value html2canvas-pro
308
+ // wants. (Previously only JPEG/WebP got a default fill; PNG defaulted
309
+ // to transparent and rendered white text on a white viewer background.)
310
+ if (so.backgroundColor === undefined) {
295
311
  so.backgroundColor = effectivePageBackground();
296
312
  }
297
313
  await waitForImages(target);
@@ -306,49 +322,98 @@ function initializeExportPlugin() {
306
322
  triggerDownload(dataUrl, filename);
307
323
  }
308
324
 
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.
325
+ // PDFs (whole-page or element-scoped) route through the browser's native
326
+ // print pipeline. It handles multi-page layout, page breaks, vector text,
327
+ // and the page's own @media print CSS — far more reliable than
328
+ // rasterizing a long page and embedding it as a single image, and gives
329
+ // selectable / copy-pasteable text in the resulting file. The user sees
330
+ // the print dialog and picks "Save as PDF" (or any installed PDF
331
+ // printer). For element-scoped PDFs, a temporary print stylesheet is
332
+ // injected that hides everything outside the target subtree.
315
333
  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);
334
+ // Both whole-page and targeted PDFs route through the browser's print
335
+ // pipeline. That gives us:
336
+ // - Vector text (selectable, copy/pasteable in the PDF)
337
+ // - Real layout (browser's actual rendering of the target's CSS,
338
+ // including cascade layers, custom properties, and computed
339
+ // fonts html2canvas-pro's computed-style walker mis-renders
340
+ // heading classes inside @layer rules)
341
+ // - Multi-page flow for tall content
342
+ // - The page's own @media print rules
343
+ // Whole-page is the default; passing a target adds a temporary
344
+ // @media print stylesheet that hides everything outside that subtree.
345
+ return printToPdf(filename, opts.target ? resolveTarget(opts) : null);
338
346
  }
339
347
 
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.
348
+ // Trigger the browser's "Save as PDF" dialog. When `target` is provided,
349
+ // the print is scoped to that element via injected @media print CSS;
350
+ // otherwise the whole page prints. Filename uses the document.title swap
351
+ // trick the print dialog seeds its suggested name from the title.
352
+ function printToPdf(filename, target) {
344
353
  const original = document.title;
345
354
  const cleaned = String(filename || '').replace(/\.pdf$/i, '') || original;
346
355
  document.title = cleaned;
356
+
357
+ let style = null;
358
+ let injectedId = null;
359
+ if (target && target.nodeType === 1) {
360
+ // The target needs an id we can reference from the stylesheet.
361
+ // Give it a throwaway one if it doesn't already have one; we'll
362
+ // remove it on cleanup so we don't leak DOM mutations.
363
+ if (!target.id) {
364
+ injectedId = 'mnfst-print-target-' + Math.random().toString(36).slice(2, 9);
365
+ target.id = injectedId;
366
+ }
367
+ const id = target.id;
368
+ style = document.createElement('style');
369
+ style.setAttribute('data-mnfst-print-scope', '');
370
+ // visibility:hidden preserves layout space (so absolute/fixed
371
+ // positioning calculations remain sane), then we un-hide the
372
+ // target subtree and pin it to the top-left of the print page.
373
+ style.textContent =
374
+ '@media print {' +
375
+ 'body * { visibility: hidden !important; }' +
376
+ '#' + cssEscape(id) + ', #' + cssEscape(id) + ' * { visibility: visible !important; }' +
377
+ '#' + cssEscape(id) + ' {' +
378
+ 'position: absolute !important;' +
379
+ 'left: 0 !important; top: 0 !important;' +
380
+ 'width: 100% !important; max-width: none !important;' +
381
+ 'margin: 0 !important;' +
382
+ '}' +
383
+ '[data-no-export] { display: none !important; }' +
384
+ '}';
385
+ document.head.appendChild(style);
386
+ }
387
+
388
+ const cleanup = () => {
389
+ document.title = original;
390
+ if (style) style.remove();
391
+ if (injectedId && target) target.removeAttribute('id');
392
+ window.removeEventListener('afterprint', onAfter);
393
+ };
394
+ const onAfter = () => cleanup();
395
+ window.addEventListener('afterprint', onAfter);
396
+
347
397
  try { window.print(); }
348
- finally {
349
- // Wait one frame so the print dialog reads the swapped title first.
350
- setTimeout(() => { document.title = original; }, 0);
398
+ catch (err) {
399
+ // If print() throws synchronously (rare), tear down immediately.
400
+ cleanup();
401
+ throw err;
402
+ }
403
+ // Some browsers don't fire afterprint reliably (e.g. when the user
404
+ // cancels with Esc in older Safari). Schedule a fallback teardown
405
+ // a few seconds out so we don't leak the title swap or the style tag.
406
+ setTimeout(cleanup, 30000);
407
+ }
408
+
409
+ // CSS.escape polyfill — used to safely build #id selectors from arbitrary
410
+ // ids. Real `CSS.escape` is widely available; fall back for very old
411
+ // browsers to a conservative regex.
412
+ function cssEscape(value) {
413
+ if (typeof CSS !== 'undefined' && typeof CSS.escape === 'function') {
414
+ return CSS.escape(String(value));
351
415
  }
416
+ return String(value).replace(/[^a-zA-Z0-9_-]/g, (c) => '\\' + c);
352
417
  }
353
418
 
354
419
  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-kTZBHsXPtj6RuLSyR323lCjIm0VTtZ64IdpyvOULMMJHqw/kIXjtQDyph5Ak/Ryv",
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.112",
4
4
  "private": false,
5
5
  "workspaces": [
6
6
  "templates/starter",