meno-core 1.0.15 → 1.0.18

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/bin/cli.ts CHANGED
@@ -27,9 +27,13 @@ Commands:
27
27
  serve Serve built files from ./dist on port 8080
28
28
  init Initialize a new project
29
29
 
30
+ Options:
31
+ build --dev Include draft pages in build (for local preview)
32
+
30
33
  Examples:
31
34
  meno dev Start dev server in current directory
32
- meno build Build static files to ./dist
35
+ meno build Build static files to ./dist (excludes drafts)
36
+ meno build --dev Build including draft pages
33
37
  meno serve Serve built files on port 8080
34
38
  meno init my-project Create new project in ./my-project
35
39
  `);
@@ -167,7 +171,7 @@ async function runDev() {
167
171
  await import('../entries/server-router');
168
172
  }
169
173
 
170
- async function runBuild() {
174
+ async function runBuild(isDev: boolean = false) {
171
175
  const projectRoot = process.cwd();
172
176
 
173
177
  // Validate project structure
@@ -179,7 +183,12 @@ async function runBuild() {
179
183
  // Set project root for path resolution
180
184
  setProjectRoot(projectRoot);
181
185
 
182
- console.log(`📁 Building project: ${projectRoot}`);
186
+ // Set dev mode environment variable (drafts are built in dev mode)
187
+ if (isDev) {
188
+ process.env.MENO_DEV_BUILD = 'true';
189
+ }
190
+
191
+ console.log(`📁 Building project: ${projectRoot}${isDev ? ' (dev mode - including drafts)' : ''}`);
183
192
 
184
193
  // Import and run build
185
194
  await import('../build-static.ts');
@@ -397,7 +406,7 @@ switch (command) {
397
406
  runDev();
398
407
  break;
399
408
  case 'build':
400
- runBuild();
409
+ runBuild(args.includes('--dev'));
401
410
  break;
402
411
  case 'serve':
403
412
  runServe();
package/build-static.ts CHANGED
@@ -319,6 +319,13 @@ async function buildCMSTemplates(
319
319
  try {
320
320
  const pageData = parseJSON<JSONPage>(templateContent);
321
321
 
322
+ // Skip draft templates in production (not in dev mode)
323
+ const isDevBuild = process.env.MENO_DEV_BUILD === 'true';
324
+ if (pageData.meta?.draft === true && !isDevBuild) {
325
+ console.log(`⏭️ Skipping draft template: ${file}`);
326
+ continue;
327
+ }
328
+
322
329
  if (!isCMSPage(pageData)) {
323
330
  console.warn(`⚠️ ${file} is in templates/ but missing meta.source: "cms"`);
324
331
  continue;
@@ -423,18 +430,33 @@ async function buildStaticPages(): Promise<void> {
423
430
  copyDirectory(functionsDir, join(distDir, "functions"));
424
431
  }
425
432
 
426
- // Copy _headers and _redirects files for static hosting (Netlify, Cloudflare Pages)
433
+ // Copy user-created root files for static hosting
427
434
  const hostingFiles: string[] = [];
428
- const headersFile = join(projectPaths.project, '_headers');
429
- const redirectsFile = join(projectPaths.project, '_redirects');
430
-
431
- if (existsSync(headersFile)) {
432
- copyFileSync(headersFile, join(distDir, '_headers'));
433
- hostingFiles.push('_headers');
435
+ const rootFilesToCopy = [
436
+ '_headers', // Netlify/Cloudflare headers
437
+ '_redirects', // Netlify/Cloudflare redirects
438
+ 'llms.txt', // LLM context
439
+ 'humans.txt', // Team credits
440
+ 'ads.txt', // Ad verification
441
+ 'security.txt', // Security contact
442
+ 'CNAME', // GitHub Pages domain
443
+ 'manifest.json', // PWA manifest
444
+ 'site.webmanifest', // PWA manifest (alt)
445
+ ];
446
+
447
+ for (const file of rootFilesToCopy) {
448
+ const filePath = join(projectPaths.project, file);
449
+ if (existsSync(filePath)) {
450
+ copyFileSync(filePath, join(distDir, file));
451
+ hostingFiles.push(file);
452
+ }
434
453
  }
435
- if (existsSync(redirectsFile)) {
436
- copyFileSync(redirectsFile, join(distDir, '_redirects'));
437
- hostingFiles.push('_redirects');
454
+
455
+ // Copy .well-known directory if exists
456
+ const wellKnownDir = join(projectPaths.project, '.well-known');
457
+ if (existsSync(wellKnownDir)) {
458
+ copyDirectory(wellKnownDir, join(distDir, '.well-known'));
459
+ hostingFiles.push('.well-known/');
438
460
  }
439
461
 
440
462
  const parts = ['Assets'];
@@ -508,6 +530,13 @@ async function buildStaticPages(): Promise<void> {
508
530
  try {
509
531
  const pageData = parseJSON<JSONPage>(pageContent);
510
532
 
533
+ // Skip draft pages in production (not in dev mode)
534
+ const isDevBuild = process.env.MENO_DEV_BUILD === 'true';
535
+ if (pageData.meta?.draft === true && !isDevBuild) {
536
+ console.log(`⏭️ Skipping draft: ${basePath}`);
537
+ continue;
538
+ }
539
+
511
540
  // Get translated slugs from page meta (if available)
512
541
  const slugs = pageData.meta?.slugs;
513
542
 
@@ -607,7 +636,7 @@ async function buildStaticPages(): Promise<void> {
607
636
  }
608
637
 
609
638
  // Run build
610
- buildStaticPages().catch((error) => {
639
+ await buildStaticPages().catch((error) => {
611
640
  console.error("❌ Build failed:", error);
612
641
  process.exit(1);
613
642
  });
@@ -387,7 +387,7 @@ export class ComponentBuilder {
387
387
  * Filter internal props from node props
388
388
  */
389
389
  private filterInternalProps(nodeProps: Record<string, unknown>, tag: string | undefined): Record<string, unknown> {
390
- const imageOnlyProps = ['src', 'alt', 'loading', 'width', 'height', 'sizes', 'srcset', 'fetchpriority', 'priority'];
390
+ const imageOnlyProps = ['src', 'alt', 'loading', 'width', 'height', 'sizes', 'srcset', 'fetchpriority'];
391
391
  const internalProps = ['type', 'tag', 'component', 'props', 'children', 'html', 'style', ...imageOnlyProps];
392
392
  const props: Record<string, unknown> = {};
393
393
 
@@ -339,6 +339,11 @@ export function processStructure(
339
339
  if (result === undefined || result === null) {
340
340
  return '';
341
341
  }
342
+ // Return objects as-is (e.g., link objects { href, target })
343
+ // The caller (e.g., href handling) will process them appropriately
344
+ if (typeof result === 'object') {
345
+ return result as any;
346
+ }
342
347
  return String(result);
343
348
  }
344
349
 
@@ -640,7 +645,19 @@ export function processStructure(
640
645
  // Regular href value - process as template
641
646
  const processedValue = processStructure(value as ComponentNode | ComponentNode[] | string | number | null | undefined, context, viewportWidth, instanceChildren, preserveResponsiveStyles, depth + 1);
642
647
  if (processedValue !== null && processedValue !== undefined) {
643
- (processed as any).href = processedValue;
648
+ // Check if result is a link object (from link-type prop like {{link}})
649
+ if (typeof processedValue === 'object' && processedValue !== null && 'href' in processedValue) {
650
+ const linkObj = processedValue as { href: string; target?: string };
651
+ (processed as any).href = linkObj.href;
652
+ if (linkObj.target) {
653
+ (processed as any).attributes = {
654
+ ...((processed as any).attributes || {}),
655
+ target: linkObj.target
656
+ };
657
+ }
658
+ } else {
659
+ (processed as any).href = processedValue;
660
+ }
644
661
  }
645
662
  }
646
663
  } else if (key === 'attributes' && typeof value === 'object' && value !== null) {
@@ -11,9 +11,10 @@ import { jsonResponse } from './shared';
11
11
 
12
12
  /**
13
13
  * Handle pages API endpoint - GET /api/pages
14
+ * Returns pages with draft status info
14
15
  */
15
16
  export function handlePagesRoute(pageService: PageService): Response {
16
- const pages = pageService.getAllPagePaths();
17
+ const pages = pageService.getAllPagesWithInfo();
17
18
  return jsonResponse({ pages });
18
19
  }
19
20
 
@@ -185,6 +185,30 @@ export class PageService {
185
185
  return this.pageCache.keys();
186
186
  }
187
187
 
188
+ /**
189
+ * Get all pages with their info (including draft status)
190
+ *
191
+ * Returns an array of page info objects including path and draft status.
192
+ *
193
+ * @returns Array of page info objects
194
+ *
195
+ * @example
196
+ * ```typescript
197
+ * const pages = pageService.getAllPagesWithInfo();
198
+ * // [{ path: "/", isDraft: false }, { path: "/about", isDraft: true }]
199
+ * ```
200
+ */
201
+ getAllPagesWithInfo(): Array<{ path: string; isDraft: boolean }> {
202
+ const paths = this.pageCache.keys();
203
+ return paths.map(path => {
204
+ const pageData = this.getPageData(path);
205
+ return {
206
+ path,
207
+ isDraft: pageData?.meta?.draft === true
208
+ };
209
+ });
210
+ }
211
+
188
212
  /**
189
213
  * Get line map for a page
190
214
  *
@@ -21,7 +21,7 @@ export function escapeHtml(unsafe: string): string {
21
21
  export function buildAttributes(props: Record<string, unknown>, exclude: string[] = []): string {
22
22
  const attrs: string[] = [];
23
23
  // Internal props that should never be rendered as HTML attributes
24
- const internalProps = ['tag', 'component', 'props', 'children', 'src', 'alt', 'loading', 'width', 'height', 'sizes', 'srcset', 'fetchpriority', 'priority'];
24
+ const internalProps = ['tag', 'component', 'props', 'children', 'src', 'alt', 'loading', 'width', 'height', 'sizes', 'srcset', 'fetchpriority'];
25
25
  const defaultExclude = [...internalProps, ...exclude];
26
26
 
27
27
  // Regex to detect unresolved template strings like {{link.target}}
@@ -15,9 +15,21 @@ import { generateUtilityCSS, extractUtilityClassesFromHTML, generateAllInteracti
15
15
  import { printMissingStyleWarnings } from '../validateStyleCoverage';
16
16
  import { formHandlerScript, needsFormHandler } from '../../client/scripts/formHandler';
17
17
  import { escapeHtml } from './attributeBuilder';
18
- import { renderPageSSR } from './ssrRenderer';
18
+ import { renderPageSSR, type PreloadImage } from './ssrRenderer';
19
19
  import type { CMSContext } from './cmsSSRProcessor';
20
20
 
21
+ /**
22
+ * Generate image preload link tags for high-priority images
23
+ * Uses imagesrcset and imagesizes for responsive image preloading
24
+ */
25
+ function generateImagePreloadTags(preloadImages: PreloadImage[]): string {
26
+ if (preloadImages.length === 0) return '';
27
+
28
+ return preloadImages
29
+ .map(img => `<link rel="preload" as="image" type="${img.type}" imagesrcset="${escapeHtml(img.srcset)}" imagesizes="${escapeHtml(img.sizes)}" fetchpriority="high">`)
30
+ .join('\n ');
31
+ }
32
+
21
33
  /**
22
34
  * Minify CSS code using regex-based minification
23
35
  * Removes comments, unnecessary whitespace, and optimizes values
@@ -302,6 +314,9 @@ img {
302
314
  ? `<link rel="preload" href="${extScriptPath}" as="script">`
303
315
  : '';
304
316
 
317
+ // Image preload tags for high-priority images (LCP optimization)
318
+ const imagePreloadTags = generateImagePreloadTags(rendered.preloadImages);
319
+
305
320
  // In production, output minified CSS on single line; in dev, preserve formatting
306
321
  const styleContent = useBundled
307
322
  ? finalCSS
@@ -312,7 +327,7 @@ img {
312
327
  <head>
313
328
  <meta charset="UTF-8">
314
329
  <meta name="viewport" content="width=device-width, initial-scale=1.0">
315
- ${iconTags ? iconTags + '\n ' : ''}${scriptPreloadTag ? scriptPreloadTag + '\n ' : ''}${fontPreloadTags ? fontPreloadTags + '\n ' : ''}${rendered.meta}
330
+ ${iconTags ? iconTags + '\n ' : ''}${scriptPreloadTag ? scriptPreloadTag + '\n ' : ''}${imagePreloadTags ? imagePreloadTags + '\n ' : ''}${fontPreloadTags ? fontPreloadTags + '\n ' : ''}${rendered.meta}
316
331
  ${configInlineScript}${cmsInlineScript}<style>${styleContent}</style>
317
332
  </head>
318
333
  <body>
@@ -35,7 +35,7 @@ export const RESPONSIVE_WIDTHS = [500, 800, 1080, 1600, 2400] as const;
35
35
  /**
36
36
  * Default sizes attribute for responsive images
37
37
  */
38
- export const DEFAULT_SIZES = '(max-width: 640px) 100vw, (max-width: 1024px) 50vw, 33vw';
38
+ export const DEFAULT_SIZES = '100vw';
39
39
 
40
40
  /**
41
41
  * Build image metadata map from manifest
@@ -5,7 +5,7 @@
5
5
 
6
6
  import type { ComponentNode, ComponentDefinition, JSONPage, CMSListNode, CMSItem } from '../../shared/types';
7
7
  import type { TemplateContext } from '../../shared/types/cms';
8
- import { processItemTemplate, processItemPropsTemplate, hasItemTemplates, buildTemplateContext, resolveItemsTemplate, type ValueResolver } from '../../shared/itemTemplateUtils';
8
+ import { processItemTemplate, processItemPropsTemplate, hasItemTemplates, buildTemplateContext, resolveItemsTemplate, resolveTemplateRawValue, type ValueResolver } from '../../shared/itemTemplateUtils';
9
9
  import { singularize } from '../../shared/types/cms';
10
10
  import type { ResponsiveStyleObject, StyleObject } from '../../shared/types';
11
11
  import type { BreakpointConfig } from '../../shared/breakpoints';
@@ -37,6 +37,18 @@ import { collectComponentJavaScript } from './jsCollector';
37
37
  import { CMSContext, processCMSTemplate, processCMSPropsTemplate, createI18nResolver, RAW_HTML_PREFIX } from './cmsSSRProcessor';
38
38
  import { ImageMetadataMap, DEFAULT_SIZES, buildImageMetadataMap } from './imageMetadata';
39
39
 
40
+ /**
41
+ * Image preload info for generating <link rel="preload"> tags in head
42
+ */
43
+ export interface PreloadImage {
44
+ /** Best available srcset (AVIF preferred, then WebP) */
45
+ srcset: string;
46
+ /** Image type (image/avif or image/webp) */
47
+ type: 'image/avif' | 'image/webp';
48
+ /** Sizes attribute */
49
+ sizes: string;
50
+ }
51
+
40
52
  // Re-export types for external consumers
41
53
  export type { CMSContext } from './cmsSSRProcessor';
42
54
  export type { PageMeta } from './metaTagGenerator';
@@ -65,6 +77,8 @@ interface SSRContext {
65
77
  componentContext?: string;
66
78
  /** Resolved component props for interactive style mapping resolution */
67
79
  componentResolvedProps?: Record<string, unknown>;
80
+ /** Array to collect high-priority images for preloading */
81
+ preloadImages?: PreloadImage[];
68
82
  }
69
83
 
70
84
  /**
@@ -101,11 +115,13 @@ async function buildComponentHTML(
101
115
  pagePath?: string,
102
116
  cmsContext?: CMSContext,
103
117
  cmsService?: CMSService
104
- ): Promise<{ html: string; interactiveStylesMap: Map<string, InteractiveStyles> }> {
118
+ ): Promise<{ html: string; interactiveStylesMap: Map<string, InteractiveStyles>; preloadImages: PreloadImage[] }> {
105
119
  // Create map to collect interactive styles during render
106
120
  const interactiveStylesMap = new Map<string, InteractiveStyles>();
121
+ // Create array to collect high-priority images for preloading
122
+ const preloadImages: PreloadImage[] = [];
107
123
 
108
- if (!node) return { html: '', interactiveStylesMap };
124
+ if (!node) return { html: '', interactiveStylesMap, preloadImages };
109
125
 
110
126
  // Register components for this render
111
127
  ssrComponentRegistry.merge(globalComponents);
@@ -133,11 +149,12 @@ async function buildComponentHTML(
133
149
  cmsService,
134
150
  elementPath: [0], // Initialize path tracking for interactive styles
135
151
  interactiveStylesMap, // Collect interactive styles during render
152
+ preloadImages, // Collect high-priority images for preloading
136
153
  };
137
154
 
138
155
  const html = await renderNode(node, ctx);
139
156
 
140
- return { html, interactiveStylesMap };
157
+ return { html, interactiveStylesMap, preloadImages };
141
158
  }
142
159
 
143
160
  /**
@@ -460,11 +477,22 @@ async function renderNode(
460
477
  // Handle object link nodes (render as <a> tag in SSR)
461
478
  if (isObjectLinkNode(node)) {
462
479
  let href: string = typeof node.href === 'string' ? node.href : '#';
480
+ let targetFromLink: string | undefined;
463
481
 
464
482
  // Process item templates in href (for CMSList context)
465
483
  const templateCtx = getTemplateContext(ctx);
466
484
  if (templateCtx && hasItemTemplates(href)) {
467
- href = processItemTemplate(href, templateCtx, getI18nResolver(ctx));
485
+ // Get raw value first to check if it's a link object (like { href: "/path", target: "_blank" })
486
+ const rawValue = resolveTemplateRawValue(href, templateCtx);
487
+ if (rawValue && typeof rawValue === 'object' && 'href' in rawValue) {
488
+ // Link object - extract href and target
489
+ const linkObj = rawValue as { href: string; target?: string };
490
+ href = String(linkObj.href);
491
+ targetFromLink = linkObj.target;
492
+ } else {
493
+ // Regular value - use string processing
494
+ href = processItemTemplate(href, templateCtx, getI18nResolver(ctx));
495
+ }
468
496
  }
469
497
 
470
498
  // Localize internal page links to current locale
@@ -542,6 +570,11 @@ async function renderNode(
542
570
  delete nodeAttributes.className;
543
571
  delete nodeAttributes.class;
544
572
 
573
+ // Add target from link object if present (and not already set in attributes)
574
+ if (targetFromLink && !nodeAttributes.target) {
575
+ nodeAttributes.target = targetFromLink;
576
+ }
577
+
545
578
  const attrs = buildAttributes(nodeAttributes, ['href']);
546
579
  const classAttr = ` class="${escapeHtml(classNames.filter(Boolean).join(' '))}"`;
547
580
 
@@ -898,7 +931,6 @@ function renderImageElement(
898
931
  const loading = imgProps.loading as string | undefined;
899
932
  const sizes = imgProps.sizes as string | undefined;
900
933
  const fetchpriority = imgProps.fetchpriority as string | undefined;
901
- const priority = imgProps.priority as boolean | undefined;
902
934
  let width = imgProps.width as string | number | undefined;
903
935
  let height = imgProps.height as string | number | undefined;
904
936
 
@@ -914,18 +946,31 @@ function renderImageElement(
914
946
  // Determine sizes attribute (prop > default)
915
947
  const sizesAttr = sizes || DEFAULT_SIZES;
916
948
 
949
+ // Collect high-priority images for preloading in head
950
+ if (fetchpriority === 'high' && metadata && ctx.preloadImages) {
951
+ // Prefer AVIF, fallback to WebP
952
+ if (metadata.avifSrcset) {
953
+ ctx.preloadImages.push({
954
+ srcset: metadata.avifSrcset,
955
+ type: 'image/avif',
956
+ sizes: sizesAttr,
957
+ });
958
+ } else if (metadata.srcset) {
959
+ ctx.preloadImages.push({
960
+ srcset: metadata.srcset,
961
+ type: 'image/webp',
962
+ sizes: sizesAttr,
963
+ });
964
+ }
965
+ }
966
+
917
967
  // Build img attributes
918
968
  let imgAttrs = '';
919
969
  if (src) imgAttrs += ` src="${escapeHtml(String(src))}"`;
920
970
  if (alt !== undefined) imgAttrs += ` alt="${escapeHtml(String(alt))}"`;
921
- // Handle priority: if true, set fetchpriority="high" and loading="eager" for LCP optimization
922
- if (priority) {
923
- imgAttrs += ' fetchpriority="high"';
924
- imgAttrs += ' loading="eager"';
925
- } else {
926
- if (fetchpriority) imgAttrs += ` fetchpriority="${escapeHtml(String(fetchpriority))}"`;
927
- if (loading) imgAttrs += ` loading="${escapeHtml(String(loading))}"`;
928
- }
971
+ if (fetchpriority) imgAttrs += ` fetchpriority="${escapeHtml(String(fetchpriority))}"`;
972
+ if (loading) imgAttrs += ` loading="${escapeHtml(String(loading))}"`;
973
+
929
974
  if (width !== undefined) imgAttrs += ` width="${escapeHtml(String(width))}"`;
930
975
  if (height !== undefined) imgAttrs += ` height="${escapeHtml(String(height))}"`;
931
976
 
@@ -1164,7 +1209,7 @@ export async function renderPageSSR(
1164
1209
  slugMappings?: SlugMap[],
1165
1210
  cmsContext?: CMSContext,
1166
1211
  cmsService?: CMSService
1167
- ): Promise<{ html: string; meta: string; title: string; javascript: string; componentCSS?: string; locale: string; interactiveStylesMap: Map<string, InteractiveStyles> }> {
1212
+ ): Promise<{ html: string; meta: string; title: string; javascript: string; componentCSS?: string; locale: string; interactiveStylesMap: Map<string, InteractiveStyles>; preloadImages: PreloadImage[] }> {
1168
1213
  // Extract page content
1169
1214
  const rootNode = pageData?.root || undefined;
1170
1215
  if (!rootNode) {
@@ -1199,10 +1244,10 @@ export async function renderPageSSR(
1199
1244
  const pageComponents = pageData?.components || {};
1200
1245
 
1201
1246
  // Render the component tree to HTML with i18n and CMS support
1202
- // Also collect interactive styles during render
1203
- const { html: contentHTML, interactiveStylesMap } = rootNode
1247
+ // Also collect interactive styles and preload images during render
1248
+ const { html: contentHTML, interactiveStylesMap, preloadImages } = rootNode
1204
1249
  ? await buildComponentHTML(rootNode, globalComponents, pageComponents, effectiveLocale, config, slugMappings, pagePath, cmsContext, cmsService)
1205
- : { html: '', interactiveStylesMap: new Map<string, InteractiveStyles>() };
1250
+ : { html: '', interactiveStylesMap: new Map<string, InteractiveStyles>(), preloadImages: [] };
1206
1251
 
1207
1252
  // Collect JavaScript and CSS from all components
1208
1253
  const javascript = collectComponentJavaScript(globalComponents, pageComponents);
@@ -1231,5 +1276,6 @@ export async function renderPageSSR(
1231
1276
  componentCSS,
1232
1277
  locale: effectiveLocale,
1233
1278
  interactiveStylesMap,
1279
+ preloadImages,
1234
1280
  };
1235
1281
  }
@@ -986,45 +986,6 @@ describe("SSR Renderer - Security (XSS Prevention)", () => {
986
986
  expect(result.html).toContain('fetchpriority="high"');
987
987
  });
988
988
 
989
- test("should render priority prop as fetchpriority=high and loading=eager", async () => {
990
- const pageData: JSONPage = {
991
- root: {
992
- type: "node",
993
- tag: "img",
994
- attributes: {
995
- src: "/test.jpg",
996
- alt: "test",
997
- priority: true
998
- }
999
- } as any
1000
- };
1001
-
1002
- const result = await renderPageSSR(pageData);
1003
-
1004
- expect(result.html).toContain('fetchpriority="high"');
1005
- expect(result.html).toContain('loading="eager"');
1006
- });
1007
-
1008
- test("priority should override explicit loading=lazy", async () => {
1009
- const pageData: JSONPage = {
1010
- root: {
1011
- type: "node",
1012
- tag: "img",
1013
- attributes: {
1014
- src: "/test.jpg",
1015
- alt: "test",
1016
- priority: true,
1017
- loading: "lazy"
1018
- }
1019
- } as any
1020
- };
1021
-
1022
- const result = await renderPageSSR(pageData);
1023
-
1024
- // priority=true should set loading="eager", ignoring the lazy value
1025
- expect(result.html).toContain('loading="eager"');
1026
- expect(result.html).not.toContain('loading="lazy"');
1027
- });
1028
989
  });
1029
990
 
1030
991
  describe("Embed content sanitization", () => {
@@ -24,6 +24,7 @@ export const CSS_PROPERTIES_DEFINITION: Record<string, CSSPropertyDefinition> =
24
24
  right: { type: 'string' },
25
25
  bottom: { type: 'string' },
26
26
  left: { type: 'string' },
27
+ inset: { type: 'string' },
27
28
  zIndex: { type: 'number' },
28
29
 
29
30
  // Dimensions
@@ -33,6 +34,7 @@ export const CSS_PROPERTIES_DEFINITION: Record<string, CSSPropertyDefinition> =
33
34
  maxWidth: { type: 'string' },
34
35
  minHeight: { type: 'string' },
35
36
  maxHeight: { type: 'string' },
37
+ aspectRatio: { type: 'string' },
36
38
 
37
39
  // Spacing
38
40
  margin: { type: 'string' },
@@ -109,6 +111,10 @@ export const CSS_PROPERTIES_DEFINITION: Record<string, CSSPropertyDefinition> =
109
111
  values: ['flex-start', 'flex-end', 'center', 'space-between', 'space-around', 'stretch'],
110
112
  type: 'select',
111
113
  },
114
+ alignSelf: {
115
+ values: ['auto', 'flex-start', 'flex-end', 'center', 'stretch', 'baseline'],
116
+ type: 'select',
117
+ },
112
118
  flexGrow: { type: 'number' },
113
119
  flexShrink: { type: 'number' },
114
120
  flexBasis: { type: 'string' },
@@ -118,13 +124,28 @@ export const CSS_PROPERTIES_DEFINITION: Record<string, CSSPropertyDefinition> =
118
124
  grid: { type: 'string' },
119
125
  gridTemplateColumns: { type: 'string' },
120
126
  gridTemplateRows: { type: 'string' },
127
+ gridTemplateAreas: { type: 'string' },
121
128
  gridGap: { type: 'string' },
122
129
  gridColumn: { type: 'string' },
123
130
  gridRow: { type: 'string' },
131
+ gridArea: { type: 'string' },
124
132
  gridAutoFlow: {
125
133
  values: ['row', 'column', 'row dense', 'column dense'],
126
134
  type: 'select',
127
135
  },
136
+ gridAutoColumns: { type: 'string' },
137
+ gridAutoRows: { type: 'string' },
138
+ justifyItems: {
139
+ values: ['start', 'end', 'center', 'stretch'],
140
+ type: 'select',
141
+ },
142
+ justifySelf: {
143
+ values: ['auto', 'start', 'end', 'center', 'stretch'],
144
+ type: 'select',
145
+ },
146
+ placeContent: { type: 'string' },
147
+ placeItems: { type: 'string' },
148
+ placeSelf: { type: 'string' },
128
149
 
129
150
  // Text & Font
130
151
  fontSize: { type: 'string' },
@@ -152,14 +173,38 @@ export const CSS_PROPERTIES_DEFINITION: Record<string, CSSPropertyDefinition> =
152
173
  },
153
174
  letterSpacing: { type: 'string' },
154
175
  wordSpacing: { type: 'string' },
176
+ wordBreak: {
177
+ values: ['normal', 'break-all', 'keep-all', 'break-word'],
178
+ type: 'select',
179
+ },
180
+ overflowWrap: {
181
+ values: ['normal', 'break-word', 'anywhere'],
182
+ type: 'select',
183
+ },
184
+ textIndent: { type: 'string' },
185
+ verticalAlign: {
186
+ values: ['baseline', 'top', 'middle', 'bottom', 'text-top', 'text-bottom', 'sub', 'super'],
187
+ type: 'select',
188
+ },
155
189
 
156
190
  // Box Shadow & Effects
157
191
  boxShadow: { type: 'string' },
158
192
  textShadow: { type: 'string' },
159
193
  filter: { type: 'string' },
194
+ backdropFilter: { type: 'string' },
160
195
  transform: { type: 'string' },
196
+ transformOrigin: { type: 'string' },
161
197
  transition: { type: 'string' },
162
198
  animation: { type: 'string' },
199
+ backfaceVisibility: {
200
+ values: ['visible', 'hidden'],
201
+ type: 'select',
202
+ },
203
+ mixBlendMode: {
204
+ values: ['normal', 'multiply', 'screen', 'overlay', 'darken', 'lighten', 'color-dodge', 'color-burn', 'hard-light', 'soft-light', 'difference', 'exclusion', 'hue', 'saturation', 'color', 'luminosity'],
205
+ type: 'select',
206
+ },
207
+ clipPath: { type: 'string' },
163
208
 
164
209
  // Overflow & Content
165
210
  overflow: {
@@ -202,6 +247,27 @@ export const CSS_PROPERTIES_DEFINITION: Record<string, CSSPropertyDefinition> =
202
247
  type: 'select',
203
248
  },
204
249
 
250
+ // Outline
251
+ outline: { type: 'string' },
252
+ outlineWidth: { type: 'string' },
253
+ outlineStyle: {
254
+ values: ['none', 'solid', 'dashed', 'dotted', 'double', 'groove', 'ridge', 'inset', 'outset'],
255
+ type: 'select',
256
+ },
257
+ outlineColor: { type: 'string' },
258
+ outlineOffset: { type: 'string' },
259
+
260
+ // Lists
261
+ listStyle: { type: 'string' },
262
+ listStyleType: {
263
+ values: ['none', 'disc', 'circle', 'square', 'decimal', 'decimal-leading-zero', 'lower-roman', 'upper-roman', 'lower-alpha', 'upper-alpha'],
264
+ type: 'select',
265
+ },
266
+ listStylePosition: {
267
+ values: ['inside', 'outside'],
268
+ type: 'select',
269
+ },
270
+
205
271
  // Miscellaneous
206
272
  float: {
207
273
  values: ['left', 'right', 'none'],
@@ -223,6 +289,15 @@ export const CSS_PROPERTIES_DEFINITION: Record<string, CSSPropertyDefinition> =
223
289
  values: ['top', 'bottom', 'left', 'right', 'center'],
224
290
  type: 'select',
225
291
  },
292
+ resize: {
293
+ values: ['none', 'both', 'horizontal', 'vertical'],
294
+ type: 'select',
295
+ },
296
+ scrollBehavior: {
297
+ values: ['auto', 'smooth'],
298
+ type: 'select',
299
+ },
300
+ accentColor: { type: 'string' },
226
301
  };
227
302
 
228
303
  /**
@@ -236,18 +311,20 @@ export const CSS_PROPERTIES = Object.keys(CSS_PROPERTIES_DEFINITION);
236
311
  * Order determines display order in the UI
237
312
  */
238
313
  export const CSS_PROPERTY_GROUPS: Record<string, string[]> = {
239
- 'Layout': ['display', 'position', 'top', 'right', 'bottom', 'left', 'zIndex'],
240
- 'Dimensions': ['width', 'height', 'minWidth', 'maxWidth', 'minHeight', 'maxHeight'],
314
+ 'Layout': ['display', 'position', 'top', 'right', 'bottom', 'left', 'inset', 'zIndex'],
315
+ 'Dimensions': ['width', 'height', 'minWidth', 'maxWidth', 'minHeight', 'maxHeight', 'aspectRatio'],
241
316
  'Spacing': ['margin', 'marginTop', 'marginRight', 'marginBottom', 'marginLeft', 'padding', 'paddingTop', 'paddingRight', 'paddingBottom', 'paddingLeft', 'gap', 'rowGap', 'columnGap'],
242
- 'Flexbox': ['flex', 'flexDirection', 'flexWrap', 'flexFlow', 'justifyContent', 'alignItems', 'alignContent', 'flexGrow', 'flexShrink', 'flexBasis', 'order'],
243
- 'Grid': ['grid', 'gridTemplateColumns', 'gridTemplateRows', 'gridGap', 'gridColumn', 'gridRow', 'gridAutoFlow'],
244
- 'Typography': ['fontSize', 'fontWeight', 'fontFamily', 'fontStyle', 'lineHeight', 'textAlign', 'textDecoration', 'textTransform', 'letterSpacing', 'wordSpacing', 'color'],
317
+ 'Flexbox': ['flex', 'flexDirection', 'flexWrap', 'flexFlow', 'justifyContent', 'alignItems', 'alignContent', 'alignSelf', 'flexGrow', 'flexShrink', 'flexBasis', 'order'],
318
+ 'Grid': ['grid', 'gridTemplateColumns', 'gridTemplateRows', 'gridTemplateAreas', 'gridGap', 'gridColumn', 'gridRow', 'gridArea', 'gridAutoFlow', 'gridAutoColumns', 'gridAutoRows', 'justifyItems', 'justifySelf', 'placeContent', 'placeItems', 'placeSelf'],
319
+ 'Typography': ['fontSize', 'fontWeight', 'fontFamily', 'fontStyle', 'lineHeight', 'textAlign', 'textDecoration', 'textTransform', 'letterSpacing', 'wordSpacing', 'wordBreak', 'overflowWrap', 'textIndent', 'verticalAlign', 'color'],
245
320
  'Background': ['background', 'backgroundColor', 'backgroundImage', 'backgroundSize', 'backgroundPosition', 'backgroundRepeat', 'opacity'],
246
321
  'Borders': ['border', 'borderWidth', 'borderStyle', 'borderColor', 'borderTop', 'borderRight', 'borderBottom', 'borderLeft', 'borderRadius', 'borderTopLeftRadius', 'borderTopRightRadius', 'borderBottomLeftRadius', 'borderBottomRightRadius'],
247
- 'Effects': ['boxShadow', 'textShadow', 'filter', 'transform', 'transition', 'animation'],
322
+ 'Outline': ['outline', 'outlineWidth', 'outlineStyle', 'outlineColor', 'outlineOffset'],
323
+ 'Effects': ['boxShadow', 'textShadow', 'filter', 'backdropFilter', 'transform', 'transformOrigin', 'transition', 'animation', 'backfaceVisibility', 'mixBlendMode', 'clipPath'],
248
324
  'Overflow': ['overflow', 'overflowX', 'overflowY', 'whiteSpace', 'textOverflow', 'visibility', 'content'],
249
325
  'Interaction': ['cursor', 'pointerEvents', 'userSelect'],
250
- 'Other': ['float', 'clear', 'boxSizing', 'objectFit', 'objectPosition'],
326
+ 'Lists': ['listStyle', 'listStyleType', 'listStylePosition'],
327
+ 'Other': ['float', 'clear', 'boxSizing', 'objectFit', 'objectPosition', 'resize', 'scrollBehavior', 'accentColor'],
251
328
  };
252
329
 
253
330
  /**
@@ -267,6 +267,30 @@ export function resolveItemsTemplate(
267
267
  return String(value);
268
268
  }
269
269
 
270
+ /**
271
+ * Resolve a template expression to its raw value (without stringification).
272
+ * Returns undefined if template cannot be resolved.
273
+ * Useful for checking if a value is an object (like link-type fields).
274
+ *
275
+ * @example
276
+ * resolveTemplateRawValue("{{article.link}}", { article: { link: { href: "/about", target: "_blank" } } })
277
+ * // Returns: { href: "/about", target: "_blank" }
278
+ */
279
+ export function resolveTemplateRawValue(
280
+ template: string,
281
+ ctx: TemplateContext
282
+ ): unknown {
283
+ // Match single template expression: {{varName.field}}
284
+ const match = template.match(/^\{\{(\w+)\.([^}]+)\}\}$/);
285
+ if (!match) return undefined;
286
+
287
+ const [, varName, fieldPath] = match;
288
+ const item = ctx[varName];
289
+ if (!item || typeof item !== 'object') return undefined;
290
+
291
+ return getNestedValue(item as Record<string, unknown>, fieldPath.trim());
292
+ }
293
+
270
294
  /**
271
295
  * Build a template context for a CMS item with the given variable name.
272
296
  * Includes both named context and legacy context for backward compatibility.
@@ -55,5 +55,7 @@ export interface PageMetaData {
55
55
  source?: 'static' | 'cms';
56
56
  /** CMS configuration with embedded schema (required when source: 'cms') */
57
57
  cms?: PageCmsConfig;
58
+ /** Draft pages are excluded from static build, sitemap, and robots.txt */
59
+ draft?: boolean;
58
60
  }
59
61
 
@@ -7,7 +7,7 @@
7
7
  export type CMSFieldType =
8
8
  | 'string' // Single-line text
9
9
  | 'text' // Multi-line text
10
- | 'rich-text' // Rich text with formatting (HTML)
10
+ | 'rich-text' // Rich text with formatting (stored as Tiptap JSON, converted to HTML for SSR)
11
11
  | 'number' // Numeric value
12
12
  | 'boolean' // True/false
13
13
  | 'image' // Image URL/path
@@ -42,6 +42,7 @@ export const propertyMap: Record<string, string> = {
42
42
  maxHeight: 'mh',
43
43
  minWidth: 'miw',
44
44
  minHeight: 'mih',
45
+ aspectRatio: 'ar',
45
46
 
46
47
  // Colors & Background
47
48
  backgroundColor: 'bgc',
@@ -69,14 +70,27 @@ export const propertyMap: Record<string, string> = {
69
70
  textDecoration: 'td',
70
71
  lineHeight: 'lh',
71
72
  letterSpacing: 'ls',
73
+ wordBreak: 'wb',
74
+ overflowWrap: 'ow',
75
+ textIndent: 'ti',
76
+ verticalAlign: 'va',
77
+
78
+ // Lists
72
79
  listStyle: 'lst',
80
+ listStyleType: 'lstt',
81
+ listStylePosition: 'lstp',
73
82
 
74
83
  // Transform & Effects
75
84
  opacity: 'o',
76
85
  transform: 'tm',
77
- boxShadow: 'bs',
86
+ transformOrigin: 'tmo',
87
+ boxShadow: 'bsh',
78
88
  textShadow: 'ts',
79
89
  filter: 'flt',
90
+ backdropFilter: 'bdf',
91
+ backfaceVisibility: 'bfv',
92
+ mixBlendMode: 'mbm',
93
+ clipPath: 'cp',
80
94
 
81
95
  // Positioning
82
96
  position: 'pos',
@@ -84,23 +98,39 @@ export const propertyMap: Record<string, string> = {
84
98
  right: 'r',
85
99
  bottom: 'bo',
86
100
  left: 'l',
101
+ inset: 'ins',
87
102
  zIndex: 'z',
88
103
 
89
104
  // Grid Layout
90
105
  gridTemplateColumns: 'gtc',
91
106
  gridTemplateRows: 'gtr',
107
+ gridTemplateAreas: 'gta',
92
108
  gridGap: 'gg',
93
109
  gridAutoFlow: 'gaf',
94
110
  gridColumn: 'gc',
95
111
  gridRow: 'gr',
112
+ gridArea: 'ga',
96
113
  gridAutoRows: 'gar',
97
114
  gridAutoColumns: 'gac',
115
+ justifyItems: 'ji',
116
+ justifySelf: 'jse',
117
+ placeContent: 'plc',
118
+ placeItems: 'pli',
119
+ placeSelf: 'pls',
98
120
 
99
121
  // Flexbox extras
100
122
  flexGrow: 'fg',
101
- flexShrink: 'fs',
123
+ flexShrink: 'fsh',
102
124
  flexBasis: 'fb',
103
125
  order: 'ord',
126
+ alignSelf: 'as',
127
+
128
+ // Outline
129
+ outline: 'ol',
130
+ outlineWidth: 'olw',
131
+ outlineStyle: 'ols',
132
+ outlineColor: 'olc',
133
+ outlineOffset: 'olo',
104
134
 
105
135
  // Other
106
136
  overflow: 'ov',
@@ -110,8 +140,11 @@ export const propertyMap: Record<string, string> = {
110
140
  transition: 'tn',
111
141
  objectFit: 'objf',
112
142
  objectPosition: 'objp',
113
- boxSizing: 'bs',
143
+ boxSizing: 'bsz',
114
144
  visibility: 'vis',
145
+ resize: 'rsz',
146
+ scrollBehavior: 'scb',
147
+ accentColor: 'acc',
115
148
  };
116
149
 
117
150
  // Mapping of prefixes to CSS property names for dynamic rule generation
@@ -142,6 +175,7 @@ export const prefixToCSSProperty: Record<string, string> = {
142
175
  mh: 'max-height',
143
176
  miw: 'min-width',
144
177
  mih: 'min-height',
178
+ ar: 'aspect-ratio',
145
179
 
146
180
  // Colors & Background
147
181
  bgc: 'background-color',
@@ -169,36 +203,57 @@ export const prefixToCSSProperty: Record<string, string> = {
169
203
  td: 'text-decoration',
170
204
  lh: 'line-height',
171
205
  ls: 'letter-spacing',
206
+ wb: 'word-break',
207
+ ow: 'overflow-wrap',
208
+ ti: 'text-indent',
209
+ va: 'vertical-align',
210
+
211
+ // Lists
172
212
  lst: 'list-style',
213
+ lstt: 'list-style-type',
214
+ lstp: 'list-style-position',
173
215
 
174
216
  // Flexbox
175
217
  fd: 'flex-direction',
176
218
  jc: 'justify-content',
177
219
  ai: 'align-items',
178
220
  ac: 'align-content',
221
+ as: 'align-self',
179
222
  flex: 'flex',
180
223
  fw: 'flex-wrap',
181
224
  fg: 'flex-grow',
182
- flsh: 'flex-shrink',
225
+ fsh: 'flex-shrink',
183
226
  fb: 'flex-basis',
184
227
  ord: 'order',
185
228
 
186
229
  // Grid
187
230
  gtc: 'grid-template-columns',
188
231
  gtr: 'grid-template-rows',
232
+ gta: 'grid-template-areas',
189
233
  gg: 'grid-gap',
190
234
  gaf: 'grid-auto-flow',
191
235
  gc: 'grid-column',
192
236
  gr: 'grid-row',
237
+ ga: 'grid-area',
193
238
  gar: 'grid-auto-rows',
194
239
  gac: 'grid-auto-columns',
240
+ ji: 'justify-items',
241
+ jse: 'justify-self',
242
+ plc: 'place-content',
243
+ pli: 'place-items',
244
+ pls: 'place-self',
195
245
 
196
246
  // Effects
197
247
  o: 'opacity',
198
248
  tm: 'transform',
249
+ tmo: 'transform-origin',
199
250
  bsh: 'box-shadow',
200
251
  ts: 'text-shadow',
201
252
  flt: 'filter',
253
+ bdf: 'backdrop-filter',
254
+ bfv: 'backface-visibility',
255
+ mbm: 'mix-blend-mode',
256
+ cp: 'clip-path',
202
257
 
203
258
  // Positioning
204
259
  pos: 'position',
@@ -206,8 +261,16 @@ export const prefixToCSSProperty: Record<string, string> = {
206
261
  r: 'right',
207
262
  bo: 'bottom',
208
263
  l: 'left',
264
+ ins: 'inset',
209
265
  z: 'z-index',
210
266
 
267
+ // Outline
268
+ ol: 'outline',
269
+ olw: 'outline-width',
270
+ ols: 'outline-style',
271
+ olc: 'outline-color',
272
+ olo: 'outline-offset',
273
+
211
274
  // Other
212
275
  ov: 'overflow',
213
276
  ovx: 'overflow-x',
@@ -218,6 +281,9 @@ export const prefixToCSSProperty: Record<string, string> = {
218
281
  objp: 'object-position',
219
282
  bsz: 'box-sizing',
220
283
  vis: 'visibility',
284
+ rsz: 'resize',
285
+ scb: 'scroll-behavior',
286
+ acc: 'accent-color',
221
287
  };
222
288
 
223
289
  // Special value mappings for specific properties
@@ -291,6 +291,7 @@ const PageMetaDataBaseSchema = z.object({
291
291
  ogImage: z.string().optional(),
292
292
  ogType: z.string().optional(),
293
293
  slugs: z.record(z.string(), z.string()).optional(),
294
+ draft: z.boolean().optional(),
294
295
  }).passthrough();
295
296
 
296
297
  // Temporary export for backward compatibility - will be replaced with full schema below
@@ -469,4 +470,5 @@ export const PageMetaDataWithCMSSchema = z.object({
469
470
  slugs: z.record(z.string(), z.string()).optional(),
470
471
  source: z.enum(['static', 'cms']).optional(),
471
472
  cms: PageCmsConfigSchema.optional(),
473
+ draft: z.boolean().optional(),
472
474
  }).passthrough();
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "meno-core",
3
- "version": "1.0.15",
3
+ "version": "1.0.18",
4
4
  "type": "module",
5
5
  "bin": {
6
6
  "meno": "./bin/cli.ts"