Created
January 5, 2026 22:25
-
-
Save misterburton/88bfa052c5d3977aeb50c41f0f06d731 to your computer and use it in GitHub Desktop.
AI-Powered Localization - Dev Workflow Tools
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| /** | |
| * ContentManager - Development Workflow Tools | |
| * | |
| * A development-only utility that detects content changes and provides | |
| * visual feedback for regenerating translations and audio narration. | |
| * Shows "MODIFIED" badges on changed content and provides copy-to-clipboard | |
| * commands for regeneration scripts. | |
| * | |
| * REQUIREMENTS: | |
| * - content-hashes.json file with stored hashes (generated by pre-translate.js) | |
| * - Elements with data-narration and/or data-l10n-id attributes | |
| * - Only runs in development environment (localhost) | |
| * | |
| * USAGE: | |
| * 1. Import and instantiate on page load: | |
| * import { ContentManager } from './content-manager.js'; | |
| * const contentManager = new ContentManager(); | |
| * contentManager.initialize(); | |
| * | |
| * 2. When content changes: | |
| * - MODIFIED badges appear on changed paragraphs | |
| * - "Pre-translate Site" button appears if translation content changed | |
| * - "Generate Narration" button appears if narrated content changed | |
| * | |
| * 3. Click buttons to copy terminal commands to clipboard | |
| * | |
| * WORKFLOW: | |
| * 1. Edit HTML content | |
| * 2. Refresh page - see MODIFIED badges | |
| * 3. Click "Pre-translate Site" → paste command in terminal | |
| * 4. Click "Generate Narration" → paste command in terminal | |
| * 5. Badges disappear after regeneration | |
| * | |
| * CSS CLASSES: | |
| * - .is-dev - Added to body in dev environment | |
| * - .content-changed - Added to modified elements | |
| * - .hide-modified-badges - Toggle visibility of badges | |
| * | |
| * @license MIT | |
| */ | |
| export class ContentManager { | |
| constructor() { | |
| // ============================================ | |
| // SINGLETON PATTERN | |
| // ============================================ | |
| if (window.contentManager) { | |
| return window.contentManager; | |
| } | |
| // ============================================ | |
| // DEVELOPMENT ENVIRONMENT CHECK | |
| // Only initialize in local development | |
| // ============================================ | |
| this.isDevEnvironment = window.location.hostname.includes('127.0.0.1') || | |
| window.location.hostname.includes('localhost') || | |
| window.location.hostname.endsWith('.local'); | |
| if (!this.isDevEnvironment) return; | |
| // Add dev class for CSS scoping | |
| document.body.classList.add('is-dev'); | |
| // ============================================ | |
| // STATE | |
| // ============================================ | |
| this.changedParagraphs = new Set(); // Indices of changed narration elements | |
| this.currentHashes = {}; // Current content hashes | |
| this.currentTranslationHash = null; // Hash of all translatable content | |
| this.storedHashes = {}; // Hashes from content-hashes.json | |
| this.pageName = this.getPageName(); | |
| window.contentManager = this; | |
| } | |
| /** | |
| * Extracts page name from URL path | |
| */ | |
| getPageName() { | |
| const path = window.location.pathname; | |
| if (path === '/' || path === '/index.html') return 'home'; | |
| const parts = path.split('/').filter(Boolean); | |
| if (parts.length === 0) return 'home'; | |
| const lastPart = parts[parts.length - 1]; | |
| if (lastPart === 'index.html') { | |
| return parts.length > 1 ? parts[parts.length - 2] : 'home'; | |
| } | |
| return lastPart.replace('.html', ''); | |
| } | |
| /** | |
| * Initializes the content manager | |
| * Call this after page load | |
| */ | |
| async initialize() { | |
| if (!this.isDevEnvironment) return; | |
| // ============================================ | |
| // LANGUAGE CHECK | |
| // Only show workflow tools for English (source) content | |
| // ============================================ | |
| const currentLang = localStorage.getItem('currentLanguage') || 'en'; | |
| if (currentLang !== 'en') { | |
| this.cleanup(); | |
| return; | |
| } | |
| // Initialize badge toggle (always available in dev) | |
| this.initBadgeToggle(); | |
| try { | |
| // ============================================ | |
| // LOAD STORED HASHES | |
| // Cache-bust to ensure fresh data | |
| // ============================================ | |
| const response = await fetch(`/content-hashes.json?v=${Date.now()}`); | |
| if (!response.ok) throw new Error('Failed to fetch content-hashes.json'); | |
| const allHashes = await response.json(); | |
| this.storedHashes = allHashes[this.pageName] || {}; | |
| await this.updateCurrentContent(); | |
| await this.detectChanges(); | |
| this.setupChangeListeners(); | |
| } catch (error) { | |
| console.error('[ContentManager] Initialization error:', error); | |
| } | |
| } | |
| /** | |
| * Creates the badge visibility toggle button | |
| */ | |
| initBadgeToggle() { | |
| // Restore saved preference | |
| const badgesHidden = localStorage.getItem('hideBadges') === 'true'; | |
| if (badgesHidden) { | |
| document.documentElement.classList.add('hide-modified-badges'); | |
| } | |
| this.createBadgeToggleButton(badgesHidden); | |
| } | |
| /** | |
| * Creates the toggle button element | |
| * @param {boolean} initiallyHidden - Initial badge visibility state | |
| */ | |
| createBadgeToggleButton(initiallyHidden) { | |
| let toggleButton = document.getElementById('badge-toggle-button'); | |
| if (toggleButton) return; | |
| toggleButton = document.createElement('button'); | |
| toggleButton.id = 'badge-toggle-button'; | |
| toggleButton.innerHTML = this.getBadgeButtonText(initiallyHidden); | |
| if (initiallyHidden) { | |
| toggleButton.classList.add('badges-hidden'); | |
| } | |
| toggleButton.addEventListener('click', () => this.toggleBadges()); | |
| document.body.appendChild(toggleButton); | |
| } | |
| /** | |
| * Gets button text based on badge visibility | |
| * @param {boolean} hidden - Are badges hidden | |
| * @param {number} count - Number of changes | |
| */ | |
| getBadgeButtonText(hidden, count = 0) { | |
| const countBadge = (hidden && count > 0) ? `<span class="badge-count">${count}</span>` : ''; | |
| return hidden | |
| ? `Show Badges${countBadge}` | |
| : `Hide Badges`; | |
| } | |
| /** | |
| * Toggles badge visibility | |
| */ | |
| toggleBadges() { | |
| const isCurrentlyHidden = document.documentElement.classList.contains('hide-modified-badges'); | |
| const newState = !isCurrentlyHidden; | |
| document.documentElement.classList.toggle('hide-modified-badges', newState); | |
| localStorage.setItem('hideBadges', newState.toString()); | |
| this.updateBadgeToggleButton(); | |
| } | |
| /** | |
| * Updates the badge toggle button text | |
| */ | |
| updateBadgeToggleButton() { | |
| const toggleButton = document.getElementById('badge-toggle-button'); | |
| if (!toggleButton) return; | |
| const isHidden = document.documentElement.classList.contains('hide-modified-badges'); | |
| const count = this.changedParagraphs ? this.changedParagraphs.size : 0; | |
| toggleButton.innerHTML = this.getBadgeButtonText(isHidden, count); | |
| toggleButton.classList.toggle('badges-hidden', isHidden); | |
| } | |
| /** | |
| * Cleans up all UI elements and state | |
| */ | |
| cleanup() { | |
| if (this.observer) { | |
| this.observer.disconnect(); | |
| this.observer = null; | |
| } | |
| // Remove buttons | |
| document.getElementById('generate-audio-button')?.remove(); | |
| document.getElementById('pre-translate-button')?.remove(); | |
| // Remove change indicators | |
| document.querySelectorAll('.content-changed').forEach(el => { | |
| el.classList.remove('content-changed'); | |
| }); | |
| this.changedParagraphs.clear(); | |
| } | |
| /** | |
| * Normalizes text for hashing | |
| * Removes dynamic content and normalizes whitespace | |
| * @param {HTMLElement} el - Element to normalize | |
| */ | |
| getNormalizedText(el) { | |
| const clone = el.cloneNode(true); | |
| // Remove dynamic elements that shouldn't affect hash | |
| clone.querySelectorAll('.copy-button, .line-number').forEach(node => node.remove()); | |
| let text = clone.textContent | |
| .replace(/\|/g, ', ') | |
| .replace(/\s*\n\s*/g, ' . ') | |
| .trim(); | |
| return text | |
| .replace(/\.\s*\./g, '.') | |
| .replace(/^\.\s*/, '') | |
| .replace(/\s+/g, ' ') | |
| .replace(/[\u200B-\u200D\uFEFF]/g, ''); | |
| } | |
| /** | |
| * Updates current content hashes | |
| */ | |
| async updateCurrentContent() { | |
| // ============================================ | |
| // NARRATION CONTENT HASHES | |
| // Hash each narrated paragraph individually | |
| // ============================================ | |
| const paragraphs = document.querySelectorAll('[data-narration]'); | |
| for (const p of paragraphs) { | |
| const index = p.dataset.narration; | |
| const text = this.getNormalizedText(p); | |
| const hash = await this.hashString(text); | |
| this.currentHashes[index] = hash; | |
| } | |
| // Also update translation content hash | |
| await this.updateCurrentTranslationContent(); | |
| } | |
| /** | |
| * Updates the translation content hash | |
| * Uses same normalization as pre-translate.js | |
| */ | |
| async updateCurrentTranslationContent() { | |
| const translatableElements = Array.from(document.querySelectorAll('[data-l10n-id]')); | |
| const normalizedForHash = {}; | |
| const allIds = translatableElements | |
| .map(el => el.dataset.l10nId) | |
| .filter(Boolean) | |
| .sort(); | |
| allIds.forEach(id => { | |
| const el = document.querySelector(`[data-l10n-id="${id}"]`); | |
| if (!el) return; | |
| let textForHash = ''; | |
| if (el.tagName === 'INPUT' || el.tagName === 'TEXTAREA') { | |
| textForHash = el.placeholder || ''; | |
| } else if (el.tagName === 'META') { | |
| textForHash = el.getAttribute('content') || ''; | |
| } else { | |
| const clone = el.cloneNode(true); | |
| clone.querySelectorAll('.footer-year, .copy-button, .line-number').forEach(e => e.remove()); | |
| textForHash = clone.textContent; | |
| } | |
| normalizedForHash[id] = textForHash | |
| .replace(/[\u200B-\u200D\uFEFF]/g, '') | |
| .replace(/\s+/g, ' ') | |
| .trim(); | |
| }); | |
| const contentStr = JSON.stringify(normalizedForHash); | |
| this.currentTranslationHash = await this.hashString(contentStr); | |
| } | |
| /** | |
| * Computes SHA-256 hash of a string | |
| * @param {string} str - String to hash | |
| */ | |
| async hashString(str) { | |
| if (!window.isSecureContext || !crypto.subtle) { | |
| // Fallback for non-secure contexts | |
| let hash = 0; | |
| for (let i = 0; i < str.length; i++) { | |
| const char = str.charCodeAt(i); | |
| hash = ((hash << 5) - hash) + char; | |
| hash = hash & hash; | |
| } | |
| return 'dev-' + Math.abs(hash).toString(16); | |
| } | |
| const msgUint8 = new TextEncoder().encode(str); | |
| const hashBuffer = await crypto.subtle.digest('SHA-256', msgUint8); | |
| const hashArray = Array.from(new Uint8Array(hashBuffer)); | |
| return hashArray.map(b => b.toString(16).padStart(2, '0')).join(''); | |
| } | |
| /** | |
| * Detects which content has changed | |
| */ | |
| async detectChanges() { | |
| this.changedParagraphs.clear(); | |
| // ============================================ | |
| // DETECT NARRATION CHANGES | |
| // Compare current hashes to stored hashes | |
| // ============================================ | |
| const paragraphs = document.querySelectorAll('[data-narration]'); | |
| for (const p of paragraphs) { | |
| const index = p.dataset.narration; | |
| const current = this.currentHashes[index]; | |
| const stored = this.storedHashes[index]; | |
| if (current !== stored) { | |
| this.changedParagraphs.add(index); | |
| p.classList.add('content-changed'); | |
| } else { | |
| p.classList.remove('content-changed'); | |
| } | |
| } | |
| this.updateGenerateButton(); | |
| this.updateTranslateButton(); | |
| this.updateBadgeToggleButton(); | |
| } | |
| /** | |
| * Updates the translation button visibility | |
| */ | |
| updateTranslateButton() { | |
| let translateButton = document.getElementById('pre-translate-button'); | |
| const storedTranslationHash = this.storedHashes._translationHash; | |
| // Hide button if content hasn't changed | |
| if (this.currentTranslationHash === storedTranslationHash) { | |
| if (translateButton) translateButton.remove(); | |
| return; | |
| } | |
| // Create button if needed | |
| if (!translateButton) { | |
| translateButton = document.createElement('button'); | |
| translateButton.id = 'pre-translate-button'; | |
| document.body.appendChild(translateButton); | |
| this.attachTranslateButtonListener(translateButton); | |
| } | |
| // ============================================ | |
| // CUSTOMIZE COMMAND | |
| // Adjust for your script location/name | |
| // ============================================ | |
| translateButton.innerHTML = ` | |
| <div class="button-title">Pre-translate Site 🌍</div> | |
| <div class="button-command">node pre-translate.js</div> | |
| <div style="font-size: 8px; opacity: 0.5; font-family: monospace; padding: 0 8px; margin-top: 2px;">(requires vercel dev @ 3000)</div> | |
| `; | |
| } | |
| /** | |
| * Attaches click handler to translate button | |
| * @param {HTMLElement} translateButton - Button element | |
| */ | |
| attachTranslateButtonListener(translateButton) { | |
| translateButton.addEventListener('click', async () => { | |
| const command = `node pre-translate.js`; | |
| const originalHTML = translateButton.innerHTML; | |
| translateButton.disabled = true; | |
| try { | |
| await navigator.clipboard.writeText(command); | |
| translateButton.innerHTML = ` | |
| <div class="button-title">Command Copied!</div> | |
| <div class="button-command">Run it in your terminal now</div> | |
| `; | |
| } catch (clipErr) { | |
| translateButton.innerHTML = '<div class="button-title">Check Console</div>'; | |
| console.log('Command:', command); | |
| } | |
| setTimeout(() => { | |
| if (document.getElementById('pre-translate-button')) { | |
| translateButton.disabled = false; | |
| translateButton.innerHTML = originalHTML; | |
| } | |
| }, 3000); | |
| }); | |
| } | |
| /** | |
| * Sets up MutationObserver for live content changes | |
| */ | |
| setupChangeListeners() { | |
| if (this.observer) { | |
| this.observer.disconnect(); | |
| } | |
| const paragraphs = document.querySelectorAll('[data-narration]'); | |
| this.observer = new MutationObserver(async (mutations) => { | |
| const hasRealChange = mutations.some(m => | |
| m.type === 'characterData' || | |
| (m.type === 'childList' && Array.from(m.addedNodes).some(n => n.nodeType === 3)) | |
| ); | |
| if (hasRealChange) { | |
| await this.updateCurrentContent(); | |
| await this.detectChanges(); | |
| } | |
| }); | |
| paragraphs.forEach(p => { | |
| this.observer.observe(p, { characterData: true, childList: true, subtree: true }); | |
| }); | |
| } | |
| /** | |
| * Updates the narration generation button | |
| */ | |
| updateGenerateButton() { | |
| let generateButton = document.getElementById('generate-audio-button'); | |
| // Hide button if no changes | |
| if (this.changedParagraphs.size === 0) { | |
| if (generateButton) generateButton.remove(); | |
| return; | |
| } | |
| // Create button if needed | |
| if (!generateButton) { | |
| generateButton = document.createElement('button'); | |
| generateButton.id = 'generate-audio-button'; | |
| document.body.appendChild(generateButton); | |
| this.attachGenerateButtonListener(generateButton); | |
| } | |
| // ============================================ | |
| // CUSTOMIZE COMMAND | |
| // Adjust for your script location/flags | |
| // ============================================ | |
| const filePath = this.getRelativePath(); | |
| generateButton.innerHTML = ` | |
| <div class="button-title">Generate Narration ⚡</div> | |
| <div class="button-command">node generate-narration.js ${filePath} --all-langs</div> | |
| `; | |
| } | |
| /** | |
| * Gets the relative file path for the current page | |
| */ | |
| getRelativePath() { | |
| let path = window.location.pathname; | |
| if (path === '/') return 'index.html'; | |
| if (path.endsWith('/')) path += 'index.html'; | |
| return path.startsWith('/') ? path.slice(1) : path; | |
| } | |
| /** | |
| * Attaches click handler to generate button | |
| * @param {HTMLElement} generateButton - Button element | |
| */ | |
| attachGenerateButtonListener(generateButton) { | |
| generateButton.addEventListener('click', async () => { | |
| const filePath = this.getRelativePath(); | |
| const command = `node generate-narration.js ${filePath} --all-langs`; | |
| const originalHTML = generateButton.innerHTML; | |
| generateButton.disabled = true; | |
| try { | |
| await navigator.clipboard.writeText(command); | |
| generateButton.innerHTML = ` | |
| <div class="button-title">Command Copied!</div> | |
| <div class="button-command">Run it in your terminal now</div> | |
| `; | |
| } catch (clipErr) { | |
| generateButton.innerHTML = '<div class="button-title">Check Console</div>'; | |
| console.log('Command:', command); | |
| } | |
| setTimeout(() => { | |
| if (document.getElementById('generate-audio-button')) { | |
| generateButton.disabled = false; | |
| generateButton.innerHTML = originalHTML; | |
| } | |
| }, 3000); | |
| }); | |
| } | |
| } | |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment