meno-core 1.0.46 → 1.0.48

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 (68) hide show
  1. package/build-astro.ts +13 -77
  2. package/dist/build-static.js +7 -7
  3. package/dist/chunks/{chunk-FED5MME6.js → chunk-3FHJUHAS.js} +5 -4
  4. package/dist/chunks/chunk-3FHJUHAS.js.map +7 -0
  5. package/dist/chunks/{chunk-XSWR3QLI.js → chunk-B2RTLDXY.js} +130 -130
  6. package/dist/chunks/chunk-B2RTLDXY.js.map +7 -0
  7. package/dist/chunks/{chunk-ZTKHJQ2Z.js → chunk-BJRKEPMP.js} +2 -2
  8. package/dist/chunks/{chunk-ORN7S4AP.js → chunk-D5E3OKSL.js} +5 -5
  9. package/dist/chunks/{chunk-KITQJYZV.js → chunk-EK4KESLU.js} +33 -3
  10. package/dist/chunks/chunk-EK4KESLU.js.map +7 -0
  11. package/dist/chunks/{chunk-C6U5T5S5.js → chunk-NKUV77SR.js} +43 -10
  12. package/dist/chunks/chunk-NKUV77SR.js.map +7 -0
  13. package/dist/chunks/{chunk-ZWYDT3QJ.js → chunk-NP76N4HQ.js} +4 -4
  14. package/dist/chunks/{chunk-ZWYDT3QJ.js.map → chunk-NP76N4HQ.js.map} +2 -2
  15. package/dist/chunks/{chunk-IF3RATBY.js → chunk-RQSTH2BS.js} +2 -2
  16. package/dist/chunks/{chunk-77ZB6353.js → chunk-TPQ7APVQ.js} +42 -86
  17. package/dist/chunks/chunk-TPQ7APVQ.js.map +7 -0
  18. package/dist/chunks/{configService-DYCUEURL.js → configService-IGJEC3MC.js} +3 -3
  19. package/dist/entries/server-router.js +8 -8
  20. package/dist/lib/client/index.js +16 -4
  21. package/dist/lib/client/index.js.map +2 -2
  22. package/dist/lib/server/index.js +1803 -383
  23. package/dist/lib/server/index.js.map +4 -4
  24. package/dist/lib/shared/index.js +8 -4
  25. package/dist/lib/shared/index.js.map +1 -1
  26. package/lib/client/core/ComponentBuilder.test.ts +38 -0
  27. package/lib/client/core/ComponentBuilder.ts +25 -1
  28. package/lib/server/astro/astroEmitHelpers.ts +5 -0
  29. package/lib/server/astro/cmsPageEmitter.ts +15 -2
  30. package/lib/server/astro/componentEmitter.ts +13 -5
  31. package/lib/server/astro/nodeToAstro.ts +23 -9
  32. package/lib/server/astro/pageEmitter.ts +15 -2
  33. package/lib/server/index.ts +1 -1
  34. package/lib/server/jsonLoader.test.ts +0 -17
  35. package/lib/server/jsonLoader.ts +0 -81
  36. package/lib/server/routes/api/variables.ts +4 -2
  37. package/lib/server/ssr/htmlGenerator.test.ts +4 -4
  38. package/lib/server/ssr/htmlGenerator.ts +7 -2
  39. package/lib/server/ssr/imageMetadata.ts +15 -9
  40. package/lib/server/ssr/ssrRenderer.test.ts +103 -1
  41. package/lib/server/ssr/ssrRenderer.ts +41 -35
  42. package/lib/server/utils/jsonLineMapper.test.ts +53 -1
  43. package/lib/server/utils/jsonLineMapper.ts +43 -3
  44. package/lib/server/webflow/buildWebflow.ts +343 -123
  45. package/lib/server/webflow/index.ts +1 -0
  46. package/lib/server/webflow/nodeToWebflow.test.ts +3170 -0
  47. package/lib/server/webflow/nodeToWebflow.ts +2141 -129
  48. package/lib/server/webflow/styleMapper.test.ts +389 -0
  49. package/lib/server/webflow/styleMapper.ts +517 -63
  50. package/lib/server/webflow/templateWrapper.ts +49 -0
  51. package/lib/server/webflow/types.ts +218 -18
  52. package/lib/shared/elementClassName.test.ts +15 -0
  53. package/lib/shared/elementClassName.ts +7 -3
  54. package/lib/shared/registry/nodeTypes/ListNodeType.ts +1 -1
  55. package/lib/shared/styleNodeUtils.test.ts +47 -1
  56. package/lib/shared/styleNodeUtils.ts +7 -7
  57. package/lib/shared/types/index.ts +1 -0
  58. package/lib/shared/types/variables.ts +37 -0
  59. package/package.json +1 -1
  60. package/dist/chunks/chunk-77ZB6353.js.map +0 -7
  61. package/dist/chunks/chunk-C6U5T5S5.js.map +0 -7
  62. package/dist/chunks/chunk-FED5MME6.js.map +0 -7
  63. package/dist/chunks/chunk-KITQJYZV.js.map +0 -7
  64. package/dist/chunks/chunk-XSWR3QLI.js.map +0 -7
  65. /package/dist/chunks/{chunk-ZTKHJQ2Z.js.map → chunk-BJRKEPMP.js.map} +0 -0
  66. /package/dist/chunks/{chunk-ORN7S4AP.js.map → chunk-D5E3OKSL.js.map} +0 -0
  67. /package/dist/chunks/{chunk-IF3RATBY.js.map → chunk-RQSTH2BS.js.map} +0 -0
  68. /package/dist/chunks/{configService-DYCUEURL.js.map → configService-IGJEC3MC.js.map} +0 -0
