Files
bookhoard/esbuild-setup.md
T
john-okeefe 5baef8160e docs: update ESBuild setup guide for Alpine.js migration pattern
Comprehensive update to esbuild-setup.md with Alpine.js integration guide
for replacing (window as any) pattern with modern reactive framework.

Key changes:
- Add Alpine.js as recommended approach over vanilla event listeners
- Include Phase 2: TypeScript Alpine registration patterns
- Update Phase 3: Template changes with @click and x-data examples
- Add Phase 5: Step-by-step TypeScript migration with exact line numbers
- Fix toast.ts, api.ts, storage.ts examples to match actual code structure
- Include troubleshooting for Alpine-specific issues
- Add migration checklist and quick reference guide

Architecture decisions:
- Alpine.js for client-side state (modals, dropdowns, theme)
- HTMX for server calls (existing pattern, keep unchanged)
- Hybrid approach: Alpine reactive components + HTMX forms
- Bundle Alpine with ESBuild (~15KB gzipped)

Template updates (27 files):
- Replace onclick="func()" with @click="func()"
- Add x-data for stateful components
- Use x-show/x-transition for modals and dropdowns
- Keep HTMX form submissions unchanged

TypeScript migrations:
- Priority 1: Core utilities (toast.ts, api.ts, storage.ts, events.ts, dom.ts)
- Priority 2: Stateful components (header.ts themeDropdown, woodPaneling.ts)
- Priority 3: Page-specific functions (collections.ts, devices.ts, etc.)
- Register functions with Alpine.global() or Alpine.data()

Testing and verification:
- Alpine DevTools for debugging reactive state
- Build step: esbuild --run scripts/build-docs-search.ts
- Verify no 404 errors for missing .js files
- Test all 151 onclick handlers work with @click

Bundle size: ~130KB minified (~40KB gzipped) with Alpine included
Browser support: ES2020 (Chrome 80+, Firefox 72+, Safari 13.1+)

Document is now 1,812 lines with comprehensive step-by-step instructions
for migrating from (window as any) exports to Alpine.js components.
2026-03-06 22:26:49 -05:00

50 KiB

ESBuild Setup Guide

Overview

Migrate to a single bundled main.js using ESBuild for better performance, simplified deployment, and broader browser compatibility (ES2020 target = Chrome 80+, Firefox 72+, Safari 13.1+, Edge 80+).

Target Bundle Size: ~100KB minified + gzip
Browser Support: ES2020 (Chrome 80+, Firefox 72+, Safari 13.1+, Edge 80+, Ubuntu 22.04 LTS browsers)
Architecture: SSR-first, procedural TypeScript, progressive enhancement (per PROJECT_GUIDELINES.md)


Status: What's Already Done

1. package.json Dependencies (Already Correct)

File: package.json
Status: Already configured - NO CHANGES NEEDED

Dependencies (lines 14-26):

  • htmx.org, alpinejs, lunr, highlight.js already in dependencies
  • esbuild, typescript, TailwindCSS tooling already in devDependencies

2. Build Scripts (Already Correct)

File: package.json
Status: Already configured - NO CHANGES NEEDED

Lines 8-10:

"build:ts": "esbuild web/src/main.ts --bundle --outfile=web/static/main.js --sourcemap --target=es2020 --minify",
"watch:ts": "esbuild web/src/main.ts --bundle --outfile=web/static/main.js --sourcemap --target=es2020",
"dev": "concurrently \"npm run watch:css\" \"npm run watch:ts\" \"npm run watch:templ\"",

Already using --target=es2020 for broad browser compatibility.

3. Entry Point (Already Exists)

File: web/src/main.ts
Status: Already exists - NO CHANGES NEEDED

Already imports all 29 modules correctly (24 lines). Main.ts stays simple - imports happen in individual files where used.


Phase 1: Clean Up docs.ts

File: web/src/docs.ts (99 lines)
Status: Imports already added
Lines to modify: 50, 56, 73 (remove window globals), 99 (replace with Alpine)

Note: Lines 3-4 already have the correct imports:

import * as lunr from "lunr";
import hljs from "highlight.js";

Step 1: Remove (window as any).lunr check

Location: Line 50

Current (lines 49-53):

  if (!(window as any).lunr) {
    console.warn("Lunr.js not loaded");
    return;
  }

Change to:

  // Lunr now bundled via ESBuild

Step 2: Replace (window as any).lunrIndex with direct lunr usage

Location: Line 56

Current (lines 55-62):

    const idx = (window as any).lunrIndex;
    if (!idx) {
      searchResults.innerHTML =
        '<p class="p-2 text-sm" style="color: var(--text-secondary)">Search index not loaded</p>';
      searchResults.classList.remove("hidden");
      return;
    }

Change to:

    const idx = lunr.Builder.loadJs(searchIndex);
    if (!idx) {
      searchResults.innerHTML =
        '<p class="p-2 text-sm" style="color: var(--text-secondary)">Search index not loaded</p>';
      searchResults.classList.remove("hidden");
      return;
    }

Step 3: Replace (window as any).docsData with direct import

Location: Line 73

Current (lines 70-76):

        .map((result: { ref: string }) => {
          const doc = (window as any).docsData?.[result.ref];
          if (!doc) return "";

Change to:

        .map((result: { ref: string }) => {
          const doc = docs[result.ref];
          if (!doc) return "";

Step 4: Replace window export with Alpine global

Location: Line 99

Current (lines 98-99):

document.addEventListener("DOMContentLoaded", () => {
  initializeDocsSearch();
});

(window as any).toggleSidebar = toggleSidebar;

Change to:

document.addEventListener("DOMContentLoaded", () => {
  initializeDocsSearch();
  
  // Register with Alpine globally
  if (typeof window.Alpine !== 'undefined') {
    window.Alpine.effect(() => {
      window.Alpine.global('docs', {
        toggleSidebar
      });
    });
  }
});

Why Alpine.global(): Makes toggleSidebar() available to Alpine templates via @click="docs.toggleSidebar()"


Phase 2: Migrate TypeScript Files to Alpine Registration

Approach: Replace (window as any) exports with Alpine.js global registration

Architecture Note: Alpine.js for Client-Side State

Why Alpine over window exports:

  • Modern, reactive framework (already in package.json)
  • Clean template syntax: @click instead of onclick="window.func()"
  • Built-in state management: x-data, x-show, x-model
  • Works with SSR (progressive enhancement)
  • No global namespace pollution

Hybrid approach:

  • Alpine: Client-side state (modals, dropdowns, theme, forms)
  • HTMX: Server calls (already using for form submissions)

Step 1: Create Alpine Registration Helper

New file: web/src/alpine.ts

import Alpine from 'alpinejs';

// Initialize Alpine
window.Alpine = Alpine;
Alpine.start();

// Re-export Alpine for other modules to use
export { Alpine };

Add to main.ts: Append this line at the end of web/src/main.ts:

import './alpine';

Step 2: Update TypeScript Files to Register with Alpine

Example: web/src/toast.ts

Current (lines 229-236):

(window as any).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),
};

Change to:

import { Alpine } from './alpine';

// Register toast API with Alpine
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),
});

