Create detailed migration plan for transitioning from window globals to ES modules + Alpine.js architecture. The plan addresses all gaps in the previous setup document and provides incremental migration phases. Changes: - Add ESBUILD_MIGRATION_PLAN.md: Complete 46KB guide with 6 phases - Add ESBUILD_README.md: Quick reference for starting migration - Add ESBUILD_IMPORT_FIXES.md: Summary of import corrections - Archive ESBUILD_SETUP_OLD.md: Preserve previous incomplete plan Key improvements: - ES module exports for TypeScript→TypeScript dependencies - Alpine.js ONLY for template bridge (not internal TS) - Incremental migration with no legacy code - Clear testing and rollback procedures - File-by-file checklists for each phase The plan corrects critical issues: - 193+ internal window reads → proper ES imports - Function wrapping (themeDropdown.ts) → restructured - Dual exports: ES modules + Alpine namespaces - SSR-first with progressive enhancement Total scope: 21 TypeScript files, 27 template files, ~1700 lines of detailed instructions. Related: Issue #ESBuild-Migration
46 KiB
ESBuild Migration Plan: TypeScript → ES Modules + Alpine
Executive Summary
Goal: Migrate from individual JavaScript files with window globals to a single bundled main.js using ESBuild, ES modules for internal dependencies, and Alpine.js for template interactivity.
Architecture Principles:
- ES Modules for all TypeScript → TypeScript dependencies
- Alpine.js ONLY as a bridge to templates (onclick → @click)
- SSR-first - Server renders complete HTML, client-side JS only for interactivity
- No window globals - everything through proper imports/exports
- Incremental migration - Each phase is complete and testable
Target Bundle: ~100-130KB minified (ES2020 target = Chrome 80+, Firefox 72+, Safari 13.1+, Edge 80+)
Current State Analysis
Problems with Current Architecture
- 274 window global references across 21 TypeScript files
- 193+ internal TypeScript dependencies via window (should be ES imports)
- 27 template files with 150+ unique onclick handlers
- Function wrapping (themeDropdown.ts wraps header.ts functions)
- Individual .js files loaded in each template (not bundled)
- Mixed patterns - some files have ES exports, some don't
Files Requiring Migration
TypeScript Source Files (21 files):
- api.ts, toast.ts, storage.ts, events.ts, dom.ts
- theme.ts, header.ts, themeDropdown.ts, woodPaneling.ts
- library.ts, collections.ts, conflicts.ts, queue.ts
- dashboard.ts, admin.ts, bookshelf.ts, search.ts
- device-management.ts, analytics.ts, custom-section-builder.ts
- docs.ts, api-explorer.ts, password_validation.ts
Template Files (27 files):
- dashboard.templ, collections.templ, header.templ, admin.templ
- docs.templ, bookshelf.templ, devices.templ, queue.templ
- conflicts.templ, analytics.templ, login.templ, register.templ
- profile.templ, settings.templ, stats.templ, sync.templ
- progress.templ, custom_section.templ, collection_rules.templ
- admin_library.templ, admin_users.templ, api_explorer.templ
- Plus 8 more modal/component templates
Architecture Principles
1. ES Module Exports (TypeScript → TypeScript)
Every utility module exports functions for other TypeScript files to import:
// api.ts
export async function apiGet(url: string): Promise<Response> { ... }
export async function apiPost(url: string, data?: unknown): Promise<Response> { ... }
export async function handleResponse<T>(response: Response): Promise<T> { ... }
// library.ts (consumes api.ts)
import { apiGet, handleResponse } from "./api";
const response = await apiGet("/libraries");
const result = await handleResponse<LibrariesResponse>(response);
2. Alpine.js Bridge (Templates → TypeScript)
Alpine is ONLY used to expose functions to templates, not for internal TS dependencies:
// api.ts (bottom of file)
import { Alpine } from "./alpine";
Alpine.global("api", {
get: apiGet,
post: apiPost,
put: apiPut,
delete: apiDelete,
handleResponse,
handleVoidResponse,
handleError,
});
Template usage:
<!-- Before -->
<button onclick="apiPost('/api/save', data)">Save</button>
<!-- After -->
<button @click="api.post('/api/save', data)">Save</button>
3. SSR-First with Progressive Enhancement
- Server renders complete HTML with data
- Client-side JavaScript only for interactivity (modals, dropdowns, forms)
- Data loaded via fetch() APIs (like docs search index)
- No window globals for data injection
Migration Strategy: Incremental with No Legacy Code
Why This Strategy Works
- No breaking changes during migration - App works at every phase
- Can ship anytime - Partial migrations are functional
- No legacy code - Each file completely migrated, no half-states
- Easy rollback - If issues arise, revert individual files/templates
- Testable at each step - Clear verification criteria
Phase 0: Foundation - Add ES Module Exports
Goal: Add proper ES exports to all utility modules WITHOUT breaking existing functionality.
Files: 8 utility modules
Duration: 1-2 hours
Step 0.1: Add Exports to Utility Modules
For each file, add export statements while keeping window exports temporarily:
web/src/api.ts (Already has Alpine, just needs ES exports)
// Add at bottom (after Alpine.global block):
export { apiGet, apiPost, apiPut, apiDelete, apiPatch, handleResponse, handleVoidResponse, handleError };
web/src/toast.ts (Already has Alpine, just needs ES exports)
// Add at bottom (after Alpine.global block):
export { showToast };
export type { ToastType };
web/src/storage.ts (Already has exports! ✅)
// Already has:
// export { getToken, setToken, ... };
// No changes needed!
web/src/events.ts
// Add at bottom:
export {
onDelegatedClick,
onDelegatedSubmit,
onDelegatedChange,
onDelegatedKeydown,
getDataAttribute,
setDataAttribute,
onClick,
onSubmit,
onChange,
onKeydown,
onInput,
preventDefault,
stopPropagation,
};
web/src/dom.ts (Already has exports! ✅)
// Already has:
// export { escapeHtml, querySelector, ... };
// No changes needed!
web/src/theme.ts
// Add at bottom:
export { applyTheme, loadTheme, changeTheme, loadUserTheme, initializeTheme };
export type { ThemeType };
web/src/header.ts
// Add at bottom:
export { toggleThemeDropdown, toggleUserMenu, changeThemeTo, logout };
web/src/woodPaneling.ts
// Add at bottom:
export { changeWoodPaneling, loadWoodPaneling, updateWoodPanelingIndicators };
Step 0.2: Verify Build
npm run build:ts
Expected: Build succeeds, no errors
Why this works: We're adding exports without changing anything else. App still uses window globals, so functionality is unchanged.
Phase 1: Internal TypeScript Dependencies
Goal: Convert all internal window.XXX reads to proper ES module imports.
Files: 13 consumer files
Duration: 4-6 hours
1.1: Simple Consumers (Low Risk)
Files with minimal window dependencies:
web/src/dashboard.ts
// Add at top:
import { apiGet, apiPost, apiPut, apiDelete, apiPatch, handleResponse, handleVoidResponse, handleError } from "./api";
import { showToast } from "./toast";
// Replace ALL instances:
// Line 22: (window as any).showToast.error("msg") → showToast("msg", "error")
// Line 37: (window as any).showToast.error("msg") → showToast("msg", "error")
// Line 128: (window as any).api.put → apiPut
// Line 135: (window as any).showToast.success("msg") → showToast("msg", "success")
// ... (10 more replacements)
web/src/search.ts
// No changes needed! ✅
// Already uses localStorage directly, doesn't read from window
// Only exports selectLibraryAndBook
web/src/device-management.ts
// Add at top:
import { showToast } from "./toast";
// Replace ALL instances:
// Lines 32-34, 39-42, 78-82, 89-92: const toast = (window as any).showToast → showToast
web/src/password_validation.ts
// No window reads found, only exports initPasswordValidation
// No changes needed
1.2: Moderate Consumers
Files with multiple window dependencies:
web/src/admin.ts
// Add at top:
import { showToast } from "./toast";
// Replace ALL instances (14 occurrences):
// Lines 12-13, 17-18, 23-24, 40-41, 45-46, 53-54, 119-120, 152-153, 161-162
// (window as any).showToast.success("msg") → showToast("msg", "success")
// (window as any).showToast.error → showToast.error
web/src/queue.ts
// Add at top:
import { showToast } from "./toast";
// Replace ALL instances (8 occurrences):
// Lines 30-32, 37-39, 56-58, 63-65, 82-84, 89-91
web/src/conflicts.ts
// Add at top:
import { showToast } from "./toast";
// Replace ALL instances (6 occurrences):
// Lines 39-41, 45-48, 53-55, 78-80, 85-87
web/src/linking.ts
// Add at top:
import { showToast } from "./toast";
// Replace ALL instances:
// (window as any).showToast.success/error("msg") → showToast("msg", "success"/"error")
web/src/custom-section-builder.ts
// Add at top:
import { showToast } from "./toast";
import { apiGet, apiPost, apiPut, apiDelete, apiPatch, handleResponse, handleVoidResponse, handleError } from "./api";
// Replace ALL instances (9 occurrences):
// Lines 7, 10, 13, 16, 19, 22, 25, 28
web/src/analytics.ts
// Add at top:
import { apiGet, apiPost, apiPut, apiDelete, apiPatch, handleResponse, handleVoidResponse, handleError } from "./api";
import { querySelector, querySelectorAll, getElementById, createElement, showElement, hideElement, toggleElement, addClass, removeClass, toggleClass, hasClass, setTextContent, setInnerHTML, escapeHtml } from "./dom";
// Replace ALL instances:
// (window as any).api.get → apiGet
// (window as any).dom.getElementById → getElementById
web/src/bookshelf.ts
// Add at top:
import { showToast } from "./toast";
import { apiGet, apiPost, apiPut, apiDelete, apiPatch, handleResponse, handleVoidResponse, handleError } from "./api";
import { onDelegatedClick, onDelegatedSubmit, onDelegatedChange, onDelegatedKeydown, getDataAttribute, setDataAttribute, onClick, onSubmit, onChange, onKeydown, onInput, preventDefault, stopPropagation } from "./events";
// Replace ALL instances
web/src/api-explorer.ts
// Add at top:
import { apiGet, apiPost, apiPut, apiDelete, apiPatch, handleResponse, handleVoidResponse, handleError } from "./api";
import { querySelector, querySelectorAll, getElementById, createElement, showElement, hideElement, toggleElement, addClass, removeClass, toggleClass, hasClass, setTextContent, setInnerHTML, escapeHtml } from "./dom";
// Replace ALL instances
1.3: Complex Consumers (High Risk)
Files with heavy window dependencies:
web/src/library.ts (55 occurrences!)
// Add at top:
import { apiGet, apiPost, apiPut, apiDelete, apiPatch, handleResponse, handleVoidResponse, handleError } from "./api";
import { showToast } from "./toast";
import { querySelector, querySelectorAll, getElementById, createElement, showElement, hideElement, toggleElement, addClass, removeClass, toggleClass, hasClass, setTextContent, setInnerHTML, escapeHtml } from "./dom";
// Systematic replacement:
// (window as any).api.get → apiGet
// (window as any).api.handleResponse → handleResponse
// (window as any).api.handleError → handleError
// (window as any).showToast.success("msg") → showToast("msg", "success")
// (window as any).dom.createElement → createElement
web/src/collections.ts (54 occurrences!)
// Add at top:
import { apiGet, apiPost, apiPut, apiDelete, apiPatch, handleResponse, handleVoidResponse, handleError } from "./api";
import { showToast } from "./toast";
import { querySelector, querySelectorAll, getElementById, createElement, showElement, hideElement, toggleElement, addClass, removeClass, toggleClass, hasClass, setTextContent, setInnerHTML, escapeHtml } from "./dom";
// Systematic replacement of all 54 instances
1.4: Restructure Function Wrapping (Option A)
web/src/themeDropdown.ts - Eliminate function wrapping
Current (WRONG):
const originalToggleThemeDropdown = (window as any).toggleThemeDropdown;
(window as any).toggleThemeDropdown = () => {
originalToggleThemeDropdown();
updateThemeIndicators();
(window as any).updateWoodPanelingIndicators?.();
};
New Approach:
import { toggleThemeDropdown as originalToggle } from "./header";
import { updateWoodPanelingIndicators } from "./woodPaneling";
export function initializeThemeDropdown() {
// Call original function
originalToggle();
// Then update indicators
updateThemeIndicators();
updateWoodPanelingIndicators();
}
// Export for Alpine
export { updateThemeIndicators };
web/src/docs.ts - Remove window globals
Current (WRONG):
if (!(window as any).lunr) { ... }
const idx = (window as any).lunrIndex;
const doc = (window as any).docsData?.[result.ref];
New Approach (data already fetched via API):
// Already using fetch in template inline JS (lines 228-285)
// Just remove window checks, fetch API handles it
// Keep only:
import * as lunr from "lunr";
import hljs from "highlight.js";
import { Alpine } from "./alpine";
// Export for Alpine
export function initializeDocsSearch() { ... }
export { toggleSidebar };
Alpine.global("docs", {
toggleSidebar,
initializeSearch: initializeDocsSearch,
});
Step 1.5: Remove Window Exports from Consumer Files
After converting all reads to imports, remove window exports from consumer files (they don't need to expose to window anymore):
web/src/dashboard.ts
// Remove these lines (if present):
// (window as any).scrollCarousel = scrollCarousel;
// (window as any).openDashboardSettings = openDashboardSettings;
// ... etc
Only keep Alpine.global() for functions that templates call directly.
Step 1.6: Verify Build and Test
npm run build:ts
go run .
Verification:
- Build succeeds
- All pages still work (dashboard, library, collections, etc.)
- Console shows no errors
- All onclick handlers still work
Why this works: Internal dependencies now use imports, but window exports still exist for templates. App is fully functional.
Phase 2: Dual Exports - ES Modules + Alpine Bridge
Goal: Establish Alpine.js as the bridge to templates while keeping ES exports for TypeScript.
Files: All files that export functions used by templates
Duration: 2-3 hours
Step 2.1: Ensure Alpine Initialization File
web/src/alpine.ts (Already exists, verify it's correct)
import Alpine from "alpinejs";
// Extend Window interface
declare global {
interface Window {
Alpine: typeof Alpine;
}
}
// Initialize Alpine
window.Alpine = Alpine;
Alpine.start();
// Re-export for other modules
export { Alpine };
Step 2.2: Register Template Functions with Alpine
For each file that templates call, register functions with Alpine:
web/src/api.ts (Already done ✅)
Alpine.global("api", {
get: apiGet,
post: apiPost,
// ... etc
});
web/src/toast.ts (Already done ✅)
Alpine.global("showToast", {
error: (message: string, duration?: number) => showToast(message, "error", duration),
success: (message: string, duration?: number) => showToast(message, "success", duration),
info: (message: string, duration?: number) => showToast(message, "info", duration),
});
web/src/header.ts
Alpine.global("header", {
logout,
toggleThemeDropdown,
toggleUserMenu,
changeThemeTo: (theme: string) => {
changeThemeTo(theme);
updateThemeIndicators(); // Call themeDropdown function
},
});
web/src/library.ts
Alpine.global("library", {
deleteLibrary,
showLibraryFolders,
addLibraryFolder,
removeLibraryFolder,
});
web/src/collections.ts
Alpine.global("collections", {
loadCollections,
createRule,
deleteRule,
testRule,
navigateToCollection,
selectColor,
closeCollectionModal,
initColorSelection,
selectIcon,
filterIcons,
showAllIcons,
populateIconGrid,
initIconSelection,
initCollectionDetail,
showAddBooksModal,
hideAddBooksModal,
searchBooksForCollections,
toggleBookSelection,
addbooksToAdd,
removeBook,
toggleBookForRemoval,
updateSelectedCount,
removebooksToAdd,
filterCollectionBooks,
backToCollections,
});
web/src/admin.ts
Alpine.global("admin", {
triggerLibraryScan,
triggerQuickScan,
loadSystemStats,
scanAllLibraries,
loadWatchStatus,
hideScanProgress,
stopScanStatusPolling,
});
web/src/queue.ts
Alpine.global("queue", {
refreshQueue,
processPendingItems,
clearFailedItems,
clearAllItems,
retryQueueItem,
deleteQueueItem,
});
web/src/conflicts.ts
Alpine.global("conflicts", {
refreshConflicts,
resolveConflict,
bulkResolve,
bulkDismiss,
dismissAllResolved,
showResolveModal,
hideResolveModal,
handleResolveSubmit,
});
web/src/docs.ts
Alpine.global("docs", {
toggleSidebar,
initializeSearch: initializeDocsSearch,
});
web/src/search.ts
Alpine.global("search", {
selectLibraryAndBook,
});
web/src/device-management.ts
Alpine.global("devices", {
copyToClipboard,
regenerateDeviceToken,
});
web/src/password_validation.ts
Alpine.global("validation", {
initPasswordValidation,
});
Step 2.3: Remove Old Window Exports
After Alpine registration, REMOVE the old window export lines:
Before:
(window as any).api = { ... };
Alpine.global("api", { ... });
After:
Alpine.global("api", { ... });
Systematically search and remove these patterns from all files.
Step 2.4: Verify Build
npm run build:ts
go run .
Verification:
- Build succeeds
- All pages still work
- Alpine is loaded (check browser DevTools:
window.Alpineshould be defined) - Console shows no errors
Why this works: Functions now registered with Alpine instead of window, but templates still use onclick="func()" so they still work.
Phase 3: Template Migration (Incremental)
Goal: Migrate templates one-by-one from onclick handlers to Alpine directives.
Strategy: One template at a time, test each, can ship after each migration.
Duration: 1-2 hours per template (27 templates = 27-54 hours total)
Template Migration Pattern
For each template file:
Step A: Remove Individual Script Tags
Before:
<script src="/static/htmx.min.js"></script>
<script src="/static/toast.js"></script>
<script src="/static/api.js"></script>
<script src="/static/events.js"></script>
<script src="/static/dom.js"></script>
<script src="/static/admin.js"></script>
After:
<script src="/static/htmx.min.js"></script>
<script src="/static/main.js"></script>
Note: Keep htmx.min.js separate (loaded before main.js)
Step B: Convert onclick to @click
Before:
<button onclick="toggleThemeDropdown()">Theme</button>
<div id="theme-dropdown" class="hidden">
<button onclick="changeThemeTo('tokyo-night')">Tokyo Night</button>
</div>
After:
<button @click="header.toggleThemeDropdown()">Theme</button>
<div x-show="themeDropdownOpen" @click.outside="themeDropdownOpen = false" x-transition>
<button @click="header.changeThemeTo('tokyo-night')">Tokyo Night</button>
</div>
Step C: Add Alpine State for UI Components
For modals, dropdowns, and any UI with show/hide state:
Before:
<button onclick="showAddBooksModal()">Add Books</button>
<div id="add-books-modal" class="hidden">
...modal content...
<button onclick="hideAddBooksModal()">Cancel</button>
</div>
After:
<div x-data="{ addBooksModalOpen: false }">
<button @click="addBooksModalOpen = true">Add Books</button>
<div x-show="addBooksModalOpen"
x-transition
@click.self="addBooksModalOpen = false"
class="fixed inset-0 ...">
...modal content...
<button @click="addBooksModalOpen = false">Cancel</button>
</div>
</div>
Important: Alpine uses namespace objects (api.post, showToast.success),
while TypeScript code uses individual functions (apiPost, showToast).
This is correct - see Phase 2 for how Alpine creates these namespace objects.
Step D: Update Namespace Calls
Before:
<button onclick="showToast.success('Saved!')">Save</button>
<button onclick="apiPost('/api/save', data)">Save</button>
After:
<button @click="showToast.success('Saved!')">Save</button>
<button @click="api.post('/api/save', data)">Save</button>
Template Order (Low Risk to High Risk)
Batch 1: Simple Pages (No complex state)
- login.templ - Only has showToast, api
- register.templ - Only has showToast, api, validation
- profile.templ - Only has showToast, api
- settings.templ - Only has showToast, api
Batch 2: Pages with Simple State
- stats.templ - Only has showToast, api, events
- sync.templ - Only has showToast, api
- progress.templ - Only has showToast, api, events
- custom_section.templ - Has modal, but simple
Batch 3: Pages with Moderate State
- collection_rules.templ - Has showToast, api, events, some state
- queue.templ - Has queue namespace, multiple modals
- conflicts.templ - Has conflicts namespace, modal
- admin.templ - Has admin namespace, modal
- admin_library.templ - Has library namespace, multiple modals
- admin_users.templ - Has header functions, simple
Batch 4: Complex Pages
- dashboard.templ - Has theme dropdown, wood paneling, collections
- bookshelf.templ - Has search, book viewing, shelf mappings
- devices.templ - Has devices namespace, multiple modals
- analytics.templ - Keep Chart.js on CDN, has analytics namespace
- api_explorer.templ - Has explorer namespace
Batch 5: Most Complex (Shared Components)
- collections.templ - Has collections namespace, multiple modals, color/icon pickers
- docs.templ - Has sidebar toggle, search, inline JS to migrate
- header.templ - Used in 17 templates! Theme dropdown, user menu
Batch 6: Modal/Component Templates
- collection_modal.templ - Shared component
- restore_system_collection_modal.templ - Shared component
- profile_modal.templ - Shared component
- profile_form.templ - Shared component
- toast.templ - Shared component (if using template for toast)
Special Cases
docs.templ - Migrate Inline JavaScript
Current (lines 124-515): Large inline script block
New Approach: Move to docs.ts
// web/src/docs.ts
export function initializeDocsPage() {
// Move toggleSection, toggleSidebar functions here
// Move search initialization here
// Keep using fetch for data loading (SSR-compatible)
}
Alpine.global("docs", {
initializePage: initializeDocsPage,
toggleSection,
toggleSidebar,
});
<!-- templates/docs.templ -->
<script>
document.addEventListener('DOMContentLoaded', () => {
window.Alpine.docs.initializePage();
});
</script>
header.templ - Most Critical (Used in 17 Places)
This template is included in 17 other templates. Test thoroughly:
<!-- Before -->
<button onclick="toggleThemeDropdown()">Theme</button>
<div id="theme-dropdown" class="hidden">
<button onclick="changeThemeTo('tokyo-night')">Tokyo Night</button>
...11 more themes...
<button onclick="changeWoodPaneling('none')">None</button>
<button onclick="changeWoodPaneling('wood-light')">Wood Light</button>
</div>
<button onclick="toggleUserMenu()">
<div id="user-menu" class="hidden">
<button onclick="logout()">Logout</button>
</div>
<!-- After -->
<div x-data="{ themeDropdownOpen: false, userMenuOpen: false }">
<button @click="themeDropdownOpen = !themeDropdownOpen">Theme</button>
<div x-show="themeDropdownOpen"
@click.outside="themeDropdownOpen = false"
x-transition:enter="transition ease-out duration-200"
x-transition:enter-start="opacity-0 scale-95"
x-transition:enter-end="opacity-100 scale-100"
class="absolute right-0 mt-2 w-64 rounded-lg shadow-lg z-50">
<button @click="header.changeThemeTo('tokyo-night'); themeDropdownOpen = false">Tokyo Night</button>
...11 more themes...
<button @click="woodPaneling.change('none'); themeDropdownOpen = false">None</button>
<button @click="woodPaneling.change('wood-light'); themeDropdownOpen = false">Wood Light</button>
</div>
<button @click="userMenuOpen = !userMenuOpen">
<div x-show="userMenuOpen"
@click.outside="userMenuOpen = false"
x-transition>
<button @click="header.logout(); userMenuOpen = false">Logout</button>
</div>
</div>
Testing Strategy for Each Template
After migrating each template:
- Build:
npm run build:ts && templ generate - Run:
go run . - Test: Visit the page, test all interactions:
- All buttons work
- Modals open/close
- Dropdowns work
- Forms submit (via HTMX)
- Toast notifications appear
- No console errors
- Verify: Check Alpine DevTools (if installed) for reactive state
Rollback Strategy
If a migrated template has issues:
# Revert the template file
git checkout templates/PROBLEM_TEMPLATE.templ
# Rebuild
templ generate
go run .
The template is now using old onclick handlers, but the bundle still has the functions registered. Everything works.
Phase 4: Data Injection (Server → Client)
Goal: Ensure server-to-client data flow works correctly without window globals.
Files: docs.templ, any future templates that need data injection
Duration: 1-2 hours
Current Situation
docs.templ currently loads data via fetch (correct approach):
// In template inline JS (lines 228-285)
fetch('/docs/search-index.json')
.then(r => r.json())
.then(data => { searchDocs = data; })
Go handler provides:
// internal/docs/http_handler.go line 417
func (h *HTTPHandler) ServeSearchIndex(c *echo.Context) error {
index, err := h.docs.GenerateSearchIndex();
return c.JSON(http.StatusOK, index)
}
Migration Strategy
Keep this pattern! It's already correct:
- Server provides JSON endpoints for data
- Client fetches data via APIs
- No window globals needed
Future Templates (Dashboard Pattern)
For new pages that follow the dashboard pattern:
- Server renders HTML with initial data (SSR)
- Client fetches updates via API when needed
- No data injection into window
Example:
// Go handler
func (h *Handler) ShowDashboard(c *echo.Context) error {
// Fetch data from database
collections := h.queries.GetCollections(c.request().Context())
// Render template with data
return templates.Dashboard(collections).Render(c)
}
// Client-side (if needed)
import { apiGet, apiPost, apiPut, apiDelete, apiPatch, handleResponse, handleVoidResponse, handleError } from "./api";
async function refreshCollections() {
const response = await apiGet("/collections");
const data = await handleResponse(response);
// Update DOM
}
Phase 5: Cleanup
Goal: Remove all legacy code and old .js files.
Duration: 1-2 hours
Step 5.1: Remove Individual .js Files
After all 27 templates are migrated and verified working:
cd /home/nymusicman/Code/bookhoard/web/static
# Remove old individual JS files
rm -f api.js toast.js events.js dom.js storage.js
rm -f theme.js header.js themeDropdown.js woodPaneling.js
rm -f search.js docs.js collections.js conflicts.js
rm -f dashboard.js admin.js admin_library.js admin_users.js
rm -f analytics.js bookshelf.js devices.js
rm -f index.js login.js profile.js
rm -f queue.js custom_section.js progress.js
rm -f register.js settings.js stats.js sync.js
rm -f testing_templ.js obsidian.js whatsapp.js midnight.js sunset.js
rm -f password_validation.js api_explorer.js
# Keep only:
# - main.js (bundled)
# - main.js.map (sourcemap)
# - htmx.min.js (separate load)
# - highlight.min.js (for docs)
# - style.css (Tailwind)
Step 5.2: Remove All Window Exports
From all TypeScript files, remove any remaining (window as any) exports:
# Search for remaining window exports
cd /home/nymusicman/Code/bookhoard/web/src
grep -rn "window as any" *.ts
Should only find:
- Type assertions being removed
- Comments referencing old pattern
Remove all:
// DELETE these lines:
(window as any).functionName = functionName;
Step 5.3: Update Build Scripts
Verify package.json scripts are correct:
{
"scripts": {
"build:css": "tailwindcss -i ./web/static/input.css -o ./web/static/style.css --watch",
"build:css:prod": "tailwindcss -i ./web/static/input.css -o ./web/static/style.css --minify",
"build:ts": "esbuild web/src/main.ts --bundle --outfile=web/static/main.js --sourcemap --target=es2020 --minify",
"build:ts:dev": "esbuild web/src/main.ts --bundle --outfile=web/static/main.js --sourcemap --target=es2020",
"build:ts:watch": "esbuild web/src/main.ts --bundle --outfile=web/static/main.js --sourcemap --target=es2020 --watch",
"build": "npm run build:ts && npm run build:css:prod",
"dev": "npm run build:ts:dev && templ generate && go run ."
}
}
Step 5.4: Final Verification
# Complete build
npm run build
templ generate
go build
# Check bundle size
ls -lh web/static/main.js
# Expected: ~120-150KB
# Run tests (if any)
go test ./...
# Manual testing
go run .
# Test all pages, verify functionality
Step 5.5: Update Documentation
Update or remove any references to old build process in documentation.
Success Criteria
Phase 0 Completion
✅ All utility modules have ES exports ✅ Build succeeds ✅ No functionality broken
Phase 1 Completion
✅ All 193+ internal window reads converted to imports ✅ All consumer files use ES module imports ✅ Function wrapping eliminated (themeDropdown.ts restructured) ✅ Build succeeds ✅ All pages still work
Phase 2 Completion
✅ All template functions registered with Alpine ✅ Old window exports removed ✅ Build succeeds ✅ Alpine loaded and functional ✅ All pages still work (onclick still works)
Phase 3 Completion
✅ All 27 templates migrated to @click ✅ All individual script tags replaced with single main.js ✅ Inline JS migrated to TypeScript where appropriate ✅ All onclick handlers converted to @click ✅ All UI state uses x-data/x-show ✅ All templates tested and working
Phase 4 Completion
✅ Server data injection uses API endpoints (not window) ✅ Docs search still works ✅ Future pages follow SSR-first pattern
Phase 5 Completion
✅ All old .js files removed ✅ No window globals remain ✅ Only main.js and main.js.map exist ✅ Bundle size ~120-150KB minified ✅ Clean codebase ready for launch
Estimated Effort
| Phase | Duration | Risk | Can Ship After |
|---|---|---|---|
| Phase 0 | 1-2 hours | Low | ✅ Yes |
| Phase 1 | 4-6 hours | Medium | ✅ Yes |
| Phase 2 | 2-3 hours | Low | ✅ Yes |
| Phase 3 | 27-54 hours | Medium-High | ✅ Yes (per template) |
| Phase 4 | 1-2 hours | Low | ✅ Yes |
| Phase 5 | 1-2 hours | Low | ❌ No (final cleanup) |
| Total | 36-69 hours |
Recommended Schedule:
- Week 1: Phases 0-2 (Foundation) - 7-11 hours
- Week 2-4: Phase 3 (Templates) - 5-10 templates per week
- Week 5: Phases 4-5 (Finalize) - 2-4 hours
Rollback Procedures
If Phase 0 or Phase 1 Fails
# Revert TypeScript changes
git checkout web/src/
# Rebuild
npm run build:ts
go run .
If Phase 2 Fails
# Revert to Phase 1 state (window exports still present)
git checkout web/src/
# Rebuild
npm run build:ts
go run .
If Phase 3 (Template Migration) Fails
# Revert specific problematic template
git checkout templates/PROBLEM_TEMPLATE.templ
# Regenerate templates
templ generate
# Rebuild
go run .
All other migrated templates continue working.
Testing Checklist
After Each Phase
- Build succeeds (
npm run build:ts) - Go build succeeds (
go build) - Application starts (
go run .) - Homepage loads
- Login works
- Dashboard loads
- No console errors
- Network tab shows no 404s for .js files
After Phase 1 (Internal Dependencies)
- Dashboard works
- Library management works
- Collections work
- Admin functions work
- Queue works
- Conflicts work
- All toast notifications work
- All API calls work
After Phase 3 (Each Template)
- Page loads
- All buttons work
- Modals open/close
- Dropdowns work
- Forms submit via HTMX
- Toast notifications appear
- No console errors
- Alpine DevTools shows reactive state (if installed)
Files Modified Summary
New Files Created
- None (alpine.ts already exists)
Source Files Modified (21 files)
- web/src/main.ts (verify imports are correct)
- web/src/alpine.ts (verify initialization is correct)
- web/src/api.ts (add ES exports, Alpine already present)
- web/src/toast.ts (add ES exports, Alpine already present)
- web/src/storage.ts (already has exports ✅)
- web/src/events.ts (add ES exports and Alpine)
- web/src/dom.ts (already has exports ✅)
- web/src/theme.ts (add ES exports and Alpine)
- web/src/header.ts (add ES exports and Alpine, restructure)
- web/src/woodPaneling.ts (add ES exports and Alpine)
- web/src/themeDropdown.ts (RESTRUCTURE to eliminate wrapping)
- web/src/library.ts (convert 55 window reads to imports, add Alpine)
- web/src/collections.ts (convert 54 window reads to imports, add Alpine)
- web/src/dashboard.ts (convert 10 window reads to imports, add Alpine)
- web/src/admin.ts (convert 14 window reads to imports, add Alpine)
- web/src/queue.ts (convert 8 window reads to imports, add Alpine)
- web/src/conflicts.ts (convert 6 window reads to imports, add Alpine)
- web/src/linking.ts (convert window reads to imports, add Alpine)
- web/src/custom-section-builder.ts (convert window reads to imports, add Alpine)
- web/src/analytics.ts (convert window reads to imports, add Alpine)
- web/src/bookshelf.ts (convert window reads to imports, add Alpine)
- web/src/api-explorer.ts (convert window reads to imports, add Alpine)
- web/src/device-management.ts (convert window reads to imports, add Alpine)
- web/src/search.ts (add Alpine registration)
- web/src/docs.ts (remove window globals, move inline JS to module, add Alpine)
- web/src/password_validation.ts (add Alpine registration)
Template Files Modified (27 files)
All templates updated to:
- Remove individual script tags
- Use single
<script src="/static/main.js"></script> - Replace
onclickwith@click - Add
x-datafor stateful components (modals, dropdowns) - Keep htmx.min.js separate
Generated Files
- web/static/main.js (bundled output with Alpine)
- web/static/main.js.map (sourcemap)
Files Deleted (Phase 5)
All individual .js files in web/static/ (except main.js, main.js.map, htmx.min.js, highlight.min.js, style.css)
External Dependencies (Not Bundled)
htmx.org
- Status: Keep as separate script tag
- Reason: Core framework, needs to load before main.js
- Location:
<script src="/static/htmx.min.js"></script>
Chart.js
- Status: Keep on CDN
- Reason: 3.4MB minified, only used on analytics page
- Location:
<script src="https://cdn.jsdelivr.net/npm/chart.js"></script>in analytics.templ only
highlight.js
- Status: Bundled via ESBuild
- Reason: Used in docs, small enough (~5KB gzipped)
- Import:
import hljs from "highlight.js";
lunr
- Status: Bundled via ESBuild
- Reason: Used in docs search, small enough (~10KB gzipped)
- Import:
import * as lunr from "lunr";
Architecture Decision Records
ADR-001: ES Modules over Window Globals
Decision: Use ES module imports/exports for all TypeScript-to-TypeScript dependencies.
Rationale:
- Standard JavaScript module system
- Better type safety with TypeScript
- Clear dependency chains
- Tree-shaking support
- No global namespace pollution
Consequences:
- Positive: Cleaner code, better IDE support, easier refactoring
- Positive: Standard pattern, easier for new developers
- Neutral: Requires build step (already using ESBuild)
ADR-002: Alpine.js for Template Interactivity Only
Decision: Use Alpine.js ONLY as a bridge between templates and TypeScript, not for internal TypeScript dependencies.
Rationale:
- Alpine is designed for template directives (@click, x-show)
- Clean separation: ES modules for code, Alpine for templates
- Avoids over-engineering simple function calls
- Keeps bundle size smaller
Consequences:
- Positive: Clean template syntax
- Positive: Progressive enhancement works
- Positive: Easy to understand data flow
- Neutral: Need to learn Alpine basics (simple)
ADR-003: SSR-First with Progressive Enhancement
Decision: Server renders complete HTML with data, client-side JavaScript only for interactivity.
Rationale:
- Faster initial page load
- Better SEO (if needed)
- Works without JavaScript (degrades gracefully)
- Simpler state management
- Aligns with HTMX philosophy
Consequences:
- Positive: Better performance
- Positive: More resilient
- Positive: Easier to debug
- Neutral: Slightly more server work (acceptable)
ADR-004: Function Wrapping Elimination
Decision: Eliminate function wrapping (themeDropdown.ts wraps header.ts functions) in favor of proper module composition.
Rationale:
- Clearer code flow
- Better testability
- Easier to understand
- Standard pattern
- App not yet deployed, can refactor
Consequences:
- Positive: Cleaner architecture
- Positive: Easier to maintain
- Negative: More work upfront (acceptable)
- Negative: Need to restructure (acceptable)
Troubleshooting
Build Errors
Error: "Cannot find module './xxx'"
Solution:
- Check import path is correct (relative, case-sensitive)
- Check file has
export {}statements - Run
npm run build:tswith clean build
Error: "Alpine is not defined"
Solution:
- Check alpine.ts is imported in main.ts:
import "./alpine"; - Check Alpine.start() is called
- Check window.Alpine is set
Error: "Cannot read property 'xxx' of undefined"
Solution:
- Check Alpine.global() is called after Alpine.start()
- Check namespace is correct (e.g.,
api.postnotwindow.api.post) - Check template uses correct namespace:
@click="api.post()"
Runtime Errors
Error: "@click handler not working"
Possible Causes:
-
Alpine not loaded
- Check browser console:
window.Alpineshould be defined - Check main.js is loaded
- Check alpine.ts imports Alpine and starts it
- Check browser console:
-
Function not registered with Alpine
- Check source file has
Alpine.global("namespace", { ... }) - Check namespace matches template usage
- Check source file has
-
Template syntax error
- Check @click syntax:
@click="namespace.function()" - Check for typos
- Check @click syntax:
Error: "x-show not working"
Possible Causes:
-
Missing x-data parent
- Add
x-data="{ varName: false }"to parent element
- Add
-
Variable name mismatch
- Check x-data variable name matches x-show variable
-
Alpine not loaded
- See above
Template Errors
Error: "templ generate fails"
Solution:
- Check template syntax (missing closing tags, etc.)
- Check for invalid templ syntax
- Check template file encoding (UTF-8)
Error: "Page not rendering correctly after migration"
Solution:
- Check all script tags are removed except main.js
- Check main.js is loaded
- Check browser console for errors
- Check Alpine DevTools for state
- Verify onclick → @click conversion is correct
Performance Issues
Issue: "Bundle size too large (>200KB)"
Possible Causes:
- Check if Chart.js accidentally bundled (should be CDN)
- Check if duplicate dependencies
- Run
esbuild --analyzeto see bundle contents
Issue: "Page load slow"
Possible Causes:
- Check main.js is minified in production
- Check sourcemap not loaded in production
- Check server compression enabled
- Check browser caching headers
Development Workflow
During Migration (Phases 0-2)
# Terminal 1: Watch TypeScript
npm run build:ts:watch
# Terminal 2: Watch Templates
templ generate -watch
# Terminal 3: Run Server
go run .
After Migration (All Phases Complete)
# Development
npm run dev
# Production Build
npm run build
templ generate
go build
FAQ
Q: Why not put everything in main.ts?
A: Main.ts imports other modules. This keeps code:
- Organized (one file per concern)
- Maintainable (easy to find code)
- Testable (can test individual modules)
- Tree-shakeable (unused code eliminated)
Q: Why Alpine.js instead of vanilla JS event listeners?
A: Alpine provides:
- Cleaner template syntax (@click vs onclick)
- Built-in state management (x-show, x-data)
- Better progressive enhancement
- SSR-friendly
- Smaller bundle than React/Vue
Q: Why keep HTMX if we have Alpine?
A: They serve different purposes:
- HTMX: Server communication (form submissions, API calls)
- Alpine: Client-side state (modals, dropdowns, UI)
They work great together.
Q: Can I use React/Vue instead of Alpine?
A: You could, but:
- Larger bundle size: React = ~40KB gzipped, Alpine = ~15KB gzipped
- More complexity: Need JSX compilation, more build tools
- Overkill: For this app's needs, Alpine is sufficient
- HTMX synergy: Alpine works better with HTMX
Q: What if I need to add a new page?
A: Follow the dashboard pattern:
- Create Go handler that renders template with data
- Create .templ file with SSR data
- Use Alpine for any client-side interactivity
- Import TypeScript modules in main.ts
- Register functions with Alpine if templates call them
Q: How do I debug issues?
A:
- Browser DevTools Console: Check for errors
- Network Tab: Check main.js loads, no 404s
- Alpine DevTools: Install browser extension to inspect state
- Sourcemaps: Use main.js.map to debug original TypeScript
- Go Logs: Check server logs for errors
Glossary
- ES Modules: Standard JavaScript module system (import/export)
- ESBuild: Fast JavaScript bundler
- Alpine.js: Lightweight JavaScript framework for UI interactivity
- HTMX: Library for dynamic web pages using HTML attributes
- Templ: Go templating language that compiles to Go code
- SSR: Server-Side Rendering - server generates complete HTML
- Progressive Enhancement: Page works without JavaScript, enhanced with it
- Tree-shaking: Removing unused code from bundle
- Sourcemap: File that maps bundled code back to source code for debugging
- Window globals: Variables attached to window object (old pattern)
- Namespace: Grouping related functions (e.g., api.post, api.get)
Appendix A: Quick Reference
Common Patterns
Import ES Module:
import { functionName } from "./module";
Export from Module:
export { functionName1, functionName2 };
export default function mainFunction() { ... }
Register with Alpine:
import { Alpine } from "./alpine";
Alpine.global("namespace", {
functionName1,
functionName2,
});
Use in Template:
<!-- Before -->
<button onclick="namespace.functionName()">
<!-- After -->
<button @click="namespace.functionName()">
Add State with Alpine:
<div x-data="{ modalOpen: false }">
<button @click="modalOpen = true">Open</button>
<div x-show="modalOpen" x-transition>
Modal content
</div>
</div>
Appendix B: File-by-File Checklist
Phase 0: Add ES Exports
- api.ts - Add exports
- toast.ts - Add exports
- storage.ts - Already has exports ✅
- events.ts - Add exports
- dom.ts - Already has exports ✅
- theme.ts - Add exports
- header.ts - Add exports
- woodPaneling.ts - Add exports
Phase 1: Internal Dependencies
- dashboard.ts - Convert 10 window reads
- search.ts - No changes needed
- device-management.ts - Convert 4 window reads
- admin.ts - Convert 14 window reads
- queue.ts - Convert 8 window reads
- conflicts.ts - Convert 6 window reads
- linking.ts - Convert window reads
- custom-section-builder.ts - Convert window reads
- analytics.ts - Convert window reads
- bookshelf.ts - Convert window reads
- api-explorer.ts - Convert window reads
- library.ts - Convert 55 window reads
- collections.ts - Convert 54 window reads
- themeDropdown.ts - RESTRUCTURE to eliminate wrapping
- docs.ts - Remove window globals
Phase 2: Alpine Registration
- api.ts - Already registered ✅
- toast.ts - Already registered ✅
- storage.ts - Add Alpine.global
- events.ts - Add Alpine.global
- dom.ts - Add Alpine.global
- theme.ts - Add Alpine.global
- header.ts - Add Alpine.global
- woodPaneling.ts - Add Alpine.global
- library.ts - Add Alpine.global
- collections.ts - Add Alpine.global
- admin.ts - Add Alpine.global
- queue.ts - Add Alpine.global
- conflicts.ts - Add Alpine.global
- docs.ts - Add Alpine.global
- search.ts - Add Alpine.global
- device-management.ts - Add Alpine.global
- password_validation.ts - Add Alpine.global
Phase 3: Template Migration
- login.templ
- register.templ
- profile.templ
- settings.templ
- stats.templ
- sync.templ
- progress.templ
- custom_section.templ
- collection_rules.templ
- queue.templ
- conflicts.templ
- admin.templ
- admin_library.templ
- admin_users.templ
- dashboard.templ
- bookshelf.templ
- devices.templ
- analytics.templ
- api_explorer.templ
- collections.templ
- docs.templ
- header.templ
- collection_modal.templ
- restore_system_collection_modal.templ
- profile_modal.templ
- profile_form.templ
- toast.templ
Phase 4: Data Injection
- Verify docs search still works
- Verify no data in window globals
- Document SSR-first pattern for future pages
Phase 5: Cleanup
- Remove all old .js files
- Remove all window exports
- Update build scripts
- Final verification
- Update documentation
End of Migration Plan
This plan provides a complete, incremental path from the current window-globals architecture to a modern ES modules + Alpine architecture, with clear phases, testing strategies, and rollback procedures.
Key Points:
- Each phase is complete and testable
- Can ship after any phase (except final cleanup)
- No legacy code remains
- Clean architecture ready for launch
- SSR-first for future pages
Next Step: Begin Phase 0 - Add ES module exports to utility modules.