All files / assets/js gfmr-multilingual.js

0% Statements 0/137
0% Branches 0/93
0% Functions 0/26
0% Lines 0/132

Press n or j to go to the next uncovered block, b, p or k for the previous block.

1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 223 224 225 226 227 228 229 230 231 232 233 234 235 236 237 238 239 240 241 242 243 244 245 246 247 248 249 250 251 252 253 254 255 256 257 258 259 260 261 262 263 264 265 266 267 268 269 270 271 272 273 274 275 276 277 278 279 280 281 282 283 284 285 286 287 288 289 290 291 292 293 294 295 296 297 298 299 300 301 302 303 304 305 306 307 308 309 310 311 312 313 314 315 316 317 318 319 320 321 322 323 324 325 326 327 328 329 330 331 332 333 334 335 336 337 338 339 340 341 342 343 344 345 346 347 348 349 350 351 352 353 354 355 356 357 358 359 360 361 362 363 364 365 366 367 368 369 370 371 372 373 374 375 376 377 378 379 380                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                       
/**
 * GFMR Multilingual Frontend Handler
 *
 * Handles language switching for multilingual Markdown blocks on the frontend.
 * Supports URL parameters, localStorage persistence, and browser language detection.
 *
 * @package WpGfmRenderer
 */
 
( function() {
	'use strict';
 
	// Language code validation regex (ISO 639-1 format)
	const VALID_LANG_REGEX = /^[a-z]{2}(-[A-Z]{2})?$/;
 
	/**
	 * GFMRMultilingual class for handling language switching
	 */
	class GFMRMultilingual {
		/**
		 * Constructor
		 * @param {HTMLElement} container - The multilingual block container
		 */
		constructor( container ) {
			this.container = container;
			this.multilingualId = container.dataset.multilingualId;
			this.languages = this.loadLanguages();
			this.defaultLang = this.sanitizeLangCode( container.dataset.defaultLanguage ) || 'en';
			this.availableLangs = this.parseAvailableLanguages( container.dataset.availableLanguages );
			this.showSwitcher = container.dataset.showSwitcher === 'true';
			this.switcher = null;
 
			// Get URL mode settings
			this.urlMode = window.wpGfmConfig?.multilingualUrlMode || 'query';
			this.configDefaultLang = window.wpGfmConfig?.multilingualDefaultLang || 'en';
			this.hideDefaultLang = window.wpGfmConfig?.multilingualHideDefaultLang !== false;
			this.configSupportedLangs = window.wpGfmConfig?.multilingualSupportedLangs || [ 'en', 'ja' ];
 
			// Detect and set initial language
			this.currentLang = this.detectLanguage();
 
			// Initialize UI
			if ( this.showSwitcher && this.availableLangs.length > 1 ) {
				this.renderSwitcher();
			}
 
			// Switch to detected language
			this.switchLanguage( this.currentLang );
 
			// Handle History API events
			window.addEventListener( 'popstate', () => this.handlePopState() );
		}
 
		/**
		 * Validate language code format
		 * @param {string} code - Language code to validate
		 * @returns {boolean}
		 */
		isValidLanguageCode( code ) {
			return typeof code === 'string' &&
				code.length <= 5 &&
				VALID_LANG_REGEX.test( code );
		}
 
		/**
		 * Sanitize language code
		 * @param {string} code - Language code to sanitize
		 * @returns {string|null}
		 */
		sanitizeLangCode( code ) {
			if ( ! code ) return null;
			const sanitized = code.toLowerCase().trim().slice( 0, 5 );
			return this.isValidLanguageCode( sanitized ) ? sanitized : null;
		}
 
		/**
		 * Load languages data from script tag
		 * @returns {Object}
		 */
		loadLanguages() {
			if ( ! this.multilingualId ) {
				return {};
			}
 
			const scriptElement = document.getElementById( this.multilingualId );
			if ( ! scriptElement ) {
				console.error( '[GFMR] Languages data script not found:', this.multilingualId );
				return {};
			}
 
			try {
				const data = JSON.parse( scriptElement.textContent );
				// Validate object keys
				const validated = {};
				for ( const [ key, value ] of Object.entries( data ) ) {
					if ( this.isValidLanguageCode( key ) && value?.html !== undefined ) {
						validated[ key ] = value;
					}
				}
				return validated;
			} catch ( e ) {
				console.error( '[GFMR] Failed to parse languages data:', e );
				return {};
			}
		}
 
		/**
		 * Parse available languages array safely
		 * @param {string} jsonStr - JSON string of available languages
		 * @returns {string[]}
		 */
		parseAvailableLanguages( jsonStr ) {
			try {
				const arr = JSON.parse( jsonStr || '[]' );
				return arr.filter( lang => this.isValidLanguageCode( lang ) );
			} catch ( e ) {
				return [];
			}
		}
 
		/**
		 * Detect the best language to display
		 * Priority: URL path (path mode) > URL param > localStorage > browser language > default
		 * @returns {string}
		 */
		detectLanguage() {
			// 1. URL path (path mode)
			if ( this.urlMode === 'path' ) {
				const pathLang = this.detectLanguageFromPath();
				if ( pathLang && this.availableLangs.includes( pathLang ) ) {
					return pathLang;
				}
			}
 
			// 2. URL parameter (highest priority)
			const params = new URLSearchParams( window.location.search );
			const urlLang = this.sanitizeLangCode( params.get( 'lang' ) );
			if ( urlLang && this.availableLangs.includes( urlLang ) ) {
				return urlLang;
			}
 
			// 3. localStorage (user preference)
			try {
				const savedLang = this.sanitizeLangCode( localStorage.getItem( 'gfmr_preferred_lang' ) );
				if ( savedLang && this.availableLangs.includes( savedLang ) ) {
					return savedLang;
				}
			} catch ( e ) {
				// localStorage may be disabled
			}
 
			// 4. Browser language
			const browserLang = navigator.language.split( '-' )[ 0 ].toLowerCase();
			if ( this.isValidLanguageCode( browserLang ) && this.availableLangs.includes( browserLang ) ) {
				return browserLang;
			}
 
			// 5. Default language
			return this.defaultLang;
		}
 
		/**
		 * Detect language from URL path (for path mode)
		 * @returns {string|null}
		 */
		detectLanguageFromPath() {
			const path = window.location.pathname;
			const segments = path.split( '/' ).filter( s => s.length > 0 );
 
			if ( segments.length > 0 && this.isValidLanguageCode( segments[ 0 ] ) ) {
				return segments[ 0 ].toLowerCase();
			}
 
			// Default language is hidden in URL, return default
			return this.configDefaultLang;
		}
 
		/**
		 * Render the language switcher UI
		 */
		renderSwitcher() {
			const switcher = document.createElement( 'div' );
			switcher.className = 'gfmr-language-switcher';
 
			this.availableLangs.forEach( lang => {
				const btn = document.createElement( 'button' );
				btn.type = 'button';
				btn.className = 'gfmr-lang-btn';
				btn.textContent = lang.toUpperCase();
				btn.dataset.lang = lang;
				btn.addEventListener( 'click', () => this.switchLanguage( lang ) );
				switcher.appendChild( btn );
			} );
 
			// Insert switcher before the content container
			this.container.parentElement.insertBefore( switcher, this.container );
			this.switcher = switcher;
		}
 
		/**
		 * Switch to a different language
		 * @param {string} lang - Language code to switch to
		 */
		switchLanguage( lang ) {
			// Validate language code
			if ( ! this.isValidLanguageCode( lang ) || ! this.languages[ lang ] ) {
				console.warn( '[GFMR] Invalid language code:', lang );
				return;
			}
 
			this.currentLang = lang;
 
			// Clean up dynamic content before switching (prevent ID conflicts)
			this.cleanupDynamicContent();
 
			// Update HTML content (server-side rendered with wp_kses_post)
			this.container.innerHTML = this.languages[ lang ].html || '';
 
			// Update button active states
			if ( this.switcher ) {
				this.switcher.querySelectorAll( '.gfmr-lang-btn' ).forEach( btn => {
					btn.classList.toggle( 'active', btn.dataset.lang === lang );
				} );
			}
 
			// Save user preference to localStorage
			try {
				localStorage.setItem( 'gfmr_preferred_lang', lang );
			} catch ( e ) {
				// localStorage may be disabled
			}
 
			// Update URL (only in path mode)
			this.updateLanguageUrl( lang );
 
			// Re-process dynamic content (Mermaid, Chart.js)
			this.reprocessDynamicContent();
		}
 
		/**
		 * Update URL when language changes (path mode only)
		 * @param {string} lang - New language code
		 */
		updateLanguageUrl( lang ) {
			if ( this.urlMode !== 'path' ) {
				return;
			}
 
			// If default language and hiding is enabled, don't change URL
			if ( this.hideDefaultLang && lang === this.configDefaultLang ) {
				return;
			}
 
			const newPath = this.buildLanguagePath( lang );
			if ( newPath && newPath !== window.location.pathname ) {
				history.pushState( { lang: lang }, '', newPath );
			}
		}
 
		/**
		 * Build language path for current URL
		 * @param {string} lang - Language code
		 * @returns {string}
		 */
		buildLanguagePath( lang ) {
			const currentPath = window.location.pathname;
			const basePath = this.stripLanguagePrefix( currentPath );
 
			// Default language is hidden in URL
			if ( this.hideDefaultLang && lang === this.configDefaultLang ) {
				return basePath;
			}
 
			// Ensure path starts with /
			const normalizedBase = basePath.startsWith( '/' ) ? basePath : '/' + basePath;
 
			// Add language prefix
			return '/' + lang + normalizedBase;
		}
 
		/**
		 * Strip language prefix from path
		 * @param {string} path - Current path
		 * @returns {string}
		 */
		stripLanguagePrefix( path ) {
			const langPattern = new RegExp( '^/(' + this.configSupportedLangs.join( '|' ) + ')(/|$)' );
			return path.replace( langPattern, '/' );
		}
 
		/**
		 * Handle History API popstate event (browser back/forward)
		 */
		handlePopState() {
			const lang = this.detectLanguageFromPath();
			if ( lang && lang !== this.currentLang ) {
				this.switchLanguage( lang );
			}
		}
 
		/**
		 * Clean up dynamic content before language switch
		 * Prevents ID conflicts for Mermaid SVGs and Chart.js canvases
		 */
		cleanupDynamicContent() {
			// Remove Mermaid SVGs
			this.container.querySelectorAll( '.mermaid svg, .gfmr-mermaid svg' ).forEach( svg => {
				svg.remove();
			} );
 
			// Destroy Chart.js instances
			this.container.querySelectorAll( 'canvas[data-chart-id]' ).forEach( canvas => {
				const chartId = canvas.dataset.chartId;
				if ( window.Chart && window.Chart.getChart ) {
					const chart = window.Chart.getChart( canvas );
					if ( chart ) {
						chart.destroy();
					}
				}
			} );
		}
 
		/**
		 * Re-process dynamic content after language switch
		 * Triggers Mermaid and Chart.js re-rendering
		 */
		reprocessDynamicContent() {
			// Re-render Mermaid diagrams
			if ( typeof window.wpGfmProcessMermaidBlocksUnified === 'function' ) {
				try {
					window.wpGfmProcessMermaidBlocksUnified( this.container, 'frontend' );
				} catch ( e ) {
					console.error( '[GFMR] Mermaid reprocessing failed:', e );
				}
			}
 
			// Re-render Chart.js charts
			if ( typeof window.wpGfmProcessChartBlocks === 'function' ) {
				try {
					window.wpGfmProcessChartBlocks( this.container );
				} catch ( e ) {
					console.error( '[GFMR] Chart.js reprocessing failed:', e );
				}
			}
		}
	}
 
	/**
	 * Initialize all multilingual blocks on the page
	 */
	function initMultilingualBlocks() {
		const containers = document.querySelectorAll( '.gfmr-markdown-rendered[data-multilingual-id]' );
 
		containers.forEach( container => {
			// Skip if already initialized
			if ( container.dataset.gfmrMultilingualInit ) {
				return;
			}
 
			// Mark as initialized
			container.dataset.gfmrMultilingualInit = 'true';
 
			// Create instance
			new GFMRMultilingual( container );
		} );
	}
 
	// Initialize on DOMContentLoaded
	if ( document.readyState === 'loading' ) {
		document.addEventListener( 'DOMContentLoaded', initMultilingualBlocks );
	} else {
		initMultilingualBlocks();
	}
 
	// Expose for external use
	window.GFMRMultilingual = GFMRMultilingual;
	window.gfmrInitMultilingualBlocks = initMultilingualBlocks;
 
} )();