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
|
|
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&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
|
|
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
|
}
|