--- name: zero-build-frontend description: Zero-build frontend development with CDN-loaded React, Tailwind CSS, and vanilla JavaScript. Use when building static web apps without bundlers, creating Leaflet maps, integrating Google Sheets as database, or developing browser extensions. Covers patterns from rosen-frontend, NJCIC map, and PocketLink projects. --- # Zero-build frontend development Patterns for building production-quality web applications without build tools, bundlers, or complex toolchains. ## React via CDN (esm.sh) ### Basic setup ```html Zero-Build React App
``` ### React with htm (no JSX, no build) ```javascript // index.js import React, { useState, useEffect, useRef } from 'react'; import { createRoot } from 'react-dom/client'; import htm from 'htm'; // Bind htm to React.createElement const html = htm.bind(React.createElement); // Components use html`` instead of JSX function App() { const [records, setRecords] = useState([]); const [loading, setLoading] = useState(true); const [search, setSearch] = useState(''); useEffect(() => { loadData(); }, []); async function loadData() { try { const response = await fetch('data/archive-data.json'); const data = await response.json(); setRecords(data.records); } catch (error) { console.error('Failed to load data:', error); } finally { setLoading(false); } } const filtered = records.filter(r => r.title.toLowerCase().includes(search.toLowerCase()) ); if (loading) { return html`
`; } return html`

Archive Explorer

setSearch(e.target.value)} class="mt-2 w-full p-2 bg-gray-800 rounded border border-gray-600 focus:border-brand-primary outline-none" />
<${RecordList} records=${filtered} />
`; } function RecordList({ records }) { return html`
${records.map(record => html` <${RecordCard} key=${record.id} record=${record} /> `)}
`; } function RecordCard({ record }) { return html`

${record.title}

${record.publication_date}

${record.summary}

