mnfst-render 0.5.22 → 0.5.23

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.
@@ -671,6 +671,51 @@ function stripDataTailwindAttr(html) {
671
671
  return html.replace(/\sdata-tailwind(?:=(["']).*?\1)?/gi, '');
672
672
  }
673
673
 
674
+ /** Theme class de-bake + synchronous bootstrap.
675
+ *
676
+ * Puppeteer applies `<html class="light">` or `<html class="dark">` based on
677
+ * the build host's system preference at prerender time. Shipping that baked
678
+ * class to users in the OPPOSITE preference causes a visible flash on every
679
+ * page load (dark→light or light→dark) until the themes plugin re-evaluates.
680
+ *
681
+ * Fix: strip `light`/`dark` from the baked `<html class>` and inject a tiny
682
+ * synchronous `<script>` at the top of `<head>` that sets the correct class
683
+ * BEFORE the first paint — based on the user's `localStorage.theme` (their
684
+ * saved preference) or `prefers-color-scheme` (their system preference).
685
+ *
686
+ * The themes plugin (`manifest.themes.js`) still runs later for reactivity
687
+ * (Alpine bindings, click handlers, system-preference change listener), but
688
+ * the initial paint already has the correct class so there's no flash.
689
+ */
690
+ function debakeThemeClass(html) {
691
+ // Strip `light`/`dark` from `<html class="...">`. When the class attribute
692
+ // becomes empty, drop the attribute entirely (including its leading space)
693
+ // while preserving the rest of the `<html ...>` tag. Bug-fixed twice — the
694
+ // earlier version's regex captured the entire `<html ... class="...` chunk
695
+ // so returning `''` for an empty cleaned class wiped the whole opening tag.
696
+ let out = html.replace(/<html\b([^>]*)>/i, (full, attrs) => {
697
+ const newAttrs = attrs.replace(/\sclass=(["'])([^"']*)\1/i, (_, q, classes) => {
698
+ const cleaned = classes
699
+ .split(/\s+/)
700
+ .filter((c) => c && c !== 'light' && c !== 'dark')
701
+ .join(' ')
702
+ .trim();
703
+ return cleaned ? ` class=${q}${cleaned}${q}` : '';
704
+ });
705
+ return `<html${newAttrs}>`;
706
+ });
707
+ // Inject the synchronous theme bootstrap as the FIRST element inside <head>
708
+ // so it runs before any CSS or other scripts. Self-contained — reads
709
+ // localStorage + prefers-color-scheme and sets the class atomically.
710
+ const bootstrap = `<script>(function(){try{var t=localStorage.getItem('theme')||'system';var d=t==='dark'||(t==='system'&&window.matchMedia('(prefers-color-scheme: dark)').matches);document.documentElement.classList.add(d?'dark':'light');}catch(e){document.documentElement.classList.add('light');}})();</script>`;
711
+ if (!out.includes('id="manifest-theme-bootstrap"')) {
712
+ // Tag the script for idempotency on rebuilds and easy debugging.
713
+ const tagged = bootstrap.replace('<script>', '<script id="manifest-theme-bootstrap">');
714
+ out = out.replace(/<head(\s[^>]*)?>/i, (m) => `${m}\n ${tagged}`);
715
+ }
716
+ return out;
717
+ }
718
+
674
719
  /** Manifest utilities plugin: <style id="utility-styles"> and <style id="utility-styles-critical"> */
675
720
  function extractUtilityStyleBlocks(html) {
676
721
  const blocks = [];
@@ -1448,6 +1493,7 @@ function generateLocaleVariantHtml({
1448
1493
  } else {
1449
1494
  html = stripDataTailwindAttr(html);
1450
1495
  }
1496
+ html = debakeThemeClass(html);
1451
1497
 
1452
1498
  const pageUtilityBlocks = [];
1453
1499
  if (bundleUtilities) {
@@ -2488,7 +2534,7 @@ async function runPrerender(config) {
2488
2534
  // Runtime-only Alpine magics whose values change after the prerender
2489
2535
  // snapshot (e.g. via media query, route change, auth state). Bindings
2490
2536
  // referencing these must re-evaluate in the live page.
2491
- const RUNTIME_MAGIC_RX = /\$(theme|locale|url|auth|search|query|toast)\b/;
2537
+ const RUNTIME_MAGIC_RX = /(?<!['"])\$(theme|locale|url|auth|search|query|toast)\b/;
2492
2538
 
2493
2539
  const isDiffBindingAttr = (name) =>
2494
2540
  name === ':class' || name === 'x-bind:class' ||
@@ -2564,10 +2610,24 @@ async function runPrerender(config) {
2564
2610
  // the element unstyled until Alpine + async data loads catch up.
2565
2611
  // The baked value IS the correct initial render.
2566
2612
  if (src === null) {
2567
- const hasBinding =
2568
- (name === 'style' && (':style' in source || 'x-bind:style' in source || 'x-show' in source)) ||
2569
- (name === 'class' && (':class' in source || 'x-bind:class' in source));
2570
- if (hasBinding) continue;
2613
+ // Keep baked `style` when an Alpine binding manages it.
2614
+ // The baked value (e.g. `mask-image: url(...)` from a `:style`
2615
+ // expression evaluating against $x data, or `display: none`
2616
+ // from `x-show`) is the correct initial render — nulling it
2617
+ // would flash the element while Alpine + async data catch up.
2618
+ // Alpine's :style/x-show handlers diff against the current
2619
+ // DOM correctly, so the baked value is safely toggled later.
2620
+ const skipStyleNull = name === 'style' &&
2621
+ (':style' in source || 'x-bind:style' in source || 'x-show' in source);
2622
+ if (skipStyleNull) continue;
2623
+ // NOTE: do NOT extend this skip to `class`. Alpine's
2624
+ // `:class="cond ? 'foo' : ''"` (string-form) treats the
2625
+ // pre-existing className as the immutable baseline and only
2626
+ // ADDS classes on top — it cannot REMOVE a baked class. If
2627
+ // the prerender baked `class="selected"` for the initial
2628
+ // tab, clicking other tabs would never strip `selected`
2629
+ // from the first one. Always restoring class to null lets
2630
+ // Alpine manage it cleanly from a blank slate.
2571
2631
  }
2572
2632
  attrsOut[name] = src; // may be null (means "remove this attribute")
2573
2633
  dirty = true;
@@ -2971,6 +3031,7 @@ async function runPrerender(config) {
2971
3031
  } else {
2972
3032
  html = stripDataTailwindAttr(html);
2973
3033
  }
3034
+ html = debakeThemeClass(html);
2974
3035
  if (bundleUtilities) {
2975
3036
  const extracted = extractUtilityStyleBlocks(html);
2976
3037
  html = extracted.html;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "mnfst-render",
3
- "version": "0.5.22",
3
+ "version": "0.5.23",
4
4
  "description": "Render Manifest sites to static HTML for SEO",
5
5
  "type": "module",
6
6
  "bin": {