mnfst-render 0.4.2 → 0.4.4

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (2) hide show
  1. package/manifest.render.mjs +96 -27
  2. package/package.json +1 -1
@@ -238,6 +238,11 @@ function resolveConfig() {
238
238
  localeSubstitutionExclude: Array.isArray(pre.localeSubstitutionExclude)
239
239
  ? pre.localeSubstitutionExclude.map(String)
240
240
  : [],
241
+ /** Explicit locale-neutral paths to render in addition to those discovered automatically.
242
+ * Each entry is expanded to all locale variants (e.g. "legal/privacy" → "cs/legal/privacy", ...) */
243
+ paths: Array.isArray(pre.paths)
244
+ ? pre.paths.map((p) => String(p).replace(/^\/+|\/+$/g, '')).filter(Boolean)
245
+ : [],
241
246
  dryRun: !!cli.dryRun,
242
247
  debugPrerender: !!(cli.debugPrerender ?? pre.debugPrerender),
243
248
  pipelineTimeout: Math.max(3000, Number(pre.pipelineTimeout) || 25000),
@@ -956,6 +961,7 @@ function markPrerenderedManifestComponents(html) {
956
961
  return html.replace(/<(x-[a-z][\w-]*)([^>]*)>/gi, (full, tag, attrs) => {
957
962
  const a = attrs || '';
958
963
  if (/\bdata-pre-rendered\s*=/i.test(a) || /\bdata-processed\s*=/i.test(a)) return full;
964
+ if (/\bdata-prerender-hydrate\b/i.test(a)) return full; // Inside data-hydrate island — skip
959
965
  const spacer = /\S/.test(a) ? ' ' : '';
960
966
  return `<${tag}${a}${spacer}data-pre-rendered="1">`;
961
967
  });
@@ -1126,32 +1132,71 @@ function loadAllLocaleContentData(manifest, rootDir, locales) {
1126
1132
  const data = manifest?.data;
1127
1133
  if (!data || typeof data !== 'object') return new Map();
1128
1134
 
1129
- const csvFiles = [];
1130
- const seen = new Set();
1131
- const addCsv = (ref) => {
1132
- if (typeof ref !== 'string' || !ref.endsWith('.csv')) return;
1133
- const p = join(rootDir, ref.startsWith('/') ? ref.slice(1) : ref);
1134
- if (!seen.has(p)) { seen.add(p); csvFiles.push(p); }
1135
- };
1135
+ // Lazy-load js-yaml for parsing per-locale YAML files
1136
+ let jsYaml = null;
1137
+ try { jsYaml = require('js-yaml'); } catch { /* yaml not available; YAML locale files will be skipped */ }
1138
+
1139
+ // Deep-merge source into target (for combining multiple data sources per locale)
1140
+ function deepMerge(target, source) {
1141
+ if (!source || typeof source !== 'object' || Array.isArray(source)) return;
1142
+ for (const key of Object.keys(source)) {
1143
+ if (source[key] && typeof source[key] === 'object' && !Array.isArray(source[key])) {
1144
+ target[key] = (target[key] && typeof target[key] === 'object') ? target[key] : {};
1145
+ deepMerge(target[key], source[key]);
1146
+ } else {
1147
+ target[key] = source[key];
1148
+ }
1149
+ }
1150
+ }
1151
+
1152
+ const result = new Map();
1153
+ for (const locale of locales) result.set(locale, {});
1136
1154
 
1137
1155
  for (const [, value] of Object.entries(data)) {
1138
- if (typeof value === 'string') { addCsv(value); continue; }
1139
- if (value && typeof value === 'object' && !Array.isArray(value)) {
1156
+ if (typeof value === 'string') {
1157
+ // Single CSV with locale columns (all locales in one file)
1158
+ if (value.endsWith('.csv')) {
1159
+ const csvPath = join(rootDir, value.startsWith('/') ? value.slice(1) : value);
1160
+ for (const locale of locales) {
1161
+ deepMerge(result.get(locale), parseCsvToKeyValue(csvPath, locale));
1162
+ }
1163
+ }
1164
+ } else if (value && typeof value === 'object' && !Array.isArray(value)) {
1140
1165
  if (value.locales) {
1166
+ // { locales: "/path/to/multi-locale.csv" } format
1141
1167
  const refs = Array.isArray(value.locales) ? value.locales : [value.locales];
1142
- refs.forEach(addCsv);
1168
+ for (const ref of refs) {
1169
+ if (typeof ref !== 'string' || !ref.endsWith('.csv')) continue;
1170
+ const csvPath = join(rootDir, ref.startsWith('/') ? ref.slice(1) : ref);
1171
+ for (const locale of locales) {
1172
+ deepMerge(result.get(locale), parseCsvToKeyValue(csvPath, locale));
1173
+ }
1174
+ }
1175
+ } else {
1176
+ // Per-locale files: { "en": "/data/content.en.yaml", "fr": "/data/content.fr.yaml", ... }
1177
+ for (const [localeKey, filePath] of Object.entries(value)) {
1178
+ if (!locales.includes(localeKey) || typeof filePath !== 'string') continue;
1179
+ const fullPath = join(rootDir, filePath.startsWith('/') ? filePath.slice(1) : filePath);
1180
+ if (!existsSync(fullPath)) continue;
1181
+ let localeData = null;
1182
+ try {
1183
+ const raw = readFileSync(fullPath, 'utf8');
1184
+ if ((filePath.endsWith('.yaml') || filePath.endsWith('.yml')) && jsYaml) {
1185
+ localeData = jsYaml.load(raw);
1186
+ } else if (filePath.endsWith('.json')) {
1187
+ localeData = JSON.parse(raw);
1188
+ } else if (filePath.endsWith('.csv')) {
1189
+ localeData = parseCsvToKeyValue(fullPath, localeKey);
1190
+ }
1191
+ } catch { /* ignore parse errors for individual locale files */ }
1192
+ if (localeData && typeof localeData === 'object') {
1193
+ deepMerge(result.get(localeKey), localeData);
1194
+ }
1195
+ }
1143
1196
  }
1144
1197
  }
1145
1198
  }
1146
1199
 
1147
- const result = new Map();
1148
- for (const locale of locales) {
1149
- const merged = {};
1150
- for (const csvPath of csvFiles) {
1151
- Object.assign(merged, parseCsvToKeyValue(csvPath, locale));
1152
- }
1153
- result.set(locale, merged);
1154
- }
1155
1200
  return result;
1156
1201
  }
1157
1202
 
@@ -1161,14 +1206,24 @@ function loadAllLocaleContentData(manifest, rootDir, locales) {
1161
1206
  */
1162
1207
  function buildSubstitutionPairs(defaultLocaleData, targetLocaleData) {
1163
1208
  const pairs = [];
1164
- for (const [key, rawDefault] of Object.entries(defaultLocaleData)) {
1165
- const rawTarget = targetLocaleData[key];
1166
- if (rawTarget == null || rawDefault == null) continue;
1167
- const from = String(rawDefault).trim();
1168
- const to = String(rawTarget).trim();
1169
- if (!from || from === to) continue;
1170
- pairs.push([from, to]);
1209
+ function collectPairs(defaultObj, targetObj) {
1210
+ if (!defaultObj || !targetObj) return;
1211
+ for (const key of Object.keys(defaultObj)) {
1212
+ const defaultVal = defaultObj[key];
1213
+ const targetVal = targetObj[key];
1214
+ if (defaultVal && typeof defaultVal === 'object') {
1215
+ // Recurse into nested objects (produced by setNestedKey for dotted CSV keys)
1216
+ collectPairs(defaultVal, targetVal && typeof targetVal === 'object' ? targetVal : {});
1217
+ } else {
1218
+ const from = String(defaultVal ?? '').trim();
1219
+ const to = String(targetVal ?? '').trim();
1220
+ if (!from || from === to) continue;
1221
+ pairs.push([from, to]);
1222
+ }
1223
+ }
1171
1224
  }
1225
+ collectPairs(defaultLocaleData, targetLocaleData);
1226
+ // Sort longest-first so more specific strings are replaced before shorter substrings
1172
1227
  pairs.sort((a, b) => b[0].length - a[0].length);
1173
1228
  return pairs;
1174
1229
  }
@@ -1266,6 +1321,9 @@ function generateLocaleVariantHtml({
1266
1321
  html = stripRedundantImgSrcBindings(html);
1267
1322
  html = stripEmptyInlineMaskStyles(html);
1268
1323
  html = stripResolvedXIconDirectives(html);
1324
+ // markPrerenderedManifestComponents must run BEFORE stripPrerenderHydrateMarkers so it can
1325
+ // detect data-prerender-hydrate markers and skip components inside hydrate islands.
1326
+ html = markPrerenderedManifestComponents(html);
1269
1327
  html = stripPrerenderHydrateMarkers(html);
1270
1328
 
1271
1329
  const fileSegments = pathToFileSegments(pathSeg ? '/' + pathSeg : '/');
@@ -1290,7 +1348,6 @@ function generateLocaleVariantHtml({
1290
1348
  '</head>',
1291
1349
  `${canonicalHreflang}${injectOgLocale ? ogLocale : ''}${routeMeta}${baseMeta}<meta name="manifest:prerendered" content="1">\n<meta name="manifest:router-base-depth" content="${routeDepth}">\n</head>`
1292
1350
  );
1293
- html = markPrerenderedManifestComponents(html);
1294
1351
 
1295
1352
  return { html, utilityBlocks: pageUtilityBlocks };
1296
1353
  }
@@ -1586,6 +1643,14 @@ async function runPrerender(config) {
1586
1643
 
1587
1644
  const defaultLocale = locales[0] ?? null;
1588
1645
  const routeSegments = discoverRoutes(manifest, config.root);
1646
+ // Merge any explicitly configured paths (manifest.prerender.paths) into the discovered segments.
1647
+ // These are treated as locale-neutral and get full locale-expansion like all other discovered paths.
1648
+ if (config.paths && config.paths.length > 0) {
1649
+ const segSet = new Set(routeSegments);
1650
+ for (const p of config.paths) {
1651
+ if (!segSet.has(p)) { routeSegments.push(p); segSet.add(p); }
1652
+ }
1653
+ }
1589
1654
  const localeSet = new Set(locales.map((l) => String(l).toLowerCase()));
1590
1655
  const localeNeutralSegments = routeSegments.filter((seg) => {
1591
1656
  if (!seg) return true;
@@ -2099,6 +2164,8 @@ async function runPrerender(config) {
2099
2164
  const stripLoopBindings = (el, itemVar, indexVar) => {
2100
2165
  const nodes = [el, ...Array.from(el.querySelectorAll('*'))];
2101
2166
  for (const node of nodes) {
2167
+ // Skip elements inside data-hydrate islands — their bindings must remain live
2168
+ if (node.hasAttribute('data-prerender-hydrate') || node.closest('[data-prerender-hydrate]')) continue;
2102
2169
  const attrs = node.attributes ? Array.from(node.attributes) : [];
2103
2170
  for (const attr of attrs) {
2104
2171
  if (!bindingAttrRegex.test(attr.name)) continue;
@@ -2295,6 +2362,9 @@ async function runPrerender(config) {
2295
2362
  html = stripRedundantImgSrcBindings(html);
2296
2363
  html = stripEmptyInlineMaskStyles(html);
2297
2364
  html = stripResolvedXIconDirectives(html);
2365
+ // markPrerenderedManifestComponents must run BEFORE stripPrerenderHydrateMarkers so it can
2366
+ // detect data-prerender-hydrate markers and skip components inside hydrate islands.
2367
+ html = markPrerenderedManifestComponents(html);
2298
2368
  html = stripPrerenderHydrateMarkers(html);
2299
2369
  html = rewriteHtmlAssetPaths(html, fileSegments.length);
2300
2370
  const liveBase = config.liveUrl.replace(/\/$/, '');
@@ -2314,7 +2384,6 @@ async function runPrerender(config) {
2314
2384
  '</head>',
2315
2385
  `${canonicalHreflang}${injectOgLocale ? ogLocale : ''}${routeMeta}${baseMeta}${prerenderedMeta}<meta name="manifest:router-base-depth" content="${routeDepth}">\n</head>`
2316
2386
  );
2317
- html = markPrerenderedManifestComponents(html);
2318
2387
  mkdirSync(outDir, { recursive: true });
2319
2388
  writeFileSync(outFile, html, 'utf8');
2320
2389
  pushDebug({
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "mnfst-render",
3
- "version": "0.4.2",
3
+ "version": "0.4.4",
4
4
  "description": "Render Manifest sites to static HTML for SEO",
5
5
  "type": "module",
6
6
  "bin": {