meno-core 1.1.1 → 1.1.3

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 (32) hide show
  1. package/dist/chunks/{chunk-7ZLF4NE5.js → chunk-3JXK2QFU.js} +2 -2
  2. package/dist/chunks/{chunk-QWTQZHG3.js → chunk-FQBIC2OB.js} +1 -1
  3. package/dist/chunks/chunk-FQBIC2OB.js.map +7 -0
  4. package/dist/chunks/{chunk-J4IPTP5X.js → chunk-LOZL5HOF.js} +29 -3
  5. package/dist/chunks/chunk-LOZL5HOF.js.map +7 -0
  6. package/dist/lib/client/index.js +2 -2
  7. package/dist/lib/server/index.js +812 -218
  8. package/dist/lib/server/index.js.map +4 -4
  9. package/dist/lib/shared/index.js +4 -2
  10. package/dist/lib/shared/index.js.map +1 -1
  11. package/lib/client/scripts/formHandler.ts +17 -0
  12. package/lib/server/index.ts +19 -0
  13. package/lib/server/middleware/cors.test.ts +1 -1
  14. package/lib/server/middleware/cors.ts +1 -1
  15. package/lib/server/routes/api/functions.ts +2 -2
  16. package/lib/server/services/ColorService.ts +38 -7
  17. package/lib/server/services/VariableService.ts +34 -5
  18. package/lib/server/themeCssCodec.test.ts +154 -0
  19. package/lib/server/themeCssCodec.ts +488 -0
  20. package/lib/server/themeCssStore.test.ts +139 -0
  21. package/lib/server/themeCssStore.ts +210 -0
  22. package/lib/shared/nodeUtils.ts +18 -0
  23. package/lib/shared/types/colors.ts +6 -2
  24. package/lib/shared/types/variables.ts +14 -7
  25. package/lib/shared/utilityClassMapper.test.ts +10 -0
  26. package/lib/shared/utilityClassMapper.ts +9 -1
  27. package/lib/shared/validation/schemas.test.ts +78 -0
  28. package/lib/shared/validation/schemas.ts +53 -5
  29. package/package.json +1 -1
  30. package/dist/chunks/chunk-J4IPTP5X.js.map +0 -7
  31. package/dist/chunks/chunk-QWTQZHG3.js.map +0 -7
  32. /package/dist/chunks/{chunk-7ZLF4NE5.js.map → chunk-3JXK2QFU.js.map} +0 -0