@@ -0,0 +1,49 @@
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
+
13
+ import { readFile } from 'fs/promises';
14
+
15
+ let cachedTemplate: string | null = null;
16
+
17
+ async function getWebflowTemplate(appName: string): Promise<string> {
18
+ if (cachedTemplate) return cachedTemplate;
19
+ const url = `https://webflow-ext.com/template/v2?name=${encodeURIComponent(appName)}`;
20
+ const res = await fetch(url);
21
+ if (!res.ok) throw new Error(`Failed to fetch Webflow template: ${res.status}`);
22
+ cachedTemplate = await res.text();
23
+ return cachedTemplate;
24
+ }
25
+
26
+ /**
27
+ * Wrap extension HTML in the Webflow Designer template.
28
+ * `manifestPath` points to the extension's `webflow.json` — its `name` field
29
+ * is forwarded as the `?name=` query param to webflow-ext.com.
30
+ *
31
+ * On any failure (manifest read, template fetch) we log a warning and return
32
+ * the raw HTML, matching the prior behavior of the two duplicate copies.
33
+ */
34
+ export async function wrapInWebflowTemplate(html: string, manifestPath: string): Promise<string> {
35
+ try {
36
+ const manifest = JSON.parse(await readFile(manifestPath, 'utf-8'));
37
+ const template = await getWebflowTemplate(manifest.name || 'Meno Import');
38
+
39
+ const headMatch = html.match(/<head[^>]*>([\s\S]*?)<\/head>/i);
40
+ const bodyMatch = html.match(/<body[^>]*>([\s\S]*?)<\/body>/i);
41
+ const headContent = headMatch ? headMatch[1] : '';
42
+ const bodyContent = bodyMatch ? bodyMatch[1] : '';
43
+
44
+ return template.replace('{{ui}}', headContent + bodyContent);
45
+ } catch (err: any) {
46
+ console.warn('Could not fetch Webflow wrapper template:', err.message);
47
+ return html;
48
+ }
49
+ }
@@ -9,18 +9,49 @@ import type { CMSFieldType } from '../../shared/types/cms';
9
9
  // Style Classes
10
10
  // ---------------------------------------------------------------------------
11
11
 
12
- /** Webflow breakpoint identifiers */
13
- export type WebflowBreakpoint = 'Desktop' | 'Tablet' | 'MobilePortrait';
12
+ /**
13
+ * Webflow Designer API breakpoint identifiers.
14
+ * `main` is the desktop / default tier; `xxl|xl|large` are above-desktop;
15
+ * `medium|small|tiny` are below-desktop.
16
+ * Reference: https://developers.webflow.com/designer/reference/set-style-properties
17
+ */
18
+ export type WebflowBreakpoint =
19
+ | 'xxl'
20
+ | 'xl'
21
+ | 'large'
22
+ | 'main'
23
+ | 'medium'
24
+ | 'small'
25
+ | 'tiny';
14
26
 