${record.tags?.map(tag => html` ${tag} `)}
`; } // Mount app const root = createRoot(document.getElementById('root')); root.render(html`<${App} />`); ``` ## Data caching with localStorage ```javascript // services/cacheService.js const CACHE_TTL = 60 * 60 * 1000; // 1 hour export function getCached(key) { const cached = localStorage.getItem(key); if (!cached) return null; try { const { data, timestamp } = JSON.parse(cached); if (Date.now() - timestamp > CACHE_TTL) { localStorage.removeItem(key); return null; } return data; } catch { localStorage.removeItem(key); return null; } } export function setCache(key, data) { localStorage.setItem(key, JSON.stringify({ data, timestamp: Date.now() })); } export async function fetchWithCache(url, cacheKey) { // Check cache first const cached = getCached(cacheKey); if (cached) return cached; // Fetch fresh data const response = await fetch(url); const data = await response.json(); // Cache for next time setCache(cacheKey, data); return data; } // Usage const records = await fetchWithCache('data/archive-data.json', 'archive-records'); ``` ## Leaflet.js maps ### Basic map setup ```html
``` ### Map application with clustering ```javascript // js/app.js class MapApp { constructor() { this.map = null; this.markers = null; this.data = []; this.filters = { year: null, county: null, status: null }; } async init() { this.setupMap(); await this.loadData(); this.renderMarkers(); this.setupFilters(); } setupMap() { // Initialize map centered on NJ this.map = L.map('map', { center: [40.0583, -74.4057], zoom: 8, scrollWheelZoom: false, // Disable mouse wheel zoom zoomControl: false // We'll add custom controls }); // Add tile layer (CARTO Voyager) L.tileLayer('https://{s}.basemaps.cartocdn.com/rastertiles/voyager/{z}/{x}/{y}{r}.png', { attribution: '© OpenStreetMap, © CARTO', maxZoom: 19 }).addTo(this.map); // Add custom zoom control (top-right) L.control.zoom({ position: 'topright' }).addTo(this.map); // Initialize marker cluster group this.markers = L.markerClusterGroup({ spiderfyOnMaxZoom: true, showCoverageOnHover: false, maxClusterRadius: 50, spiderLegPolylineOptions: { weight: 1.5, color: '#2dc8d2' } }); this.map.addLayer(this.markers); } async loadData() { const response = await fetch('data/grantees.json'); this.data = await response.json(); } renderMarkers() { this.markers.clearLayers(); const filtered = this.data.filter(item => { if (this.filters.year && item.year !== this.filters.year) return false; if (this.filters.county && item.county !== this.filters.county) return false; if (this.filters.status && item.status !== this.filters.status) return false; return true; }); filtered.forEach(item => { if (!item.lat || !item.lng) return; const marker = L.marker([item.lat, item.lng], { icon: this.createIcon(item.status) }); marker.bindPopup(this.createPopup(item)); this.markers.addLayer(marker); }); // Update count display document.getElementById('count').textContent = filtered.length; } createIcon(status) { const colors = { 'Active': '#2dc8d2', 'Completed': '#666666', 'Pending': '#f34213' }; return L.divIcon({ html: `
`, className: 'custom-marker', iconSize: [16, 16], iconAnchor: [8, 8] }); } createPopup(item) { return ` `; } setupFilters() { // Year filter const years = [...new Set(this.data.map(d => d.year))].sort(); const yearSelect = document.getElementById('year-filter'); years.forEach(year => { const option = document.createElement('option'); option.value = year; option.textContent = year; yearSelect.appendChild(option); }); yearSelect.addEventListener('change', (e) => { this.filters.year = e.target.value || null; this.renderMarkers(); }); // Similar for county, status filters... } } // Initialize on load document.addEventListener('DOMContentLoaded', () => { const app = new MapApp(); app.init(); }); ``` ## Google Sheets as database ### Fetching published CSV ```javascript // Google Sheets published as CSV const SHEET_URL = 'https://docs.google.com/spreadsheets/d/e/SPREADSHEET_ID/pub?gid=0&single=true&output=csv'; async function loadFromSheets() { const response = await fetch(SHEET_URL); const csv = await response.text(); // Parse with PapaParse (CDN) const { data, errors } = Papa.parse(csv, { header: true, skipEmptyLines: true, transformHeader: (h) => h.trim().toLowerCase().replace(/\s+/g, '_') }); if (errors.length > 0) { console.warn('CSV parsing errors:', errors); } return data; } ``` ### Real-time state with localStorage ```javascript class DataManager { constructor(sheetUrl, cacheKey) { this.sheetUrl = sheetUrl; this.cacheKey = cacheKey; this.data = []; this.localState = this.loadLocalState(); } loadLocalState() { const stored = localStorage.getItem(`${this.cacheKey}-state`); return stored ? JSON.parse(stored) : {}; } saveLocalState() { localStorage.setItem(`${this.cacheKey}-state`, JSON.stringify(this.localState)); } async refresh() { const response = await fetch(this.sheetUrl); const csv = await response.text(); this.data = Papa.parse(csv, { header: true, skipEmptyLines: true }).data; // Merge with local state this.data.forEach(row => { const localData = this.localState[row.id]; if (localData) { Object.assign(row, localData); } }); return this.data; } updateLocal(id, updates) { this.localState[id] = { ...this.localState[id], ...updates }; this.saveLocalState(); // Update in-memory data too const item = this.data.find(d => d.id === id); if (item) Object.assign(item, updates); } } // Usage const manager = new DataManager(SHEET_URL, 'volunteer-data'); await manager.refresh(); // Mark task as complete (stored locally) manager.updateLocal('task-123', { completed: true, completed_at: new Date().toISOString() }); ``` ## Browser extension (Manifest V3) ### manifest.json ```json { "manifest_version": 3, "name": "PocketLink", "version": "1.0.0", "description": "Create shortlinks from right-click context menu", "permissions": [ "contextMenus", "storage", "activeTab", "scripting", "notifications", "offscreen" ], "background": { "service_worker": "background.js", "type": "module" }, "action": { "default_popup": "popup.html", "default_icon": { "16": "icons/icon16.png", "48": "icons/icon48.png", "128": "icons/icon128.png" } }, "options_page": "options.html", "icons": { "16": "icons/icon16.png", "48": "icons/icon48.png", "128": "icons/icon128.png" } } ``` ### Service worker (background.js) ```javascript // background.js - Service Worker // Create context menu on install chrome.runtime.onInstalled.addListener(() => { chrome.contextMenus.create({ id: 'create-shortlink', title: 'Create Shortlink', contexts: ['page', 'link'] }); }); // Handle context menu click chrome.contextMenus.onClicked.addListener(async (info, tab) => { if (info.menuItemId !== 'create-shortlink') return; const url = info.linkUrl || info.pageUrl; try { const shortUrl = await createShortlink(url); await copyToClipboard(shortUrl); showNotification('Shortlink Created', shortUrl); } catch (error) { showNotification('Error', error.message); } }); async function createShortlink(longUrl) { const { apiToken } = await chrome.storage.sync.get('apiToken'); if (!apiToken) throw new Error('API token not configured'); const response = await fetch('https://api-ssl.bitly.com/v4/shorten', { method: 'POST', headers: { 'Authorization': `Bearer ${apiToken}`, 'Content-Type': 'application/json' }, body: JSON.stringify({ long_url: longUrl }) }); if (!response.ok) throw new Error('API request failed'); const data = await response.json(); return data.link; } // Clipboard methods (three fallback strategies) // Method 1: Offscreen API (preferred) async function copyToClipboard(text) { try { await copyViaOffscreen(text); } catch { try { await copyViaContentScript(text); } catch { await copyViaPopup(text); } } } async function copyViaOffscreen(text) { await chrome.offscreen.createDocument({ url: 'offscreen.html', reasons: ['CLIPBOARD'], justification: 'Copy shortlink to clipboard' }); await chrome.runtime.sendMessage({ type: 'copy', text }); await chrome.offscreen.closeDocument(); } async function copyViaContentScript(text) { const [tab] = await chrome.tabs.query({ active: true, currentWindow: true }); await chrome.scripting.executeScript({ target: { tabId: tab.id }, func: (text) => navigator.clipboard.writeText(text), args: [text] }); } function showNotification(title, message) { chrome.notifications.create({ type: 'basic', iconUrl: 'icons/icon48.png', title, message }); } ``` ### Options page ```html

