Files
bookhoard/ESBUILD_MIGRATION_PLAN.md
T
john-okeefe e20857d760 docs: Create comprehensive ESBuild migration plan
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
2026-03-08 01:14:07 -05:00

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:

  1. ES Modules for all TypeScript → TypeScript dependencies
  2. Alpine.js ONLY as a bridge to templates (onclick → @click)
  3. SSR-first - Server renders complete HTML, client-side JS only for interactivity
  4. No window globals - everything through proper imports/exports
  5. 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

  1. 274 window global references across 21 TypeScript files
  2. 193+ internal TypeScript dependencies via window (should be ES imports)
  3. 27 template files with 150+ unique onclick handlers
  4. Function wrapping (themeDropdown.ts wraps header.ts functions)
  5. Individual .js files loaded in each template (not bundled)
  6. 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

  1. No breaking changes during migration - App works at every phase
  2. Can ship anytime - Partial migrations are functional
  3. No legacy code - Each file completely migrated, no half-states
  4. Easy rollback - If issues arise, revert individual files/templates
  5. 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.Alpine should 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)

  1. login.templ - Only has showToast, api
  2. register.templ - Only has showToast, api, validation
  3. profile.templ - Only has showToast, api
  4. settings.templ - Only has showToast, api

Batch 2: Pages with Simple State

  1. stats.templ - Only has showToast, api, events
  2. sync.templ - Only has showToast, api
  3. progress.templ - Only has showToast, api, events
  4. custom_section.templ - Has modal, but simple

Batch 3: Pages with Moderate State

  1. collection_rules.templ - Has showToast, api, events, some state
  2. queue.templ - Has queue namespace, multiple modals
  3. conflicts.templ - Has conflicts namespace, modal
  4. admin.templ - Has admin namespace, modal
  5. admin_library.templ - Has library namespace, multiple modals
  6. admin_users.templ - Has header functions, simple

Batch 4: Complex Pages

  1. dashboard.templ - Has theme dropdown, wood paneling, collections
  2. bookshelf.templ - Has search, book viewing, shelf mappings
  3. devices.templ - Has devices namespace, multiple modals
  4. analytics.templ - Keep Chart.js on CDN, has analytics namespace
  5. api_explorer.templ - Has explorer namespace

Batch 5: Most Complex (Shared Components)

  1. collections.templ - Has collections namespace, multiple modals, color/icon pickers
  2. docs.templ - Has sidebar toggle, search, inline JS to migrate
  3. header.templ - Used in 17 templates! Theme dropdown, user menu

Batch 6: Modal/Component Templates

  1. collection_modal.templ - Shared component
  2. restore_system_collection_modal.templ - Shared component
  3. profile_modal.templ - Shared component
  4. profile_form.templ - Shared component
  5. 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:

  1. Build: npm run build:ts && templ generate
  2. Run: go run .
  3. 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
  4. 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:

  1. Server provides JSON endpoints for data
  2. Client fetches data via APIs
  3. No window globals needed

Future Templates (Dashboard Pattern)

For new pages that follow the dashboard pattern:

  1. Server renders HTML with initial data (SSR)
  2. Client fetches updates via API when needed
  3. 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 onclick with @click
  • Add x-data for 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:ts with 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.post not window.api.post)
  • Check template uses correct namespace: @click="api.post()"

Runtime Errors

Error: "@click handler not working"

Possible Causes:

  1. Alpine not loaded

    • Check browser console: window.Alpine should be defined
    • Check main.js is loaded
    • Check alpine.ts imports Alpine and starts it
  2. Function not registered with Alpine

    • Check source file has Alpine.global("namespace", { ... })
    • Check namespace matches template usage
  3. Template syntax error

    • Check @click syntax: @click="namespace.function()"
    • Check for typos

Error: "x-show not working"

Possible Causes:

  1. Missing x-data parent

    • Add x-data="{ varName: false }" to parent element
  2. Variable name mismatch

    • Check x-data variable name matches x-show variable
  3. 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 --analyze to 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:

  1. Create Go handler that renders template with data
  2. Create .templ file with SSR data
  3. Use Alpine for any client-side interactivity
  4. Import TypeScript modules in main.ts
  5. Register functions with Alpine if templates call them

Q: How do I debug issues?

A:

  1. Browser DevTools Console: Check for errors
  2. Network Tab: Check main.js loads, no 404s
  3. Alpine DevTools: Install browser extension to inspect state
  4. Sourcemaps: Use main.js.map to debug original TypeScript
  5. 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.