Usage in templates:

<!-- Before -->
<button onclick="showToast.success('Saved!')">Save</button>
<button onclick="showToast.error('Failed!')">Retry</button>

<!-- After -->
<button @click="showToast.success('Saved!')">Save</button>
<button @click="showToast.error('Failed!')">Retry</button>

Example: web/src/api.ts

Current (lines 90-99):

(window as any).api = {
  get: apiGet,
  post: apiPost,
  put: apiPut,
  delete: apiDelete,
  patch: apiPatch,
  handleResponse,
  handleVoidResponse,
  handleError,
};

Change to:

import { Alpine } from './alpine';

Alpine.global('api', {
  get: apiGet,
  post: apiPost,
  put: apiPut,
  delete: apiDelete,
  patch: apiPatch,
  handleResponse,
  handleVoidResponse,
  handleError,
});

Usage in templates:

<!-- Before -->
<button onclick="api.post('/api/save', data)">Save</button>

<!-- After -->
<button @click="api.post('/api/save', data)">Save</button>

Pattern: Stateful Components (Dropdowns, Modals)

Example: web/src/header.ts (theme dropdown)

Current approach: Multiple window exports

New approach: Create Alpine component with state

Add to web/src/header.ts:

import { Alpine } from './alpine';

// Register theme dropdown component
Alpine.data('themeDropdown', () => ({
  open: false,
  
  toggle() {
    this.open = !this.open;
  },
  
  changeTheme(theme: string) {
    changeThemeTo(theme); // Reuse existing function
    this.open = false;
  },
  
  init() {
    // Load saved theme on init
    applyTheme(loadTheme());
  }
}));

Usage in templates:

<!-- Before -->
<button onclick="toggleThemeDropdown()">Theme</button>
<div id="theme-dropdown" class="hidden">
  <button onclick="changeThemeTo('tokyo-night')">Tokyo Night</button>
</div>

<!-- After -->
<div x-data="themeDropdown">
  <button @click="toggle()">Theme</button>
  <div x-show="open" @click.away="open = false" x-transition>
    <button @click="changeTheme('tokyo-night')">Tokyo Night</button>
  </div>
</div>

Files Requiring Alpine Migration

High priority (used by 10+ templates):

  1. web/src/toast.ts - Register showToast
  2. web/src/api.ts - Register api object
  3. web/src/events.ts - Register event functions
  4. web/src/storage.ts - Register storage helpers

Medium priority (stateful components): 5. web/src/header.ts - Create themeDropdown component 6. web/src/woodPaneling.ts - Create woodPaneling component 7. web/src/collections.ts - Register collection functions 8. web/src/conflicts.ts - Register conflict functions 9. web/src/search.ts - Register search functions 10. web/src/dom.ts - Register DOM helpers

Lower priority (page-specific): 11. web/src/dashboard.ts - Dashboard-specific functions 12. web/src/devices.ts - Device management functions 13. web/src/queue.ts - Queue management functions 14. web/src/analytics.ts - Analytics functions (Chart.js) 15. web/src/admin.ts - Admin functions

Keep as-is for now (already working):

  • Lunr/Highlight.js imports (docs.ts) - already handled
  • Chart.js usage - already loaded via CDN

Phase 3: Update Templates to Alpine Directives

Template Migration Strategy

151 onclick handlers need migration across 27 templates. Use this approach:

  1. Add x-data component to sections with state
  2. Replace onclick with @click
  3. Replace class="hidden" with x-show="!open"
  4. Add transitions with x-transition
  5. Use x-model for form inputs

Template Changes (27 files)


1. templates/collections.templ

File: templates/collections.templ (271 lines)

Remove script blocks (lines 12-13, 111-112): DELETE all individual <script> tags.

Replace final script (line 269):

<script src="/static/collections.js"></script>

Change to:

<script src="/static/main.js"></script>

Update onclick handlers:

Line 63 - Collection card:

<!-- Before -->
<div onclick="navigateToCollection(this)" data-href={...}>

<!-- After -->
<div @click="collections.navigate(this)" data-href={...} x-data>

Line 119 - Back button:

<!-- Before -->
<button onclick="backToCollections()" class="btn-secondary">

<!-- After -->
<button @click="collections.back()" class="btn-secondary">

Line 156 - Show add books modal:

<!-- Before -->
<button onclick="showAddBooksModal()" class="btn-primary">

<!-- After -->
<button @click="collections.showAddModal()" class="btn-primary">

Lines 231, 252 - Hide modal:

<!-- Before -->
<button onclick="hideAddBooksModal()">

<!-- After -->
<button @click="collections.hideAddModal()">

Line 255 - Add selected books:

<!-- Before -->
<button type="button" onclick="addSelectedBooks()">

<!-- After -->
<button type="button" @click="collections.addSelected()">

2. templates/docs.templ

File: templates/docs.templ (518 lines)

Remove external scripts (lines 16-18):

<script src="https://cdn.jsdelivr.net/npm/lunr@2.3.9/lunr.min.js"></script>
<script src="/static/lunr-flex.min.js"></script>
<script src="https://cdnjs.cloudflare.com/ajax/libs/highlight.js/11.9.0/highlight.min.js"></script>