@@ -0,0 +1,210 @@
1
+ /**
2
+ * theme.css store — the read/modify/write layer over a project's `src/styles/theme.css`,
3
+ * which is the single source of truth for design tokens (colors + variables). It replaces
4
+ * the old `colors.json` / `variables.json` pair for astro-format projects.
5
+ *
6
+ * Colors and variables share ONE file, so {@link saveColors} / {@link saveVariables} each
7
+ * re-read the current file and replace only their half (preserving the other half AND any
8
+ * `raw` passthrough), then re-serialize. All writes are funnelled through a single in-process
9
+ * queue so a colors save and a variables save can't clobber each other.
10
+ *
11
+ * On first access for a project that still carries legacy JSON, {@link migrateIfNeeded}
12
+ * regenerates theme.css from those JSON files (authoritative) and deletes them — a one-way,
13
+ * idempotent migration. Legacy JSON-format projects (no `src/pages`) are not handled here;
14
+ * the services keep their JSON path for those.
15
+ */
16
+
17
+ import { existsSync, readFileSync } from 'node:fs';
18
+ import { rm } from 'node:fs/promises';
19
+ import { join } from 'node:path';
20
+ import { getProjectRoot } from './projectContext';
21
+ import { writeFile, readTextFile, fileExists, ensureDir } from './runtime';
22
+ import { configService } from './services/configService';
23
+ import { parseThemeCss, serializeThemeCss, type ScaleConfig, type ThemeModel } from './themeCssCodec';
24
+ import type { ThemeConfig } from '../shared/types/colors';
25
+ import type { CSSVariable } from '../shared/types/variables';
26
+ import { createLogger } from '../shared/logger';
27
+
28
+ const log = createLogger('themeCssStore');
29
+
30
+ /** Absolute path to a project's theme stylesheet. */
31
+ export function themeCssPath(root: string = getProjectRoot()): string {
32
+ return join(root, 'src', 'styles', 'theme.css');
33
+ }
34
+
35
+ /**
36
+ * True when a project keeps its tokens in `theme.css` (astro-format). Detected by an
37
+ * existing theme.css, an `src/pages` layout, or an explicit `"format": "astro"`. Legacy
38
+ * JSON-format projects return false and keep reading colors.json / variables.json.
39
+ */
40
+ export function isThemeCssProject(root: string = getProjectRoot()): boolean {
41
+ if (existsSync(themeCssPath(root))) return true;
42
+ if (existsSync(join(root, 'src', 'pages'))) return true;
43
+ try {
44
+ const cfg = JSON.parse(readFileSync(join(root, 'project.config.json'), 'utf8')) as { format?: string };
45
+ if (cfg.format === 'astro') return true;
46
+ } catch {
47
+ /* no/invalid config → not astro */
48
+ }
49
+ return false;
50
+ }
51
+
52
+ // All writes (and the migration) run serialized so concurrent color/variable saves
53
+ // read-modify-write the shared file without races.
54
+ let writeChain: Promise<unknown> = Promise.resolve();
55
+ function enqueue<T>(fn: () => Promise<T>): Promise<T> {
56
+ const run = writeChain.then(fn, fn);
57
+ writeChain = run.then(
58
+ () => undefined,
59
+ () => undefined,
60
+ );
61
+ return run;
62
+ }
63
+
64
+ async function readJsonSafe<T>(path: string): Promise<T | null> {
65
+ try {
66
+ return JSON.parse(await readTextFile(path)) as T;
67
+ } catch {
68
+ return null;
69
+ }
70
+ }
71
+
72
+ function scaleConfig(): ScaleConfig {
73
+ return { breakpoints: configService.getBreakpoints(), responsiveScales: configService.getResponsiveScales() };
74
+ }
75
+
76
+ /** Empty-but-valid model for a project with no theme.css yet. */
77
+ function emptyModel(): ThemeModel {
78
+ return { themes: { default: 'default', themes: { default: { colors: {} } } }, variables: [], raw: [] };
79
+ }
80
+
81
+ /** Parse the on-disk theme.css (or an empty model if absent). Assumes config is loaded. */
82
+ async function readModel(root: string): Promise<ThemeModel> {
83
+ const path = themeCssPath(root);
84
+ if (!(await fileExists(path))) return emptyModel();
85
+ return parseThemeCss(await readTextFile(path), configService.getBreakpoints());
86
+ }
87
+
88
+ /** Serialize + persist a model (creating `src/styles` as needed). Assumes config is loaded. */
89
+ async function writeModel(root: string, model: ThemeModel): Promise<void> {
90
+ const css = serializeThemeCss(model, scaleConfig());
91
+ await ensureDir(join(root, 'src', 'styles'));
92
+ await writeFile(themeCssPath(root), css);
93
+ }
94
+
95
+ /** True when a theme has at least one color (i.e. theme.css already holds real tokens). */
96
+ function hasColors(themes: ThemeConfig): boolean {
97
+ return Object.values(themes.themes ?? {}).some((t) => Object.keys(t.colors ?? {}).length > 0);
98
+ }
99
+
100
+ /** Union of two theme configs — `current` (theme.css) wins on conflict; incoming adds keys. */
101
+ function mergeThemes(current: ThemeConfig, incoming: ThemeConfig | null): ThemeConfig {
102
+ if (!incoming?.themes) return current;
103
+ if (!hasColors(current)) return incoming; // empty theme.css → adopt the JSON wholesale
104
+ const out: ThemeConfig = { default: current.default, palette: current.palette, themes: { ...current.themes } };
105
+ for (const [name, theme] of Object.entries(incoming.themes)) {
106
+ const existing = out.themes[name];
107
+ out.themes[name] = existing ? { ...existing, colors: { ...theme.colors, ...existing.colors } } : theme;
108
+ }
109
+ return out;
110
+ }
111
+
112
+ /** Union of two variable lists by `cssVar` — `current` wins on conflict; incoming adds new. */
113
+ function mergeVariables(current: CSSVariable[], incoming: CSSVariable[] | undefined): CSSVariable[] {
114
+ if (!incoming?.length) return current;
115
+ const seen = new Set(current.map((v) => v.cssVar));
116
+ return [...current, ...incoming.filter((v) => !seen.has(v.cssVar))];
117
+ }
118
+
119
+ /** Marker the serializer always stamps — distinguishes an authored theme.css from a derived one. */
120
+ const AUTHORED_MARKER = 'Meno design tokens';
121
+
122
+ /**
123
+ * Fold a project's `colors.json` + `variables.json` into theme.css, then delete the JSON.
124
+ * Serves two cases:
125
+ * - Legacy migration — theme.css is absent or in the OLD derived format (the previous
126
+ * converter's DO-NOT-EDIT output). The JSON is authoritative: theme.css is rebuilt from it
127
+ * (we must NOT merge the derived file, whose unsectioned `:root` + duplicate `[theme]` blocks
128
+ * would parse into a bogus extra theme).
129
+ * - Overlay — theme.css is already authored (new format) and an external tool (e.g. the
130
+ * style-guide applier) dropped JSON additions. A UNION merge (theme.css wins on key conflict,
131
+ * JSON adds new keys) folds those in without clobbering anything.
132
+ * Foreign CSS (`raw`, e.g. @font-face) is preserved either way. No-op when no JSON is present, so
133
+ * it's safe to run on every load/save. Must be called inside {@link enqueue}.
134
+ */
135
+ async function migrateIfNeeded(root: string): Promise<void> {
136
+ const colorsJson = join(root, 'colors.json');
137
+ const variablesJson = join(root, 'variables.json');
138
+ const hasColorsJson = existsSync(colorsJson);
139
+ const hasVarsJson = existsSync(variablesJson);
140
+ if (!hasColorsJson && !hasVarsJson) return;
141
+ try {
142
+ const jsonColors = hasColorsJson ? await readJsonSafe<ThemeConfig>(colorsJson) : null;
143
+ const jsonVars = hasVarsJson ? await readJsonSafe<{ variables: CSSVariable[] }>(variablesJson) : null;
144
+
145
+ let current = emptyModel();
146
+ let authored = false;
147
+ if (await fileExists(themeCssPath(root))) {
148
+ const text = await readTextFile(themeCssPath(root));
149
+ authored = text.includes(AUTHORED_MARKER);
150
+ current = parseThemeCss(text, configService.getBreakpoints());
151
+ }
152
+ // Authored → overlay-merge (theme.css wins). Derived/absent → JSON authoritative (base empty),
153
+ // but keep any foreign CSS the derived file happened to carry.
154
+ const base = authored ? current : emptyModel();
155
+ const merged: ThemeModel = {
156
+ themes: mergeThemes(base.themes, jsonColors?.themes ? jsonColors : null),
157
+ variables: mergeVariables(base.variables, jsonVars?.variables),
158
+ raw: current.raw,
159
+ };
160
+ await writeModel(root, merged);
161
+ await rm(colorsJson, { force: true });
162
+ await rm(variablesJson, { force: true });
163
+ log.info('Folded colors.json / variables.json into src/styles/theme.css');
164
+ } catch (err) {
165
+ // Leave the JSON in place so a later attempt can retry; surface but don't throw.
166
+ log.warn('theme.css migration failed; keeping JSON files:', err);
167
+ }
168
+ }
169
+
170
+ /** Load the full token model (runs the one-time JSON→theme.css migration first). */
171
+ export async function loadThemeModel(root: string = getProjectRoot()): Promise<ThemeModel> {
172
+ await configService.load();
173
+ return enqueue(async () => {
174
+ await migrateIfNeeded(root);
175
+ return readModel(root);
176
+ });
177
+ }
178
+
179
+ /** Persist colors (themes) into theme.css, preserving the variables + raw already there. */
180
+ export async function saveColors(themes: ThemeConfig, root: string = getProjectRoot()): Promise<void> {
181
+ await configService.load();
182
+ return enqueue(async () => {
183
+ await migrateIfNeeded(root);
184
+ const current = await readModel(root);
185
+ await writeModel(root, { themes, variables: current.variables, raw: current.raw });
186
+ });
187
+ }
188
+
189
+ /** Persist variables into theme.css, preserving the colors + raw already there. */
190
+ export async function saveVariables(variables: CSSVariable[], root: string = getProjectRoot()): Promise<void> {
191
+ await configService.load();
192
+ return enqueue(async () => {
193
+ await migrateIfNeeded(root);
194
+ const current = await readModel(root);
195
+ await writeModel(root, { themes: current.themes, variables, raw: current.raw });
196
+ });
197
+ }
198
+
199
+ /**
200
+ * Re-serialize theme.css from its own current contents — used after a `project.config.json`
201
+ * scale-knob change to regenerate the AUTO `@media` region from the new config.
202
+ */
203
+ export async function regenerateThemeCss(root: string = getProjectRoot()): Promise<void> {
204
+ await configService.load();
205
+ return enqueue(async () => {
206
+ await migrateIfNeeded(root);
207
+ if (!(await fileExists(themeCssPath(root)))) return;
208
+ await writeModel(root, await readModel(root));
209
+ });
210
+ }
@@ -91,6 +91,24 @@ export function isHtmlNode(node: ComponentNode | null | undefined): node is Html
91
91
  return node?.type === NODE_TYPE.NODE;
