meno-core 1.1.3 → 1.1.5
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-3JXK2QFU.js → chunk-H6EOTPJA.js} +2 -2
- package/dist/chunks/{chunk-LOZL5HOF.js → chunk-RLBV2R6J.js} +603 -17
- package/dist/chunks/chunk-RLBV2R6J.js.map +7 -0
- package/dist/chunks/{chunk-FQBIC2OB.js → chunk-YMBUSHII.js} +1 -1
- package/dist/chunks/{chunk-FQBIC2OB.js.map → chunk-YMBUSHII.js.map} +1 -1
- package/dist/lib/client/index.js +46 -28
- package/dist/lib/client/index.js.map +2 -2
- package/dist/lib/server/index.js +1304 -241
- package/dist/lib/server/index.js.map +4 -4
- package/dist/lib/shared/index.js +4 -2
- package/dist/lib/shared/index.js.map +1 -1
- package/lib/client/core/ComponentBuilder.test.ts +32 -0
- package/lib/client/core/ComponentBuilder.ts +30 -0
- package/lib/client/theme.test.ts +2 -2
- package/lib/client/theme.ts +28 -26
- package/lib/server/createServer.ts +5 -1
- package/lib/server/cssAudit.test.ts +270 -0
- package/lib/server/cssAudit.ts +815 -0
- package/lib/server/deMirror.test.ts +61 -0
- package/lib/server/deMirror.ts +317 -0
- package/lib/server/fileWatcher.test.ts +113 -0
- package/lib/server/fileWatcher.ts +42 -1
- package/lib/server/index.ts +19 -0
- package/lib/server/routes/api/core-routes.ts +18 -0
- package/lib/server/routes/api/cssAudit.test.ts +101 -0
- package/lib/server/routes/api/cssAudit.ts +138 -0
- package/lib/server/routes/api/deMirror.test.ts +105 -0
- package/lib/server/routes/api/deMirror.ts +127 -0
- package/lib/server/routes/api/pages.ts +2 -2
- package/lib/server/services/componentService.test.ts +43 -0
- package/lib/server/services/componentService.ts +49 -12
- package/lib/server/services/configService.ts +0 -5
- package/lib/server/services/pageService.test.ts +15 -0
- package/lib/server/services/pageService.ts +44 -22
- package/lib/server/ssr/htmlGenerator.test.ts +29 -1
- package/lib/server/ssr/htmlGenerator.ts +86 -14
- package/lib/server/themeCssCodec.test.ts +67 -0
- package/lib/server/themeCssCodec.ts +67 -5
- package/lib/server/webflow/templateWrapper.ts +54 -0
- package/lib/shared/cssGeneration.test.ts +64 -0
- package/lib/shared/cssGeneration.ts +6 -1
- package/lib/shared/interfaces/contentProvider.ts +9 -0
- package/lib/shared/tailwindThemeScale.ts +1 -0
- package/lib/shared/types/cms.ts +1 -0
- package/lib/shared/types/comment.ts +9 -3
- package/lib/shared/utilityClassConfig.ts +471 -10
- package/lib/shared/utilityClassMapper.test.ts +173 -2
- package/lib/shared/utilityClassMapper.ts +37 -0
- package/lib/shared/utilityClassNames.ts +164 -0
- package/lib/shared/utils/fileUtils.ts +11 -0
- package/lib/shared/validation/schemas.ts +1 -0
- package/lib/shared/viewportUnits.test.ts +34 -1
- package/lib/shared/viewportUnits.ts +43 -0
- package/package.json +3 -1
- package/vite.config.ts +4 -1
- package/dist/chunks/chunk-LOZL5HOF.js.map +0 -7
- /package/dist/chunks/{chunk-3JXK2QFU.js.map → chunk-H6EOTPJA.js.map} +0 -0
|
@@ -7,11 +7,11 @@
|
|
|
7
7
|
*/
|
|
8
8
|
|
|
9
9
|
import { existsSync, readdirSync, mkdirSync, rmdirSync } from 'node:fs';
|
|
10
|
-
import { join } from 'node:path';
|
|
10
|
+
import { join, relative, sep } from 'node:path';
|
|
11
11
|
import type { PageCache } from '../pageCache';
|
|
12
12
|
import { parseJSON } from '../jsonLoader';
|
|
13
13
|
import { buildLineMap } from '../utils/jsonLineMapper';
|
|
14
|
-
import { projectPaths } from '../projectContext';
|
|
14
|
+
import { projectPaths, getProjectRoot } from '../projectContext';
|
|
15
15
|
import { logRuntimeError } from '../../shared/errorLogger';
|
|
16
16
|
import { isPathWithinRoot } from '../../shared/pathSecurity';
|
|
17
17
|
import type { JSONPage } from '../../shared/types';
|
|
@@ -64,6 +64,13 @@ export class PageService {
|
|
|
64
64
|
const lineMap = buildLineMap(content);
|
|
65
65
|
this.pageCache.set(path, content, lineMap);
|
|
66
66
|
}
|
|
67
|
+
// Reconcile: drop cached paths the provider no longer reports. Without this, a page whose
|
|
68
|
+
// path CHANGES on disk (a rename, or a folder-index file `blog/index.astro` that now maps
|
|
69
|
+
// to `/blog` instead of `/blog/index`) leaves its old key behind as a ghost entry — it
|
|
70
|
+
// keeps showing up in the page picker until the process restarts.
|
|
71
|
+
for (const path of this.pageCache.keys()) {
|
|
72
|
+
if (!pages.has(path)) this.pageCache.delete(path);
|
|
73
|
+
}
|
|
67
74
|
}
|
|
68
75
|
|
|
69
76
|
/**
|
|
@@ -241,30 +248,45 @@ export class PageService {
|
|
|
241
248
|
*
|
|
242
249
|
* @example
|
|
243
250
|
* ```typescript
|
|
244
|
-
* const pages = pageService.getAllPagesWithInfo();
|
|
251
|
+
* const pages = await pageService.getAllPagesWithInfo();
|
|
245
252
|
* // [{ path: "/", isDraft: false }, { path: "/about", isDraft: true }]
|
|
246
253
|
* ```
|
|
247
254
|
*/
|
|
248
|
-
getAllPagesWithInfo():
|
|
255
|
+
async getAllPagesWithInfo(): Promise<
|
|
256
|
+
Array<{ path: string; isDraft: boolean; folder?: string; source?: 'cms' | 'ssr'; sourcePath?: string }>
|
|
257
|
+
> {
|
|
249
258
|
const paths = this.pageCache.keys().filter((p) => !p.startsWith('/_sketch/'));
|
|
250
|
-
return
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
path
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
259
|
+
return Promise.all(
|
|
260
|
+
paths.map(async (path) => {
|
|
261
|
+
const pageData = this.getPageData(path);
|
|
262
|
+
// Extract folder from path: /blog/post -> blog, /templates/blog-post -> templates
|
|
263
|
+
// Root-level pages (e.g., /, /about) have no folder
|
|
264
|
+
const pathWithoutSlash = path === '/' ? 'index' : path.substring(1);
|
|
265
|
+
const slashIndex = pathWithoutSlash.lastIndexOf('/');
|
|
266
|
+
const folder = slashIndex > 0 ? pathWithoutSlash.substring(0, slashIndex) : undefined;
|
|
267
|
+
// Surface a non-static page source (`cms` template / `ssr` live page) so the editor's
|
|
268
|
+
// page picker can group them apart from plain static pages. Static/undefined omitted.
|
|
269
|
+
const metaSource = pageData?.meta?.source;
|
|
270
|
+
const source = metaSource === 'cms' || metaSource === 'ssr' ? metaSource : undefined;
|
|
271
|
+
// For template pages the logical editor path (`/templates/<col>`) diverges from the
|
|
272
|
+
// on-disk file (`src/pages/<routeDir>/[slug].astro`), so a naive `<pagesDir>/<path>`
|
|
273
|
+
// mapping opens the wrong (nonexistent) file. Ask the provider to resolve the real
|
|
274
|
+
// source file and surface it project-root-relative so "Open source file" targets it.
|
|
275
|
+
// Only resolved for templates — normal pages' paths map directly.
|
|
276
|
+
let sourcePath: string | undefined;
|
|
277
|
+
if (source === 'cms' && this.provider?.resolveSourcePath) {
|
|
278
|
+
const abs = await this.provider.resolveSourcePath(path);
|
|
279
|
+
if (abs) sourcePath = relative(getProjectRoot(), abs).split(sep).join('/');
|
|
280
|
+
}
|
|
281
|
+
return {
|
|
282
|
+
path,
|
|
283
|
+
isDraft: pageData?.meta?.draft === true,
|
|
284
|
+
...(folder ? { folder } : {}),
|
|
285
|
+
...(source ? { source } : {}),
|
|
286
|
+
...(sourcePath ? { sourcePath } : {}),
|
|
287
|
+
};
|
|
288
|
+
}),
|
|
289
|
+
);
|
|
268
290
|
}
|
|
269
291
|
|
|
270
292
|
/**
|
|
@@ -93,7 +93,6 @@ const mockConfigService = {
|
|
|
93
93
|
getLibraries: mock(() => undefined),
|
|
94
94
|
getResponsiveScales: mock(() => ({ enabled: false, baseReference: 16 })),
|
|
95
95
|
getCustomCode: mock(() => ({})),
|
|
96
|
-
getShowMenoBadge: mock(() => false),
|
|
97
96
|
getRemConversion: mock(() => ({ enabled: false, baseFontSize: 16 })),
|
|
98
97
|
};
|
|
99
98
|
|
|
@@ -314,6 +313,35 @@ describe('generateSSRHTML', () => {
|
|
|
314
313
|
expect(result).toContain('.my-component { color: red; }');
|
|
315
314
|
});
|
|
316
315
|
|
|
316
|
+
// A page can `import '../styles/extra.css'` in its .astro frontmatter — foreign
|
|
317
|
+
// passthrough the real Astro build bundles. These assert the meno-core SSR canvas
|
|
318
|
+
// (Meno Select) surfaces the same imports so styling doesn't vanish in select mode.
|
|
319
|
+
test('links a remote CSS import captured in frontmatter passthrough', async () => {
|
|
320
|
+
const page = { ...minimalPage, _frontmatter: "import 'https://cdn.example.com/extra.css';" };
|
|
321
|
+
const result = (await generateSSRHTML(page)) as string;
|
|
322
|
+
expect(result).toContain('<link rel="stylesheet" href="https://cdn.example.com/extra.css">');
|
|
323
|
+
// In the head, after the generated stylesheet (so it can override utilities).
|
|
324
|
+
expect(result.indexOf('id="meno-styles"')).toBeLessThan(result.indexOf('cdn.example.com/extra.css'));
|
|
325
|
+
});
|
|
326
|
+
|
|
327
|
+
test('ignores the regenerated theme.css import', async () => {
|
|
328
|
+
const page = { ...minimalPage, _frontmatter: "import '../styles/theme.css';" };
|
|
329
|
+
const result = (await generateSSRHTML(page)) as string;
|
|
330
|
+
expect(result).not.toContain('data-meno-page-css');
|
|
331
|
+
});
|
|
332
|
+
|
|
333
|
+
test('gracefully skips an unresolvable local CSS import (no crash, no tag)', async () => {
|
|
334
|
+
const page = { ...minimalPage, _frontmatter: "import '../styles/definitely-missing.css';" };
|
|
335
|
+
const result = (await generateSSRHTML(page)) as string;
|
|
336
|
+
expect(result).toStartWith('<!DOCTYPE html>');
|
|
337
|
+
expect(result).not.toContain('definitely-missing.css');
|
|
338
|
+
});
|
|
339
|
+
|
|
340
|
+
test('injects no page-CSS tags when the page has no frontmatter passthrough', async () => {
|
|
341
|
+
const result = (await generateSSRHTML(minimalPage)) as string;
|
|
342
|
+
expect(result).not.toContain('data-meno-page-css');
|
|
343
|
+
});
|
|
344
|
+
|
|
317
345
|
test('should minify CSS in production mode (useBuiltBundle=true)', async () => {
|
|
318
346
|
mockRenderPageSSR.mockImplementation(async () => ({
|
|
319
347
|
html: '<div>Content</div>',
|
|
@@ -18,7 +18,7 @@ import {
|
|
|
18
18
|
extractUtilityClassesFromHTML,
|
|
19
19
|
generateAllInteractiveCSS,
|
|
20
20
|
} from '../../shared/cssGeneration';
|
|
21
|
-
import {
|
|
21
|
+
import { rewriteViewportUnitsInStylesheet } from '../../shared/viewportUnits';
|
|
22
22
|
import { printMissingStyleWarnings } from '../validateStyleCoverage';
|
|
23
23
|
import { formHandlerScript, needsFormHandler } from '../../client/scripts/formHandler';
|
|
24
24
|
import { menoFilterScript, needsMenoFilter } from '../../client/meno-filter/script.generated';
|
|
@@ -34,6 +34,7 @@ import { renderPageSSR, type PreloadImage } from './ssrRenderer';
|
|
|
34
34
|
import type { CMSContext } from './cmsSSRProcessor';
|
|
35
35
|
import { resolveProjectPath } from '../projectContext';
|
|
36
36
|
import { readTextFile } from '../runtime/fs';
|
|
37
|
+
import { posix } from 'node:path';
|
|
37
38
|
|
|
38
39
|
/**
|
|
39
40
|
* Generate image preload link tags for high-priority images
|
|
@@ -84,6 +85,79 @@ function minifyCSS(code: string): string {
|
|
|
84
85
|
);
|
|
85
86
|
}
|
|
86
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
|
+
|
|
108
|
+
const seen = new Set<string>();
|
|
109
|
+
const specs: string[] = [];
|
|
110
|
+
const re = /import\b[^'"]*['"]([^'"]+\.css)(?:\?[^'"]*)?['"]/g;
|
|
111
|
+
let m: RegExpExecArray | null;
|
|
112
|
+
// biome-ignore lint/suspicious/noAssignInExpressions: standard regex exec loop
|
|
113
|
+
while ((m = re.exec(frontmatter)) !== null) {
|
|
114
|
+
const spec = m[1];
|
|
115
|
+
if (!spec || seen.has(spec)) continue;
|
|
116
|
+
seen.add(spec);
|
|
117
|
+
if (spec.endsWith('styles/theme.css')) continue; // regenerated by the color/variable services
|
|
118
|
+
specs.push(spec);
|
|
119
|
+
}
|
|
120
|
+
if (specs.length === 0) return '';
|
|
121
|
+
|
|
122
|
+
// The page's source directory, reconstructed from its URL path
|
|
123
|
+
// (src/pages/<dir>). dirname('/') and dirname('/about') both yield the pages root.
|
|
124
|
+
const pageDir = posix.join('src/pages', posix.dirname(pagePath || '/'));
|
|
125
|
+
|
|
126
|
+
const tags: string[] = [];
|
|
127
|
+
for (const spec of specs) {
|
|
128
|
+
// Remote stylesheet — link it directly (can't inline what we can't read).
|
|
129
|
+
if (/^(?:https?:)?\/\//.test(spec)) {
|
|
130
|
+
tags.push(`<link rel="stylesheet" href="${escapeHtml(spec)}">`);
|
|
131
|
+
continue;
|
|
132
|
+
}
|
|
133
|
+
// 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;
|
|
136
|
+
// 2. leading traversal stripped, anchored at src/ — robust for the dominant
|
|
137
|
+
// `../styles/*.css` pattern even on localized `[locale]` routes where
|
|
138
|
+
// pagePath doesn't map 1:1 to the source file's depth.
|
|
139
|
+
const candidates = spec.startsWith('/')
|
|
140
|
+
? [spec.slice(1)]
|
|
141
|
+
: [posix.normalize(posix.join(pageDir, spec)), `src/${spec.replace(/^(?:\.\.?\/)+/, '')}`];
|
|
142
|
+
let css: string | null = null;
|
|
143
|
+
for (const rel of candidates) {
|
|
144
|
+
if (rel.startsWith('..')) continue; // never read outside the project root
|
|
145
|
+
try {
|
|
146
|
+
css = await readTextFile(resolveProjectPath(rel));
|
|
147
|
+
break;
|
|
148
|
+
} catch {
|
|
149
|
+
/* try the next candidate */
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
if (css == null) continue;
|
|
153
|
+
// Stabilize viewport units for the design canvas (same as the generated sheet)
|
|
154
|
+
// and neutralize any literal `</style>` so it can't break out of the tag.
|
|
155
|
+
const content = rewriteViewportUnitsInStylesheet(css).replace(/<\/style/gi, '<\\/style');
|
|
156
|
+
tags.push(`<style${nonceAttr} data-meno-page-css="${escapeHtml(spec)}">${content}</style>`);
|
|
157
|
+
}
|
|
158
|
+
return tags.join('\n ');
|
|
159
|
+
}
|
|
160
|
+
|
|
87
161
|
/**
|
|
88
162
|
* Result of SSR HTML generation with separate JS for CSP compliance
|
|
89
163
|
*/
|
|
@@ -320,18 +394,10 @@ export async function generateSSRHTML(
|
|
|
320
394
|
await configService.load();
|
|
321
395
|
const globalLibraries = configService.getLibraries() || { js: [], css: [] };
|
|
322
396
|
const globalCustomCode = configService.getCustomCode();
|
|
323
|
-
// The Meno badge previously used inline event handlers (onmouseenter /
|
|
324
|
-
// onmouseleave) to drive its opacity hover, which CSP with `'nonce-…'` and
|
|
325
|
-
// no `'unsafe-inline'` rejects. Move the hover to a stylesheet rule using a
|
|
326
|
-
// dedicated class so nonces aren't needed for this purely-presentational
|
|
327
|
-
// effect.
|
|
328
|
-
const menoBadgeHtml = configService.getShowMenoBadge()
|
|
329
|
-
? `<style${nonceAttr}>.meno-badge{position:fixed;bottom:12px;left:12px;z-index:9999;background:#000;color:#fff;padding:4px 10px;border-radius:6px;font-size:12px;font-family:system-ui,sans-serif;text-decoration:none;opacity:0.8;transition:opacity 0.2s}.meno-badge:hover,.meno-badge:focus{opacity:1}</style><a class="meno-badge" href="https://meno.so" target="_blank" rel="noopener">Made in Meno</a>`
|
|
330
|
-
: '';
|
|
331
397
|
const mergedCustomCode = {
|
|
332
398
|
head: [globalCustomCode.head, pageCustomCode?.head].filter(Boolean).join('\n'),
|
|
333
399
|
bodyStart: [globalCustomCode.bodyStart, pageCustomCode?.bodyStart].filter(Boolean).join('\n'),
|
|
334
|
-
bodyEnd: [globalCustomCode.bodyEnd, pageCustomCode?.bodyEnd
|
|
400
|
+
bodyEnd: [globalCustomCode.bodyEnd, pageCustomCode?.bodyEnd].filter(Boolean).join('\n'),
|
|
335
401
|
};
|
|
336
402
|
const componentLibraries = collectComponentLibraries(components, pageData.components || {});
|
|
337
403
|
// Merge global + component first (component libraries always extend)
|
|
@@ -540,7 +606,7 @@ picture {
|
|
|
540
606
|
// canvas can pin viewport units to a stable height (avoids the
|
|
541
607
|
// iframe.height ↔ 100vh feedback loop). var() fallback to 1vh means this
|
|
542
608
|
// is a no-op in production / page mode where --design-vh is unset.
|
|
543
|
-
const cssWithStableViewport =
|
|
609
|
+
const cssWithStableViewport = rewriteViewportUnitsInStylesheet(combinedCSS);
|
|
544
610
|
|
|
545
611
|
// Minify CSS in production mode
|
|
546
612
|
const finalCSS = useBundled ? minifyCSS(cssWithStableViewport) : cssWithStableViewport;
|
|
@@ -661,24 +727,30 @@ picture {
|
|
|
661
727
|
// HTML. Without it, nonce-only `script-src` would drop the recreated script
|
|
662
728
|
// and CMS hot-reload (updated `window.__MENO_CMS__` data) would silently fail.
|
|
663
729
|
const liveReloadScript = injectLiveReload
|
|
664
|
-
? `<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 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){
|
|
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>`
|
|
665
731
|
: '';
|
|
666
732
|
|
|
667
733
|
// Scroll position handlers for preview mode iframe switching
|
|
668
734
|
const scrollHandlerScript = injectLiveReload
|
|
669
|
-
? `<script${nonceAttr}>(function(){window.addEventListener('message',function(e){if(e.data.type==='GET_SCROLL_POSITION'){window.parent.postMessage({type:'SCROLL_POSITION_RESPONSE',scrollX:window.scrollX,scrollY:window.scrollY},'*')}else if(e.data.type==='SET_SCROLL_POSITION'){window.scrollTo(e.data.scrollX,e.data.scrollY)}})})()</script>`
|
|
735
|
+
? `<script${nonceAttr}>(function(){window.addEventListener('message',function(e){if(e.data.type==='GET_SCROLL_POSITION'){window.parent.postMessage({type:'SCROLL_POSITION_RESPONSE',scrollX:window.scrollX,scrollY:window.scrollY},'*')}else if(e.data.type==='SET_SCROLL_POSITION'){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';window.scrollTo(e.data.scrollX,e.data.scrollY);de.style.scrollBehavior=pd;if(b)b.style.scrollBehavior=pb}})})()</script>`
|
|
670
736
|
: '';
|
|
671
737
|
|
|
672
738
|
// In production, output minified CSS on single line; in dev, preserve formatting
|
|
673
739
|
const styleContent = useBundled ? finalCSS : `\n ${combinedCSS.split('\n').join('\n ')}\n `;
|
|
674
740
|
|
|
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);
|
|
746
|
+
|
|
675
747
|
const htmlDocument = `<!DOCTYPE html>
|
|
676
748
|
<html lang="${rendered.locale}" theme="${themeConfig.default}">
|
|
677
749
|
<head>
|
|
678
750
|
<meta charset="UTF-8">
|
|
679
751
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
|
680
752
|
${cspNonce ? `<meta name="csp-nonce" content="${cspNonce}">\n ` : ''}${iconTags ? `${iconTags}\n ` : ''}${scriptPreloadTag ? `${scriptPreloadTag}\n ` : ''}${imagePreloadTags ? `${imagePreloadTags}\n ` : ''}${fontPreloadTags ? `${fontPreloadTags}\n ` : ''}${libraryTags.headCSS ? `${libraryTags.headCSS}\n ` : ''}${libraryTags.headJS ? `${libraryTags.headJS}\n ` : ''}${rendered.meta}
|
|
681
|
-
${configInlineScript}${cmsInlineScript}${clientDataScripts}<style id="meno-styles"${nonceAttr}>${styleContent}</style>${mergedCustomCode.head ? `\n ${mergedCustomCode.head}` : ''}
|
|
753
|
+
${configInlineScript}${cmsInlineScript}${clientDataScripts}<style id="meno-styles"${nonceAttr}>${styleContent}</style>${pageCssTags ? `\n ${pageCssTags}` : ''}${mergedCustomCode.head ? `\n ${mergedCustomCode.head}` : ''}
|
|
682
754
|
</head>
|
|
683
755
|
<body>${mergedCustomCode.bodyStart ? `\n ${mergedCustomCode.bodyStart}` : ''}
|
|
684
756
|
<div id="root">
|
|
@@ -47,6 +47,73 @@ describe('parseThemeCss', () => {
|
|
|
47
47
|
expect(pad).toMatchObject({ value: '64px', group: 'padding', type: 'padding' });
|
|
48
48
|
});
|
|
49
49
|
|
|
50
|
+
test('an unrecognized section header does not cascade into colors', () => {
|
|
51
|
+
// Hand-written / AI-authored theme.css using non-vocabulary headers ("Brand",
|
|
52
|
+
// "Typography", "Radius"). Regression: these used to inherit the preceding
|
|
53
|
+
// `Colors` section, so font/size/radius tokens were misfiled as colors.
|
|
54
|
+
const css = `:root {
|
|
55
|
+
/* Colors: light */
|
|
56
|
+
--text: #262626;
|
|
57
|
+
--primary: #ff5e1f;
|
|
58
|
+
|
|
59
|
+
/* Brand */
|
|
60
|
+
--accent: #f6821f;
|
|
61
|
+
|
|
62
|
+
/* Typography */
|
|
63
|
+
--font-sans: "Inter", sans-serif;
|
|
64
|
+
--leading-tight: 1.05;
|
|
65
|
+
|
|
66
|
+
/* Radius */
|
|
67
|
+
--rounded-full: 9999px;
|
|
68
|
+
}`;
|
|
69
|
+
const model = parseThemeCss(css, BREAKPOINTS);
|
|
70
|
+
// Color-valued tokens (incl. those under "Brand") still land as colors via the heuristic.
|
|
71
|
+
expect(model.themes.themes.light?.colors).toEqual({
|
|
72
|
+
text: '#262626',
|
|
73
|
+
primary: '#ff5e1f',
|
|
74
|
+
accent: '#f6821f',
|
|
75
|
+
});
|
|
76
|
+
// Non-color tokens under unrecognized headers must NOT be colors.
|
|
77
|
+
const colorNames = Object.keys(model.themes.themes.light?.colors ?? {});
|
|
78
|
+
expect(colorNames).not.toContain('font-sans');
|
|
79
|
+
expect(colorNames).not.toContain('leading-tight');
|
|
80
|
+
expect(colorNames).not.toContain('rounded-full');
|
|
81
|
+
// They become variables instead (group is inferred; exact group is best-effort).
|
|
82
|
+
const varNames = model.variables.map((v) => v.cssVar);
|
|
83
|
+
expect(varNames).toContain('--font-sans');
|
|
84
|
+
expect(varNames).toContain('--leading-tight');
|
|
85
|
+
expect(varNames).toContain('--rounded-full');
|
|
86
|
+
});
|
|
87
|
+
|
|
88
|
+
test('decorated section header resolves to its canonical group', () => {
|
|
89
|
+
// AI-authored theme.css: the header carries a descriptive note after the
|
|
90
|
+
// canonical label. Regression: strict equality dropped these into 'other',
|
|
91
|
+
// so `--tracking-*` never appeared in the letter-spacing variable picker.
|
|
92
|
+
const css = `:root {
|
|
93
|
+
/* Letter-spacing — negative on display type is the Stripe signature */
|
|
94
|
+
--tracking-tighter: -0.03em;
|
|
95
|
+
--tracking-tight: -0.02em;
|
|
96
|
+
|
|
97
|
+
/* Font Size (display scale) */
|
|
98
|
+
--h1-size: 48px;
|
|
99
|
+
}`;
|
|
100
|
+
const model = parseThemeCss(css, BREAKPOINTS);
|
|
101
|
+
const tighter = model.variables.find((v) => v.cssVar === '--tracking-tighter');
|
|
102
|
+
expect(tighter).toMatchObject({ value: '-0.03em', group: 'letter-spacing' });
|
|
103
|
+
const h1 = model.variables.find((v) => v.cssVar === '--h1-size');
|
|
104
|
+
expect(h1).toMatchObject({ value: '48px', group: 'font-size' });
|
|
105
|
+
});
|
|
106
|
+
|
|
107
|
+
test('tracking-named token infers letter-spacing group without a section header', () => {
|
|
108
|
+
// No comment section: the name idiom (`tracking`) drives the group.
|
|
109
|
+
const css = `:root {
|
|
110
|
+
--tracking-wide: 0.08em;
|
|
111
|
+
}`;
|
|
112
|
+
const model = parseThemeCss(css, BREAKPOINTS);
|
|
113
|
+
const wide = model.variables.find((v) => v.cssVar === '--tracking-wide');
|
|
114
|
+
expect(wide).toMatchObject({ value: '0.08em', group: 'letter-spacing' });
|
|
115
|
+
});
|
|
116
|
+
|
|
50
117
|
test('override @media maps to per-variable scales; auto region is discarded', () => {
|
|
51
118
|
const css = `:root {
|
|
52
119
|
/* Font Size */
|
|
@@ -67,11 +67,52 @@ function sectionLabelForGroup(group?: VariableGroup): string {
|
|
|
67
67
|
return VARIABLE_GROUPS.find((g) => g.value === group)?.label ?? OTHER_LABEL;
|
|
68
68
|
}
|
|
69
69
|
|
|
70
|
-
|
|
70
|
+
/**
|
|
71
|
+
* Normalize a section header for matching: lowercase, hyphens/dashes → spaces
|
|
72
|
+
* (so `Letter-spacing` ≡ `Letter Spacing`), whitespace collapsed. This is what
|
|
73
|
+
* lets a hand-/AI-written header like `Letter-spacing — negative on display type`
|
|
74
|
+
* still resolve to the canonical `letter-spacing` group.
|
|
75
|
+
*/
|
|
76
|
+
function normalizeSectionLabel(s: string): string {
|
|
77
|
+
return s
|
|
78
|
+
.trim()
|
|
79
|
+
.toLowerCase()
|
|
80
|
+
.replace(/[-–—]+/g, ' ')
|
|
81
|
+
.replace(/\s+/g, ' ')
|
|
82
|
+
.trim();
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
const NORMALIZED_LABEL_TO_GROUP = new Map<string, VariableGroup>(
|
|
86
|
+
VARIABLE_GROUPS.map((g) => [normalizeSectionLabel(g.label), g.value]),
|
|
87
|
+
);
|
|
71
88
|
|
|
72
|
-
/**
|
|
89
|
+
/**
|
|
90
|
+
* Non-`other` canonical labels, longest first — so a decorated header is matched
|
|
91
|
+
* against the most specific label as a prefix (`Border Radius …` beats a shorter
|
|
92
|
+
* hypothetical `Border …`).
|
|
93
|
+
*/
|
|
94
|
+
const PREFIX_LABELS: { norm: string; group: VariableGroup }[] = VARIABLE_GROUPS.filter((g) => g.value !== 'other')
|
|
95
|
+
.map((g) => ({ norm: normalizeSectionLabel(g.label), group: g.value }))
|
|
96
|
+
.sort((a, b) => b.norm.length - a.norm.length);
|
|
97
|
+
|
|
98
|
+
/**
|
|
99
|
+
* Reverse of {@link sectionLabelForGroup}: a section header back to its group, or null.
|
|
100
|
+
* Tolerant of decorated headers — an exact canonical label wins, otherwise a header
|
|
101
|
+
* that *begins* with a canonical label followed by a word boundary (`Letter Spacing —
|
|
102
|
+
* negative on display type`, `Font Size (display)`) still resolves. A label embedded
|
|
103
|
+
* mid-header does NOT match (avoids false positives like `Sizes & Spacing`).
|
|
104
|
+
*/
|
|
73
105
|
export function groupForSectionLabel(label: string): VariableGroup | null {
|
|
74
|
-
|
|
106
|
+
const norm = normalizeSectionLabel(label);
|
|
107
|
+
const exact = NORMALIZED_LABEL_TO_GROUP.get(norm);
|
|
108
|
+
if (exact) return exact;
|
|
109
|
+
for (const { norm: labelNorm, group } of PREFIX_LABELS) {
|
|
110
|
+
if (norm.startsWith(labelNorm)) {
|
|
111
|
+
const next = norm.charAt(labelNorm.length);
|
|
112
|
+
if (next === '' || !/[a-z0-9]/.test(next)) return group;
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
return null;
|
|
75
116
|
}
|
|
76
117
|
|
|
77
118
|
/** True if a CSS value reads as a color (used to classify un-sectioned `:root` decls). */
|
|
@@ -83,6 +124,18 @@ export function isColorValue(value: string): boolean {
|
|
|
83
124
|
return false;
|
|
84
125
|
}
|
|
85
126
|
|
|
127
|
+
/**
|
|
128
|
+
* Variable-name idioms that map to a group but don't contain the group string —
|
|
129
|
+
* mostly Tailwind's own token names, which AI-generated theme.css files lean on.
|
|
130
|
+
* Matched on hyphen/underscore-delimited word boundaries so `--tracking-tight`
|
|
131
|
+
* hits but an unrelated `--x-radiusy` does not.
|
|
132
|
+
*/
|
|
133
|
+
const NAME_HINT_GROUPS: { needle: RegExp; group: VariableGroup }[] = [
|
|
134
|
+
{ needle: /(^|[-_])tracking([-_]|$)/, group: 'letter-spacing' },
|
|
135
|
+
{ needle: /(^|[-_])leading([-_]|$)/, group: 'line-height' },
|
|
136
|
+
{ needle: /(^|[-_])(radius|rounded)([-_]|$)/, group: 'border-radius' },
|
|
137
|
+
];
|
|
138
|
+
|
|
86
139
|
/**
|
|
87
140
|
* Best-effort group for a variable that carries no comment section (hand-written or
|
|
88
141
|
* legacy theme.css). px/rem tokens are inherently ambiguous (font-size vs padding vs
|
|
@@ -99,6 +152,11 @@ export function inferGroup(cssVar: string, value: string): VariableGroup {
|
|
|
99
152
|
if (/^\d*\.\d+$/.test(v) || /^[1-9](\.\d+)?$/.test(v)) return 'line-height';
|
|
100
153
|
// Last-ditch: a hint embedded in the variable name.
|
|
101
154
|
const name = cssVar.replace(/^--/, '').toLowerCase();
|
|
155
|
+
// Common Tailwind / AI naming that doesn't literally contain the group string
|
|
156
|
+
// (`--tracking-tight` is letter-spacing, `--leading-none` is line-height, …).
|
|
157
|
+
for (const { needle, group } of NAME_HINT_GROUPS) {
|
|
158
|
+
if (needle.test(name)) return group;
|
|
159
|
+
}
|
|
102
160
|
for (const g of VARIABLE_GROUPS) {
|
|
103
161
|
if (g.value !== 'other' && name.includes(g.value)) return g.value;
|
|
104
162
|
}
|
|
@@ -429,8 +487,12 @@ export function parseThemeCss(css: string, breakpoints?: BreakpointConfig): Them
|
|
|
429
487
|
const dn = inner.match(/^colors\s*:\s*([\w-]+)/i);
|
|
430
488
|
if (dn) defaultName = dn[1]!;
|
|
431
489
|
} else {
|
|
432
|
-
|
|
433
|
-
|
|
490
|
+
// Unrecognized header (e.g. a hand-written "Brand" / "Typography" label, or an
|
|
491
|
+
// inline note) resets the section to null rather than inheriting the previous
|
|
492
|
+
// one — otherwise everything after a `/* Colors */` block would silently cascade
|
|
493
|
+
// into colors. With section=null the per-decl value heuristic decides: color
|
|
494
|
+
// values stay colors, everything else becomes a variable (group via inferGroup).
|
|
495
|
+
section = groupForSectionLabel(inner);
|
|
434
496
|
}
|
|
435
497
|
continue;
|
|
436
498
|
}
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Webflow Designer Extension template wrapper.
|
|
3
|
+
*
|
|
4
|
+
* The Webflow Designer injects the `webflow` API global only when extension
|
|
5
|
+
* HTML is wrapped in a template fetched from `webflow-ext.com`. This module
|
|
6
|
+
* fetches the template once per process, caches it, then splices the
|
|
7
|
+
* extension's <head>/<body> content into the `{{ui}}` placeholder.
|
|
8
|
+
*
|
|
9
|
+
* Used by both the studio's mounted `/webflow-extension/*` route and the
|
|
10
|
+
* standalone serve.ts on port 1337, so they stay in sync automatically.
|
|
11
|
+
*
|
|
12
|
+
* NOTE: this is the ONLY survivor of the retired `core/lib/server/webflow/`
|
|
13
|
+
* tree (the Meno→Webflow exporter, removed in 0d11d0d5). It is direction-neutral
|
|
14
|
+
* — it only wraps extension HTML so the Designer injects the `webflow` global —
|
|
15
|
+
* so it is reused as-is by the Webflow→Meno import extension.
|
|
16
|
+
*/
|
|
17
|
+
|
|
18
|
+
import { readFile } from 'node:fs/promises';
|
|
19
|
+
|
|
20
|
+
let cachedTemplate: string | null = null;
|
|
21
|
+
|
|
22
|
+
async function getWebflowTemplate(appName: string): Promise<string> {
|
|
23
|
+
if (cachedTemplate) return cachedTemplate;
|
|
24
|
+
const url = `https://webflow-ext.com/template/v2?name=${encodeURIComponent(appName)}`;
|
|
25
|
+
const res = await fetch(url);
|
|
26
|
+
if (!res.ok) throw new Error(`Failed to fetch Webflow template: ${res.status}`);
|
|
27
|
+
cachedTemplate = await res.text();
|
|
28
|
+
return cachedTemplate;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* Wrap extension HTML in the Webflow Designer template.
|
|
33
|
+
* `manifestPath` points to the extension's `webflow.json` — its `name` field
|
|
34
|
+
* is forwarded as the `?name=` query param to webflow-ext.com.
|
|
35
|
+
*
|
|
36
|
+
* On any failure (manifest read, template fetch) we log a warning and return
|
|
37
|
+
* the raw HTML, matching the prior behavior of the two duplicate copies.
|
|
38
|
+
*/
|
|
39
|
+
export async function wrapInWebflowTemplate(html: string, manifestPath: string): Promise<string> {
|
|
40
|
+
try {
|
|
41
|
+
const manifest = JSON.parse(await readFile(manifestPath, 'utf-8'));
|
|
42
|
+
const template = await getWebflowTemplate(manifest.name || 'Export to Meno');
|
|
43
|
+
|
|
44
|
+
const headMatch = html.match(/<head[^>]*>([\s\S]*?)<\/head>/i);
|
|
45
|
+
const bodyMatch = html.match(/<body[^>]*>([\s\S]*?)<\/body>/i);
|
|
46
|
+
const headContent = headMatch?.[1] ?? '';
|
|
47
|
+
const bodyContent = bodyMatch?.[1] ?? '';
|
|
48
|
+
|
|
49
|
+
return template.replace('{{ui}}', headContent + bodyContent);
|
|
50
|
+
} catch (err: any) {
|
|
51
|
+
console.warn('Could not fetch Webflow wrapper template:', err.message);
|
|
52
|
+
return html;
|
|
53
|
+
}
|
|
54
|
+
}
|
|
@@ -150,6 +150,34 @@ describe('cssGeneration', () => {
|
|
|
150
150
|
expect(rule).toBe('border: 1px solid var(--primary);');
|
|
151
151
|
});
|
|
152
152
|
|
|
153
|
+
test('bare / numeric border widths render a visible shorthand (no Preflight)', () => {
|
|
154
|
+
// `border` alone would be invisible without a border-style, so it expands to `<width> solid`
|
|
155
|
+
// (color omitted → defaults to currentColor).
|
|
156
|
+
expect(generateRuleForClass('border')).toBe('border: 1px solid;');
|
|
157
|
+
expect(generateRuleForClass('border-2')).toBe('border: 2px solid;');
|
|
158
|
+
expect(generateRuleForClass('border-0')).toBe('border: 0px solid;');
|
|
159
|
+
});
|
|
160
|
+
|
|
161
|
+
test('bare / numeric per-side widths split into longhands, no color (a border-color class wins)', () => {
|
|
162
|
+
expect(generateRuleForClass('border-b')).toBe('border-bottom-width: 1px; border-bottom-style: solid;');
|
|
163
|
+
expect(generateRuleForClass('border-t-4')).toBe('border-top-width: 4px; border-top-style: solid;');
|
|
164
|
+
});
|
|
165
|
+
|
|
166
|
+
test('logical-axis border widths use border-inline / border-block', () => {
|
|
167
|
+
expect(generateRuleForClass('border-x')).toBe('border-inline: 1px solid;');
|
|
168
|
+
expect(generateRuleForClass('border-y-2')).toBe('border-block: 2px solid;');
|
|
169
|
+
});
|
|
170
|
+
|
|
171
|
+
test('border style keyword on its own', () => {
|
|
172
|
+
expect(generateRuleForClass('border-solid')).toBe('border-style: solid;');
|
|
173
|
+
expect(generateRuleForClass('border-dashed')).toBe('border-style: dashed;');
|
|
174
|
+
});
|
|
175
|
+
|
|
176
|
+
test('unsupported border forms stay unrecognized (foreign-safe)', () => {
|
|
177
|
+
expect(generateRuleForClass('border-gray-200')).toBeNull();
|
|
178
|
+
expect(generateRuleForClass('border-wrapper')).toBeNull();
|
|
179
|
+
});
|
|
180
|
+
|
|
153
181
|
test('border-color with named CSS color outputs directly', () => {
|
|
154
182
|
const rule = generateRuleForClass('border-[red]');
|
|
155
183
|
expect(rule).toBe('border-color: red;');
|
|
@@ -356,6 +384,42 @@ describe('cssGeneration', () => {
|
|
|
356
384
|
expect(generateRuleForClass('[text-overflow:ellipsis]')).toBe('text-overflow: ellipsis;');
|
|
357
385
|
expect(generateRuleForClass('[clip-path:circle(50%)]')).toBe('clip-path: circle(50%);');
|
|
358
386
|
});
|
|
387
|
+
|
|
388
|
+
test('filter functions wrap the value in their function', () => {
|
|
389
|
+
expect(generateRuleForClass('blur-[70px]')).toBe('filter: blur(70px);');
|
|
390
|
+
expect(generateRuleForClass('backdrop-blur-[8px]')).toBe('backdrop-filter: blur(8px);');
|
|
391
|
+
expect(generateRuleForClass('blur-(--glow)')).toBe('filter: blur(var(--glow));');
|
|
392
|
+
expect(generateRuleForClass('brightness-50')).toBe('filter: brightness(0.5);');
|
|
393
|
+
expect(generateRuleForClass('grayscale')).toBe('filter: grayscale(100%);');
|
|
394
|
+
expect(generateRuleForClass('hue-rotate-90')).toBe('filter: hue-rotate(90deg);');
|
|
395
|
+
expect(generateRuleForClass('drop-shadow-[0_4px_6px_#0003]')).toBe('filter: drop-shadow(0 4px 6px #0003);');
|
|
396
|
+
expect(generateRuleForClass('backdrop-opacity-50')).toBe('backdrop-filter: opacity(0.5);');
|
|
397
|
+
// Length named scale is deliberately not absorbed (no blur theme tokens).
|
|
398
|
+
expect(generateRuleForClass('blur-sm')).toBeNull();
|
|
399
|
+
});
|
|
400
|
+
|
|
401
|
+
test('per-axis transforms and side radius expand correctly', () => {
|
|
402
|
+
expect(generateRuleForClass('skew-x-[10deg]')).toBe('transform: skewX(10deg);');
|
|
403
|
+
expect(generateRuleForClass('scale-y-95')).toBe('scale: 1 0.95;');
|
|
404
|
+
expect(generateRuleForClass('rounded-t-lg')).toBe(
|
|
405
|
+
'border-top-left-radius: var(--radius-lg); border-top-right-radius: var(--radius-lg);',
|
|
406
|
+
);
|
|
407
|
+
expect(generateRuleForClass('rounded-l-[8px]')).toBe(
|
|
408
|
+
'border-top-left-radius: 8px; border-bottom-left-radius: 8px;',
|
|
409
|
+
);
|
|
410
|
+
});
|
|
411
|
+
|
|
412
|
+
test('new statics and viewport keywords', () => {
|
|
413
|
+
expect(generateRuleForClass('italic')).toBe('font-style: italic;');
|
|
414
|
+
expect(generateRuleForClass('text-nowrap')).toBe('white-space: nowrap;');
|
|
415
|
+
expect(generateRuleForClass('break-words')).toBe('overflow-wrap: break-word;');
|
|
416
|
+
expect(generateRuleForClass('isolate')).toBe('isolation: isolate;');
|
|
417
|
+
expect(generateRuleForClass('snap-x')).toBe('scroll-snap-type: x mandatory;');
|
|
418
|
+
expect(generateRuleForClass('mix-blend-multiply')).toBe('mix-blend-mode: multiply;');
|
|
419
|
+
expect(generateRuleForClass('columns-3')).toBe('columns: 3;');
|
|
420
|
+
expect(generateRuleForClass('h-dvh')).toBe('height: 100dvh;');
|
|
421
|
+
expect(generateRuleForClass('shadow-inner')).toBe('box-shadow: var(--shadow-inner);');
|
|
422
|
+
});
|
|
359
423
|
});
|
|
360
424
|
});
|
|
361
425
|
|