mnfst-render 0.5.21 → 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 = [];
@@ -701,6 +746,7 @@ function injectBeforeHeadClose(html, snippet) {
701
746
  return out.replace(/<\/head>/i, `${snippet}\n</head>`);
702
747
  }
703
748
 
749
+
704
750
  function indexHtmlUsesTailwind(rootDir) {
705
751
  const indexPath = join(rootDir, 'index.html');
706
752
  if (!existsSync(indexPath)) return false;
@@ -814,6 +860,29 @@ function runTailwindCliForPrerender(rootDir, outputDir, pre) {
814
860
  console.error('prerender: Tailwind CLI did not produce prerender.tailwind.css');
815
861
  return false;
816
862
  }
863
+ // Strip Tailwind preflight rules that conflict with Manifest's element-level
864
+ // resets. Tailwind's `hr { height: 0; border-top-width: 1px }` would win on
865
+ // specificity over Manifest's `:where(hr) {...}` reset (same `@layer base`,
866
+ // higher specificity), even when Manifest CSS loads after Tailwind. Removing
867
+ // the specific conflicting rules here is surgical: Tailwind's other utility
868
+ // classes (mt-6, md:hidden, etc.) keep their normal `@layer utilities`
869
+ // behaviour and continue to override Manifest's `*` reset as expected.
870
+ try {
871
+ const compiled = readFileSync(outCss, 'utf8');
872
+ // Inside Tailwind's `@layer base { ... }` block, remove the bare `hr { ... }`
873
+ // declaration only. Other element resets in the same layer don't conflict
874
+ // with Manifest's `:where()` resets (they target other elements or rely on
875
+ // Manifest's resets winning later in source order at equal specificity).
876
+ const stripped = compiled.replace(
877
+ /(\s*)hr\s*\{\s*height:\s*0;\s*color:\s*inherit;\s*border-top-width:\s*1px;?\s*\}/g,
878
+ ''
879
+ );
880
+ if (stripped !== compiled) {
881
+ writeFileSync(outCss, stripped, 'utf8');
882
+ }
883
+ } catch (e) {
884
+ console.warn('prerender: failed to strip conflicting Tailwind preflight rules:', e?.message || e);
885
+ }
817
886
  process.stdout.write(`prerender: wrote ${relative(rootDir, outCss)}\n`);
818
887
  return true;
819
888
  }
@@ -1424,6 +1493,7 @@ function generateLocaleVariantHtml({
1424
1493
  } else {
1425
1494
  html = stripDataTailwindAttr(html);
1426
1495
  }
1496
+ html = debakeThemeClass(html);
1427
1497
 
1428
1498
  const pageUtilityBlocks = [];
1429
1499
  if (bundleUtilities) {
@@ -2464,7 +2534,7 @@ async function runPrerender(config) {
2464
2534
  // Runtime-only Alpine magics whose values change after the prerender
2465
2535
  // snapshot (e.g. via media query, route change, auth state). Bindings
2466
2536
  // referencing these must re-evaluate in the live page.
2467
- 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/;
2468
2538
 
2469
2539
  const isDiffBindingAttr = (name) =>
2470
2540
  name === ':class' || name === 'x-bind:class' ||
@@ -2540,10 +2610,24 @@ async function runPrerender(config) {
2540
2610
  // the element unstyled until Alpine + async data loads catch up.
2541
2611
  // The baked value IS the correct initial render.
2542
2612
  if (src === null) {
2543
- const hasBinding =
2544
- (name === 'style' && (':style' in source || 'x-bind:style' in source || 'x-show' in source)) ||
2545
- (name === 'class' && (':class' in source || 'x-bind:class' in source));
2546
- 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.
2547
2631
  }
2548
2632
  attrsOut[name] = src; // may be null (means "remove this attribute")
2549
2633
  dirty = true;
@@ -2947,6 +3031,7 @@ async function runPrerender(config) {
2947
3031
  } else {
2948
3032
  html = stripDataTailwindAttr(html);
2949
3033
  }
3034
+ html = debakeThemeClass(html);
2950
3035
  if (bundleUtilities) {
2951
3036
  const extracted = extractUtilityStyleBlocks(html);
2952
3037
  html = extracted.html;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "mnfst-render",
3
- "version": "0.5.21",
3
+ "version": "0.5.23",
4
4
  "description": "Render Manifest sites to static HTML for SEO",
5
5
  "type": "module",
6
6
  "bin": {