92
92
  }
93
93
 
94
+ /**
95
+ * Whether a node stores its utility-class styling on `attributes.class` — the class-styling system's
96
+ * `attributes.class` storage path. HTML elements plus the html-like content nodes (embed/link/markdown)
97
+ * qualify: each carries a `style` AND an `attributes` map, and the codec already round-trips a class on
98
+ * them. Component instances are NOT included here — they store on the `class` PROP (a separate
99
+ * eligibility path; see isClassEligible / migrateOneNode, the two callers that must agree). Excluded:
100
+ * list/island/slot/custom (`canHaveStyle:false` or no styling model) and locale-list (its root class is
101
+ * not codec-wired yet — joins in Phase 2). Single source of truth for "what can hold an `attributes.class`".
102
+ */
103
+ export function isClassStylableNode(node: ComponentNode | null | undefined): boolean {
104
+ return (
105
+ node?.type === NODE_TYPE.NODE ||
106
+ node?.type === NODE_TYPE.EMBED ||
107
+ node?.type === NODE_TYPE.LINK ||
108
+ node?.type === NODE_TYPE.MARKDOWN
109
+ );
110
+ }
111
+
94
112
  /**
95
113
  * Get the component name from a ComponentNode (for component instances)
96
114
  */
@@ -12,10 +12,14 @@ export interface ColorVariables {
12
12
  }
