mnfst 0.5.41 → 0.5.43

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.
@@ -173,6 +173,169 @@ function getBasePath() {
173
173
  return (typeof window.getManifestBasePath === 'function' ? window.getManifestBasePath() : '') || '';
174
174
  }
175
175
 
176
+ // Locale codes from manifest data (same idea as ManifestRouting.matchesCondition).
177
+ function getLocalizationCodesFromManifest() {
178
+ const localizationCodes = [];
179
+ try {
180
+ const manifest = window.ManifestComponentsRegistry?.manifest || window.manifest;
181
+ if (manifest?.data && typeof manifest.data === 'object') {
182
+ Object.values(manifest.data).forEach((dataSource) => {
183
+ if (typeof dataSource === 'object' && dataSource !== null) {
184
+ Object.keys(dataSource).forEach((key) => {
185
+ if (key.match(/^[a-z]{2}(-[A-Z]{2})?$/)) {
186
+ localizationCodes.push(key);
187
+ }
188
+ });
189
+ }
190
+ });
191
+ }
192
+ } catch {
193
+ /* ignore */
194
+ }
195
+ return [...new Set(localizationCodes)];
196
+ }
197
+
198
+ function logicalSegmentsFromPathname(pathname) {
199
+ const logical = pathnameToLogical(pathname);
200
+ const s = logical.replace(/^\/+|\/+$/g, '');
201
+ return s ? s.split('/') : [];
202
+ }
203
+
204
+ const STICKY_LOCALE_SKIP_FIRST_SEGMENTS = new Set([
205
+ 'api',
206
+ 'assets',
207
+ 'static',
208
+ 'public',
209
+ 'dist',
210
+ 'icons',
211
+ 'fonts',
212
+ 'media',
213
+ '.well-known',
214
+ ]);
215
+
216
+ const STICKY_LOCALE_STATIC_FILE_EXT = new Set([
217
+ 'js', 'mjs', 'cjs', 'css', 'map', 'png', 'jpg', 'jpeg', 'webp', 'gif', 'svg', 'ico',
218
+ 'woff', 'woff2', 'ttf', 'eot', 'json', 'xml', 'txt', 'pdf', 'zip', 'wasm', 'avif',
219
+ 'mp4', 'webm', 'mp3',
220
+ ]);
221
+
222
+ function shouldSkipStickyLocaleForLogicalSegments(segments) {
223
+ if (!segments.length) return false;
224
+ if (STICKY_LOCALE_SKIP_FIRST_SEGMENTS.has(segments[0])) return true;
225
+ const last = segments[segments.length - 1];
226
+ if (!last || !last.includes('.')) return false;
227
+ const ext = last.slice(last.lastIndexOf('.') + 1).toLowerCase();
228
+ return STICKY_LOCALE_STATIC_FILE_EXT.has(ext);
229
+ }
230
+
231
+ // manifest.prerender.localeRouteExclude → JSON array in meta; logical path prefixes (after locale).
232
+ function parseLocaleRouteExcludePatterns() {
233
+ const meta = document.querySelector('meta[name="manifest:locale-route-exclude"]');
234
+ const raw = meta?.getAttribute('content') || '';
235
+ if (raw.trim().startsWith('[')) {
236
+ try {
237
+ const parsed = JSON.parse(raw.replace(/"/g, '"'));
238
+ if (Array.isArray(parsed)) {
239
+ return parsed.map((s) => String(s).trim()).filter(Boolean);
240
+ }
241
+ } catch {
242
+ /* fall through */
243
+ }
244
+ }
245
+ const legacy = document.querySelector('meta[name="manifest:locale-sticky-exclude"]');
246
+ const legacyRaw = legacy?.getAttribute('content') || '';
247
+ if (!legacyRaw.trim()) return [];
248
+ return legacyRaw.split(',').map((s) => s.trim().replace(/^\/+/, '').split('/')[0]).filter(Boolean);
249
+ }
250
+
251
+ function logicalPathMatchesLocaleRouteExclude(segments, patterns) {
252
+ if (!patterns.length || !segments.length) return false;
253
+ const lower = segments.map((s) => s.toLowerCase());
254
+ for (const pattern of patterns) {
255
+ const p = String(pattern)
256
+ .trim()
257
+ .replace(/^\/+/, '')
258
+ .split('/')
259
+ .filter(Boolean)
260
+ .map((x) => x.toLowerCase());
261
+ if (p.length === 0) continue;
262
+ if (lower.length < p.length) continue;
263
+ let match = true;
264
+ for (let i = 0; i < p.length; i++) {
265
+ if (lower[i] !== p[i]) {
266
+ match = false;
267
+ break;
268
+ }
269
+ }
270
+ if (match) return true;
271
+ }
272
+ return false;
273
+ }
274
+
275
+ // /fr/legal/terms → /legal/terms when patterns include "legal" or "legal/terms" (prefix match).
276
+ function normalizeRedundantLocalePrefixInUrl() {
277
+ if (!isPrerenderedStaticBuild()) return false;
278
+ const codes = getLocalizationCodesFromManifest();
279
+ const patterns = parseLocaleRouteExcludePatterns();
280
+ if (!codes.length || !patterns.length) return false;
281
+
282
+ const segs = logicalSegmentsFromPathname(window.location.pathname);
283
+ if (segs.length < 2) return false;
284
+ if (!codes.includes(segs[0])) return false;
285
+ const rest = segs.slice(1);
286
+ if (!logicalPathMatchesLocaleRouteExclude(rest, patterns)) return false;
287
+
288
+ const newLogical = '/' + rest.join('/');
289
+ const base = getBasePath();
290
+ let newPathname = base ? base.replace(/\/+$/, '') + newLogical : newLogical;
291
+ newPathname = newPathname.replace(/\/{2,}/g, '/');
292
+ if (!newPathname.startsWith('/')) newPathname = '/' + newPathname;
293
+ if (newPathname === window.location.pathname) return false;
294
+
295
+ const prevLogical = pathnameToLogical(window.location.pathname);
296
+ const u = new URL(window.location.href);
297
+ u.pathname = newPathname;
298
+ history.replaceState(null, '', u.toString());
299
+ currentRoute = pathnameToLogical(newPathname);
300
+ const np = currentRoute === '/' ? '/' : String(currentRoute).replace(/^\/|\/$/g, '');
301
+ window.dispatchEvent(new CustomEvent('manifest:route-change', {
302
+ detail: { from: prevLogical, to: currentRoute, normalizedPath: np }
303
+ }));
304
+ return true;
305
+ }
306
+
307
+ // When the URL already has a locale prefix (e.g. /zh/pricing), keep it for same-origin links
308
+ // that omit the prefix (/articles → /zh/articles). No-op on default-locale URLs (/pricing).
309
+ function applyStickyLocaleToPathname(absolutePathname) {
310
+ const codes = getLocalizationCodesFromManifest();
311
+ if (!codes.length) return absolutePathname;
312
+
313
+ const currentSegs = logicalSegmentsFromPathname(window.location.pathname);
314
+ const sticky = currentSegs.length && codes.includes(currentSegs[0]) ? currentSegs[0] : null;
315
+ if (!sticky) return absolutePathname;
316
+
317
+ const targetSegs = logicalSegmentsFromPathname(absolutePathname);
318
+ if (targetSegs.length && codes.includes(targetSegs[0])) {
319
+ return absolutePathname;
320
+ }
321
+
322
+ const routeEx = parseLocaleRouteExcludePatterns();
323
+ if (routeEx.length && logicalPathMatchesLocaleRouteExclude(targetSegs, routeEx)) {
324
+ return absolutePathname;
325
+ }
326
+
327
+ if (shouldSkipStickyLocaleForLogicalSegments(targetSegs)) {
328
+ return absolutePathname;
329
+ }
330
+
331
+ const base = getBasePath();
332
+ const newLogical = targetSegs.length ? `/${sticky}/${targetSegs.join('/')}` : `/${sticky}`;
333
+ const normalizedLogical = newLogical.replace(/\/{2,}/g, '/') || '/';
334
+ if (!base) return normalizedLogical;
335
+ const combined = `${base}${normalizedLogical}`.replace(/([^:])\/{2,}/g, '$1/');
336
+ return combined.startsWith('/') ? combined : `/${combined}`;
337
+ }
338
+
176
339
  function pathnameToLogical(pathname) {
177
340
  const base = getBasePath();
178
341
  if (!base) {
@@ -252,27 +415,79 @@ function resolveHref(href) {
252
415
  const url = new URL(href, baseUrl);
253
416
  if (url.origin !== window.location.origin) return href;
254
417
  let path = url.pathname.replace(/\/$/, '') || '/';
255
- if (!base) return path.startsWith('/') ? path : '/' + path;
256
- if (path === base || path.startsWith(base + '/')) return path;
257
- // path may be above base (e.g. /gadget when base is /src/dist) or unrelated; take only the route part and put under base so we never stack.
258
- if (path.startsWith('/')) {
418
+ let resolved;
419
+ if (!base) {
420
+ resolved = path.startsWith('/') ? path : '/' + path;
421
+ } else if (path === base || path.startsWith(base + '/')) {
422
+ resolved = path;
423
+ } else if (path.startsWith('/')) {
259
424
  const pathSegs = path.split('/').filter(Boolean);
260
425
  const baseSegs = base.split('/').filter(Boolean);
261
426
  let i = 0;
262
427
  while (i < baseSegs.length && i < pathSegs.length && baseSegs[i] === pathSegs[i]) i++;
263
428
  const routeSegs = pathSegs.slice(i);
264
- if (routeSegs.length) return base + '/' + routeSegs.join('/');
429
+ if (routeSegs.length) {
430
+ resolved = base + '/' + routeSegs.join('/');
431
+ } else {
432
+ const out = base + (path.startsWith('/') ? path : '/' + path);
433
+ resolved = out.startsWith('/') ? out : '/' + out;
434
+ }
435
+ } else {
436
+ const out = base + (path.startsWith('/') ? path : '/' + path);
437
+ resolved = out.startsWith('/') ? out : '/' + out;
265
438
  }
266
- const out = base + (path.startsWith('/') ? path : '/' + path);
267
- return out.startsWith('/') ? out : '/' + out;
439
+ return applyStickyLocaleToPathname(resolved);
268
440
  } catch {
269
441
  const base = getBasePath();
270
442
  const safe = (href || '').trim();
271
- if (!safe) return base || '/';
272
- return base ? (base + (safe.startsWith('/') ? safe : '/' + safe)) : (safe.startsWith('/') ? safe : '/' + safe);
443
+ if (!safe) return applyStickyLocaleToPathname(base || '/');
444
+ const raw = base ? (base + (safe.startsWith('/') ? safe : '/' + safe)) : (safe.startsWith('/') ? safe : '/' + safe);
445
+ return applyStickyLocaleToPathname(raw);
273
446
  }
274
447
  }
275
448
 
449
+ // Prerendered MPA: same-origin navigations use full page loads; rewrite targets so locale prefix sticks.
450
+ function installMpaStickyLocaleLinks() {
451
+ document.addEventListener('click', (event) => {
452
+ if (event.defaultPrevented) return;
453
+ if (event.button !== 0) return;
454
+ if (event.metaKey || event.ctrlKey || event.shiftKey || event.altKey) return;
455
+
456
+ const link = event.target.closest('a');
457
+ if (!link || link.closest('[data-manifest-skip-locale-sticky]')) return;
458
+ if (link.hasAttribute('download')) return;
459
+
460
+ const hrefAttr = link.getAttribute('href');
461
+ if (!hrefAttr || hrefAttr.startsWith('mailto:') || hrefAttr.startsWith('tel:') || hrefAttr.startsWith('javascript:')) return;
462
+ if (hrefAttr.startsWith('#')) return;
463
+
464
+ let url;
465
+ try {
466
+ url = new URL(hrefAttr, window.location.href);
467
+ } catch {
468
+ return;
469
+ }
470
+ if (url.origin !== window.location.origin) return;
471
+
472
+ const path = url.pathname.replace(/\/$/, '') || '/';
473
+ const adjusted = applyStickyLocaleToPathname(path);
474
+ if (adjusted === path) return;
475
+
476
+ event.preventDefault();
477
+ url.pathname = adjusted;
478
+
479
+ const dest = url.toString();
480
+ if (link.target === '_blank') {
481
+ const features = link.relList?.contains('noopener') || link.relList?.contains('noreferrer')
482
+ ? 'noopener,noreferrer'
483
+ : undefined;
484
+ window.open(dest, '_blank', features);
485
+ } else {
486
+ window.location.assign(dest);
487
+ }
488
+ }, true);
489
+ }
490
+
276
491
  // Intercept link clicks to prevent page reloads
277
492
  function interceptLinkClicks() {
278
493
  // Use capture phase to intercept before other handlers
@@ -356,20 +571,20 @@ function interceptLinkClicks() {
356
571
 
357
572
  // Initialize navigation
358
573
  function initializeNavigation() {
359
- // Set initial route (logical path for matching)
360
574
  currentRoute = pathnameToLogical(window.location.pathname);
575
+ normalizeRedundantLocalePrefixInUrl();
361
576
 
362
- // In prerendered/static output, use default browser navigation (no interception)
577
+ // In prerendered/static output, use default browser navigation (no SPA interception)
363
578
  if (!isPrerenderedStaticBuild()) {
364
- // Intercept link clicks
365
579
  interceptLinkClicks();
366
580
 
367
- // Listen for popstate events (browser back/forward)
368
581
  window.addEventListener('popstate', () => {
369
582
  if (!isInternalNavigation) {
370
583
  handleRouteChange();
371
584
  }
372
585
  });
586
+ } else {
587
+ installMpaStickyLocaleLinks();
373
588
  }
374
589
 
375
590
  // Handle initial route
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "mnfst",
3
- "version": "0.5.41",
3
+ "version": "0.5.43",
4
4
  "private": false,
5
5
  "workspaces": [
6
6
  "templates/starter",