DELETE these lines entirely.

Note: lunr-flex.min.js doesn't exist - this causes 404 errors.

Replace docs.js script (line 517):

<script src="/static/docs.js"></script>

Change to:

<script src="/static/main.js"></script>

Update sidebar toggle (line 23-27):

<!-- Before -->
<button 
  class="lg:hidden fixed top-4 left-4 z-50 bg-background-secondary border border-border rounded p-2"
  onclick="toggleSidebar()"
  aria-label="Toggle menu">

Change to:

<!-- After -->
<div x-data="{ sidebarOpen: false }">
  <button 
    class="lg:hidden fixed top-4 left-4 z-50 bg-background-secondary border border-border rounded p-2"
    @click="sidebarOpen = !sidebarOpen"
    aria-label="Toggle menu">

Add Alpine state to sidebar (line 37):

<!-- Before -->
<div class="sidebar fixed left-0 top-0 bottom-0 w-72 bg-background-secondary ...">

Change to:

<!-- After -->
<div class="sidebar fixed left-0 top-0 bottom-0 w-72 bg-background-secondary ..."
     :class="sidebarOpen ? 'translate-x-0' : '-translate-x-full'"
     x-show="sidebarOpen"
     x-transition.opacity>

3. templates/admin.templ

File: templates/admin.templ (125 lines)

Replace script block (lines 9-11):

<script src="/static/api.js"></script>
<script src="/static/toast.js"></script>
<script src="/static/admin.js"></script>

Change to:

<script src="/static/main.js"></script>

4. templates/admin_library.templ

File: templates/admin_library.templ (163 lines)

Replace script block (lines 156-160):

<script src="/static/api.js"></script>
<script src="/static/events.js"></script>
<script src="/static/dom.js"></script>
<script src="/static/toast.js"></script>
<script src="/static/admin_library.js"></script>

Change to:

<script src="/static/main.js"></script>

5. templates/dashboard.templ

File: templates/dashboard.templ (310 lines)

Remove first script block (lines 15-19):

<script src="/static/api.js"></script>
<script src="/static/events.js"></script>
<script src="/static/dom.js"></script>
<script src="/static/toast.js"></script>
<script src="/static/storage.js"></script>

DELETE these lines entirely.

Replace dashboard.js script (line 80):

<script src="/static/dashboard.js"></script>

Change to:

<script src="/static/main.js"></script>

6. templates/header.templ

File: templates/header.templ (188 lines)

Replace script block (lines 184-187):

<script src="/static/storage.js"></script>
<script src="/static/theme.js"></script>
<script src="/static/header.js"></script>
<script src="/static/themeDropdown.js"></script>

Change to:

<script src="/static/main.js"></script>

Update theme dropdown (lines 48-93): This is a perfect Alpine use case - replace with Alpine component.

Current structure (simplified):

<button onclick="toggleThemeDropdown()">Theme</button>
<div id="theme-dropdown" class="hidden">
  <button onclick="changeThemeTo('tokyo-night')">Tokyo Night</button>
  <button onclick="changeThemeTo('dracula')">Dracula</button>
  <!-- ... more themes ... -->
</div>

Change to Alpine:

<div x-data="themeDropdown">
  <button @click="toggle()">Theme</button>
  
  <div x-show="open" 
       @click.away="open = 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-56 rounded-lg shadow-lg z-50"
       :class="open ? '' : 'hidden'">
    
    <div class="py-1">
      <button @click="changeTheme('tokyo-night')" 
              class="block w-full text-left px-3 py-2 rounded hover:opacity-80">
        Tokyo Night
      </button>
      
      <button @click="changeTheme('dracula')"
              class="block w-full text-left px-3 py-2 rounded hover:opacity-80">
        Dracula
      </button>
      
      <!-- ... more themes ... -->
    </div>
  </div>
</div>

Update user menu (lines 57-60, 65):

<!-- Before -->
<button onclick="toggleUserMenu()">
<div id="user-menu" class="hidden">
  <button onclick="logout()">Logout</button>

Change to:

<div x-data="{ userMenuOpen: false }">
  <button @click="userMenuOpen = !userMenuOpen">
  
  <div x-show="userMenuOpen" 
       @click.away="userMenuOpen = false"
       x-transition>
    <button @click="api.post('/api/auth/logout').then(() => window.location.href = '/login')">
      Logout
    </button>
  </div>
</div>

7. templates/analytics.templ

File: templates/analytics.templ (100 lines)

Remove first script block (lines 10-14):

<script src="/static/api.js"></script>
<script src="/static/events.js"></script>
<script src="/static/dom.js"></script>
<script src="/static/toast.js"></script>
<script src="https://cdn.jsdelivr.net/npm/chart.js"></script>

DELETE these lines entirely.

Note: Chart.js remains on CDN (intentionally not bundled - 3.4MB, only used on 1 page).

Replace analytics.js script (line 97):

<script src="/static/analytics.js"></script>

Change to:

<script src="/static/main.js"></script>
<script src="https://cdn.jsdelivr.net/npm/chart.js"></script>

8. templates/bookshelf.templ

File: templates/bookshelf.templ (266 lines)

Remove first script block (lines 10-13):

<script src="/static/api.js"></script>
<script src="/static/events.js"></script>
<script src="/static/toast.js"></script>
<script src="/static/dom.js"></script>

DELETE these lines entirely.

Replace bookshelf.js script (line 63):

<script src="/static/bookshelf.js"></script>

Change to:

<script src="/static/main.js"></script>

9. templates/devices.templ

File: templates/devices.templ (870 lines)

Replace script block (lines 12-16):

<script src="/static/api.js"></script>
<script src="/static/events.js"></script>
<script src="/static/toast.js"></script>
<script src="/static/dom.js"></script>
<script src="/static/devices.js"></script>

Change to:

<script src="/static/main.js"></script>

10. templates/index.templ

File: templates/index.templ (165 lines)

Remove first script block (lines 10-12):

<script src="/static/api.js"></script>
<script src="/static/events.js"></script>
<script src="/static/toast.js"></script>

