Skip to content

Instantly share code, notes, and snippets.

@misterburton
Created January 5, 2026 22:25
Show Gist options
  • Select an option

  • Save misterburton/88bfa052c5d3977aeb50c41f0f06d731 to your computer and use it in GitHub Desktop.

Select an option

Save misterburton/88bfa052c5d3977aeb50c41f0f06d731 to your computer and use it in GitHub Desktop.
AI-Powered Localization - Dev Workflow Tools
/**
* 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