meno-core 1.1.5 → 1.1.6

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.
@@ -85,26 +85,9 @@ function minifyCSS(code: string): string {
85
85
  );
86
86
  }
87
87
 
88
- /**
89
- * Inline a page's hand-authored CSS imports into the head.
90
- *
91
- * A page can `import '../styles/extra.css'` in its `.astro` frontmatter. The codec
92
- * can't model such an import, so it's captured verbatim as `_frontmatter`
93
- * passthrough — the real `astro build` bundles + injects it, but the meno-core SSR
94
- * canvas (Meno Select) never learned about it, so hand-imported stylesheets rendered
95
- * in Astro mode yet vanished in select mode. Here we extract those side-effect CSS
96
- * imports, read each file, and inline it into a `<style>` so the select canvas
97
- * matches the build. Best-effort: an unresolvable/remote-only import is skipped, not
98
- * fatal. theme.css / local libraries are regenerated elsewhere and are never captured
99
- * as passthrough, so they don't appear here.
100
- */
101
- async function collectPageCssImports(
102
- frontmatter: string | undefined,
103
- pagePath: string | undefined,
104
- nonceAttr: string,
105
- ): Promise<string> {
106
- if (!frontmatter?.includes('.css')) return '';
107
-
88
+ /** Side-effect CSS import specifiers in a frontmatter passthrough block, in source order. */
89
+ function extractCssImportSpecs(frontmatter: string | undefined): string[] {
90
+ if (!frontmatter?.includes('.css')) return [];
108
91
  const seen = new Set<string>();
109
92
  const specs: string[] = [];
110
93
  const re = /import\b[^'"]*['"]([^'"]+\.css)(?:\?[^'"]*)?['"]/g;
@@ -117,39 +100,110 @@ async function collectPageCssImports(
117
100
  if (spec.endsWith('styles/theme.css')) continue; // regenerated by the color/variable services
118
101
  specs.push(spec);
119
102
  }
120
- if (specs.length === 0) return '';
103
+ return specs;
104
+ }
105
+
106
+ /**
107
+ * Component definitions reachable from the page: a name counts as used when it
108
+ * appears (quoted) in the page's JSON, or in the JSON of an already-used component
109
+ * (components nest). Same best-effort heuristic the de-mirror audit uses — a false
110
+ * positive merely inlines a stylesheet the build would have bundled on another page.
111
+ */
112
+ function usedComponentDefs(pageData: JSONPage, components: Record<string, ComponentDefinition>): ComponentDefinition[] {
113
+ const entries = Object.entries(components);
114
+ const used = new Map<string, ComponentDefinition>();
115
+ let hay = JSON.stringify(pageData ?? '');
116
+ let grew = true;
117
+ while (grew) {
118
+ grew = false;
119
+ for (const [name, def] of entries) {
120
+ if (used.has(name) || !hay.includes(`"${name}"`)) continue;
121
+ used.set(name, def);
122
+ hay += JSON.stringify(def ?? '');
123
+ grew = true;
124
+ }
125
+ }
126
+ return [...used.values()];
127
+ }
121
128
 
129
+ /**
130
+ * Inline hand-authored CSS imports into the head.
131
+ *
132
+ * A page or component can `import '../styles/extra.css'` in its `.astro`
133
+ * frontmatter. The codec can't model such an import, so it's captured verbatim as
134
+ * `_frontmatter` passthrough — the real `astro build` bundles + injects it, but the
135
+ * meno-core SSR canvas (Meno Select) never learned about it, so hand-imported
136
+ * stylesheets rendered in Astro mode yet vanished in select mode. Here we extract
137
+ * those side-effect CSS imports — from the page AND from every component the page
138
+ * (transitively) uses — read each file, and inline it into a `<style>` so the
139
+ * select canvas matches the build. Best-effort: an unresolvable/remote-only import
140
+ * is skipped, not fatal. theme.css / local libraries are regenerated elsewhere and
141
+ * are never captured as passthrough, so they don't appear here.
142
+ */
143
+ async function collectPageCssImports(
144
+ pageData: JSONPage,
145
+ components: Record<string, ComponentDefinition>,
146
+ pagePath: string | undefined,
147
+ nonceAttr: string,
148
+ ): Promise<string> {
122
149
  // The page's source directory, reconstructed from its URL path
123
150
  // (src/pages/<dir>). dirname('/') and dirname('/about') both yield the pages root.
124
151
  const pageDir = posix.join('src/pages', posix.dirname(pagePath || '/'));
125
152
 
153
+ // (spec, baseDir) pairs. Component imports resolve against src/components (where
154
+ // every component .astro lives) and come first; the page's own imports come last
155
+ // so they can override component styles, matching the build's cascade order
156
+ // (a page imports its components before its own stylesheet).
157
+ const sources: Array<{ spec: string; baseDir: string }> = [];
158
+ if (Object.values(components).some((d) => d?.component?._frontmatter?.includes('.css'))) {
159
+ for (const def of usedComponentDefs(pageData, components)) {
160
+ for (const spec of extractCssImportSpecs(def.component?._frontmatter)) {
161
+ sources.push({ spec, baseDir: 'src/components' });
162
+ }
163
+ }
164
+ }
165
+ for (const spec of extractCssImportSpecs(pageData._frontmatter)) {
166
+ sources.push({ spec, baseDir: pageDir });
167
+ }
168
+ if (sources.length === 0) return '';
169
+
126
170
  const tags: string[] = [];
127
- for (const spec of specs) {
171
+ const linkedRemote = new Set<string>();
172
+ const inlinedFiles = new Set<string>();
173
+ for (const { spec, baseDir } of sources) {
128
174
  // Remote stylesheet — link it directly (can't inline what we can't read).
129
175
  if (/^(?:https?:)?\/\//.test(spec)) {
176
+ if (linkedRemote.has(spec)) continue;
177
+ linkedRemote.add(spec);
130
178
  tags.push(`<link rel="stylesheet" href="${escapeHtml(spec)}">`);
131
179
  continue;
132
180
  }
133
181
  // Candidate project-relative paths, tried in order:
134
- // 1. resolved against the page's own directory — correct for `./sibling.css`
135
- // and correctly-depthed `../` on un-prefixed routes;
182
+ // 1. resolved against the importing file's own directory — correct for
183
+ // `./sibling.css` and correctly-depthed `../` on un-prefixed routes;
136
184
  // 2. leading traversal stripped, anchored at src/ — robust for the dominant
137
185
  // `../styles/*.css` pattern even on localized `[locale]` routes where
138
186
  // pagePath doesn't map 1:1 to the source file's depth.
139
187
  const candidates = spec.startsWith('/')
140
188
  ? [spec.slice(1)]
141
- : [posix.normalize(posix.join(pageDir, spec)), `src/${spec.replace(/^(?:\.\.?\/)+/, '')}`];
189
+ : [posix.normalize(posix.join(baseDir, spec)), `src/${spec.replace(/^(?:\.\.?\/)+/, '')}`];
142
190
  let css: string | null = null;
191
+ let resolved: string | null = null;
143
192
  for (const rel of candidates) {
144
193
  if (rel.startsWith('..')) continue; // never read outside the project root
145
194
  try {
146
195
  css = await readTextFile(resolveProjectPath(rel));
196
+ resolved = rel;
147
197
  break;
148
198
  } catch {
149
199
  /* try the next candidate */
150
200
  }
151
201
  }
152
- if (css == null) continue;
202
+ if (css == null || resolved == null) continue;
203
+ // Same file imported from several places (e.g. two components sharing a
204
+ // stylesheet) is inlined once.
205
+ if (inlinedFiles.has(resolved)) continue;
206
+ inlinedFiles.add(resolved);
153
207
  // Stabilize viewport units for the design canvas (same as the generated sheet)
154
208
  // and neutralize any literal `</style>` so it can't break out of the tag.
155
209
  const content = rewriteViewportUnitsInStylesheet(css).replace(/<\/style/gi, '<\\/style');
@@ -719,6 +773,11 @@ picture {
719
773
  // matches the iframe document's locked-in CSP — so Chromium would drop the
720
774
  // node and ALL CSS would vanish. Mutating the already-approved node's text
721
775
  // doesn't re-trigger nonce validation (same trick StyleInjector uses in dev).
776
+ // The `data-meno-page-css` tags (hand-imported stylesheets inlined by
777
+ // collectPageCssImports) get the same in-place treatment, matched by index +
778
+ // attribute; a changed tag SET (an import added or removed) falls back to a
779
+ // full reload, since inserting a new <style> node would need a nonce the
780
+ // locked-in CSP no longer accepts.
722
781
  //
723
782
  // For the same reason, the `<script id="meno-cms-*">` context scripts (which
724
783
  // ARE recreated, since an inline script must be a fresh node to re-execute)
@@ -727,7 +786,7 @@ picture {
727
786
  // HTML. Without it, nonce-only `script-src` would drop the recreated script
728
787
  // and CMS hot-reload (updated `window.__MENO_CMS__` data) would silently fail.
729
788
  const liveReloadScript = injectLiveReload
730
- ? `<script${nonceAttr}>(function(){var ws,timer,gen=0,lastSrvRoot=null,dn=document.querySelector('meta[name="csp-nonce"]'),docNonce=dn?dn.getAttribute('content'):'';function strip(s){return s?s.replace(/[?&]_r=\\d+/,''):''}function iScroll(x,y){var de=document.documentElement,b=document.body,pd=de.style.scrollBehavior,pb=b?b.style.scrollBehavior:'';de.style.scrollBehavior='auto';if(b)b.style.scrollBehavior='auto';try{window.scrollTo(x,y)}catch(e){}de.style.scrollBehavior=pd;if(b)b.style.scrollBehavior=pb}function classList(el){return (el.getAttribute('class')||'').split(/\\s+/).filter(Boolean)}function syncEl(cur,srv,old){var cc=classList(cur),sc=classList(srv),oc=old?new Set(classList(old)):new Set();var rt=cc.filter(function(c){return !oc.has(c)});var seen=new Set(),fin=[];sc.concat(rt).forEach(function(c){if(!seen.has(c)){seen.add(c);fin.push(c)}});var fs=fin.join(' ');if((cur.getAttribute('class')||'')!==fs){if(fs)cur.setAttribute('class',fs);else cur.removeAttribute('class')}for(var i=0;i<srv.attributes.length;i++){var a=srv.attributes[i];if(a.name==='class')continue;var ov=old?old.getAttribute(a.name):null;if(a.value!==ov){if(cur.getAttribute(a.name)!==a.value)cur.setAttribute(a.name,a.value)}}if(old){for(var i=0;i<old.attributes.length;i++){var a=old.attributes[i];if(a.name==='class')continue;if(!srv.hasAttribute(a.name)&&cur.hasAttribute(a.name))cur.removeAttribute(a.name)}}}function syncText(cur,srv){var cc=cur.childNodes,sc=srv.childNodes;for(var i=0;i<sc.length;i++){var s=sc[i],c=cc[i];if(s.nodeType===3&&c&&c.nodeType===3){if(c.textContent!==s.textContent)c.textContent=s.textContent}}}function ek(el){var p=el.getAttribute('data-element-path'),ci=el.getAttribute('data-cms-item-index');return ci?p+'|'+ci:p}function structKey(root){var e=root.querySelectorAll('[data-element-path]'),k=[];for(var i=0;i<e.length;i++)k.push(ek(e[i]));return k.sort().join('~')}function softSync(curR,srvR,oldR){var ce=curR.querySelectorAll('[data-element-path]');var sbp={},se=srvR.querySelectorAll('[data-element-path]');for(var i=0;i<se.length;i++)sbp[ek(se[i])]=se[i];var obp={};if(oldR){var oe=oldR.querySelectorAll('[data-element-path]');for(var i=0;i<oe.length;i++)obp[ek(oe[i])]=oe[i]}for(var i=0;i<ce.length;i++){var c=ce[i],p=ek(c),s=sbp[p];if(!s)continue;syncEl(c,s,obp[p]);syncText(c,s)}syncText(curR,srvR)}function connect(){ws=new WebSocket(${wsUrl});ws.onmessage=function(e){var d=JSON.parse(e.data);if(d.type==='hmr:libraries-update'){location.reload()}else if(d.type==='hmr:update'||d.type==='hmr:cms-update'||d.type==='hmr:colors-update'||d.type==='hmr:variables-update')hotReload()};ws.onclose=function(){clearTimeout(timer);timer=setTimeout(connect,1000)}}function hotReload(){var g=++gen;var sx=window.scrollX,sy=window.scrollY;fetch(location.href,{cache:'no-store'}).then(function(r){return r.text()}).then(function(html){if(g!==gen)return;var p=new DOMParser();var d=p.parseFromString(html,'text/html');var or=document.getElementById('root'),nr=d.getElementById('root');var oscr=document.querySelector('script[src^="/_scripts/"]'),nscr=d.querySelector('script[src^="/_scripts/"]');var oss=oscr?strip(oscr.getAttribute('src')):'',nss=nscr?strip(nscr.getAttribute('src')):'';var hardReset=(oss!==nss)||!lastSrvRoot||!or||!nr||structKey(lastSrvRoot)!==structKey(nr);if(or&&nr){if(hardReset){if(or.innerHTML!==nr.innerHTML)or.innerHTML=nr.innerHTML}else{softSync(or,nr,lastSrvRoot)}}if(nr)lastSrvRoot=nr.cloneNode(true);var os=document.getElementById('meno-styles'),ns=d.getElementById('meno-styles');if(os&&ns&&os.textContent!==ns.textContent)os.textContent=ns.textContent;var nh=d.documentElement;if(nh){var nl=nh.getAttribute('lang')||'en',nt=nh.getAttribute('theme')||'light';if(document.documentElement.getAttribute('lang')!==nl)document.documentElement.setAttribute('lang',nl);if(document.documentElement.getAttribute('theme')!==nt)document.documentElement.setAttribute('theme',nt)}var ocms=document.querySelectorAll('script[id^="meno-cms-"]'),ncms=d.querySelectorAll('script[id^="meno-cms-"]');var ock=JSON.stringify(Array.prototype.map.call(ocms,function(s){return [s.id,s.textContent]}));var nck=JSON.stringify(Array.prototype.map.call(ncms,function(s){return [s.id,s.textContent]}));if(ock!==nck){ocms.forEach(function(s){s.remove()});ncms.forEach(function(s){var c=document.createElement('script');c.type=s.type;c.id=s.id;if(docNonce)c.nonce=docNonce;c.textContent=s.textContent;document.head.appendChild(c)})}window.__menoHotReload=true;var olib=document.querySelectorAll('body > script[src^="/libraries/"]'),nlib=d.querySelectorAll('body > script[src^="/libraries/"]');var olk=JSON.stringify(Array.prototype.map.call(olib,function(s){return strip(s.getAttribute('src'))}).sort());var nlk=JSON.stringify(Array.prototype.map.call(nlib,function(s){return strip(s.getAttribute('src'))}).sort());if(olk!==nlk){olib.forEach(function(o){o.remove()});nlib.forEach(function(n){var src=n.getAttribute('src');var ls=document.createElement('script');ls.src=src+(src.indexOf('?')>-1?'&':'?')+'_r='+Date.now();document.body.appendChild(ls)})}if(!hardReset){iScroll(sx,sy)}else{if(oscr)oscr.remove();if(nscr){var src=nscr.getAttribute('src');var s=document.createElement('script');s.src=src+(src.indexOf('?')>-1?'&':'?')+'_r='+Date.now();s.onload=function(){document.dispatchEvent(new Event('DOMContentLoaded'));iScroll(sx,sy)};s.onerror=function(){iScroll(sx,sy)};document.body.appendChild(s)}else{document.dispatchEvent(new Event('DOMContentLoaded'));iScroll(sx,sy)}}}).catch(function(){location.reload()})}var iR=document.getElementById('root');if(iR)lastSrvRoot=iR.cloneNode(true);connect()})()</script>`
789
+ ? `<script${nonceAttr}>(function(){var ws,timer,gen=0,lastSrvRoot=null,dn=document.querySelector('meta[name="csp-nonce"]'),docNonce=dn?dn.getAttribute('content'):'';function strip(s){return s?s.replace(/[?&]_r=\\d+/,''):''}function iScroll(x,y){var de=document.documentElement,b=document.body,pd=de.style.scrollBehavior,pb=b?b.style.scrollBehavior:'';de.style.scrollBehavior='auto';if(b)b.style.scrollBehavior='auto';try{window.scrollTo(x,y)}catch(e){}de.style.scrollBehavior=pd;if(b)b.style.scrollBehavior=pb}function classList(el){return (el.getAttribute('class')||'').split(/\\s+/).filter(Boolean)}function syncEl(cur,srv,old){var cc=classList(cur),sc=classList(srv),oc=old?new Set(classList(old)):new Set();var rt=cc.filter(function(c){return !oc.has(c)});var seen=new Set(),fin=[];sc.concat(rt).forEach(function(c){if(!seen.has(c)){seen.add(c);fin.push(c)}});var fs=fin.join(' ');if((cur.getAttribute('class')||'')!==fs){if(fs)cur.setAttribute('class',fs);else cur.removeAttribute('class')}for(var i=0;i<srv.attributes.length;i++){var a=srv.attributes[i];if(a.name==='class')continue;var ov=old?old.getAttribute(a.name):null;if(a.value!==ov){if(cur.getAttribute(a.name)!==a.value)cur.setAttribute(a.name,a.value)}}if(old){for(var i=0;i<old.attributes.length;i++){var a=old.attributes[i];if(a.name==='class')continue;if(!srv.hasAttribute(a.name)&&cur.hasAttribute(a.name))cur.removeAttribute(a.name)}}}function syncText(cur,srv){var cc=cur.childNodes,sc=srv.childNodes;for(var i=0;i<sc.length;i++){var s=sc[i],c=cc[i];if(s.nodeType===3&&c&&c.nodeType===3){if(c.textContent!==s.textContent)c.textContent=s.textContent}}}function ek(el){var p=el.getAttribute('data-element-path'),ci=el.getAttribute('data-cms-item-index');return ci?p+'|'+ci:p}function structKey(root){var e=root.querySelectorAll('[data-element-path]'),k=[];for(var i=0;i<e.length;i++)k.push(ek(e[i]));return k.sort().join('~')}function softSync(curR,srvR,oldR){var ce=curR.querySelectorAll('[data-element-path]');var sbp={},se=srvR.querySelectorAll('[data-element-path]');for(var i=0;i<se.length;i++)sbp[ek(se[i])]=se[i];var obp={};if(oldR){var oe=oldR.querySelectorAll('[data-element-path]');for(var i=0;i<oe.length;i++)obp[ek(oe[i])]=oe[i]}for(var i=0;i<ce.length;i++){var c=ce[i],p=ek(c),s=sbp[p];if(!s)continue;syncEl(c,s,obp[p]);syncText(c,s)}syncText(curR,srvR)}function connect(){ws=new WebSocket(${wsUrl});ws.onmessage=function(e){var d=JSON.parse(e.data);if(d.type==='hmr:libraries-update'){location.reload()}else if(d.type==='hmr:update'||d.type==='hmr:cms-update'||d.type==='hmr:colors-update'||d.type==='hmr:variables-update')hotReload()};ws.onclose=function(){clearTimeout(timer);timer=setTimeout(connect,1000)}}function hotReload(){var g=++gen;var sx=window.scrollX,sy=window.scrollY;fetch(location.href,{cache:'no-store'}).then(function(r){return r.text()}).then(function(html){if(g!==gen)return;var p=new DOMParser();var d=p.parseFromString(html,'text/html');var or=document.getElementById('root'),nr=d.getElementById('root');var oscr=document.querySelector('script[src^="/_scripts/"]'),nscr=d.querySelector('script[src^="/_scripts/"]');var oss=oscr?strip(oscr.getAttribute('src')):'',nss=nscr?strip(nscr.getAttribute('src')):'';var hardReset=(oss!==nss)||!lastSrvRoot||!or||!nr||structKey(lastSrvRoot)!==structKey(nr);if(or&&nr){if(hardReset){if(or.innerHTML!==nr.innerHTML)or.innerHTML=nr.innerHTML}else{softSync(or,nr,lastSrvRoot)}}if(nr)lastSrvRoot=nr.cloneNode(true);var os=document.getElementById('meno-styles'),ns=d.getElementById('meno-styles');if(os&&ns&&os.textContent!==ns.textContent)os.textContent=ns.textContent;var ops=document.querySelectorAll('style[data-meno-page-css]'),nps=d.querySelectorAll('style[data-meno-page-css]');var pcMismatch=ops.length!==nps.length;if(!pcMismatch){for(var pi=0;pi<nps.length;pi++){if(ops[pi].getAttribute('data-meno-page-css')!==nps[pi].getAttribute('data-meno-page-css')){pcMismatch=true;break}if(ops[pi].textContent!==nps[pi].textContent)ops[pi].textContent=nps[pi].textContent}}if(pcMismatch){location.reload();return}var nh=d.documentElement;if(nh){var nl=nh.getAttribute('lang')||'en',nt=nh.getAttribute('theme')||'light';if(document.documentElement.getAttribute('lang')!==nl)document.documentElement.setAttribute('lang',nl);if(document.documentElement.getAttribute('theme')!==nt)document.documentElement.setAttribute('theme',nt)}var ocms=document.querySelectorAll('script[id^="meno-cms-"]'),ncms=d.querySelectorAll('script[id^="meno-cms-"]');var ock=JSON.stringify(Array.prototype.map.call(ocms,function(s){return [s.id,s.textContent]}));var nck=JSON.stringify(Array.prototype.map.call(ncms,function(s){return [s.id,s.textContent]}));if(ock!==nck){ocms.forEach(function(s){s.remove()});ncms.forEach(function(s){var c=document.createElement('script');c.type=s.type;c.id=s.id;if(docNonce)c.nonce=docNonce;c.textContent=s.textContent;document.head.appendChild(c)})}window.__menoHotReload=true;var olib=document.querySelectorAll('body > script[src^="/libraries/"]'),nlib=d.querySelectorAll('body > script[src^="/libraries/"]');var olk=JSON.stringify(Array.prototype.map.call(olib,function(s){return strip(s.getAttribute('src'))}).sort());var nlk=JSON.stringify(Array.prototype.map.call(nlib,function(s){return strip(s.getAttribute('src'))}).sort());if(olk!==nlk){olib.forEach(function(o){o.remove()});nlib.forEach(function(n){var src=n.getAttribute('src');var ls=document.createElement('script');ls.src=src+(src.indexOf('?')>-1?'&':'?')+'_r='+Date.now();document.body.appendChild(ls)})}if(!hardReset){iScroll(sx,sy)}else{if(oscr)oscr.remove();if(nscr){var src=nscr.getAttribute('src');var s=document.createElement('script');s.src=src+(src.indexOf('?')>-1?'&':'?')+'_r='+Date.now();s.onload=function(){document.dispatchEvent(new Event('DOMContentLoaded'));iScroll(sx,sy)};s.onerror=function(){iScroll(sx,sy)};document.body.appendChild(s)}else{document.dispatchEvent(new Event('DOMContentLoaded'));iScroll(sx,sy)}}}).catch(function(){location.reload()})}var iR=document.getElementById('root');if(iR)lastSrvRoot=iR.cloneNode(true);connect()})()</script>`
731
790
  : '';
732
791
 
733
792
  // Scroll position handlers for preview mode iframe switching
@@ -738,11 +797,12 @@ picture {
738
797
  // In production, output minified CSS on single line; in dev, preserve formatting
739
798
  const styleContent = useBundled ? finalCSS : `\n ${combinedCSS.split('\n').join('\n ')}\n `;
740
799
 
741
- // Inline any hand-authored CSS the page imports in its frontmatter (foreign
742
- // passthrough the Astro build bundles but the select canvas otherwise misses).
743
- // Placed after #meno-styles so it can override generated utilities, matching the
744
- // intent of a page pulling in its own stylesheet.
745
- const pageCssTags = await collectPageCssImports(pageData._frontmatter, pagePath, nonceAttr);
800
+ // Inline any hand-authored CSS the page or its components import in frontmatter
801
+ // (foreign passthrough the Astro build bundles but the select canvas otherwise
802
+ // misses). Placed after #meno-styles so it can override generated utilities,
803
+ // matching the intent of a file pulling in its own stylesheet. NOTE: `path`, not
804
+ // `pagePath` the positional param is stuck at '/' for options-object calls.
805
+ const pageCssTags = await collectPageCssImports(pageData, components, path, nonceAttr);
746
806
 
747
807
  const htmlDocument = `<!DOCTYPE html>
748
808
  <html lang="${rendered.locale}" theme="${themeConfig.default}">
@@ -1140,6 +1140,70 @@ describe('ssrRenderer', () => {
1140
1140
  expect(classMatch?.[1]).toContain('text-[#ff0000]');
1141
1141
  });
1142
1142
 
1143
+ test('instance class override BEATS the root prop-bound style for the same property', async () => {
1144
+ // The root's maxWidth is prop-bound ({{maxWidth}} → "100%" → max-w-full at render);
1145
+ // the instance overrides it with class="max-w-[283px]". Both classes have equal
1146
+ // specificity, so if both land on the element the stylesheet ORDER picks the winner —
1147
+ // the root's max-w-full must therefore never be emitted at all, mirroring the
1148
+ // meno-astro runtime's mergeInstanceClasses/inlineStyle (where the instance wins).
1149
+ const componentDef: ComponentDefinition = {
1150
+ component: {
1151
+ structure: {
1152
+ type: 'node',
1153
+ tag: 'h1',
1154
+ style: { maxWidth: '{{maxWidth}}' },
1155
+ children: '{{text}}',
1156
+ },
1157
+ interface: {
1158
+ text: { type: 'string', default: 'Hi' },
1159
+ maxWidth: { type: 'string', default: '100%' },
1160
+ },
1161
+ },
1162
+ };
1163
+
1164
+ const node = {
1165
+ type: 'component',
1166
+ component: 'Heading',
1167
+ props: { text: 'Hi', maxWidth: '100%', class: 'max-w-[283px]' },
1168
+ };
1169
+
1170
+ const html = await render(node, { globalComponents: { Heading: componentDef } });
1171
+ const classMatch = html.match(/<h1[^>]*\sclass="([^"]*)"/);
1172
+ expect(classMatch).not.toBeNull();
1173
+ expect(classMatch?.[1]).toContain('max-w-[283px]');
1174
+ expect(classMatch?.[1]).not.toContain('max-w-full');
1175
+ });
1176
+
1177
+ test('instance class override drops the conflicting root class but keeps other breakpoints', async () => {
1178
+ // Root carries a static base class AND a tablet override; the instance overrides only
1179
+ // the base — the root's tablet class must survive (conflicts are per-breakpoint).
1180
+ const componentDef: ComponentDefinition = {
1181
+ component: {
1182
+ structure: {
1183
+ type: 'node',
1184
+ tag: 'div',
1185
+ attributes: { class: 'max-w-full max-lg:max-w-[90%] flex' },
1186
+ children: 'Content',
1187
+ },
1188
+ interface: {},
1189
+ },
1190
+ };
1191
+
1192
+ const node = {
1193
+ type: 'component',
1194
+ component: 'Card',
1195
+ props: { class: 'max-w-[283px]' },
1196
+ };
1197
+
1198
+ const html = await render(node, { globalComponents: { Card: componentDef } });
1199
+ const classMatch = html.match(/<div[^>]*\sclass="([^"]*)"/);
1200
+ expect(classMatch).not.toBeNull();
1201
+ expect(classMatch?.[1]).toContain('max-w-[283px]');
1202
+ expect(classMatch?.[1]).toContain('max-lg:max-w-[90%]');
1203
+ expect(classMatch?.[1]).toContain('flex');
1204
+ expect(classMatch?.[1]?.split(/\s+/)).not.toContain('max-w-full');
1205
+ });
1206
+
1143
1207
  test('component with defineVars: true adds data attributes', async () => {
1144
1208
  const componentDef: ComponentDefinition = {
1145
1209
  component: {
@@ -1380,6 +1444,42 @@ describe('ssrRenderer', () => {
1380
1444
  const html = await render(node, { globalComponents: { InlineStyleComp: compDef } });
1381
1445
  expect(html).toContain('<div');
1382
1446
  });
1447
+
1448
+ test('var-bridges template-bound style values in a component structure (templateStyleVars.ts)', async () => {
1449
+ // A prop-bound declaration (`gap: "{{gap}}px"`) must render exactly like the real
1450
+ // Astro build: the element's class reads `var(--m-base-gap)` (so auto-responsive
1451
+ // scaling never rewrites the value) and the element sets the variable inline.
1452
+ const compDef: ComponentDefinition = {
1453
+ component: {
1454
+ structure: {
1455
+ type: 'node',
1456
+ tag: 'div',
1457
+ style: { base: { gap: '{{gap}}px', display: 'flex' } },
1458
+ children: 'bridged',
1459
+ },
1460
+ interface: {
1461
+ gap: { type: 'number', default: 24 },
1462
+ },
1463
+ },
1464
+ };
1465
+
1466
+ const node = {
1467
+ type: 'component',
1468
+ component: 'BridgedGap',
1469
+ props: { gap: 40 },
1470
+ };
1471
+
1472
+ const html = await render(node, { globalComponents: { BridgedGap: compDef } });
1473
+ // Class reads the bridge variable, not the concrete value — the mapper normalizes
1474
+ // `var(--x)` values to the self-describing shorthand form `gap-(--x)`, exactly as
1475
+ // the meno-astro runtime's style() does (same mapper).
1476
+ expect(html).toContain('gap-(--m-base-gap)');
1477
+ expect(html).not.toContain('gap-[40px]');
1478
+ // …and the element sets the variable inline to the resolved value.
1479
+ expect(html).toContain('--m-base-gap: 40px');
1480
+ // Static values in the same style object keep their normal utility class.
1481
+ expect(html).toContain('flex');
1482
+ });
1383
1483
  });
1384
1484
 
1385
1485
  // -----------------------------------------------------------------------
@@ -18,10 +18,11 @@ import {
18
18
  } from '../../shared/itemTemplateUtils';
19
19
  import { singularize, isItemDraftForLocale } from '../../shared/types/cms';
20
20
  import { applyCmsFilters, applyCmsSorting } from '../../shared/cmsQuery';
21
- import type { ResponsiveStyleObject, StyleObject } from '../../shared/types';
21
+ import type { ResponsiveStyleObject, StyleObject, StyleValue } from '../../shared/types';
22
22
  import type { BreakpointConfig } from '../../shared/breakpoints';
23
23
  import type { ResponsiveScales } from '../../shared/responsiveScaling';
24
24
  import { processStructure, isResponsiveStyle, isHtmlMapping, resolveHtmlMapping } from '../../client/templateEngine';
25
+ import { getTemplateVars } from '../../shared/templateStyleVars';
25
26
  import { resolvePropsFromDefinition } from '../../shared/propResolver';
26
27
  import { loadBreakpointConfig, loadI18nConfig } from '../jsonLoader';
27
28
  import { configService } from '../services/configService';
@@ -76,7 +77,11 @@ function getDOMPurify(): DOMPurifyLike | null {
76
77
  }
77
78
  return _DOMPurify;
78
79
  }
79
- import { responsiveStylesToClasses } from '../../shared/utilityClassMapper';
80
+ import {
81
+ responsiveStylesToClasses,
82
+ mergeInstanceClasses,
83
+ stripClassOverriddenStyleProps,
84
+ } from '../../shared/utilityClassMapper';
80
85
  import { validateStyleCoverage } from '../validateStyleCoverage';
81
86
  import { generateElementClassName, type ElementClassContext } from '../../shared/elementClassName';
82
87
  import type { InteractiveStyles } from '../../shared/types/styles';
@@ -1587,6 +1592,12 @@ async function renderNode(
1587
1592
  embedCssVariables = registerInteractiveStyles(ctx, elementClass, embedInteractiveStyles);
1588
1593
  }
1589
1594
 
1595
+ // Var-bridge for template-bound style values (see templateStyleVars.ts).
1596
+ const embedTemplateVars = getTemplateVars(node);
1597
+ if (embedTemplateVars) {
1598
+ embedCssVariables = { ...embedCssVariables, ...embedTemplateVars };
1599
+ }
1600
+
1590
1601
  const embedStyleAttr = buildCssVariableStyleAttr(embedCssVariables);
1591
1602
 
1592
1603
  // Add attribute className if present
@@ -1614,9 +1625,11 @@ async function renderNode(
1614
1625
 
1615
1626
  const attrs = buildAttributes(nodeAttributes);
1616
1627
  const classAttr = classNames.length > 0 ? ` class="${escapeHtml(classNames.filter(Boolean).join(' '))}"` : '';
1628
+ // Var-bridge for template-bound style values (see templateStyleVars.ts).
1629
+ const embedStyleAttr = buildCssVariableStyleAttr(getTemplateVars(node) ?? {});
1617
1630
 
1618
1631
  // Always use span for embeds - valid inside <p> and other phrasing content
1619
- return `<span${classAttr}${attrs}${editorAttrs(ctx, { isSlotContent: isSlotContent(node) })}>${optimizedHtml}</span>`;
1632
+ return `<span${classAttr}${embedStyleAttr}${attrs}${editorAttrs(ctx, { isSlotContent: isSlotContent(node) })}>${optimizedHtml}</span>`;
1620
1633
  }
1621
1634
 
1622
1635
  // Handle link nodes (render as <a> tag in SSR)
@@ -1684,9 +1697,15 @@ async function renderNode(
1684
1697
  if (olinkInteractiveStyles && olinkInteractiveStyles.length > 0) {
1685
1698
  olinkCssVariables = registerInteractiveStyles(ctx, elementClass, olinkInteractiveStyles);
1686
1699
  }
1687
- olinkStyleAttr = buildCssVariableStyleAttr(olinkCssVariables);
1688
1700
  }
1689
1701
 
1702
+ // Var-bridge for template-bound style values (see templateStyleVars.ts).
1703
+ const olinkTemplateVars = getTemplateVars(node);
1704
+ if (olinkTemplateVars) {
1705
+ olinkCssVariables = { ...olinkCssVariables, ...olinkTemplateVars };
1706
+ }
1707
+ olinkStyleAttr = buildCssVariableStyleAttr(olinkCssVariables);
1708
+
1690
1709
  // Add attribute className if present
1691
1710
  const attrClassName = (nodeAttributes.className || nodeAttributes.class || '') as string;
1692
1711
  if (attrClassName) {
@@ -1843,6 +1862,16 @@ async function renderNode(
1843
1862
  }
1844
1863
  }
1845
1864
 
1865
+ // Var-bridge for template-bound style values (see templateStyleVars.ts): the node's
1866
+ // utility classes read var(--m-<bp>-<prop>); set the concrete value inline, mirroring
1867
+ // the meno-astro runtime. Component instances never carry these (bridge skips them).
1868
+ const nodeTemplateVars = getTemplateVars(node);
1869
+ if (nodeTemplateVars) {
1870
+ for (const [varName, value] of Object.entries(nodeTemplateVars)) {
1871
+ (resolvedStyle as Record<string, string | number>)[varName] = value;
1872
+ }
1873
+ }
1874
+
1846
1875
  // Merge resolved style into props.style, and merge attributes
1847
1876
  // Handle class/className specially - merge with utility classes instead of replacing
1848
1877
  const attrClassName = (nodeAttributes.className || nodeAttributes.class || '') as string;
@@ -1984,20 +2013,30 @@ async function renderComponent(
1984
2013
  mergeNodeStyles(rootNode, propsWithStyleAndAttrs.style as StyleObject | ResponsiveStyleObject);
1985
2014
  }
1986
2015
 
1987
- // Merge className from component instance (includes responsive utility classes)
2016
+ // Merge className from component instance (includes responsive utility classes).
2017
+ // Conflict-aware, instance-over-root: a root class targeting the same
2018
+ // (breakpoint, CSS property) as an instance class is dropped, and the same
2019
+ // properties are stripped from the root's style object — a prop-bound root
2020
+ // style (maxWidth: {_mapping} → "100%") mints a root utility class
2021
+ // (max-w-full) during the recursive render, which would otherwise fight the
2022
+ // instance's max-w-[283px] on stylesheet order (equal specificity). Mirrors
2023
+ // the meno-astro runtime's mergeInstanceClasses/inlineStyle pair, so Meno
2024
+ // Select agrees with a real Astro build on which value wins.
1988
2025
  if (propsWithStyleAndAttrs.className) {
2026
+ const instanceClassName = propsWithStyleAndAttrs.className as string;
2027
+ if (rootNode.style) {
2028
+ const stripped = stripClassOverriddenStyleProps(rootNode.style as StyleValue, instanceClassName);
2029
+ if (stripped) rootNode.style = stripped as Record<string, unknown>;
2030
+ else delete rootNode.style;
2031
+ }
1989
2032
  if (isHtmlNode(rootNode) || isLinkNode(rootNode)) {
1990
2033
  // For HTML and Link root nodes, merge into attributes where the renderer reads them
1991
2034
  if (!rootNode.attributes) rootNode.attributes = {};
1992
- const existingClass = (rootNode.attributes.class || '') as string;
1993
- rootNode.attributes.class = existingClass
1994
- ? `${existingClass} ${propsWithStyleAndAttrs.className}`
1995
- : (propsWithStyleAndAttrs.className as string);
2035
+ const own = ((rootNode.attributes.class || '') as string).split(/\s+/).filter(Boolean);
2036
+ rootNode.attributes.class = mergeInstanceClasses(own, instanceClassName).join(' ');
1996
2037
  } else {
1997
- const existingClassName = rootNode.props.className || '';
1998
- rootNode.props.className = existingClassName
1999
- ? `${existingClassName} ${propsWithStyleAndAttrs.className}`
2000
- : propsWithStyleAndAttrs.className;
2038
+ const own = ((rootNode.props.className || '') as string).split(/\s+/).filter(Boolean);
2039
+ rootNode.props.className = mergeInstanceClasses(own, instanceClassName).join(' ');
2001
2040
  }
2002
2041
  }
2003
2042
 
@@ -2402,9 +2441,15 @@ function renderLocaleList(node: import('../../shared/types').LocaleListNode, ctx
2402
2441
  if (localeListInteractiveStyles && localeListInteractiveStyles.length > 0) {
2403
2442
  localeListCssVariables = registerInteractiveStyles(ctx, elementClass, localeListInteractiveStyles);
2404
2443
  }
2405
- localeListStyleAttr = buildCssVariableStyleAttr(localeListCssVariables);
2406
2444
  }
2407
2445
 
2446
+ // Var-bridge for template-bound style values (see templateStyleVars.ts).
2447
+ const localeListTemplateVars = getTemplateVars(node);
2448
+ if (localeListTemplateVars) {
2449
+ localeListCssVariables = { ...localeListCssVariables, ...localeListTemplateVars };
2450
+ }
2451
+ localeListStyleAttr = buildCssVariableStyleAttr(localeListCssVariables);
2452
+
2408
2453
  const containerClassAttr = containerClasses.length > 0 ? ` class="${escapeHtml(containerClasses.join(' '))}"` : '';
2409
2454
 
2410
2455
  // Convert item styles to utility classes
@@ -0,0 +1,49 @@
1
+ /**
2
+ * Design-canvas bridge for `{{template}}`-bound style declarations — meno-core's mirror
3
+ * of the meno-astro var-bridge (packages/astro `runtime/cssValue.ts` templateVarName +
4
+ * `runtime/style.ts` resolveMappingsInFlat).
5
+ *
6
+ * A style value bound to a prop template (`gap: "{{gap}}px"`) can't be a static utility
7
+ * class in a real Astro build — its value is per-instance — so meno-astro emits the class
8
+ * `gap-[var(--m-<bp>-gap)]` and sets that variable inline on the element. Two consequences
9
+ * the design canvas must reproduce for WYSIWYG parity:
10
+ *
11
+ * 1. Auto-responsive scaling never applies to the declaration (the scaler can't scale a
12
+ * `var()`), so the value renders CONSTANT across breakpoints in a real build. Before
13
+ * this bridge, the canvas resolved the template to a concrete class (`gap-[40px]`)
14
+ * which the scaler happily shrank at tablet/mobile — values looked responsive in
15
+ * Fast Design Mode and fixed on the published site.
16
+ * 2. The inline style sets the VARIABLE, not the property, so interactive class rules
17
+ * (`:hover`, `.is-open`) still override the declaration by normal specificity.
18
+ *
19
+ * `processStructure` (templateEngine) performs the bridging when it resolves a component
20
+ * structure's templates: the resolved style carries `var(--m-<bp>-<prop>)` values and the
21
+ * variable assignments land on the node under {@link TEMPLATE_VARS_KEY} — an EPHEMERAL,
22
+ * render-only field (never persisted, never validated). The client builders apply it via
23
+ * their element ref (like interactive-style variables); the SSR renderer folds it into the
24
+ * element's inline `style="…"`.
25
+ */
26
+
27
+ /**
28
+ * The CSS custom-property name bridging a `{{template}}`-bound declaration to its utility
29
+ * rule. MUST stay byte-identical to meno-astro's `templateVarName` (runtime/cssValue.ts) —
30
+ * the canvas and the real build render the same variable names so what you inspect in
31
+ * Fast Design Mode matches the published output.
32
+ */
33
+ export function templateVarName(breakpoint: string, cssProp: string): string {
34
+ const kebab = cssProp.replace(/[A-Z]/g, (m) => `-${m.toLowerCase()}`);
35
+ return `--m-${breakpoint}-${kebab}`;
36
+ }
37
+
38
+ /** Ephemeral node field carrying the bridged variable assignments (render-only). */
39
+ export const TEMPLATE_VARS_KEY = '__templateVars';
40
+
41
+ /** Read the bridged `--m-<bp>-<prop>` assignments off a processed node, if any. */
42
+ export function getTemplateVars(node: unknown): Record<string, string> | undefined {
43
+ if (!node || typeof node !== 'object') return undefined;
44
+ const vars = (node as Record<string, unknown>)[TEMPLATE_VARS_KEY];
45
+ if (!vars || typeof vars !== 'object' || Array.isArray(vars)) return undefined;
46
+ const entries = Object.entries(vars as Record<string, unknown>);
47
+ if (entries.length === 0) return undefined;
48
+ return vars as Record<string, string>;
49
+ }
@@ -7,6 +7,9 @@ import {
7
7
  splitStyleByClassability,
8
8
  isClassableStyleValue,
9
9
  styleHasMapping,
10
+ classMergeKey,
11
+ mergeInstanceClasses,
12
+ stripClassOverriddenStyleProps,
10
13
  } from './utilityClassMapper';
11
14
  import { getStyleValue, clearRegistry } from './styleValueRegistry';
12
15
  import { defaultTailwindThemeVariables } from './tailwindThemeScale';
@@ -986,4 +989,81 @@ describe('utilityClassMapper', () => {
986
989
  expect(styleHasMapping(undefined)).toBe(false);
987
990
  });
988
991
  });
992
+
993
+ describe('classMergeKey', () => {
994
+ test('same (breakpoint, property) → same key across value forms', () => {
995
+ expect(classMergeKey('max-w-full')).toBe(classMergeKey('max-w-[283px]'));
996
+ expect(classMergeKey('p-4')).toBe(classMergeKey('p-[13px]'));
997
+ });
998
+
999
+ test('breakpoint forms normalize to one key (max-lg / legacy tablet / bare lg)', () => {
1000
+ const key = classMergeKey('max-lg:p-4');
1001
+ expect(classMergeKey('tablet:p-[10px]')).toBe(key);
1002
+ expect(classMergeKey('lg:p-2.5')).toBe(key);
1003
+ expect(classMergeKey('p-4')).not.toBe(key); // base ≠ tablet
1004
+ });
1005
+
1006
+ test('bare color-token class recovers the property from its root', () => {
1007
+ expect(classMergeKey('bg-accent')).toBe(classMergeKey('bg-[var(--gray-200)]'));
1008
+ expect(classMergeKey('text-muted')).toBe(classMergeKey('text-[#ff0000]'));
1009
+ });
1010
+
1011
+ test('unrecognized classes (element hashes, foreign) yield null', () => {
1012
+ expect(classMergeKey('m_hero_ab12cd')).toBeNull();
1013
+ expect(classMergeKey('js-toggle')).toBeNull();
1014
+ });
1015
+ });
1016
+
1017
+ describe('mergeInstanceClasses', () => {
1018
+ test('instance class drops the root class for the same property', () => {
1019
+ expect(mergeInstanceClasses(['max-w-full', 'flex'], 'max-w-[283px]')).toEqual(['flex', 'max-w-[283px]']);
1020
+ });
1021
+
1022
+ test('conflicts are per-breakpoint — root tablet override survives a base instance class', () => {
1023
+ expect(mergeInstanceClasses(['max-w-full', 'max-lg:max-w-[90%]'], 'max-w-[283px]')).toEqual([
1024
+ 'max-lg:max-w-[90%]',
1025
+ 'max-w-[283px]',
1026
+ ]);
1027
+ });
1028
+
1029
+ test('null-key classes on either side are always kept', () => {
1030
+ expect(mergeInstanceClasses(['m_hero_ab12cd', 'p-4'], 'p-[24px] js-x')).toEqual([
1031
+ 'm_hero_ab12cd',
1032
+ 'p-[24px]',
1033
+ 'js-x',
1034
+ ]);
1035
+ });
1036
+
1037
+ test('empty instance string is a no-op', () => {
1038
+ expect(mergeInstanceClasses(['p-4'], '')).toEqual(['p-4']);
1039
+ });
1040
+ });
1041
+
1042
+ describe('stripClassOverriddenStyleProps', () => {
1043
+ test('drops the flat style property an instance class overrides', () => {
1044
+ expect(stripClassOverriddenStyleProps({ maxWidth: '100%', display: 'flex' }, 'max-w-[283px]')).toEqual({
1045
+ display: 'flex',
1046
+ });
1047
+ });
1048
+
1049
+ test('drops per-breakpoint: base class strips base only, tablet override survives', () => {
1050
+ expect(
1051
+ stripClassOverriddenStyleProps({ base: { maxWidth: '100%' }, tablet: { maxWidth: '90%' } }, 'max-w-[283px]'),
1052
+ ).toEqual({ tablet: { maxWidth: '90%' } });
1053
+ });
1054
+
1055
+ test('unresolved bindings are stripped by property key too', () => {
1056
+ const mapping = { _mapping: true as const, prop: 'maxWidth', values: { narrow: '283px' } };
1057
+ expect(stripClassOverriddenStyleProps({ maxWidth: mapping, color: 'red' }, 'max-w-[283px]')).toEqual({
1058
+ color: 'red',
1059
+ });
1060
+ });
1061
+
1062
+ test('returns the same object when nothing conflicts, null when nothing remains', () => {
1063
+ const style = { maxWidth: '100%' };
1064
+ expect(stripClassOverriddenStyleProps(style, 'p-[24px]')).toBe(style);
1065
+ expect(stripClassOverriddenStyleProps(style, 'max-w-[283px]')).toBeNull();
1066
+ expect(stripClassOverriddenStyleProps(null, 'max-w-[283px]')).toBeNull();
1067
+ });
1068
+ });
989
1069
  });