DELETE these lines entirely.

Replace index.js script (line 138):

<script src="/static/index.js"></script>

Change to:

<script src="/static/main.js"></script>

11. templates/login.templ

File: templates/login.templ (81 lines)

Replace script block (lines 10-12):

<script src="/static/api.js"></script>
<script src="/static/toast.js"></script>
<script src="/static/login.js"></script>

Change to:

<script src="/static/main.js"></script>

12. templates/profile.templ

File: templates/profile.templ (68 lines)

Replace script block (lines 9-11):

<script src="/static/api.js"></script>
<script src="/static/toast.js"></script>
<script src="/static/profile.js"></script>

Change to:

<script src="/static/main.js"></script>

13. templates/queue.templ

File: templates/queue.templ (169 lines)

Remove first script block (lines 12-15):

<script src="/static/api.js"></script>
<script src="/static/events.js"></script>
<script src="/static/toast.js"></script>
<script src="/static/dom.js"></script>

DELETE these lines entirely.

Replace queue.js script (line 166):

<script src="/static/queue.js"></script>

Change to:

<script src="/static/main.js"></script>

14. templates/conflicts.templ

File: templates/conflicts.templ (239 lines)

Remove first script block (lines 12-15):

<script src="/static/api.js"></script>
<script src="/static/events.js"></script>
<script src="/static/toast.js"></script>
<script src="/static/dom.js"></script>

DELETE these lines entirely.

Replace conflicts.js script (line 234):

<script src="/static/conflicts.js"></script>

Change to:

<script src="/static/main.js"></script>

15. templates/custom_section.templ

File: templates/custom_section.templ (168 lines)

Replace script block (lines 10-13):

<script src="/static/api.js"></script>
<script src="/static/events.js"></script>
<script src="/static/toast.js"></script>
<script src="/static/custom_section.js"></script>

Change to:

<script src="/static/main.js"></script>

16. templates/admin_users.templ

File: templates/admin_users.templ (145 lines)

Replace script block (lines 9-12):

<script src="/static/api.js"></script>
<script src="/static/events.js"></script>
<script src="/static/toast.js"></script>
<script src="/static/admin_users.js"></script>

Change to:

<script src="/static/main.js"></script>

17. templates/collection_rules.templ

File: templates/collection_rules.templ (420 lines)

Replace script block (lines 10-12):

<script src="/static/api.js"></script>
<script src="/static/events.js"></script>
<script src="/static/collection_rules.js"></script>

Change to:

<script src="/static/main.js"></script>

18. templates/progress.templ

File: templates/progress.templ (114 lines)

Replace script block (lines 12-14):

<script src="/static/api.js"></script>
<script src="/static/events.js"></script>
<script src="/static/progress.js"></script>

Change to:

<script src="/static/main.js"></script>

19. templates/register.templ

File: templates/register.templ (97 lines)

Replace script block (lines 10-12):

<script src="/static/api.js"></script>
<script src="/static/toast.js"></script>
<script src="/static/register.js"></script>

Change to:

<script src="/static/main.js"></script>

20. templates/settings.templ

File: templates/settings.templ (157 lines)

Replace script block (lines 9-11):

<script src="/static/api.js"></script>
<script src="/static/toast.js"></script>
<script src="/static/settings.js"></script>

Change to:

<script src="/static/main.js"></script>

21. templates/stats.templ

File: templates/stats.templ (162 lines)

Replace script block (lines 9-11):

<script src="/static/api.js"></script>
<script src="/static/events.js"></script>
<script src="/static/stats.js"></script>

Change to:

<script src="/static/main.js"></script>

22. templates/sync.templ

File: templates/sync.templ (184 lines)

Replace script block (lines 9-12):

<script src="/static/api.js"></script>
<script src="/static/events.js"></script>
<script src="/static/toast.js"></script>
<script src="/static/sync.js"></script>

Change to:

<script src="/static/main.js"></script>

23. templates/testing_templ.templ

File: templates/testing_templ.templ (115 lines)

Replace script block (lines 9-11):

<script src="/static/api.js"></script>
<script src="/static/events.js"></script>
<script src="/static/testing_templ.js"></script>

Change to:

<script src="/static/main.js"></script>

24. themes/obsidian.templ

File: themes/obsidian.templ (91 lines)

Replace script block (lines 9-11):

<script src="/static/api.js"></script>
<script src="/static/toast.js"></script>
<script src="/static/obsidian.js"></script>

Change to:

<script src="/static/main.js"></script>

25. themes/whatsapp.templ

File: themes/whatsapp.templ (86 lines)

Replace script block (lines 9-11):

<script src="/static/api.js"></script>
<script src="/static/toast.js"></script>
<script src="/static/whatsapp.js"></script>

Change to:

<script src="/static/main.js"></script>

26. themes/midnight.templ

File: themes/midnight.templ (91 lines)

Replace script block (lines 9-11):

<script src="/static/toast.js"></script>
<script src="/static/api.js"></script>
<script src="/static/midnight.js"></script>

Change to:

<script src="/static/main.js"></script>

27. themes/sunset.templ (same pattern as other themes)

File: themes/sunset.templ (86 lines)

Replace script block (lines 9-11):

<script src="/static/toast.js"></script>
<script src="/static/api.js"></script>
<script src="/static/sunset.js"></script>

Change to:

<script src="/static/main.js"></script>

Remaining Templates (7-26)

For templates 7-26, follow this pattern:

  1. Remove all individual script tags (usually 2-5 scripts)
  2. Replace with single script: <script src="/static/main.js"></script>
  3. Replace onclick with @click
  4. Add x-data for stateful components (modals, dropdowns)

Quick find-replace patterns:

# In each template file:
onclick="funcName()" → @click="module.funcName()"
onclick="func('param')" → @click="module.func('param')"
class="hidden" → x-show="!isOpen" (with x-data parent)

Common stateful patterns:

Modal pattern:

<!-- Before -->
<button onclick="showModal()">Open</button>
<div id="modal" class="hidden">
  <button onclick="hideModal()">Close</button>
</div>