13
13
 
14
14
  /**
15
- * Theme configuration with color set and metadata
15
+ * Theme configuration with color set and metadata.
16
+ *
17
+ * `label` is an editor-only display string. The theme.css codec does not persist
18
+ * it (a theme is identified by its name — the `[theme="<name>"]` selector), so it
19
+ * is optional; consumers fall back to the (prettified) theme name.
16
20
  */
17
21
  export interface Theme {
18
- label: string;
22
+ label?: string;
19
23
  colors: Record<string, string>;
20
24
  }
21
25
 
@@ -146,22 +146,29 @@ export function getGroupForProperty(prop: string): VariableGroup | null {
146
146
  }
147
147
 
148
148
  /**
149
- * A single CSS custom property definition
149
+ * A single CSS custom property definition.
150
+ *
151
+ * Source of truth is `src/styles/theme.css` (the `theme.css` codec), so the CSS
152
+ * variable name (`cssVar`) IS a token's identity — `name`/`prop_name` are
153
+ * editor-only labels that the codec no longer persists, and `group`/`type` are
154
+ * DERIVED on parse (from the var's comment-section header, falling back to a
155
+ * value heuristic), not stored. They stay on the in-memory object because the
156
+ * pickers and the responsive-scaling generator still consult them.
150
157
  */
