meno-core 1.1.3 → 1.1.4

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.
Files changed (50) hide show
  1. package/dist/chunks/{chunk-LOZL5HOF.js → chunk-UOF4MCAD.js} +64 -2
  2. package/dist/chunks/chunk-UOF4MCAD.js.map +7 -0
  3. package/dist/chunks/{chunk-3JXK2QFU.js → chunk-ZEDLSHYQ.js} +2 -2
  4. package/dist/lib/client/index.js +46 -28
  5. package/dist/lib/client/index.js.map +2 -2
  6. package/dist/lib/server/index.js +1267 -233
  7. package/dist/lib/server/index.js.map +4 -4
  8. package/dist/lib/shared/index.js +3 -1
  9. package/dist/lib/shared/index.js.map +1 -1
  10. package/lib/client/core/ComponentBuilder.test.ts +32 -0
  11. package/lib/client/core/ComponentBuilder.ts +30 -0
  12. package/lib/client/theme.test.ts +2 -2
  13. package/lib/client/theme.ts +28 -26
  14. package/lib/server/createServer.ts +5 -1
  15. package/lib/server/cssAudit.test.ts +270 -0
  16. package/lib/server/cssAudit.ts +815 -0
  17. package/lib/server/deMirror.test.ts +61 -0
  18. package/lib/server/deMirror.ts +317 -0
  19. package/lib/server/fileWatcher.test.ts +23 -0
  20. package/lib/server/fileWatcher.ts +6 -1
  21. package/lib/server/index.ts +19 -0
  22. package/lib/server/routes/api/core-routes.ts +18 -0
  23. package/lib/server/routes/api/cssAudit.test.ts +101 -0
  24. package/lib/server/routes/api/cssAudit.ts +138 -0
  25. package/lib/server/routes/api/deMirror.test.ts +105 -0
  26. package/lib/server/routes/api/deMirror.ts +127 -0
  27. package/lib/server/routes/api/pages.ts +2 -2
  28. package/lib/server/services/componentService.test.ts +43 -0
  29. package/lib/server/services/componentService.ts +49 -12
  30. package/lib/server/services/pageService.test.ts +15 -0
  31. package/lib/server/services/pageService.ts +44 -22
  32. package/lib/server/ssr/htmlGenerator.test.ts +29 -0
  33. package/lib/server/ssr/htmlGenerator.ts +83 -3
  34. package/lib/server/themeCssCodec.test.ts +67 -0
  35. package/lib/server/themeCssCodec.ts +67 -5
  36. package/lib/server/webflow/templateWrapper.ts +54 -0
  37. package/lib/shared/cssGeneration.test.ts +28 -0
  38. package/lib/shared/interfaces/contentProvider.ts +9 -0
  39. package/lib/shared/types/cms.ts +1 -0
  40. package/lib/shared/utilityClassConfig.ts +2 -0
  41. package/lib/shared/utilityClassMapper.test.ts +53 -0
  42. package/lib/shared/utilityClassNames.ts +74 -0
  43. package/lib/shared/utils/fileUtils.ts +11 -0
  44. package/lib/shared/validation/schemas.ts +1 -0
  45. package/lib/shared/viewportUnits.test.ts +34 -1
  46. package/lib/shared/viewportUnits.ts +43 -0
  47. package/package.json +3 -1
  48. package/vite.config.ts +4 -1
  49. package/dist/chunks/chunk-LOZL5HOF.js.map +0 -7
  50. /package/dist/chunks/{chunk-3JXK2QFU.js.map → chunk-ZEDLSHYQ.js.map} +0 -0
@@ -314,6 +314,35 @@ describe('generateSSRHTML', () => {
314
314
  expect(result).toContain('.my-component { color: red; }');
315
315
  });
316
316
 