<!-- After -->
<div x-data="{ modalOpen: false }">
  <button @click="modalOpen = true">Open</button>
  <div x-show="modalOpen" x-transition>
    <button @click="modalOpen = false">Close</button>
  </div>
</div>

Confirm delete pattern:

<!-- Before -->
<button onclick="confirmDelete()">Delete</button>

<!-- After -->
<button @click="if(confirm('Are you sure?')) module.deleteItem()">Delete</button>

Form submission (keep HTMX for server calls):

<!-- Keep HTMX for forms -->
<form hx-post="/api/save" hx-target="#result">
  <!-- Alpine for client-side validation -->
  <input x-model="formData.name" @input="validate = true">
  <span x-show="validate && !formData.name" class="error">Name required</span>
</form>

File: themes/sunset.templ (86 lines)

Replace script block (lines 9-11):

<script src="/static/toast.js"></script>
<script src="/static/api.js"></script>
<script src="/static/sunset.js"></script>

Change to:

<script src="/static/main.js"></script>

Phase 4: Build and Verify

Step 1: Create Alpine Registration File

File: web/src/alpine.ts (already exists, needs update)

Current file (missing window.Alpine):

import Alpine from "alpinejs";

// Initialize Alpine
Alpine.start();

// Re-export Alpine for other modules to use
export { Alpine };

Change to (add window.Alpine + type declaration):

import Alpine from "alpinejs";

// Extend Window interface to include Alpine
declare global {
  interface Window {
    Alpine: typeof Alpine;
  }
}

// Initialize Alpine
window.Alpine = Alpine;
Alpine.start();

// Re-export Alpine for other modules to use
export { Alpine };

Why type declaration: Fixes TypeScript error "Property 'Alpine' does not exist on type 'Window'"

Why window.Alpine: Required for Alpine DevTools browser extension to work.

Step 2: Update main.ts

File: web/src/main.ts

Add at end (after all other imports):

import './alpine';

Step 3: Build the Bundle

npm run build:ts

Expected output: Creates web/static/main.js (~350-450KB unminified, ~120-150KB minified with Alpine bundled) + main.js.map sourcemap

Why larger: Alpine.js adds ~15KB gzipped to bundle (worth it for cleaner code).

Step 4: Regenerate Templates

templ generate

Expected output: Regenerates all .templ files with updated Alpine directives

Step 5: Verify Alpine is Loaded

head -n 50 web/static/main.js | grep -i alpine

Check for: Alpine initialization code present in bundle.

Step 6: Test in Browser

  1. Start dev server: go run .

  2. Open browser DevTools (F12) → Console

  3. Verify Alpine loaded:

    typeof window.Alpine !== 'undefined'  // should be true
    
  4. Test key pages:

    • Dashboard (tests theme dropdown, wood paneling)
    • Docs page (tests lunr search, sidebar toggle)
    • Collections (tests modal, multiple onclick handlers)
    • Header (tests theme dropdown, user menu)
    • Devices (tests multiple modals)
  5. Check Alpine DevTools (optional):

    • Install Alpine DevTools browser extension
    • Inspect x-data components in DevTools panel
    • Verify reactive state changes

Step 7: Verify Functionality

In browser console, test Alpine globals:

// Should all be accessible via Alpine
Alpine.stores?.toast  // Toast store
Alpine.global('showToast')  // Global function

Test interactions:

  • Theme dropdown opens/closes smoothly
  • Modals open with transitions
  • Toast notifications appear
  • Forms still submit via HTMX
  • Search works on docs page
  • All 151 onclick handlers work with @click

Step 8: Check Bundle Size

ls -lh web/static/main.js

Expected: ~120-150KB (minified with Alpine included)

Breakdown:

  • App code: ~100KB
  • Alpine.js: ~15KB gzipped
  • Lunr: ~10KB gzipped
  • Highlight.js: ~5KB gzipped
  • Total: ~130KB / ~40KB gzipped

Phase 5: Migrate TypeScript Files to Alpine (Step-by-Step)

Priority 1: Core Utilities (Do These First)

1. web/src/toast.ts

Current state: Already has Alpine import at line 1, but not using it yet

Add import (already at line 1):

import { Alpine } from "./alpine";

Replace export (lines 229-236):

// Before
(window as any).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),
};

Change to:

// After - Register toast API with Alpine
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),
});

Template usage:

<!-- Before -->
<button onclick="showToast.success('Saved!')">Save</button>

<!-- After -->
<button @click="showToast.success('Saved!')">Save</button>

2. web/src/api.ts

Add import (at top):

import { Alpine } from './alpine';

Replace export (lines 90-99):

// Before
(window as any).api = {
  get: apiGet,
  post: apiPost,
  put: apiPut,
  delete: apiDelete,
  patch: apiPatch,
  handleResponse,
  handleVoidResponse,
  handleError,
};

Change to:

// After - Register API with Alpine
Alpine.global('api', {
  get: apiGet,
  post: apiPost,
  put: apiPut,
  delete: apiDelete,
  patch: apiPatch,
  handleResponse,
  handleVoidResponse,
  handleError,
});

Also update line 85-87 (toast error calls):

// Before
if ((window as any).showToast?.error) {
  (window as any).showToast.error(message);
}

// After (will work once toast.ts is migrated)
if (window.Alpine?.stores?.toast?.error) {
  window.Alpine.stores.toast.error(message);
}
// Or keep using window.showToast during migration

Template usage:

<!-- Before -->
<button onclick="api.post('/api/save', data)">Save</button>

<!-- After -->
<button @click="api.post('/api/save', data)">Save</button>

3. web/src/storage.ts

Add import (at top):

import { Alpine } from './alpine';

Replace export (lines 53-67):

// Before
(window as any).storage = {
  getToken,
  setToken,
  removeToken,
  getRefreshToken,
  setRefreshToken,
  removeRefreshToken,
  getTheme,
  setTheme,
  getSelectedLibrary,
  setSelectedLibrary,
  getSelectedBook,
  setSelectedBook,
  clearAll,
};

Change to:

// After - Register storage helpers with Alpine
Alpine.global('storage', {
  getToken,
  setToken,
  removeToken,
  getRefreshToken,
  setRefreshToken,
  removeRefreshToken,
  getTheme,
  setTheme,
  getSelectedLibrary,
  setSelectedLibrary,
  getSelectedBook,
  setSelectedBook,
  clearAll,
});