151
158
  export interface CSSVariable {
152
- /** Display name (e.g., "H1 Font Size") */
153
- name: string;
154
- /** Optional prop name alias for organizational purposes */
159
+ /** Optional display label. Not persisted by the theme.css codec — defaults to `cssVar`. */
160
+ name?: string;
161
+ /** Optional prop name alias for organizational purposes (legacy; not persisted). */
155
162
  prop_name?: string;
156
- /** CSS custom property name (e.g., "--h1-fs") */
163
+ /** CSS custom property name (e.g., "--h1-fs") — the token's persistent identity. */
157
164
  cssVar: string;
158
165
  /** Base value (e.g., "48px") */
159
166
  value: string;
160
- /** Category for responsive scaling */
167
+ /** Category for responsive scaling (derived from {@link group} via getDefaultScalingType). */
161
168
  type: VariableType;
162
169
  /** Optional per-variable breakpoint scale overrides */
163
170
  scales?: Record<string, string>;
164
- /** Optional UI filtering group for the variable picker */
171
+ /** UI filtering group for the variable picker (derived from the theme.css comment section). */
165
172
  group?: VariableGroup;
166
173
  }
167
174
 
@@ -97,6 +97,16 @@ describe('utilityClassMapper', () => {
97
97
  expect(classToStyle('font-(--l-ff)')).toEqual({ prop: 'fontFamily', value: 'var(--l-ff)' });
98
98
  });
99
99
 
100
+ test('a color var whose token name can NOT be a bare token uses the arbitrary form + round-trips', () => {
101
+ // A CSS-color name (`--salmon`) or palette-shaped name (`--text-100`) can't be a bare token
102
+ // (`bg-salmon` would render the CSS color salmon), and the `bg-(--x)` shorthand reverses to the
103
+ // bare value — dropping var(). The arbitrary form keeps the variable exact and reverses cleanly,
104
+ // so the value can live in a class instead of the style object (static AND interactive styles).
105
+ expect(stylesToClasses({ backgroundColor: 'var(--salmon)' })).toContain('bg-[var(--salmon)]');
106
+ expect(stylesToClasses({ color: 'var(--text-100)' })).toContain('text-[var(--text-100)]');
107
+ expect(classToStyle('bg-[var(--salmon)]')).toEqual({ prop: 'backgroundColor', value: 'var(--salmon)' });
108
+ });
109
+
100
110
  test('border/outline shorthand var binding stays the shorthand (not the color longhand)', () => {
101
111
  // `border`/`outline` roots are shared by a shorthand and a color longhand. A `var(--x)` value
102
112
  // is opaque and reads as a color, so the bare `border-(--x)` form would round-trip to
@@ -243,9 +243,17 @@ function propertyValueToClass(prop: string, value: string | number): string | nu
243
243
  }
244
244
  // A `var(--token)` on a color-token prop IS a Meno token reference → emit the bare token
245
245
  // form (`bg-muted`), matching the bare-value path so `var(--muted)` and `muted` agree.
246
- // (Token-aware inverse recovers it; palette-shaped names fall through to the var shorthand.)
246
+ // (Token-aware inverse recovers it; palette-shaped names fall through to the arbitrary form.)
247
247
  else if (colorTokenProps.has(prop) && isBareColorTokenName(varName)) {
248
248
  className = `${root}-${varName}`;
249
+ }
250
+ // A color var whose token name can't be a bare token — a CSS color name (`--salmon`) or a
251
+ // palette-shaped name (`--text-100`). The `root-(--x)` shorthand would reverse to the BARE
252
+ // value (classToStyle strips `var()` for color props), losing the variable — and a CSS-color
253
+ // name would then render as the literal color. The arbitrary form keeps `var(--x)` exact and
254
+ // round-trips losslessly (`bg-[var(--x)]` → `var(--x)`), so the value can live in a class.
255
+ else if (colorTokenProps.has(prop)) {
256
+ className = `${root}-[var(${varMatch[1]})]`;
249
257
  } else className = `${root}-(${varMatch[1]})`;
250
258
  }
251
259
  }
@@ -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 (union of base and list)
130
- * Validates prop definitions from component interfaces
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.union([ListPropDefinitionSchema, BasePropDefinitionSchema]);
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
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "meno-core",
3
- "version": "1.1.1",
3
+ "version": "1.1.3",
4
4
  "type": "module",
5
5
  "bin": {
6
6
  "meno": "./dist/bin/cli.js"