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.
- package/dist/chunks/{chunk-H6EOTPJA.js → chunk-GHNLTFCN.js} +44 -12
- package/dist/chunks/chunk-GHNLTFCN.js.map +7 -0
- package/dist/chunks/{chunk-RLBV2R6J.js → chunk-KX2LPIZZ.js} +68 -1
- package/dist/chunks/{chunk-RLBV2R6J.js.map → chunk-KX2LPIZZ.js.map} +2 -2
- package/dist/lib/client/index.js +56 -12
- package/dist/lib/client/index.js.map +2 -2
- package/dist/lib/server/index.js +85 -19
- package/dist/lib/server/index.js.map +3 -3
- package/dist/lib/shared/index.js +7 -1
- package/dist/lib/shared/index.js.map +1 -1
- package/lib/client/core/ComponentBuilder.test.ts +137 -0
- package/lib/client/core/ComponentBuilder.ts +52 -13
- package/lib/client/core/builders/embedBuilder.ts +15 -1
- package/lib/client/core/builders/linkNodeBuilder.ts +15 -1
- package/lib/client/core/builders/localeListBuilder.ts +15 -1
- package/lib/client/templateEngine.test.ts +106 -3
- package/lib/client/templateEngine.ts +50 -10
- package/lib/server/ssr/htmlGenerator.test.ts +105 -2
- package/lib/server/ssr/htmlGenerator.ts +92 -32
- package/lib/server/ssr/ssrRenderer.test.ts +100 -0
- package/lib/server/ssr/ssrRenderer.ts +59 -14
- package/lib/shared/templateStyleVars.ts +49 -0
- package/lib/shared/utilityClassMapper.test.ts +80 -0
- package/lib/shared/utilityClassMapper.ts +116 -0
- package/package.json +1 -1
- package/dist/chunks/chunk-H6EOTPJA.js.map +0 -7
|
@@ -85,26 +85,9 @@ function minifyCSS(code: string): string {
|
|
|
85
85
|
);
|
|
86
86
|
}
|
|
87
87
|
|
|
88
|
-
/**
|
|
89
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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(
|
|
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
|
|
742
|
-
// passthrough the Astro build bundles but the select canvas otherwise
|
|
743
|
-
// Placed after #meno-styles so it can override generated utilities,
|
|
744
|
-
// intent of a
|
|
745
|
-
|
|
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 {
|
|
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
|
|
1993
|
-
rootNode.attributes.class =
|
|
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
|
|
1998
|
-
rootNode.props.className =
|
|
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
|
});
|