Template usage:

<!-- Before -->
<button onclick="const token = storage.getToken()">Get Token</button>

<!-- After -->
<button @click="const token = storage.getToken()">Get Token</button>

Priority 2: Stateful Components

4. web/src/header.ts (Theme Dropdown)

Add import (at top):

import { Alpine } from './alpine';
import { changeThemeTo, loadTheme, applyTheme } from './theme';

Add Alpine component (at end of file):

Alpine.data('themeDropdown', () => ({
  open: false,
  
  toggle() {
    this.open = !this.open;
  },
  
  changeTheme(theme: string) {
    changeThemeTo(theme);
    this.open = false;
  },
  
  init() {
    const savedTheme = loadTheme();
    if (savedTheme) applyTheme(savedTheme);
  }
}));

Template usage:

<div x-data="themeDropdown">
  <button @click="toggle()">Theme</button>
  <div x-show="open" @click.away="open = false" x-transition>
    <button @click="changeTheme('tokyo-night')">Tokyo Night</button>
  </div>
</div>

5. web/src/woodPaneling.ts

Add Alpine component (at end of file):

import { Alpine } from './alpine';

Alpine.data('woodPaneling', () => ({
  current: localStorage.getItem('woodPaneling') || 'none',
  
  change(style: string) {
    this.current = style;
    localStorage.setItem('woodPaneling', style);
    // Apply logic here...
  }
}));

Template usage:

<div x-data="woodPaneling">
  <button @click="change('none')" :class="current === 'none' ? 'active' : ''">None</button>
  <button @click="change('wood-light')" :class="current === 'wood-light' ? 'active' : ''">Wood Light</button>
</div>

Priority 3: Page-Specific Functions

6. web/src/collections.ts

Add import:

import { Alpine } from './alpine';

Register as namespace (at end):

Alpine.global('collections', {
  back: backToCollections,
  showAddModal: showAddBooksModal,
  hideAddModal: hideAddBooksModal,
  addSelected: addSelectedBooks,
  removeBook: removeBook,
  navigate: navigateToCollection
});

7. web/src/devices.ts, queue.ts, conflicts.ts

Follow same pattern as collections.ts - register as namespace with Alpine.global()


Phase 6: Clean Up (Optional)

Remove Individual JS Files

After verifying everything works, remove old individual .js files:

rm web/static/api.js
rm web/static/events.js
rm web/static/dom.js
rm web/static/toast.js
rm web/static/storage.js
rm web/static/theme.js
rm web/static/header.js
rm web/static/themeDropdown.js
rm web/static/woodPaneling.js
rm web/static/search.js
rm web/static/docs.js
rm web/static/collections.js
rm web/static/conflicts.js
rm web/static/dashboard.js
rm web/static/admin*.js
rm web/static/analytics.js
rm web/static/bookshelf.js
rm web/static/devices.js
rm web/static/index.js
rm web/static/login.js
rm web/static/profile.js
rm web/static/queue.js
rm web/static/custom_section.js
rm web/static/progress.js
rm web/static/register.js
rm web/static/settings.js
rm web/static/stats.js
rm web/static/sync.js
rm web/static/testing_templ.js
rm web/static/obsidian.js
rm web/static/whatsapp.js
rm web/static/midnight.js
rm web/static/sunset.js

Note: Keep main.js and main.js.map


Troubleshooting

Issue: "Alpine is not defined"

Cause: Alpine not initialized or alpine.ts not imported in main.ts
Fix: Ensure import './alpine'; is at end of main.ts and Alpine.start() is called

Issue: "Alpine.global is not a function"

Cause: Using old Alpine syntax
Fix: Use Alpine.data() for components or register globals before Alpine.start()

Issue: "lunr is not defined"

Cause: Missing import in docs.ts
Fix: Ensure import * as lunr from 'lunr'; is at line 4

Issue: "hljs is not defined"

Cause: Missing import in docs.ts
Fix: Ensure import hljs from 'highlight.js'; is at line 5

Issue: @click handlers not working

Cause: Alpine not loaded or syntax error in directive
Fix: Check browser console, verify Alpine initialized, check directive syntax

Issue: x-show elements always visible

Cause: Missing x-cloak CSS or Alpine not loaded before DOM ready
Fix: Add [x-cloak] { display: none !important; } to CSS, ensure Alpine loads early

Issue: Bundle too large (>200KB minified)

Cause: Check if large dependencies accidentally bundled
Fix: Verify Chart.js is NOT bundled (should remain CDN link in analytics.templ)

Issue: 404 errors for .js files

Cause: Script tags not updated in templates
Fix: Check that all template script tags point to /static/main.js

Issue: Theme dropdown doesn't close

Cause: Missing @click.away directive
Fix: Add @click.away="open = false" to dropdown element

Issue: Search not working on docs page

Cause: lunr not properly imported or bundled
Fix: Check docs.ts imports and rebuild with npm run build:ts

Issue: Modals don't open

Cause: x-show variable not reactive or parent x-data missing
Fix: Ensure modal is wrapped in x-data="{ modalOpen: false }" and button uses @click="modalOpen = true"

Issue: HTMX forms stopped working after Alpine migration

Cause: Alpine event handlers conflicting with HTMX
Fix: HTMX and Alpine work together - ensure hx-post is on form, @click is on buttons (not form)


External Dependencies (Not Bundled)

Chart.js

File: templates/analytics.templ (line 14)
Reason: 3.4MB minified, only used on 1 page (analytics)
Approach: Keep as CDN link: <script src="https://cdn.jsdelivr.net/npm/chart.js"></script>

htmx.org

File: Loaded via separate script tag in base templates
Reason: Core framework, needs to load before main.js
Approach: Keep as separate script tag (not bundled), works great with Alpine


Architecture Notes

Why Alpine.js Over Window Exports?