317
+ // A page can `import '../styles/extra.css'` in its .astro frontmatter — foreign
318
+ // passthrough the real Astro build bundles. These assert the meno-core SSR canvas
319
+ // (Meno Select) surfaces the same imports so styling doesn't vanish in select mode.
320
+ test('links a remote CSS import captured in frontmatter passthrough', async () => {
321
+ const page = { ...minimalPage, _frontmatter: "import 'https://cdn.example.com/extra.css';" };
322
+ const result = (await generateSSRHTML(page)) as string;
323
+ expect(result).toContain('<link rel="stylesheet" href="https://cdn.example.com/extra.css">');
324
+ // In the head, after the generated stylesheet (so it can override utilities).
325
+ expect(result.indexOf('id="meno-styles"')).toBeLessThan(result.indexOf('cdn.example.com/extra.css'));
326
+ });
327
+
328
+ test('ignores the regenerated theme.css import', async () => {
329
+ const page = { ...minimalPage, _frontmatter: "import '../styles/theme.css';" };
330
+ const result = (await generateSSRHTML(page)) as string;
331
+ expect(result).not.toContain('data-meno-page-css');
332
+ });
333
+
334
+ test('gracefully skips an unresolvable local CSS import (no crash, no tag)', async () => {
335
+ const page = { ...minimalPage, _frontmatter: "import '../styles/definitely-missing.css';" };
336
+ const result = (await generateSSRHTML(page)) as string;
337
+ expect(result).toStartWith('<!DOCTYPE html>');
338
+ expect(result).not.toContain('definitely-missing.css');
339
+ });
340
+
341
+ test('injects no page-CSS tags when the page has no frontmatter passthrough', async () => {
342
+ const result = (await generateSSRHTML(minimalPage)) as string;
343
+ expect(result).not.toContain('data-meno-page-css');
344
+ });
345
+
317
346
  test('should minify CSS in production mode (useBuiltBundle=true)', async () => {
318
347
  mockRenderPageSSR.mockImplementation(async () => ({
319
348
  html: '<div>Content</div>',
@@ -18,7 +18,7 @@ import {
18
18
  extractUtilityClassesFromHTML,
19
19
  generateAllInteractiveCSS,
20
20
  } from '../../shared/cssGeneration';
21
- import { rewriteViewportUnits } from '../../shared/viewportUnits';
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
  */
@@ -540,7 +614,7 @@ picture {
540
614
  // canvas can pin viewport units to a stable height (avoids the
541
615
  // iframe.height ↔ 100vh feedback loop). var() fallback to 1vh means this
542
616
  // is a no-op in production / page mode where --design-vh is unset.
543
- const cssWithStableViewport = rewriteViewportUnits(combinedCSS);
617
+ const cssWithStableViewport = rewriteViewportUnitsInStylesheet(combinedCSS);
544
618
 
545
619
  // Minify CSS in production mode
546
620
  const finalCSS = useBundled ? minifyCSS(cssWithStableViewport) : cssWithStableViewport;
@@ -672,13 +746,19 @@ picture {
672
746
  // In production, output minified CSS on single line; in dev, preserve formatting
673
747
  const styleContent = useBundled ? finalCSS : `\n ${combinedCSS.split('\n').join('\n ')}\n `;
674
748
 
749
+ // Inline any hand-authored CSS the page imports in its frontmatter (foreign
750
+ // passthrough the Astro build bundles but the select canvas otherwise misses).
751
+ // Placed after #meno-styles so it can override generated utilities, matching the
752
+ // intent of a page pulling in its own stylesheet.
753
+ const pageCssTags = await collectPageCssImports(pageData._frontmatter, pagePath, nonceAttr);
754
+
675
755
  const htmlDocument = `<!DOCTYPE html>
676
756
  <html lang="${rendered.locale}" theme="${themeConfig.default}">
677
757
  <head>
678
758
  <meta charset="UTF-8">
679
759
  <meta name="viewport" content="width=device-width, initial-scale=1.0">
680
760
  ${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}` : ''}
761
+ ${configInlineScript}${cmsInlineScript}${clientDataScripts}<style id="meno-styles"${nonceAttr}>${styleContent}</style>${pageCssTags ? `\n ${pageCssTags}` : ''}${mergedCustomCode.head ? `\n ${mergedCustomCode.head}` : ''}
682
762
  </head>
683
763
  <body>${mergedCustomCode.bodyStart ? `\n ${mergedCustomCode.bodyStart}` : ''}
684
764
  <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
- const LABEL_TO_GROUP = new Map<string, VariableGroup>(VARIABLE_GROUPS.map((g) => [g.label.toLowerCase(), g.value]));
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
- /** Reverse of {@link sectionLabelForGroup}: a section header back to its group, or null. */
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
- return LABEL_TO_GROUP.get(label.trim().toLowerCase()) ?? null;
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
- const g = groupForSectionLabel(inner);
433
- if (g) section = g;
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;');
@@ -56,6 +56,15 @@ export interface PageProvider {
56
56
  * Optional — callers fall back to ".json" when not provided.
57
57
  */
58
58
  extension?(): string;
59
+
60
+ /**
61
+ * Absolute path to the source file backing a page path, or null if it doesn't
62
+ * exist. Unlike the naive `<pagesDir>/<path>.<ext>` mapping, this resolves cases
63
+ * where the logical editor path diverges from the on-disk file — notably CMS
64
+ * template pages (`/templates/<col>` → `src/pages/<routeDir>/[slug].astro`). Used
65
+ * to point external tools ("Open source file") at the real file. Optional.
66
+ */
67
+ resolveSourcePath?(path: string): Promise<string | null>;
59
68
  }
60
69
 
61
70
  /**
@@ -27,6 +27,7 @@ export interface CMSFieldDefinition {
27
27
  accept?: string; // For 'file' type - MIME filter (e.g. "application/pdf", "*/*")
28
28
  collection?: string; // For 'reference' type - target collection ID
29
29
  multiple?: boolean; // For 'reference' type - allows array of IDs
30
+ editor?: 'basic' | 'extended'; // For 'rich-text' type: which editor + render helper (Basic → richText(); Extended → richTextWithComponents())
30
31
  }
31
32
 
32
33
  /** Data delivery strategy for client-side CMS data */
@@ -339,6 +339,8 @@ export const prefixToCSSProperty: Record<string, string> = {
339
339
  'border-r': 'border-right',
340
340
  'border-b': 'border-bottom',
341
341
  'border-l': 'border-left',
342
+ 'border-x': 'border-inline',
343
+ 'border-y': 'border-block',
342
344
  rounded: 'border-radius',
343
345
  'rounded-tl': 'border-top-left-radius',
344
346
  'rounded-tr': 'border-top-right-radius',
@@ -298,6 +298,59 @@ describe('utilityClassMapper', () => {
298
298
  });
299
299
  });
300
300
 
301
+ describe('border width/style family (no Preflight → self-contained shorthand)', () => {
302
+ // Bare/numeric widths expand to `<width> solid` so they render on their own — Meno has no
303
+ // Preflight to supply a default border-style. Color is left off (defaults to currentColor).
304
+ test('bare and numeric all-side widths', () => {
305
+ expect(classToStyle('border')).toEqual({ prop: 'border', value: '1px solid' });
306
+ expect(classToStyle('border-0')).toEqual({ prop: 'border', value: '0px solid' });
307
+ expect(classToStyle('border-2')).toEqual({ prop: 'border', value: '2px solid' });
308
+ expect(classToStyle('border-4')).toEqual({ prop: 'border', value: '4px solid' });
309
+ });
310
+
311
+ test('bare and numeric per-side widths', () => {
312
+ expect(classToStyle('border-b')).toEqual({ prop: 'borderBottom', value: '1px solid' });
313
+ expect(classToStyle('border-t')).toEqual({ prop: 'borderTop', value: '1px solid' });
314
+ expect(classToStyle('border-b-2')).toEqual({ prop: 'borderBottom', value: '2px solid' });
315
+ expect(classToStyle('border-l-4')).toEqual({ prop: 'borderLeft', value: '4px solid' });
316
+ });
317
+
318
+ test('logical axes use border-inline / border-block', () => {
319
+ expect(classToStyle('border-x')).toEqual({ prop: 'borderInline', value: '1px solid' });
320
+ expect(classToStyle('border-y-2')).toEqual({ prop: 'borderBlock', value: '2px solid' });
321
+ });
322
+
323
+ test('border style keywords', () => {
324
+ expect(classToStyle('border-solid')).toEqual({ prop: 'borderStyle', value: 'solid' });
325
+ expect(classToStyle('border-dashed')).toEqual({ prop: 'borderStyle', value: 'dashed' });
326
+ expect(classToStyle('border-none')).toEqual({ prop: 'borderStyle', value: 'none' });
327
+ });
328
+
329
+ test('color forms are unaffected (own branch); palette + foreign stay unrecognized', () => {
330
+ // A defined token → border-color, stored as the bare token name (gated on knownTokens).
331
+ expect(classToStyle('border-primary', new Set(['primary']))).toEqual({
332
+ prop: 'borderColor',
333
+ value: 'primary',
334
+ });
335
+ // Arbitrary / var color shorthands (var collapses to the bare token name for the editor).
336
+ expect(classToStyle('border-[#fff]')).toEqual({ prop: 'borderColor', value: '#fff' });
337
+ expect(classToStyle('border-(--border)')).toEqual({ prop: 'borderColor', value: 'border' });
338
+ // Tailwind palette is deliberately not supported; foreign classes stay foreign.
339
+ expect(classToStyle('border-gray-200')).toBeNull();
340
+ expect(classToStyle('border-wrapper')).toBeNull();
341
+ });
342
+
343
+ test('composes across classes without collision', () => {
344
+ expect(classesToStyles(['border', 'border-b-2', 'border-dashed'])).toEqual({
345
+ base: {
346
+ border: '1px solid',
347
+ borderBottom: '2px solid',
348
+ borderStyle: 'dashed',
349
+ },
350
+ });
351
+ });
352
+ });
353
+
301
354
  describe('Tailwind named scales → var(--token)', () => {
302
355
  test('named scales resolve to a plain variable reference (no baked default)', () => {
303
356
  expect(classToStyle('text-lg')).toEqual({ prop: 'fontSize', value: 'var(--text-lg)' });
@@ -378,6 +378,66 @@ function negateCssValue(root: string, value: string): string | null {
378
378
  return /^\d/.test(value) ? `-${value}` : null;
379
379
  }
380
380
 
381
+ /**
382
+ * Border-width/style shorthand family — Tailwind's `border`, `border-2`, `border-t`,
383
+ * `border-b-4`, `border-solid`, … Meno has no Preflight to supply a default border-style,
384
+ * so a bare `border` (width only) would render invisible; these instead expand to a
385
+ * `<width> solid` shorthand — visible exactly as the class reads, with no smuggled design
386
+ * value (`1px` is the class's literal meaning). The color is left OFF: an omitted border
387
+ * color defaults to `currentColor` (inherits the text color), and — crucially — the per-side
388
+ * forms then emit no color longhand, so a separate `border-<token>` / `border-[#hex]` class
389
+ * controls the color with no cascade fight. Style is overridable via `border-dashed` etc.
390
+ * `border-x` / `border-y` use the logical `border-inline` / `border-block` shorthands.
391
+ *
392
+ * Colors (`border-primary`, `border-[#fff]`, `border-(--x)`) and arbitrary shorthands
393
+ * (`border-[1px_solid_red]`) are handled by the color-token / arbitrary / var branches — this
394
+ * only absorbs the bare width/style idioms that were previously dropped.
395
+ */
396
+ const BORDER_SIDE_PROPERTY: Record<string, string> = {
397
+ border: 'border',
398
+ 'border-t': 'border-top',
399
+ 'border-r': 'border-right',
400
+ 'border-b': 'border-bottom',
401
+ 'border-l': 'border-left',
402
+ 'border-x': 'border-inline',
403
+ 'border-y': 'border-block',
404
+ };
405
+ const BORDER_STYLE_KEYWORDS: ReadonlySet<string> = new Set(['solid', 'dashed', 'dotted', 'double', 'none', 'hidden']);
406
+ /** Default style composed onto a bare/numeric border width so it renders on its own (color inherits). */
407
+ const BORDER_STYLE = 'solid';
408
+
409
+ /**
410
+ * Parse a border width/style class. `root` is `border` (all-side widths, bare sides, style
411
+ * keywords) or a side root like `border-t` (a sided width, `border-t-2`). Returns null for
412
+ * anything that isn't a bare width / style keyword (colors fall through to their own branch).
413
+ */
414
+ function parseBorderClass(root: string, rest: string): ParsedUtilityClass | null {
415
+ // Sided width: `border-t-2`, `border-b-4`, `border-x-8` (entered as the side root).
416
+ if (root !== 'border') {
417
+ const sideProp = BORDER_SIDE_PROPERTY[root];
418
+ if (sideProp && /^\d+$/.test(rest)) {
419
+ return { property: sideProp, value: `${rest}px ${BORDER_STYLE}`, root, kind: 'numeric' };
420
+ }
421
+ return null;
422
+ }
423
+ // Bare side: `border-t` … `border-y` arrive here as root `border`, rest = the side letter.
424
+ // Re-root to the side (`border-t`) so the CSS generator emits per-side longhands.
425
+ const sideRoot = `border-${rest}`;
426
+ const sideProp = BORDER_SIDE_PROPERTY[sideRoot];
427
+ if (sideProp) {
428
+ return { property: sideProp, value: `1px ${BORDER_STYLE}`, root: sideRoot, kind: 'keyword' };
429
+ }
430
+ // All-side width: `border-0`, `border-2`, `border-4` (bare `border` is handled upstream).
431
+ if (/^\d+$/.test(rest)) {
432
+ return { property: 'border', value: `${rest}px ${BORDER_STYLE}`, root: 'border', kind: 'numeric' };
433
+ }
434
+ // Border style on its own: `border-solid`, `border-dashed`, `border-none`, …
435
+ if (BORDER_STYLE_KEYWORDS.has(rest)) {
436
+ return { property: 'border-style', value: rest, root: 'border', kind: 'keyword' };
437
+ }
438
+ return null;
439
+ }
440
+
381
441
  /**
382
442
  * Parse a single (unprefixed) utility class name into its CSS property and
383
443
  * decoded value. Returns null for class names that aren't Meno utilities.
@@ -405,6 +465,12 @@ export function parseUtilityClass(className: string, knownTokens?: ReadonlySet<s
405
465
  };
406
466
  }
407
467
 
468
+ // Bare `border` → a visible 1px all-side border (see parseBorderClass). The dashed/sided/
469
+ // numeric forms flow through the root loop below into parseBorderClass.
470
+ if (className === 'border') {
471
+ return { property: 'border', value: `1px ${BORDER_STYLE}`, root: 'border', kind: 'keyword' };
472
+ }
473
+
408
474
  // Leading-`-` negative form (`-mt-4`, `-top-2`, `-translate-y-1/2`, `-rotate-3`): parse the
409
475
  // positive class, then negate its value when the root is negatable. Not a negatable root or a
410
476
  // non-numeric value → unrecognized (don't misread foreign `-foo` classes as utilities).
@@ -480,6 +546,14 @@ function parseRootValue(root: string, rest: string, knownTokens?: ReadonlySet<st
480
546
  return { property, value, root, kind: 'var' };
481
547
  }
482
548
 
549
+ // Border width/style family (`border-2`, `border-b`, `border-t-4`, `border-solid`, …).
550
+ // Placed after the arbitrary `[..]` and var `(--x)` escapes so those win; colors
551
+ // (`border-primary`) return null here and fall through to the color-token branch below.
552
+ if (root === 'border' || BORDER_SIDE_PROPERTY[root]) {
553
+ const border = parseBorderClass(root, rest);
554
+ if (border) return border;
555
+ }
556
+
483
557
  // Named keyword: w-full, m-auto, rounded-full
484
558
  const keyword = keywordValues[root]?.[rest];
485
559
  if (keyword !== undefined) {
@@ -37,6 +37,17 @@ export const getBaseName = (filePath: string): string => {
37
37
  return parts[parts.length - 1] || '';
38
38
  };
39
39
 
40
+ /**
41
+ * Collapse a folder-index route name to its directory route, mirroring Astro's routing:
42
+ * `blog/index` => `blog` (serves `/blog`), while the root `index` and non-index names are
43
+ * returned unchanged. Keep this in sync with the copy in astro's `astroPageProvider.ts`
44
+ * (duplicated across the package boundary to avoid a publish-ordering coupling).
45
+ * @example collapseFolderIndex('blog/index') => 'blog'
46
+ * @example collapseFolderIndex('index') => 'index'
47
+ */
48
+ export const collapseFolderIndex = (name: string): string =>
49
+ name.endsWith('/index') ? name.slice(0, -'/index'.length) : name;
50
+
40
51
  /**
41
52
  * Map page filename to route path
42
53
  * @example mapPageNameToPath('index') => '/'
@@ -839,6 +839,7 @@ export const CMSFieldDefinitionSchema = z
839
839
  accept: z.string().optional(),
840
840
  collection: z.string().optional(),
841
841
  multiple: z.boolean().optional(),
842
+ editor: z.enum(['basic', 'extended']).optional(), // For 'rich-text' type: selects the render helper (Basic → richText(); Extended → richTextWithComponents())
842
843
  })
843
844
  .passthrough();
844
845