meno-core 1.1.2 → 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.
- package/dist/chunks/{chunk-J4IPTP5X.js → chunk-UOF4MCAD.js} +92 -4
- package/dist/chunks/chunk-UOF4MCAD.js.map +7 -0
- package/dist/chunks/{chunk-7ZLF4NE5.js → chunk-ZEDLSHYQ.js} +2 -2
- package/dist/lib/client/index.js +46 -28
- package/dist/lib/client/index.js.map +2 -2
- package/dist/lib/server/index.js +1287 -236
- package/dist/lib/server/index.js.map +4 -4
- package/dist/lib/shared/index.js +5 -1
- 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/scripts/formHandler.ts +17 -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 +23 -0
- package/lib/server/fileWatcher.ts +6 -1
- package/lib/server/index.ts +19 -0
- package/lib/server/middleware/cors.test.ts +1 -1
- package/lib/server/middleware/cors.ts +1 -1
- 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/functions.ts +2 -2
- 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/pageService.test.ts +15 -0
- package/lib/server/services/pageService.ts +44 -22
- package/lib/server/ssr/htmlGenerator.test.ts +29 -0
- package/lib/server/ssr/htmlGenerator.ts +83 -3
- 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 +28 -0
- package/lib/shared/interfaces/contentProvider.ts +9 -0
- package/lib/shared/nodeUtils.ts +18 -0
- package/lib/shared/types/cms.ts +1 -0
- package/lib/shared/utilityClassConfig.ts +2 -0
- package/lib/shared/utilityClassMapper.test.ts +63 -0
- package/lib/shared/utilityClassMapper.ts +9 -1
- package/lib/shared/utilityClassNames.ts +74 -0
- package/lib/shared/utils/fileUtils.ts +11 -0
- package/lib/shared/validation/schemas.test.ts +78 -0
- package/lib/shared/validation/schemas.ts +54 -5
- 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-J4IPTP5X.js.map +0 -7
- /package/dist/chunks/{chunk-7ZLF4NE5.js.map → chunk-ZEDLSHYQ.js.map} +0 -0
|
@@ -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') => '/'
|
|
@@ -201,6 +201,84 @@ describe('Schema Validation', () => {
|
|
|
201
201
|
});
|
|
202
202
|
});
|
|
203
203
|
|
|
204
|
+
describe('list prop authoring mistakes surface a clear, prop-named error', () => {
|
|
205
|
+
// Collect every message in a zod error tree (union branches included), so
|
|
206
|
+
// assertions don't depend on which branch zod happens to surface.
|
|
207
|
+
const allMessages = (error: import('zod').ZodError): string[] => {
|
|
208
|
+
const out: string[] = [];
|
|
209
|
+
const walk = (issues: import('zod').ZodIssue[]) => {
|
|
210
|
+
for (const issue of issues) {
|
|
211
|
+
out.push(issue.message);
|
|
212
|
+
if (issue.code === 'invalid_union') {
|
|
213
|
+
for (const ue of (issue as import('zod').ZodInvalidUnionIssue).unionErrors) walk(ue.errors);
|
|
214
|
+
}
|
|
215
|
+
}
|
|
216
|
+
};
|
|
217
|
+
walk(error.issues);
|
|
218
|
+
return out;
|
|
219
|
+
};
|
|
220
|
+
|
|
221
|
+
test('a bare-string `default` with no `itemSchema` is rejected with the list-prop requirement', () => {
|
|
222
|
+
// The single most common first guess. The codec round-trips it and
|
|
223
|
+
// `astro build` renders it, so the editor load path is the only guard.
|
|
224
|
+
const propDef = { type: 'list', default: ['First', 'Second'] };
|
|
225
|
+
|
|
226
|
+
const result = PropDefinitionSchema.safeParse(propDef);
|
|
227
|
+
|
|
228
|
+
expect(result.success).toBe(false);
|
|
229
|
+
if (!result.success) {
|
|
230
|
+
expect(allMessages(result.error).some((m) => m.includes('list prop requires `itemSchema`'))).toBe(true);
|
|
231
|
+
}
|
|
232
|
+
});
|
|
233
|
+
|
|
234
|
+
test('an object-array `default` still requires `itemSchema`', () => {
|
|
235
|
+
const propDef = { type: 'list', default: [{ label: 'First' }] };
|
|
236
|
+
const result = PropDefinitionSchema.safeParse(propDef);
|
|
237
|
+
expect(result.success).toBe(false);
|
|
238
|
+
});
|
|
239
|
+
|
|
240
|
+
test('the correct form (itemSchema + object-array default) passes', () => {
|
|
241
|
+
const propDef = {
|
|
242
|
+
type: 'list',
|
|
243
|
+
itemSchema: { label: { type: 'string', default: 'Item' } },
|
|
244
|
+
default: [{ label: 'First' }, { label: 'Second' }],
|
|
245
|
+
};
|
|
246
|
+
expect(PropDefinitionSchema.safeParse(propDef).success).toBe(true);
|
|
247
|
+
});
|
|
248
|
+
|
|
249
|
+
test('a component file with a bad list prop reports the prop error, NOT the misleading JSONPage refine', () => {
|
|
250
|
+
// Regression for the union-dirty-branch bug: a `{ component }` payload
|
|
251
|
+
// whose list prop is malformed used to surface "JSONPage must have at
|
|
252
|
+
// least one of: meta, components, or root" (the page branch's refine),
|
|
253
|
+
// which names nothing useful. It must surface the list-prop error instead.
|
|
254
|
+
const componentFile = {
|
|
255
|
+
component: {
|
|
256
|
+
interface: { items: { type: 'list', default: ['a', 'b'] } },
|
|
257
|
+
structure: { type: 'node', tag: 'div', children: [] },
|
|
258
|
+
},
|
|
259
|
+
};
|
|
260
|
+
|
|
261
|
+
const result = PageDataSchema.safeParse(componentFile);
|
|
262
|
+
|
|
263
|
+
expect(result.success).toBe(false);
|
|
264
|
+
if (!result.success) {
|
|
265
|
+
const messages = allMessages(result.error);
|
|
266
|
+
expect(messages.some((m) => m.includes('list prop requires `itemSchema`'))).toBe(true);
|
|
267
|
+
expect(messages.some((m) => m.includes('JSONPage must have'))).toBe(false);
|
|
268
|
+
}
|
|
269
|
+
});
|
|
270
|
+
|
|
271
|
+
test('an empty payload (a real page with no fields) still reports the JSONPage refine', () => {
|
|
272
|
+
// The guard only fires on a present `component` key — a genuinely empty
|
|
273
|
+
// page payload must keep its existing refine message.
|
|
274
|
+
const result = PageDataSchema.safeParse({});
|
|
275
|
+
expect(result.success).toBe(false);
|
|
276
|
+
if (!result.success) {
|
|
277
|
+
expect(allMessages(result.error).some((m) => m.includes('JSONPage must have'))).toBe(true);
|
|
278
|
+
}
|
|
279
|
+
});
|
|
280
|
+
});
|
|
281
|
+
|
|
204
282
|
describe('Missing required fields handled', () => {
|
|
205
283
|
test('prop definition without default', () => {
|
|
206
284
|
const propDef = {
|
|
@@ -126,10 +126,47 @@ export const ListPropDefinitionSchema = z
|
|
|
126
126
|
.passthrough();
|
|
127
127
|
|
|
128
128
|
/**
|
|
129
|
-
* Prop definition schema
|
|
130
|
-
*
|
|
129
|
+
* Prop definition schema — discriminates on `type` instead of a plain
|
|
130
|
+
* `z.union([List, Base])`.
|
|
131
|
+
*
|
|
132
|
+
* Why not a bare union: a `type: "list"` prop with a bad shape (the single most
|
|
133
|
+
* common authoring mistake — a bare-string `default` and/or a missing
|
|
134
|
+
* `itemSchema`, e.g. `{ type: "list", default: ["a", "b"] }`) matches NEITHER
|
|
135
|
+
* union branch (List wants `itemSchema` + an object-array `default`; Base's
|
|
136
|
+
* `type` enum has no `"list"`). The union then reports a noisy pile of
|
|
137
|
+
* cross-branch issues (`type` not in the base enum, `default[0]` expected object,
|
|
138
|
+
* …) — and, worse, when this prop sits inside a component file the outer
|
|
139
|
+
* `PageDataSchema` union surfaces the *page* branch's refine instead (see
|
|
140
|
+
* `JSONPageSchema` / `PageDataSchema` below), so the real error reads
|
|
141
|
+
* "JSONPage must have …root". The codec round-trips it and `astro build`
|
|
142
|
+
* renders it, so the only place it ever surfaces is here.
|
|
143
|
+
*
|
|
144
|
+
* Discriminating on `type` lets a list prop be validated by ONLY
|
|
145
|
+
* `ListPropDefinitionSchema` and collapses its failure into one actionable
|
|
146
|
+
* message that names the requirement, at the prop's own path.
|
|
131
147
|
*/
|
|
132
|
-
export const PropDefinitionSchema = z
|
|
148
|
+
export const PropDefinitionSchema = z
|
|
149
|
+
.object({ type: z.string() })
|
|
150
|
+
.passthrough()
|
|
151
|
+
.superRefine((val, ctx) => {
|
|
152
|
+
const branch = val.type === 'list' ? ListPropDefinitionSchema : BasePropDefinitionSchema;
|
|
153
|
+
const result = branch.safeParse(val);
|
|
154
|
+
if (result.success) return;
|
|
155
|
+
if (val.type === 'list') {
|
|
156
|
+
// Collapse the list-prop failure into one actionable message rather than
|
|
157
|
+
// replaying zod's per-field noise. `itemSchema` is required and `default`
|
|
158
|
+
// must be an array of OBJECTS (one `{ field: value }` record per item).
|
|
159
|
+
ctx.addIssue({
|
|
160
|
+
code: z.ZodIssueCode.custom,
|
|
161
|
+
message:
|
|
162
|
+
'list prop requires `itemSchema` and an object-array `default` ' +
|
|
163
|
+
'(each item is a `{ field: value }` object, not a bare string)',
|
|
164
|
+
});
|
|
165
|
+
return;
|
|
166
|
+
}
|
|
167
|
+
// Non-list props keep their precise per-field errors.
|
|
168
|
+
for (const issue of result.error.issues) ctx.addIssue(issue);
|
|
169
|
+
});
|
|
133
170
|
|
|
134
171
|
/**
|
|
135
172
|
* Style mapping schema
|
|
@@ -722,6 +759,14 @@ export const JSONPageSchema = z
|
|
|
722
759
|
// Marks a page whose source is outside the meno-astro dialect (read-only in the editor).
|
|
723
760
|
// Explicit (not just .passthrough()) so it survives a future tightening of this schema.
|
|
724
761
|
_unsupported: z.object({ reason: z.string() }).passthrough().optional(),
|
|
762
|
+
// A component payload (`{ component: {...} }`) must NEVER validate as a page.
|
|
763
|
+
// Without this guard the page branch parses structurally (it's `.passthrough()`)
|
|
764
|
+
// and only trips the refine below — so when both branches of `PageDataSchema`
|
|
765
|
+
// fail, zod treats this branch as the "closest" (dirty) one and surfaces
|
|
766
|
+
// "JSONPage must have …root", masking the real component error (e.g. a malformed
|
|
767
|
+
// list prop). Rejecting a present `component` key makes this branch hard-fail on
|
|
768
|
+
// component payloads, so `PageDataSchema` reports the component branch instead.
|
|
769
|
+
component: z.undefined().optional(),
|
|
725
770
|
})
|
|
726
771
|
.passthrough()
|
|
727
772
|
.refine(
|
|
@@ -752,8 +797,11 @@ export const PageDataWithComponentSchema = z
|
|
|
752
797
|
.passthrough();
|
|
753
798
|
|
|
754
799
|
/**
|
|
755
|
-
* Page data schema - union of JSONPage and PageDataWithComponent
|
|
756
|
-
* PageDataWithComponent checked first because it's more specific
|
|
800
|
+
* Page data schema - union of JSONPage and PageDataWithComponent.
|
|
801
|
+
* PageDataWithComponent is checked first because it's more specific; the
|
|
802
|
+
* `component: z.undefined()` guard on `JSONPageSchema` keeps a component payload
|
|
803
|
+
* from being mis-reported as a page (see that schema's comment), so a malformed
|
|
804
|
+
* prop surfaces its own error instead of the page-branch refine.
|
|
757
805
|
*/
|
|
758
806
|
export const PageDataSchema = z.union([PageDataWithComponentSchema, JSONPageSchema]);
|
|
759
807
|
|
|
@@ -791,6 +839,7 @@ export const CMSFieldDefinitionSchema = z
|
|
|
791
839
|
accept: z.string().optional(),
|
|
792
840
|
collection: z.string().optional(),
|
|
793
841
|
multiple: z.boolean().optional(),
|
|
842
|
+
editor: z.enum(['basic', 'extended']).optional(), // For 'rich-text' type: selects the render helper (Basic → richText(); Extended → richTextWithComponents())
|
|
794
843
|
})
|
|
795
844
|
.passthrough();
|
|
796
845
|
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { describe, it, expect } from 'bun:test';
|
|
2
|
-
import { rewriteViewportUnits } from './viewportUnits';
|
|
2
|
+
import { rewriteViewportUnits, rewriteViewportUnitsInStylesheet } from './viewportUnits';
|
|
3
3
|
|
|
4
4
|
describe('rewriteViewportUnits', () => {
|
|
5
5
|
it('rewrites basic vh values in declarations', () => {
|
|
@@ -102,3 +102,36 @@ describe('rewriteViewportUnits', () => {
|
|
|
102
102
|
expect(rewriteViewportUnits('top: 0vh;')).toBe('top: calc(var(--design-vh, 1vh) * 0);');
|
|
103
103
|
});
|
|
104
104
|
});
|
|
105
|
+
|
|
106
|
+
describe('rewriteViewportUnitsInStylesheet', () => {
|
|
107
|
+
it('preserves an escaped selector carrying a DECIMAL viewport unit', () => {
|
|
108
|
+
// `text-[clamp(56px,9.5vw,150px)]` escapes to `…9\.5vw…`; the value-level
|
|
109
|
+
// rewriter matches `.5vw` after the backslash and corrupts the selector, so
|
|
110
|
+
// the browser drops the rule. Stylesheet-scoped rewrite touches only bodies.
|
|
111
|
+
const rule = '.text-\\[clamp\\(56px\\,9\\.5vw\\,150px\\)\\] { font-size: clamp(56px,9.5vw,150px); }';
|
|
112
|
+
expect(rewriteViewportUnitsInStylesheet(rule)).toBe(
|
|
113
|
+
'.text-\\[clamp\\(56px\\,9\\.5vw\\,150px\\)\\] { font-size: clamp(56px,calc(var(--design-vw, 1vw) * 9.5),150px); }',
|
|
114
|
+
);
|
|
115
|
+
// Rationale guard: the raw value rewriter alone corrupts this selector.
|
|
116
|
+
expect(rewriteViewportUnits(rule).split('{')[0]).toContain('calc(');
|
|
117
|
+
});
|
|
118
|
+
|
|
119
|
+
it('preserves an escaped selector when a viewport unit follows a no-space comma', () => {
|
|
120
|
+
const rule = '.p-\\[80px_clamp\\(20px\\,5vw\\,64px\\)\\] { padding: 80px clamp(20px,5vw,64px); }';
|
|
121
|
+
expect(rewriteViewportUnitsInStylesheet(rule)).toBe(
|
|
122
|
+
'.p-\\[80px_clamp\\(20px\\,5vw\\,64px\\)\\] { padding: 80px clamp(20px,calc(var(--design-vw, 1vw) * 5),64px); }',
|
|
123
|
+
);
|
|
124
|
+
});
|
|
125
|
+
|
|
126
|
+
it('rewrites declaration values inside @media blocks while keeping nested selectors literal', () => {
|
|
127
|
+
const sheet = '@media (max-width: 767px) { .h-\\[50dvh\\] { height: 50dvh; } }';
|
|
128
|
+
expect(rewriteViewportUnitsInStylesheet(sheet)).toBe(
|
|
129
|
+
'@media (max-width: 767px) { .h-\\[50dvh\\] { height: calc(var(--design-dvh, 1dvh) * 50); } }',
|
|
130
|
+
);
|
|
131
|
+
});
|
|
132
|
+
|
|
133
|
+
it('is a no-op on empty / brace-free input', () => {
|
|
134
|
+
expect(rewriteViewportUnitsInStylesheet('')).toBe('');
|
|
135
|
+
expect(rewriteViewportUnitsInStylesheet('.a { color: red; }')).toBe('.a { color: red; }');
|
|
136
|
+
});
|
|
137
|
+
});
|
|
@@ -53,6 +53,49 @@ export function rewriteViewportUnits(input: string): string {
|
|
|
53
53
|
});
|
|
54
54
|
}
|
|
55
55
|
|
|
56
|
+
/**
|
|
57
|
+
* Apply {@link rewriteViewportUnits} to a whole stylesheet, rewriting ONLY the
|
|
58
|
+
* declaration bodies — never the selectors or at-rule preludes.
|
|
59
|
+
*
|
|
60
|
+
* {@link rewriteViewportUnits} is regex-based and can't reliably tell a
|
|
61
|
+
* declaration value's viewport unit from one buried in an *escaped*
|
|
62
|
+
* arbitrary-value class SELECTOR. The `VIEWPORT_UNIT_RE` lookbehind guards the
|
|
63
|
+
* common selector shapes (`.mh-100vh`, `.min-h-[100svh]`), but a class whose
|
|
64
|
+
* arbitrary value carries a viewport unit after an escaped `.` or `,` slips
|
|
65
|
+
* through — e.g. `text-[clamp(56px,9.5vw,150px)]` escapes to
|
|
66
|
+
* `.text-\[clamp\(56px\,9\.5vw\,150px\)\]`, where the `.5vw` sits right after a
|
|
67
|
+
* backslash and the rewriter injects `calc(...)` INTO the selector. The result
|
|
68
|
+
* is invalid CSS the browser drops entirely, so the rule — and the design-mode
|
|
69
|
+
* viewport pin it carries — silently vanishes and the element renders unstyled.
|
|
70
|
+
*
|
|
71
|
+
* Splitting selector text from declaration bodies at brace boundaries removes
|
|
72
|
+
* the ambiguity: every selector / prelude ends at a `{`, every declaration body
|
|
73
|
+
* ends at a `}`, so only body text is handed to the value rewriter. Correct
|
|
74
|
+
* through `@media` nesting too (a nested rule's selector still ends at its own
|
|
75
|
+
* `{`). Assumes braces only ever delimit blocks — true for the generated
|
|
76
|
+
* utility/interactive/theme sheets (none emit literal `{`/`}` inside a value or
|
|
77
|
+
* string). Mirror of meno-astro's `rewriteViewportUnitsInStylesheet` (keep in
|
|
78
|
+
* sync — see packages/astro/lib/integration/viewportUnits.ts).
|
|
79
|
+
*/
|
|
80
|
+
export function rewriteViewportUnitsInStylesheet(css: string): string {
|
|
81
|
+
if (!css) return css;
|
|
82
|
+
let out = '';
|
|
83
|
+
let seg = '';
|
|
84
|
+
for (let i = 0; i < css.length; i++) {
|
|
85
|
+
const ch = css[i];
|
|
86
|
+
if (ch === '{') {
|
|
87
|
+
out += seg + ch; // `seg` is a selector / at-rule prelude — keep it literal
|
|
88
|
+
seg = '';
|
|
89
|
+
} else if (ch === '}') {
|
|
90
|
+
out += rewriteViewportUnits(seg) + ch; // `seg` is a declaration body — rewrite its values
|
|
91
|
+
seg = '';
|
|
92
|
+
} else {
|
|
93
|
+
seg += ch;
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
return out + seg; // trailing text outside any block — never a declaration value
|
|
97
|
+
}
|
|
98
|
+
|
|
56
99
|
/**
|
|
57
100
|
* Names of the CSS custom properties the runtime sets/clears to switch
|
|
58
101
|
* between design-mode (stable px) and page-mode (no override, vh = actual
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "meno-core",
|
|
3
|
-
"version": "1.1.
|
|
3
|
+
"version": "1.1.4",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"bin": {
|
|
6
6
|
"meno": "./dist/bin/cli.js"
|
|
@@ -23,6 +23,7 @@
|
|
|
23
23
|
},
|
|
24
24
|
"dependencies": {
|
|
25
25
|
"@emotion/css": "^11.13.5",
|
|
26
|
+
"css-tree": "^3.2.1",
|
|
26
27
|
"esbuild": "^0.25.0",
|
|
27
28
|
"isomorphic-dompurify": "^2.32.0",
|
|
28
29
|
"jsep": "^1.4.0",
|
|
@@ -33,6 +34,7 @@
|
|
|
33
34
|
"zod": "^3.22.4"
|
|
34
35
|
},
|
|
35
36
|
"devDependencies": {
|
|
37
|
+
"@types/css-tree": "^2.3.11",
|
|
36
38
|
"@types/markdown-it": "^14.1.2",
|
|
37
39
|
"@types/react": "^18.2.0",
|
|
38
40
|
"@types/react-dom": "^18.2.0",
|
package/vite.config.ts
CHANGED
|
@@ -19,7 +19,10 @@ export default defineConfig({
|
|
|
19
19
|
'react',
|
|
20
20
|
'react-dom',
|
|
21
21
|
'bun',
|
|
22
|
-
// Node.js built-ins (server code)
|
|
22
|
+
// Node.js built-ins (server code) — bare and node:-prefixed specifiers.
|
|
23
|
+
// The regex covers all `node:`-prefixed imports (e.g. `node:path`) so they
|
|
24
|
+
// stay external instead of being browser-stubbed by Vite.
|
|
25
|
+
/^node:/,
|
|
23
26
|
'path',
|
|
24
27
|
'fs',
|
|
25
28
|
'fs/promises',
|