mnfst-render 0.4.3 → 0.4.5

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 +114 -18
  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),
@@ -1127,32 +1132,71 @@ function loadAllLocaleContentData(manifest, rootDir, locales) {
1127
1132
  const data = manifest?.data;
1128
1133
  if (!data || typeof data !== 'object') return new Map();
1129
1134
 
1130
- const csvFiles = [];
1131
- const seen = new Set();
1132
- const addCsv = (ref) => {
1133
- if (typeof ref !== 'string' || !ref.endsWith('.csv')) return;
1134
- const p = join(rootDir, ref.startsWith('/') ? ref.slice(1) : ref);
1135
- if (!seen.has(p)) { seen.add(p); csvFiles.push(p); }
1136
- };
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, {});
1137
1154
 
1138
1155
  for (const [, value] of Object.entries(data)) {
1139
- if (typeof value === 'string') { addCsv(value); continue; }
1140
- 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)) {
1141
1165
  if (value.locales) {
1166
+ // { locales: "/path/to/multi-locale.csv" } format
1142
1167
  const refs = Array.isArray(value.locales) ? value.locales : [value.locales];
1143
- 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
+ }
1144
1196
  }
1145
1197
  }
1146
1198
  }
1147
1199
 
1148
- const result = new Map();
1149
- for (const locale of locales) {
1150
- const merged = {};
1151
- for (const csvPath of csvFiles) {
1152
- Object.assign(merged, parseCsvToKeyValue(csvPath, locale));
1153
- }
1154
- result.set(locale, merged);
1155
- }
1156
1200
  return result;
1157
1201
  }
1158
1202
 
@@ -1599,6 +1643,14 @@ async function runPrerender(config) {
1599
1643
 
1600
1644
  const defaultLocale = locales[0] ?? null;
1601
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
+ }
1602
1654
  const localeSet = new Set(locales.map((l) => String(l).toLowerCase()));
1603
1655
  const localeNeutralSegments = routeSegments.filter((seg) => {
1604
1656
  if (!seg) return true;
@@ -2101,6 +2153,48 @@ async function runPrerender(config) {
2101
2153
  pushDebug({ path: displayPath, stage: 'post-xfor-mark', metrics: afterMark });
2102
2154
  }
2103
2155
 
2156
+ // For static x-for clones that contain data-hydrate elements, inject the loop-scope
2157
+ // variable as x-data on the clone element itself. This ensures that after the loop
2158
+ // template is removed, data-hydrate bindings referencing loop variables (e.g.
2159
+ // plan?.price?.[currency]?.[frequency]) continue to work at runtime via the injected scope.
2160
+ // The parent Alpine scope (e.g. <main x-data="{ currency, frequency }") remains accessible.
2161
+ await page.evaluate(() => {
2162
+ const loopVarRx = /^\s*(?:\(\s*([A-Za-z_$][\w$]*)(?:\s*,\s*([A-Za-z_$][\w$]*))?\s*\)|([A-Za-z_$][\w$]*))\s+in\s+/;
2163
+ document.querySelectorAll('template[x-for][data-prerender-static-generated="1"]').forEach((tpl) => {
2164
+ if (tpl.hasAttribute('data-prerender-hydrate') || tpl.closest('[data-prerender-hydrate]')) return;
2165
+ const xFor = (tpl.getAttribute('x-for') || '').trim();
2166
+ const m = xFor.match(loopVarRx);
2167
+ const itemVar = m ? (m[1] || m[3] || '') : '';
2168
+ if (!itemVar) return;
2169
+ const first = tpl.content && tpl.content.firstElementChild;
2170
+ if (!first) return;
2171
+ const tag = first.tagName;
2172
+ const cls = first.getAttribute('class') || '';
2173
+ let n = tpl.nextElementSibling;
2174
+ while (n && n.tagName === tag && (n.getAttribute('class') || '') === cls) {
2175
+ // Only process clones that contain data-hydrate descendants
2176
+ if (
2177
+ !n.hasAttribute('x-data') &&
2178
+ (n.hasAttribute('data-prerender-hydrate') || n.querySelector('[data-prerender-hydrate]'))
2179
+ ) {
2180
+ try {
2181
+ const A = window.Alpine;
2182
+ if (A && typeof A.$data === 'function') {
2183
+ const scope = A.$data(n);
2184
+ if (scope && Object.prototype.hasOwnProperty.call(scope, itemVar)) {
2185
+ const raw = scope[itemVar];
2186
+ // Serialize only own-enumerable properties to avoid circular refs / proxies
2187
+ const snapshot = JSON.parse(JSON.stringify(raw));
2188
+ n.setAttribute('x-data', JSON.stringify({ [itemVar]: snapshot }));
2189
+ }
2190
+ }
2191
+ } catch { /* serialisation failed — leave binding as-is */ }
2192
+ }
2193
+ n = n.nextElementSibling;
2194
+ }
2195
+ });
2196
+ });
2197
+
2104
2198
  // Strip loop-scope bindings from x-for clones while <template> nodes still exist.
2105
2199
  // (If we remove static templates first, querySelectorAll('template[x-for]') misses them and clones
2106
2200
  // keep x-text/x-bind referencing card/item — Alpine then mutates or errors on the static HTML.)
@@ -2112,6 +2206,8 @@ async function runPrerender(config) {
2112
2206
  const stripLoopBindings = (el, itemVar, indexVar) => {
2113
2207
  const nodes = [el, ...Array.from(el.querySelectorAll('*'))];
2114
2208
  for (const node of nodes) {
2209
+ // Skip elements inside data-hydrate islands — their bindings must remain live
2210
+ if (node.hasAttribute('data-prerender-hydrate') || node.closest('[data-prerender-hydrate]')) continue;
2115
2211
  const attrs = node.attributes ? Array.from(node.attributes) : [];
2116
2212
  for (const attr of attrs) {
2117
2213
  if (!bindingAttrRegex.test(attr.name)) continue;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "mnfst-render",
3
- "version": "0.4.3",
3
+ "version": "0.4.5",
4
4
  "description": "Render Manifest sites to static HTML for SEO",
5
5
  "type": "module",
6
6
  "bin": {