meno-core 1.0.13 → 1.0.15

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/build-static.ts CHANGED
@@ -361,7 +361,7 @@ async function buildCMSTemplates(
361
361
  let finalHtml = result.html;
362
362
  if (result.javascript) {
363
363
  const scriptPath = await getScriptPath(result.javascript, distDir);
364
- finalHtml = finalHtml.replace('</body>', ` <script src="${scriptPath}"></script>\n</body>`);
364
+ finalHtml = finalHtml.replace('</body>', ` <script src="${scriptPath}" defer></script>\n</body>`);
365
365
  }
366
366
 
367
367
  const outputPath = locale === i18nConfig.defaultLocale
@@ -537,7 +537,7 @@ async function buildStaticPages(): Promise<void> {
537
537
  if (result.javascript) {
538
538
  const scriptPath = await getScriptPath(result.javascript, distDir);
539
539
  // Insert script reference before </body>
540
- finalHtml = finalHtml.replace('</body>', ` <script src="${scriptPath}"></script>\n</body>`);
540
+ finalHtml = finalHtml.replace('</body>', ` <script src="${scriptPath}" defer></script>\n</body>`);
541
541
  }
542
542
 
543
543
  // Determine locale-specific output path with translated slug
@@ -18,6 +18,30 @@ import { escapeHtml } from './attributeBuilder';
18
18
  import { renderPageSSR } from './ssrRenderer';
19
19
  import type { CMSContext } from './cmsSSRProcessor';
20
20
 
21
+ /**
22
+ * Minify CSS code using regex-based minification
23
+ * Removes comments, unnecessary whitespace, and optimizes values
24
+ */
25
+ function minifyCSS(code: string): string {
26
+ if (!code.trim()) return code;
27
+
28
+ return code
29
+ // Remove CSS comments
30
+ .replace(/\/\*[\s\S]*?\*\//g, '')
31
+ // Remove whitespace around special characters
32
+ .replace(/\s*([{};:,>~+])\s*/g, '$1')
33
+ // Collapse multiple spaces/newlines into single space
34
+ .replace(/\s+/g, ' ')
35
+ // Remove space after opening brace
36
+ .replace(/\{\s+/g, '{')
37
+ // Remove space before closing brace
38
+ .replace(/\s+\}/g, '}')
39
+ // Remove trailing semicolons before closing braces
40
+ .replace(/;}/g, '}')
41
+ // Remove leading/trailing whitespace
42
+ .trim();
43
+ }
44
+
21
45
  /**
22
46
  * Result of SSR HTML generation with separate JS for CSP compliance
23
47
  */
@@ -190,7 +214,7 @@ export async function generateSSRHTML(
190
214
  const themeColorVariablesCSS = generateThemeColorVariablesCSS(themeConfig);
191
215
 
192
216
  // Include component CSS (no longer generating inline style classes)
193
- const allCSS = rendered.componentCSS || '';
217
+ const componentCSS = rendered.componentCSS || '';
194
218
 
195
219
  // Extract and generate utility CSS from rendered HTML
196
220
  const usedUtilityClasses = extractUtilityClassesFromHTML(rendered.html);
@@ -206,6 +230,44 @@ export async function generateSSRHTML(
206
230
  // Print warnings for any unmapped styles found during build
207
231
  printMissingStyleWarnings(false);
208
232
 
233
+ // Build base CSS (reset styles)
234
+ const baseCSS = `* {
235
+ margin: 0;
236
+ padding: 0;
237
+ box-sizing: border-box;
238
+ }
239
+ body {
240
+ font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'Roboto', 'Oxygen', 'Ubuntu', 'Cantarell', sans-serif;
241
+ }
242
+ button {
243
+ background: none;
244
+ border: none;
245
+ padding: 0;
246
+ font: inherit;
247
+ cursor: pointer;
248
+ outline: inherit;
249
+ }
250
+ img {
251
+ width: 100%;
252
+ height: 100%;
253
+ }
254
+ .olink {
255
+ text-decoration: none;
256
+ }`;
257
+
258
+ // Combine all CSS
259
+ const combinedCSS = [
260
+ fontCSS,
261
+ themeColorVariablesCSS,
262
+ baseCSS,
263
+ componentCSS,
264
+ utilityCSS,
265
+ interactiveCSS
266
+ ].filter(Boolean).join('\n');
267
+
268
+ // Minify CSS in production mode
269
+ const finalCSS = useBundled ? minifyCSS(combinedCSS) : combinedCSS;
270
+
209
271
  // Load prefetch config for client-side prefetching
210
272
  const prefetchConfig = await loadPrefetchConfig();
211
273
  // Only include non-default values to minimize payload
@@ -240,46 +302,18 @@ export async function generateSSRHTML(
240
302
  ? `<link rel="preload" href="${extScriptPath}" as="script">`
241
303
  : '';
242
304
 
305
+ // In production, output minified CSS on single line; in dev, preserve formatting
306
+ const styleContent = useBundled
307
+ ? finalCSS
308
+ : `\n ${combinedCSS.split('\n').join('\n ')}\n `;
309
+
243
310
  const htmlDocument = `<!DOCTYPE html>
244
311
  <html lang="${rendered.locale}" theme="${themeConfig.default}">
245
312
  <head>
246
313
  <meta charset="UTF-8">
247
314
  <meta name="viewport" content="width=device-width, initial-scale=1.0">
248
315
  ${iconTags ? iconTags + '\n ' : ''}${scriptPreloadTag ? scriptPreloadTag + '\n ' : ''}${fontPreloadTags ? fontPreloadTags + '\n ' : ''}${rendered.meta}
249
- ${configInlineScript}${cmsInlineScript}<style>
250
- ${fontCSS ? fontCSS + '\n ' : ''}${themeColorVariablesCSS ? themeColorVariablesCSS + '\n ' : ''}* {
251
- margin: 0;
252
- padding: 0;
253
- box-sizing: border-box;
254
- }
255
- body {
256
- font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'Roboto', 'Oxygen', 'Ubuntu', 'Cantarell', sans-serif;
257
- }
258
- button {
259
- background: none;
260
- border: none;
261
- padding: 0;
262
- font: inherit;
263
- cursor: pointer;
264
- outline: inherit;
265
- }
266
-
267
- img {
268
- width: 100%;
269
- height: 100%;
270
- }
271
-
272
- .olink {
273
- text-decoration: none;
274
- }
275
-
276
- ${allCSS}
277
-
278
- ${utilityCSS}
279
- </style>${interactiveCSS ? `
280
- <style id="interactive-styles">
281
- ${interactiveCSS}
282
- </style>` : ''}
316
+ ${configInlineScript}${cmsInlineScript}<style>${styleContent}</style>
283
317
  </head>
284
318
  <body>
285
319
  <div id="root">
@@ -7,6 +7,7 @@ import type { JSONPage } from '../../shared/types';
7
7
  import type { I18nConfig } from '../../shared/types/components';
8
8
  import { DEFAULT_I18N_CONFIG, resolveI18nValue } from '../../shared/i18n';
9
9
  import { escapeHtml } from './attributeBuilder';
10
+ import { buildSlugIndex, getLocaleLinks, type SlugMap } from '../../shared/slugTranslator';
10
11
 
11
12
  /**
12
13
  * Page meta information for SEO
@@ -38,15 +39,26 @@ export function extractPageMeta(pageData: JSONPage): PageMeta {
38
39
  return meta;
39
40
  }
40
41
 
42
+ /**
43
+ * Options for hreflang tag generation
44
+ */
45
+ export interface HreflangOptions {
46
+ slugMappings?: SlugMap[];
47
+ pagePath?: string;
48
+ baseUrl?: string;
49
+ }
50
+
41
51
  /**
42
52
  * Generate HTML meta tags string
43
53
  * Resolves i18n values using the provided locale and config
54
+ * Optionally generates hreflang tags for multilingual pages
44
55
  */
45
56
  export function generateMetaTags(
46
57
  meta: PageMeta,
47
58
  url: string = '',
48
59
  locale: string = 'en',
49
- config: I18nConfig = DEFAULT_I18N_CONFIG
60
+ config: I18nConfig = DEFAULT_I18N_CONFIG,
61
+ hreflangOptions?: HreflangOptions
50
62
  ): string {
51
63
  const tags: string[] = [];
52
64
 
@@ -102,5 +114,24 @@ export function generateMetaTags(
102
114
  tags.push(`<link rel="canonical" href="${escapeHtml(url)}" />`);
103
115
  }
104
116
 
117
+ // Hreflang tags for multilingual pages
118
+ if (hreflangOptions?.slugMappings && hreflangOptions.slugMappings.length > 0 && config.locales.length > 1) {
119
+ const { slugMappings, pagePath = '/', baseUrl = '' } = hreflangOptions;
120
+ const slugIndex = buildSlugIndex(slugMappings);
121
+ const localeLinks = getLocaleLinks(pagePath, locale, config, slugIndex);
122
+
123
+ for (const link of localeLinks) {
124
+ const hrefUrl = baseUrl ? `${baseUrl}${link.path}` : link.path;
125
+ tags.push(`<link rel="alternate" hreflang="${escapeHtml(link.langTag)}" href="${escapeHtml(hrefUrl)}" />`);
126
+ }
127
+
128
+ // Add x-default pointing to default locale version
129
+ const defaultLink = localeLinks.find(l => l.locale === config.defaultLocale);
130
+ if (defaultLink) {
131
+ const xDefaultUrl = baseUrl ? `${baseUrl}${defaultLink.path}` : defaultLink.path;
132
+ tags.push(`<link rel="alternate" hreflang="x-default" href="${escapeHtml(xDefaultUrl)}" />`);
133
+ }
134
+ }
135
+
105
136
  return tags.join('\n ');
106
137
  }
@@ -461,6 +461,12 @@ async function renderNode(
461
461
  if (isObjectLinkNode(node)) {
462
462
  let href: string = typeof node.href === 'string' ? node.href : '#';
463
463
 
464
+ // Process item templates in href (for CMSList context)
465
+ const templateCtx = getTemplateContext(ctx);
466
+ if (templateCtx && hasItemTemplates(href)) {
467
+ href = processItemTemplate(href, templateCtx, getI18nResolver(ctx));
468
+ }
469
+
464
470
  // Localize internal page links to current locale
465
471
  if (href.startsWith('/') && !href.startsWith('//') && locale && i18nConfig) {
466
472
  if (slugMappings) {
@@ -1205,8 +1211,12 @@ export async function renderPageSSR(
1205
1211
  // Build full URL for meta tags
1206
1212
  const fullUrl = baseUrl ? `${baseUrl}${pagePath}` : pagePath;
1207
1213
 
1208
- // Generate meta tags with i18n resolution
1209
- const metaTags = generateMetaTags(meta, fullUrl, effectiveLocale, config);
1214
+ // Generate meta tags with i18n resolution and hreflang tags
1215
+ const metaTags = generateMetaTags(meta, fullUrl, effectiveLocale, config, {
1216
+ slugMappings,
1217
+ pagePath,
1218
+ baseUrl,
1219
+ });
1210
1220
 
1211
1221
  // Resolve title for use in HTML template
1212
1222
  const resolvedTitle = resolveI18nValue(meta.title, effectiveLocale, config);
@@ -170,11 +170,95 @@ describe("SSR Renderer - generateMetaTags", () => {
170
170
 
171
171
  test("should handle empty meta", () => {
172
172
  const meta = {};
173
-
173
+
174
174
  const tags = generateMetaTags(meta);
175
-
175
+
176
176
  expect(tags).toBe("");
177
177
  });
178
+
179
+ test("should generate hreflang tags when slugMappings provided", () => {
180
+ const meta = { title: "About Us" };
181
+ const i18nConfig = {
182
+ defaultLocale: 'en',
183
+ locales: [
184
+ { code: 'en', name: 'English', nativeName: 'English', langTag: 'en-US' },
185
+ { code: 'pl', name: 'Polish', nativeName: 'Polski', langTag: 'pl-PL' },
186
+ { code: 'de', name: 'German', nativeName: 'Deutsch', langTag: 'de-DE' },
187
+ ],
188
+ };
189
+ const slugMappings = [
190
+ { pageId: 'about', slugs: { en: 'about', pl: 'o-nas', de: 'uber-uns' } },
191
+ ];
192
+
193
+ const tags = generateMetaTags(meta, 'https://example.com/about', 'en', i18nConfig, {
194
+ slugMappings,
195
+ pagePath: '/about',
196
+ baseUrl: 'https://example.com',
197
+ });
198
+
199
+ expect(tags).toContain('<link rel="alternate" hreflang="en-US" href="https://example.com/about" />');
200
+ expect(tags).toContain('<link rel="alternate" hreflang="pl-PL" href="https://example.com/pl/o-nas" />');
201
+ expect(tags).toContain('<link rel="alternate" hreflang="de-DE" href="https://example.com/de/uber-uns" />');
202
+ expect(tags).toContain('<link rel="alternate" hreflang="x-default" href="https://example.com/about" />');
203
+ });
204
+
205
+ test("should not generate hreflang tags for single locale", () => {
206
+ const meta = { title: "About Us" };
207
+ const i18nConfig = {
208
+ defaultLocale: 'en',
209
+ locales: [
210
+ { code: 'en', name: 'English', nativeName: 'English', langTag: 'en-US' },
211
+ ],
212
+ };
213
+ const slugMappings = [
214
+ { pageId: 'about', slugs: { en: 'about' } },
215
+ ];
216
+
217
+ const tags = generateMetaTags(meta, 'https://example.com/about', 'en', i18nConfig, {
218
+ slugMappings,
219
+ pagePath: '/about',
220
+ baseUrl: 'https://example.com',
221
+ });
222
+
223
+ expect(tags).not.toContain('hreflang');
224
+ });
225
+
226
+ test("should not generate hreflang tags without slugMappings", () => {
227
+ const meta = { title: "About Us" };
228
+ const i18nConfig = {
229
+ defaultLocale: 'en',
230
+ locales: [
231
+ { code: 'en', name: 'English', nativeName: 'English', langTag: 'en-US' },
232
+ { code: 'pl', name: 'Polish', nativeName: 'Polski', langTag: 'pl-PL' },
233
+ ],
234
+ };
235
+
236
+ const tags = generateMetaTags(meta, 'https://example.com/about', 'en', i18nConfig);
237
+
238
+ expect(tags).not.toContain('hreflang');
239
+ });
240
+
241
+ test("should escape special characters in hreflang URLs", () => {
242
+ const meta = { title: "Test" };
243
+ const i18nConfig = {
244
+ defaultLocale: 'en',
245
+ locales: [
246
+ { code: 'en', name: 'English', nativeName: 'English', langTag: 'en-US' },
247
+ { code: 'pl', name: 'Polish', nativeName: 'Polski', langTag: 'pl-PL' },
248
+ ],
249
+ };
250
+ const slugMappings = [
251
+ { pageId: 'test', slugs: { en: 'test&page', pl: 'test-strona' } },
252
+ ];
253
+
254
+ const tags = generateMetaTags(meta, 'https://example.com/test&page', 'en', i18nConfig, {
255
+ slugMappings,
256
+ pagePath: '/test&page',
257
+ baseUrl: 'https://example.com',
258
+ });
259
+
260
+ expect(tags).toContain('href="https://example.com/test&amp;page"');
261
+ });
178
262
  });
179
263
 
180
264
  describe("SSR Renderer - renderPageSSR", () => {
@@ -1298,6 +1382,63 @@ describe("SSR Renderer - CMSList rendering", () => {
1298
1382
  expect(result.html).toContain('data-cms-list="true"');
1299
1383
  expect(result.html).toContain('data-collection="blog-posts"');
1300
1384
  });
1385
+
1386
+ test("should process item templates in object-link href", async () => {
1387
+ const pageData: JSONPage = {
1388
+ root: {
1389
+ type: "cms-list",
1390
+ collection: "blog-posts",
1391
+ children: [
1392
+ {
1393
+ type: "object-link",
1394
+ href: "/blog/{{item.slug}}",
1395
+ children: [
1396
+ { type: "node", tag: "span", children: ["{{item.title}}"] }
1397
+ ]
1398
+ }
1399
+ ]
1400
+ } as unknown as ComponentNode
1401
+ };
1402
+
1403
+ const mockCmsService = new CMSService();
1404
+ mockCmsService.queryItems = async () => mockCmsItems;
1405
+
1406
+ const result = await renderPageSSR(pageData, {}, '/', '', undefined, undefined, undefined, undefined, mockCmsService);
1407
+
1408
+ // Should render object-link as <a> with processed href
1409
+ expect(result.html).toContain('href="/blog/post-1"');
1410
+ expect(result.html).toContain('href="/blog/post-2"');
1411
+ expect(result.html).toContain('First Post');
1412
+ expect(result.html).toContain('Second Post');
1413
+ });
1414
+
1415
+ test("should process item templates in anchor tag attributes", async () => {
1416
+ const pageData: JSONPage = {
1417
+ root: {
1418
+ type: "cms-list",
1419
+ collection: "blog-posts",
1420
+ children: [
1421
+ {
1422
+ type: "node",
1423
+ tag: "a",
1424
+ attributes: { href: "/blog/{{item.slug}}" },
1425
+ children: ["{{item.title}}"]
1426
+ }
1427
+ ]
1428
+ } as unknown as ComponentNode
1429
+ };
1430
+
1431
+ const mockCmsService = new CMSService();
1432
+ mockCmsService.queryItems = async () => mockCmsItems;
1433
+
1434
+ const result = await renderPageSSR(pageData, {}, '/', '', undefined, undefined, undefined, undefined, mockCmsService);
1435
+
1436
+ // Should render anchor with processed href from attributes
1437
+ expect(result.html).toContain('href="/blog/post-1"');
1438
+ expect(result.html).toContain('href="/blog/post-2"');
1439
+ expect(result.html).toContain('First Post');
1440
+ expect(result.html).toContain('Second Post');
1441
+ });
1301
1442
  });
1302
1443
 
1303
1444
  describe("SSR Renderer - Interactive Styles", () => {
@@ -137,10 +137,16 @@ function validateSingleProp(
137
137
  break;
138
138
 
139
139
  case 'link':
140
- // Link type expects an object with href (and optional target)
140
+ // Link type accepts either:
141
+ // 1. An object with href (and optional target): { href: "/path", target: "_blank" }
142
+ // 2. A plain string URL (coerced to { href: string }): "/path" or "https://..."
141
143
  if (typeof value === 'object' && value !== null && 'href' in value) {
142
144
  coercedValue = value;
143
145
  typeValid = true;
146
+ } else if (typeof value === 'string') {
147
+ // Coerce string to link object for convenience (e.g., from CMS templates)
148
+ coercedValue = value;
149
+ typeValid = true;
144
150
  } else {
145
151
  typeValid = false;
146
152
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "meno-core",
3
- "version": "1.0.13",
3
+ "version": "1.0.15",
4
4
  "type": "module",
5
5
  "bin": {
6
6
  "meno": "./bin/cli.ts"