Alpine is the modern approach for this codebase:

  1. Reactive state management: x-data, x-show, x-model instead of manual DOM manipulation
  2. Clean templates: @click instead of onclick="window.func()"
  3. Component-based: Alpine.data() for reusable components
  4. SSR-friendly: Works with progressive enhancement
  5. No global pollution: Functions registered in Alpine scope, not window
  6. Better DX: Alpine DevTools for debugging reactive state

Hybrid Approach: Alpine + HTMX

  • Alpine: Client-side state (modals, dropdowns, theme, form validation)
  • HTMX: Server calls (form submissions, API calls, data fetching)
  • Why both: HTMX excels at server communication, Alpine excels at client-side state

ESBuild + Alpine Benefits

  • Single bundle: Alpine bundled with app code (~15KB gzipped)
  • No CDN dependency: Faster load, no network request for Alpine
  • Tree-shaking: Unused Alpine features not included
  • ES2020 target: Works on Chrome 80+, Firefox 72+, Safari 13.1+

Migration Path: Window → Alpine

Old approach:

(window as any).showToast = showToast;
<button onclick="showToast('Saved!')">Save</button>

New approach:

Alpine.global('showToast', showToast);
<button @click="showToast('Saved!')">Save</button>

Why Not Put Everything in main.ts?

Main.ts stays simple (24 lines) because:

  • Imports where used: docs.ts imports lunr, not main.ts
  • Tree-shaking: Only what's used gets bundled
  • Maintainability: Each file has its own dependencies
  • Testing: Can test individual modules independently
  • Alpine registration: Each file registers its own Alpine globals/components

Success Criteria

Single main.js bundle (~130KB minified with Alpine)
All 27 templates use single script tag
No 404 errors for missing .js files
Alpine.js loaded and functional
No (window as any) usage in TypeScript
Lunr search works on docs page
Syntax highlighting works on docs page
Theme dropdown opens/closes with Alpine
Modals use x-show/x-transition
All @click handlers work (151 migrated)
Toast notifications work via Alpine
HTMX forms still submit correctly
Chart.js loads on analytics page (CDN)
Browser console shows no errors
Alpine DevTools shows reactive components
Bundle targets ES2020 for broad compatibility
Sourcemap generated for debugging


Post-Migration: Development Workflow

Watch Mode (Development)

npm run dev

Watches both TypeScript and templates, rebuilds on changes.

Production Build

npm run build:ts
templ generate
go build

Creates minified bundle, regenerates templates, builds Go binary.

Debugging

Use main.js.map sourcemap in browser DevTools to debug original TypeScript sources.


Files Modified Summary

New Files Created

  • web/src/alpine.ts (new) - Alpine initialization and exports

Configuration Files

  • package.json (already correct - no changes needed)
  • tsconfig.json (already ES2020 - no changes needed)

Source Files Modified

  • web/src/main.ts (add import './alpine' at end)
  • web/src/docs.ts (remove 4 window globals, add Alpine registration)
  • web/src/toast.ts (replace window export with Alpine.global)
  • web/src/api.ts (replace window export with Alpine.global)
  • web/src/storage.ts (replace window export with Alpine.global)
  • web/src/events.ts (replace window export with Alpine.global)
  • web/src/header.ts (add Alpine.data themeDropdown component)
  • web/src/woodPaneling.ts (add Alpine.data component)
  • web/src/collections.ts (register namespace with Alpine.global)
  • web/src/conflicts.ts (register namespace with Alpine.global)
  • web/src/devices.ts (register namespace with Alpine.global)
  • web/src/queue.ts (register namespace with Alpine.global)
  • web/src/search.ts (register namespace with Alpine.global)
  • web/src/dom.ts (register namespace with Alpine.global)
  • Plus 10+ more TypeScript files (register functions with Alpine)

Template Files Modified (27 files)

All templates updated to:

  1. Remove individual script tags
  2. Use single <script src="/static/main.js"></script>
  3. Replace onclick with @click
  4. Add x-data for stateful components (modals, dropdowns)

Templates:

  1. templates/collections.templ (migrate 7 onclick handlers)
  2. templates/docs.templ (migrate sidebar toggle to Alpine)
  3. templates/admin.templ
  4. templates/admin_library.templ
  5. templates/dashboard.templ (theme dropdown, wood paneling)
  6. templates/header.templ (theme dropdown, user menu - KEY FILE)
  7. templates/analytics.templ (keep Chart.js CDN)
  8. templates/bookshelf.templ
  9. templates/devices.templ (multiple modals)
  10. templates/index.templ
  11. templates/login.templ
  12. templates/profile.templ
  13. templates/queue.templ
  14. templates/conflicts.templ
  15. templates/custom_section.templ
  16. templates/admin_users.templ
  17. templates/collection_rules.templ
  18. templates/progress.templ
  19. templates/register.templ
  20. templates/settings.templ
  21. templates/stats.templ
  22. templates/sync.templ
  23. templates/testing_templ.templ
  24. themes/obsidian.templ
  25. themes/whatsapp.templ
  26. themes/midnight.templ
  27. themes/sunset.templ

Generated Files

  • web/static/main.js (new bundled output with Alpine)
  • web/static/main.js.map (new sourcemap)

Files to Delete (After Verification)

All individual .js files in web/static/ (except main.js and main.js.map):

  • api.js, events.js, dom.js, toast.js, storage.js
  • theme.js, header.js, themeDropdown.js, woodPaneling.js
  • docs.js, search.js, collections.js, conflicts.js
  • dashboard.js, admin*.js, analytics.js, bookshelf.js
  • devices.js, index.js, login.js, profile.js, queue.js
  • custom_section.js, progress.js, register.js, settings.js
  • stats.js, sync.js, testing_templ.js
  • obsidian.js, whatsapp.js, midnight.js, sunset.js

Migration Checklist

Use this checklist to track progress:

Phase 1: Clean Up docs.ts

  • Add imports for lunr and hljs (lines 3-4)
  • Remove (window as any).lunr check (line 50)
  • Replace (window as any).lunrIndex with lunr.Builder.loadJs (line 56)
  • Replace (window as any).docsData with docs import (line 73)
  • Replace window export with Alpine registration (line 99)