PocketLink Settings

``` ```javascript // options.js document.addEventListener('DOMContentLoaded', async () => { const tokenInput = document.getElementById('apiToken'); const saveButton = document.getElementById('save'); const status = document.getElementById('status'); // Load saved token const { apiToken } = await chrome.storage.sync.get('apiToken'); if (apiToken) tokenInput.value = apiToken; saveButton.addEventListener('click', async () => { const token = tokenInput.value.trim(); if (!token) { showStatus('Please enter an API token', 'error'); return; } // Validate token by making test request try { const response = await fetch('https://api-ssl.bitly.com/v4/user', { headers: { 'Authorization': `Bearer ${token}` } }); if (!response.ok) throw new Error('Invalid token'); await chrome.storage.sync.set({ apiToken: token }); showStatus('Settings saved successfully!', 'success'); } catch { showStatus('Invalid API token', 'error'); } }); function showStatus(message, type) { status.textContent = message; status.className = `status ${type}`; status.style.display = 'block'; setTimeout(() => { status.style.display = 'none'; }, 3000); } }); ``` ## Cache busting for deployments ```html ``` ## Deployment patterns ### Static hosting (FTP/SFTP) ``` # Directory structure for WordPress wp-content deployment wp-content/ └── archive-explorer/ ├── index.html ├── index.js ├── index.css ├── components/ │ ├── Sidebar.js │ ├── RecordList.js │ └── RecordCard.js └── data/ └── archive-data.json ``` ### Path management for subdirectory deployment ```javascript // constants.js // Auto-detect base path from current URL const getBasePath = () => { const path = window.location.pathname; const lastSlash = path.lastIndexOf('/'); return path.substring(0, lastSlash + 1); }; export const BASE_PATH = getBasePath(); export const DATA_URL = `${BASE_PATH}data/archive-data.json`; // Usage const response = await fetch(DATA_URL); ``` ## Performance tips - **Lazy load large JSON**: Parse incrementally or paginate - **Use CSS containment**: `contain: layout style` on repeated elements - **Debounce search input**: Wait 300ms after typing stops - **Virtualize long lists**: Only render visible items - **Preconnect to CDNs**: ``