15
- /** Webflow pseudo-state identifiers */
16
- export type WebflowPseudoState = 'hover' | 'focus' | 'active' | 'visited' | 'focus-visible';
27
+ /**
28
+ * Webflow Designer API pseudo-state identifiers.
29
+ * Reference: https://developers.webflow.com/designer/reference/set-style-properties
30
+ */
31
+ export type WebflowPseudoState =
32
+ | 'noPseudo'
33
+ | 'hover'
34
+ | 'focus'
35
+ | 'focus-visible'
36
+ | 'focus-within'
37
+ | 'active'
38
+ | 'visited'
39
+ | 'pressed'
40
+ | 'before'
41
+ | 'after'
42
+ | 'placeholder'
43
+ | 'empty'
44
+ | 'first-child'
45
+ | 'last-child'
46
+ | 'nth-child(odd)'
47
+ | 'nth-child(even)';
17
48
 
18
49
  /** CSS properties as a flat record */
19
50
  export type CSSProperties = Record<string, string>;
20
51
 
21
52
  /** A named Webflow style class with responsive + pseudo-state overrides */
22
53
  export interface WebflowStyleClass {
23
- /** Unique class name (e.g., "c-navigation-hamburger") */
54
+ /** Unique class name (e.g., "navigation-hamburger" for components, "p-about-grid5" for pages) */
24
55
  name: string;
25
56
  /** Base (Desktop) CSS properties */
26
57
  base: CSSProperties;
@@ -46,20 +77,113 @@ export interface WebflowElement {
46
77
  comboClasses?: string[];
47
78
  /** Inline text content (for text nodes) */
48
79
  textContent?: string;
49
- /** Child elements */
50
- children?: WebflowElement[];
80
+ /**
81
+ * Child elements. Plain strings are inline text runs (Webflow String nodes)
82
+ * — used for mixed content like `<h1>foo <span>bar</span> baz</h1>`.
83
+ */
84
+ children?: Array<WebflowElement | string>;
51
85
  /** HTML attributes (src, alt, href, target, etc.) */
52
86
  attributes?: Record<string, string | number | boolean>;
53
- /** Raw HTML content (for embed nodes) */
54
- rawHtml?: string;
55
- /** Whether this element is conditionally rendered */
56
- conditional?: {
57
- prop: string;
58
- condition: 'truthy' | 'equals';
59
- value?: string;
87
+ /**
88
+ * Inline SVG markup for an embed node. The Webflow extension uploads this
89
+ * as an `image/svg+xml` asset and emits an Image element — Webflow's
90
+ * Designer API has no public method to set HtmlEmbed code content.
91
+ */
92
+ svgSource?: string;
93
+ /** Source URL for an `<img>`-rooted embed; uploaded into Webflow as an asset. */
94
+ imageSrc?: string;
95
+ /** Alt text paired with `svgSource` / `imageSrc`. */
96
+ imageAlt?: string;
97
+ /**
98
+ * Pre-fetched image bytes for `<img>` / embed elements whose source URL
99
+ * the Webflow Designer iframe can't reach (e.g. project-relative paths
100
+ * like `/images/foo.webp`, which would resolve against `designer.webflow.com`).
101
+ * The extension uploads these directly via `createAsset` instead of
102
+ * `fetch`-ing them.
103
+ */
104
+ imageDataBase64?: string;
105
+ imageDataMime?: string;
106
+ imageDataFileName?: string;
107
+ /**
108
+ * Embed payload that is neither SVG nor a single `<img>`. The extension
109
+ * logs it and skips insertion (no equivalent Webflow API exists).
110
+ */
111
+ unsupportedEmbed?: { reason: string; preview: string; label?: string };
112
+ /**
113
+ * When set, this element is an instance of a Webflow Component registered
114
+ * via `payload.components`. The extension appends the registered Component
115
+ * (looked up by name) instead of building children. `tag`/`className`/etc.
116
+ * remain set as a fallback for older extensions that don't understand
117
+ * `componentRef`.
118
+ */
119
+ componentRef?: string;
120
+ /**
121
+ * Inline-expanded element tree to use when the Webflow Designer API can't
122
+ * register Components (older runtime, missing `canCreateComponents`
123
+ * permission). Mirrors the pre-promotion expansion of the Meno component so
124
+ * the extension can render the same markup it does today.
125
+ */
126
+ inlineFallback?: WebflowElement[];
127
+ /**
128
+ * Bound CMS list marker. When set, this element is the synthetic wrapper
129
+ * for `<list sourceType="collection">` emitted in bound mode (see
130
+ * `buildWebflowPayload({ bindCollectionLists: true })`). Value is the Meno
131
+ * collection slug; the extension translates that to a Webflow collection
132
+ * ID via the v1 sync ID map and inserts a `DynamoWrapper` preset.
133
+ * Children are the *single* rendered item template (not N copies), with
134
+ * field references preserved as `menoBind` markers below.
135
+ */
136
+ menoCollectionRef?: string;
137
+ /**
138
+ * Per-element CMS field binding markers. Captured at server emit time from
139
+ * the source `{{field}}` template; the extension translates them into
140
+ * `data-meno-bind-*` custom attributes on the inserted element and surfaces
141
+ * a manual-bind checklist. Webflow's Designer API has no `setBinding` /
142
+ * `setCollection` write surface yet (Apr 2026 — confirmed by Webflow
143
+ * staff Plata + Selser on the developer forum), so the user does the
144
+ * actual field binding once in the Designer UI after insertion.
145
+ */
146
+ menoBind?: {
147
+ /** Bind this element's text content to this CMS field slug. */
148
+ textField?: string;
149
+ /** Map of attribute name → CMS field slug to bind. */
150
+ attrFields?: Record<string, string>;
60
151
  };
61
152
  }
62
153
 
154
+ // ---------------------------------------------------------------------------
155
+ // Bound-list sentinel (server-side internal — extension consumes `menoBind`)
156
+ // ---------------------------------------------------------------------------
157
+
158
+ /**
159
+ * String sentinel inserted in place of a real CMS field value when emitting a
160
+ * bound Collection List. The existing template engine is reused unchanged: a
161
+ * synthetic placeholder item maps every schema field to
162
+ * `${SENTINEL_PREFIX}${fieldSlug}${SENTINEL_SUFFIX}`, so `{{post.title}}`
163
+ * resolves to that string. A post-walk then converts those strings into
164
+ * `WebflowElement.menoBind` markers and replaces them with a readable
165
+ * placeholder so the user can see what each child is meant to display.
166
+ */
167
+ export const MENO_BIND_SENTINEL_PREFIX = '__MENO_BIND__:';
168
+ export const MENO_BIND_SENTINEL_SUFFIX = ':__';
169
+ export const MENO_BIND_SENTINEL_RE = /__MENO_BIND__:([^:]+):__/g;
170
+ export const MENO_BIND_SENTINEL_EXACT_RE = /^__MENO_BIND__:([^:]+):__$/;
171
+
172
+ /** Synthetic tag for the bound Collection List wrapper. */
173
+ export const COLLECTION_LIST_TAG = '__collection_list__';
174
+
175
+ /**
176
+ * A Meno component promoted to a Webflow Component. The extension calls
177
+ * `webflow.registerComponent` once per entry, then `parent.append(component)`
178
+ * for every element in the page tree that has a matching `componentRef`.
179
+ */
180
+ export interface WebflowComponentDef {
181
+ /** Component name as it appears in Webflow's Components panel. */
182
+ name: string;
183
+ /** Element tree that becomes the Component's body. */
184
+ elements: WebflowElement[];
185
+ }
186
+
63
187
  // ---------------------------------------------------------------------------
64
188
  // Pages
65
189
  // ---------------------------------------------------------------------------
@@ -71,7 +195,9 @@ export interface WebflowPage {
71
195
  /** URL slug (e.g., "about", "blog/post-1") */
72
196
  slug: string;
73
197
  /** Meta description */
74
- metaDescription?: string;
198
+ description?: string;
199
+ /** Comma-separated keywords from page meta */
200
+ keywords?: string;
75
201
  /** Open Graph title */
76
202
  ogTitle?: string;
77
203
  /** Open Graph description */
@@ -84,6 +210,21 @@ export interface WebflowPage {
84
210
  locale?: string;
85
211
  }
86
212
 
213
+ /**
214
+ * A component-level script bundled into the export. The Webflow extension
215
+ * concatenates these into a single `<script>` injected at the end of `<body>`
216
+ * so interactive Meno components (FAQ accordion, dropdowns, mobile menu) work
217
+ * once published.
218
+ */
219
+ export interface WebflowScript {
220
+ /** Component name (used by the runtime to find element instances). */
221
+ componentName: string;
222
+ /** Component's JavaScript code (verbatim from `Component.js`). */
223
+ code: string;
224
+ /** Whether the source defined defineVars (true / explicit list / undefined). */
225
+ defineVars?: true | string[];
226
+ }
227
+
87
228
  // ---------------------------------------------------------------------------
88
229
  // CMS
89
230
  // ---------------------------------------------------------------------------
@@ -112,12 +253,25 @@ export interface WebflowCMSField {
112
253
  required?: boolean;
113
254
  /** Options for Option type fields */
114
255
  options?: string[];
256
+ /** Help text shown to editors in the Webflow CMS UI */
257
+ helpText?: string;
258
+ /**
259
+ * For Reference fields: Meno collection slug of the target collection.
260
+ * The sync orchestrator resolves this to a Webflow collection ID at create
261
+ * time (pass 2 of the two-pass collection create — references can only be
262
+ * added once their target exists).
263
+ */
264
+ referenceCollection?: string;
265
+ /** True for multi-reference (array of IDs); false / undefined for single ref. */
266
+ multiReference?: boolean;
115
267
  }
116
268
 
117
269
  /** A Webflow CMS collection definition */
118
270
  export interface WebflowCMSCollection {
119
- /** Collection display name */
271
+ /** Collection display name (plural) */
120
272
  name: string;
273
+ /** Singular form of the display name (Webflow requires this for new collections) */
274
+ singularName: string;
121
275
  /** Collection slug */
122
276
  slug: string;
123
277
  /** URL pattern for detail pages */
@@ -146,6 +300,14 @@ export interface WebflowAssetRef {
146
300
  // Export Payload
147
301
  // ---------------------------------------------------------------------------
148
302
 
303
+ /** Per-locale slug mapping for a single source page */
304
+ export interface WebflowSlugMap {
305
+ /** The page's canonical id (e.g., "about", "blog/post-1") */
306
+ pageId: string;
307
+ /** Locale code → translated slug */
308
+ slugs: Record<string, string>;
309
+ }
310
+
149
311
  /** Complete Webflow export payload */
150
312
  export interface WebflowExportPayload {
151
313
  /** Export format version */
@@ -160,8 +322,46 @@ export interface WebflowExportPayload {
160
322
  cms: WebflowCMSCollection[];
161
323
  /** Asset references (images, fonts, etc.) */
162
324
  assets: WebflowAssetRef[];
163
- /** CSS variables used in styles (for global embed) */
164
- cssVariables?: Record<string, string>;
325
+ /** Per-locale slug translations so the consumer can route between locales */
326
+ slugMappings?: WebflowSlugMap[];
327
+ /** Component scripts — bundled at body end so components stay interactive. */
328
+ scripts?: WebflowScript[];
329
+ /**
330
+ * Meno components promoted to Webflow Components (currently `Navigation`
331
+ * and `Footer`). The extension registers each one before inserting page
332
+ * elements; pages reference them via `WebflowElement.componentRef`.
333
+ */
334
+ components?: WebflowComponentDef[];
335
+ /**
336
+ * Concatenated `Component.css` sidecars (raw CSS) for components that ship
337
+ * hand-written styles. Component-scoped rules — data-attribute selectors,
338
+ * runtime state classes (e.g. `.is-open`), `:checked ~` siblings — aren't
339
+ * representable in Webflow's class system and are silently lost when only
340
+ * the per-element classes are emitted. The user pastes this into Site
341
+ * Settings → Custom Code → Head Code along with the combo-class overrides.
342
+ */
343
+ componentCss?: string;
344
+ /**
345
+ * Raw CSS for `interactiveStyles` rules that Webflow's class system can't
346
+ * represent natively — anything with a `prefix` (descendant/sibling
347
+ * selector built from a state class on an ancestor), a class-style
348
+ * `postfix` like `.is-open`, or breakpoint-divided pseudo states.
349
+ * Pseudo-only rules with empty prefix continue to flow through Webflow's
350
+ * `Style.setProperties({ pseudo })`. Pasted into Site Settings → Custom
351
+ * Code → Head Code along with `componentCss`.
352
+ */
353
+ interactiveCss?: string;
354
+ /**
355
+ * Project i18n summary so the extension can show a locale picker. Always
356
+ * present; single-locale projects still receive their lone locale here.
357
+ * `selectedLocale` reflects which locale's pages were emitted in this
358
+ * payload — the extension uses it to round-trip the picker selection.
359
+ */
360
+ i18n?: {
361
+ defaultLocale: string;
362
+ locales: Array<{ code: string; name: string; nativeName?: string }>;
363
+ selectedLocale: string;
364
+ };
165
365
  }
166
366
 
167
367
  // ---------------------------------------------------------------------------
@@ -164,6 +164,21 @@ describe("generateElementClassName", () => {
164
164
 
165
165
  expect(className1).not.toBe(className2);
166
166
  });
167
+
168
+ test("should generate different hashes for sibling paths (only last index differs)", () => {
169
+ // Regression: a 5-char hash truncated from the high-order digits
170
+ // collapsed to one value when only the final path index changed,
171
+ // making every set of siblings share a class in the Webflow export.
172
+ const base = ["Layout", [0, 0, 1, 0, 0]] as const;
173
+ const siblings = [0, 1, 2, 3, 4].map((i) =>
174
+ generateElementClassName({
175
+ fileType: "component",
176
+ fileName: base[0],
177
+ path: [...base[1], i],
178
+ })
179
+ );
180
+ expect(new Set(siblings).size).toBe(siblings.length);
181
+ });
167
182
  });
168
183
 
169
184
  describe("Component-relative paths", () => {
@@ -33,13 +33,17 @@ export interface ElementClassContext {
33
33
  * Simple djb2 hash function that works in browser and server
34
34
  * Returns a short alphanumeric string
35
35
  */
36
- function shortHash(input: string): string {
36
+ export function shortHash(input: string): string {
37
37
  let hash = 5381;
38
38
  for (let i = 0; i < input.length; i++) {
39
39
  hash = ((hash << 5) + hash) ^ input.charCodeAt(i);
40
40
  }
41
- // Convert to base36 (alphanumeric) and take first 5 chars
42
- return Math.abs(hash).toString(36).slice(0, 5);
41
+ // Take the LAST 5 base36 chars: the low-order digits change with every
42
+ // input byte, so sibling paths that share a long prefix and differ only
43
+ // in their final index still produce distinct hashes. Slicing from the
44
+ // start kept the high-order digits, which barely move when only the
45
+ // last char of the input changes — every set of siblings collided.
46
+ return Math.abs(hash).toString(36).slice(-5);
43
47
  }
44
48
 
45
49
  /**
@@ -107,7 +107,7 @@ export const ListNodeType = createNodeType({
107
107
  },
108
108
 
109
109
  treeDisplay: {
110
- icon: 'HTML_ELEMENT',
110
+ icon: 'ARRAY',
111
111
  getLabel: (node) => {
112
112
  const listNode = node as ListNode;
113
113
  const sourceType = listNode.sourceType || 'prop';
@@ -1,6 +1,6 @@
1
1
  import { describe, test, expect } from 'bun:test';
2
2
  import { applyStylesToNode, mergeNodeStyles, extractStylesFromNode } from './styleNodeUtils';
3
- import type { HtmlNode, ComponentInstanceNode } from './types';
3
+ import type { HtmlNode, ComponentInstanceNode, LinkNode } from './types';
4
4
  import { NODE_TYPE } from './constants';
5
5
 
6
6
  describe('styleNodeUtils', () => {
@@ -37,6 +37,13 @@ describe('styleNodeUtils', () => {
37
37
  const result = applyStylesToNode(node as any, styles);
38
38
  expect(result.style).toEqual(styles);
39
39
  });
40
+
41
+ test('should apply styles to link node at top level', () => {
42
+ const node: LinkNode = { type: NODE_TYPE.LINK, href: '/foo', children: [] };
43
+ const styles = { backgroundColor: '#252525', color: '#ffffff' };
44
+ const result = applyStylesToNode(node, styles);
45
+ expect(result.style).toEqual(styles);
46
+ });
40
47
  });
41
48
 
42
49
  describe('mergeNodeStyles', () => {
@@ -87,6 +94,34 @@ describe('styleNodeUtils', () => {
87
94
  const result = mergeNodeStyles(node, instanceStyles);
88
95
  expect((result as ComponentInstanceNode).props?.style).toEqual(instanceStyles);
89
96
  });
97
+
98
+ test('should merge styles for link node at top level', () => {
99
+ const node: LinkNode = {
100
+ type: NODE_TYPE.LINK,
101
+ href: '/foo',
102
+ children: [],
103
+ style: { color: '#000000', backgroundColor: '#ffffff' }
104
+ };
105
+ const instanceStyles = { backgroundColor: '#252525', color: '#ffffff' };
106
+ const result = mergeNodeStyles(node, instanceStyles);
107
+ expect(result.style).toEqual({ backgroundColor: '#252525', color: '#ffffff' });
108
+ });
109
+
110
+ test('should deep-merge responsive styles for link node', () => {
111
+ const node: LinkNode = {
112
+ type: NODE_TYPE.LINK,
113
+ href: '/foo',
114
+ children: [],
115
+ style: { base: { color: '#000', padding: '8px' }, tablet: { fontSize: '14px' }, mobile: {} }
116
+ };
117
+ const instanceStyles = { base: { backgroundColor: '#252525', color: '#fff' }, tablet: {}, mobile: {} };
118
+ const result = mergeNodeStyles(node, instanceStyles);
119
+ expect(result.style).toEqual({
120
+ base: { color: '#fff', padding: '8px', backgroundColor: '#252525' },
121
+ tablet: { fontSize: '14px' },
122
+ mobile: {},
123
+ });
124
+ });
90
125
  });
91
126
 
92
127
  describe('extractStylesFromNode', () => {
@@ -128,5 +163,16 @@ describe('styleNodeUtils', () => {
128
163
  const styles = extractStylesFromNode(node as any);
129
164
  expect(styles).toEqual({ width: '100%' });
130
165
  });
166
+
167
+ test('should extract styles from link node', () => {
168
+ const node: LinkNode = {
169
+ type: NODE_TYPE.LINK,
170
+ href: '/foo',
171
+ children: [],
172
+ style: { backgroundColor: '#252525' }
173
+ };
174
+ const styles = extractStylesFromNode(node);
175
+ expect(styles).toEqual({ backgroundColor: '#252525' });
176
+ });
131
177
  });
132
178
  });
@@ -4,7 +4,7 @@
4
4
  */
5
5
 
6
6
  import type { ComponentNode, HtmlNode, ComponentInstanceNode, StyleValue, ResponsiveStyleObject } from './types';
7
- import { isComponentNode, isHtmlNode, isEmbedNode, isListNode } from './nodeUtils';
7
+ import { isComponentNode, isHtmlNode, isEmbedNode, isListNode, isLinkNode } from './nodeUtils';
8
8
  import { isResponsiveStyle } from './styleUtils';
9
9
 
10
10
  /**
@@ -26,8 +26,8 @@ export function applyStylesToNode(
26
26
  }
27
27
 
28
28
  node.props.style = { ...(node.props.style || {}), ...styles };
29
- } else if (isHtmlNode(node) || isEmbedNode(node) || isListNode(node)) {
30
- // HTML node, Embed node, and List node: put styles at top level
29
+ } else if (isHtmlNode(node) || isEmbedNode(node) || isListNode(node) || isLinkNode(node)) {
30
+ // HTML node, Embed node, List node, and Link node: put styles at top level
31
31
  node.style = styles as StyleValue;
32
32
  }
33
33
 
@@ -79,8 +79,8 @@ export function mergeNodeStyles(
79
79
  ): ComponentNode {
80
80
  if (!instanceStyles) return node;
81
81
 
82
- if (isHtmlNode(node) || isEmbedNode(node) || isListNode(node)) {
83
- // For HTML nodes, Embed nodes, and List nodes: merge instance styles with existing top-level styles
82
+ if (isHtmlNode(node) || isEmbedNode(node) || isListNode(node) || isLinkNode(node)) {
83
+ // For HTML nodes, Embed nodes, List nodes, and Link nodes: merge instance styles with existing top-level styles
84
84
  const existingStyle = node.style;
85
85
  if (existingStyle && typeof existingStyle === 'object') {
86
86
  node.style = deepMergeStyles(existingStyle, instanceStyles);
@@ -117,8 +117,8 @@ export function extractStylesFromNode(
117
117
  if (!style) return {};
118
118
 
119
119
  return style as Record<string, string | number>;
120
- } else if (isHtmlNode(node) || isEmbedNode(node)) {
121
- // HTML node and Embed node: styles are at top level
120
+ } else if (isHtmlNode(node) || isEmbedNode(node) || isLinkNode(node)) {
121
+ // HTML node, Embed node, and Link node: styles are at top level
122
122
  const style = node.style;
123
123
  if (!style) return {};
124
124
 
@@ -153,6 +153,7 @@ export {
153
153
  VARIABLE_GROUPS,
154
154
  VARIABLE_GROUP_VALUES,
155
155
  VARIABLE_TYPES,
156
+ VARIABLE_TYPE_VALUES,
156
157
  getGroupForProperty,
157
158
  getGroupAbbreviation,
158
159
  getDefaultScalingType,
@@ -19,7 +19,13 @@ export type VariableGroup =
19
19
  | 'line-height' | 'letter-spacing'
20
20
  | 'margin' | 'padding' | 'gap'
21
21
  | 'size'
22
+ | 'position'
22
23
  | 'border-radius' | 'border-width'
24
+ | 'outline'
25
+ | 'shadow'
26
+ | 'filter'
27
+ | 'duration'
28
+ | 'aspect-ratio'
23
29
  | 'opacity' | 'z-index'
24
30
  | 'text-align'
25
31
  | 'other';
@@ -35,8 +41,14 @@ export const VARIABLE_GROUPS: { value: VariableGroup; label: string }[] = [
35
41
  { value: 'padding', label: 'Padding' },
36
42
  { value: 'gap', label: 'Gap' },
37
43
  { value: 'size', label: 'Size' },
44
+ { value: 'position', label: 'Position' },
38
45
  { value: 'border-radius', label: 'Border Radius' },
39
46
  { value: 'border-width', label: 'Border Width' },
47
+ { value: 'outline', label: 'Outline' },
48
+ { value: 'shadow', label: 'Shadow' },
49
+ { value: 'filter', label: 'Filter' },
50
+ { value: 'duration', label: 'Duration' },
51
+ { value: 'aspect-ratio', label: 'Aspect Ratio' },
40
52
  { value: 'opacity', label: 'Opacity' },
41
53
  { value: 'z-index', label: 'Z-Index' },
42
54
  { value: 'text-align', label: 'Text Align' },
@@ -56,6 +68,26 @@ const CSS_PROPERTY_TO_GROUP: Record<string, VariableGroup> = {
56
68
  'text-align': 'text-align',
57
69
  'opacity': 'opacity',
58
70
  'z-index': 'z-index',
71
+ 'top': 'position',
72
+ 'right': 'position',
73
+ 'bottom': 'position',
74
+ 'left': 'position',
75
+ 'inset': 'position',
76
+ 'inset-block': 'position',
77
+ 'inset-inline': 'position',
78
+ 'inset-block-start': 'position',
79
+ 'inset-block-end': 'position',
80
+ 'inset-inline-start': 'position',
81
+ 'inset-inline-end': 'position',
82
+ 'box-shadow': 'shadow',
83
+ 'text-shadow': 'shadow',
84
+ 'filter': 'filter',
85
+ 'backdrop-filter': 'filter',
86
+ 'aspect-ratio': 'aspect-ratio',
87
+ 'transition-duration': 'duration',
88
+ 'animation-duration': 'duration',
89
+ 'transition-delay': 'duration',
90
+ 'animation-delay': 'duration',
59
91
  };
60
92
 
61
93
  /** Prefix-based mappings for CSS properties */
@@ -81,6 +113,8 @@ const CSS_PROPERTY_PREFIX_GROUPS: { prefix: string; group: VariableGroup }[] = [
81
113
  { prefix: 'border-right-width', group: 'border-width' },
82
114
  { prefix: 'border-bottom-width', group: 'border-width' },
83
115
  { prefix: 'border-left-width', group: 'border-width' },
116
+ { prefix: 'outline-width', group: 'outline' },
117
+ { prefix: 'outline-offset', group: 'outline' },
84
118
  ];
85
119
 
86
120
  /**
@@ -142,6 +176,9 @@ export const VARIABLE_TYPES: { value: VariableType; label: string }[] = [
142
176
  { value: 'none', label: 'None' },
143
177
  ];
144
178
 
179
+ /** All valid variable scaling type values */
180
+ export const VARIABLE_TYPE_VALUES: VariableType[] = VARIABLE_TYPES.map(t => t.value);
181
+
145
182
  /**
146
183
  * Returns an abbreviation for a variable group by taking the first letter of each word.
147
184
  * e.g. 'letter-spacing' -> 'ls', 'font-size' -> 'fs', 'spacing' -> 's'
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "meno-core",
3
- "version": "1.0.46",
3
+ "version": "1.0.48",
4
4
  "type": "module",
5
5
  "bin": {
6
6
  "meno": "./dist/bin/cli.js"