Phase 2: Create Alpine Infrastructure

  • Create web/src/alpine.ts with Alpine initialization
  • Add import './alpine' to web/src/main.ts
  • Build and verify Alpine is loaded (npm run build:ts)

Phase 3: Migrate Core TypeScript Files (Priority 1)

  • web/src/toast.ts - Register with Alpine.global
  • web/src/api.ts - Register with Alpine.global
  • web/src/storage.ts - Register with Alpine.global
  • web/src/events.ts - Register with Alpine.global
  • web/src/dom.ts - Register with Alpine.global

Phase 4: Migrate Stateful Components (Priority 2)

  • web/src/header.ts - Create themeDropdown Alpine.data component
  • web/src/woodPaneling.ts - Create woodPaneling Alpine.data component
  • Test theme dropdown in browser
  • Test wood paneling in browser

Phase 5: Migrate Page-Specific Files (Priority 3)

  • web/src/collections.ts - Register namespace
  • web/src/devices.ts - Register namespace
  • web/src/queue.ts - Register namespace
  • web/src/conflicts.ts - Register namespace
  • web/src/search.ts - Register namespace

Phase 6: Update Templates (27 files)

  • templates/header.templ (CRITICAL - theme dropdown)
  • templates/docs.templ (sidebar toggle)
  • templates/dashboard.templ (wood paneling)
  • templates/collections.templ (7 onclick handlers)
  • templates/admin.templ through themes/sunset.templ (23 remaining)
  • Run templ generate after template changes

Phase 7: Test and Verify

  • Build: npm run build:ts
  • Check bundle size: ~130KB minified
  • Start dev server: go run .
  • Test all 151 onclick handlers
  • Verify Alpine DevTools shows components
  • Test HTMX forms still work
  • Check browser console for errors
  • Test on Chrome, Firefox, Safari (ES2020 compatibility)

Phase 8: Clean Up

  • Delete all individual .js files from web/static/
  • Commit changes
  • Deploy and test in production

Additional Resources

Alpine.js Documentation

Alpine + HTMX Integration

ESBuild Documentation

Migration Tips

  1. Start small: Migrate 1-2 files at a time, test frequently
  2. Use Alpine DevTools: Install browser extension for debugging
  3. Keep HTMX for forms: Don't replace hx-post with Alpine fetch
  4. Test in browser: Console errors will show missing Alpine globals
  5. Progressive migration: Can use window exports AND Alpine during transition

Configuration

  • package.json (already correct)
  • tsconfig.json (already ES2020)

Source Files

  • web/src/docs.ts (add imports, remove 4 window globals)

Template Files (27 files)

All templates updated to use single <script src="/static/main.js"></script> tag:

  1. templates/collections.templ
  2. templates/docs.templ
  3. templates/admin.templ
  4. templates/admin_library.templ
  5. templates/dashboard.templ
  6. templates/header.templ
  7. templates/analytics.templ
  8. templates/bookshelf.templ
  9. templates/devices.templ
  10. templates/index.templ
  11. templates/login.templ
  12. templates/profile.templ
  13. templates/queue.templ
  14. templates/conflicts.templ
  15. templates/custom_section.templ
  16. templates/admin_users.templ
  17. templates/collection_rules.templ
  18. templates/progress.templ
  19. templates/register.templ
  20. templates/settings.templ
  21. templates/stats.templ
  22. templates/sync.templ
  23. templates/testing_templ.templ
  24. themes/obsidian.templ
  25. themes/whatsapp.templ
  26. themes/midnight.templ
  27. themes/sunset.templ

Generated Files

  • web/static/main.js (new bundled output)
  • web/static/main.js.map (new sourcemap)

Files to Delete (After Verification)

All individual .js files in web/static/ (except main.js and main.js.map)


Summary: What We're Achieving

The Problem

  • 151 inline onclick handlers using window.funcName()
  • 20+ TypeScript files exporting to (window as any)
  • 27 template files with 3-5 script tags each
  • Manual DOM manipulation for state (modals, dropdowns)
  • No reactive state management
  • Global namespace pollution

The Solution

  1. Bundle with ESBuild: Single ~130KB minified bundle (ES2020 target)
  2. Alpine.js for state: Reactive components with x-data, @click, x-show
  3. Keep HTMX for forms: Server-side communication remains unchanged
  4. Modern patterns: No more (window as any), clean templates

Benefits

  • Performance: Single HTTP request for all JS, better caching
  • Maintainability: Alpine components vs imperative DOM manipulation
  • Type safety: No more window as any hacks
  • Developer experience: Clean templates, Alpine DevTools, sourcemaps
  • User experience: Smooth transitions, reactive UI, faster loads
  • Bundle size: Only ~40KB gzipped (was 30+ HTTP requests before)

Key Architecture Decisions

  1. Alpine over vanilla event listeners: Reactive state is cleaner
  2. Alpine over React/Vue: Lightweight, SSR-friendly, works with HTMX
  3. Bundle Alpine: No CDN dependency, faster load, tree-shaking
  4. Keep HTMX: Don't fix what works - HTMX excels at server communication
  5. Progressive migration: Migrate incrementally, can mix old/new during transition

Migration Effort

  • TypeScript files: 20+ files (5-10 min each) = ~3 hours
  • Templates: 27 files (10-15 min each) = ~5 hours
  • Testing: 2-3 hours
  • Total: ~10-12 hours for complete migration

Maintenance Going Forward

  • Adding new feature? Create Alpine component or register function
  • New page? Single <script src="/static/main.js"> tag
  • Need state? x-data="{ open: false }"
  • Server call? hx-post="/api/endpoint" (HTMX)

Quick Reference: Alpine Syntax

Old (Window) New (Alpine)
onclick="func()" @click="func()"
onclick="obj.method()" @click="obj.method()"
class="hidden" x-show="!open"
Manual DOM toggle x-data="{ open: false }" + @click="open = !open"
onclick="window.api.get()" @click="api.get()"
Form validation x-model="formData.name" + <span x-show="!name">Error</span>

Last Updated: March 2026
ESBuild Version: Latest (via npm)
Alpine.js Version: Latest (via npm)
Target: ES2020 (Chrome 80+, Firefox 72+, Safari 13.1+)