/** * UI System - Template registration and DOM element utilities * * This module provides a lightweight template registration system that supports * both development and production modes: * - In development (?dev mode): Templates are loaded dynamically from files * - In production: Templates are bundled into templates.json for performance */ import { updateUi } from '../ui.js'; import { createNavigableElement } from './navigable-element.js'; /** * @typedef {object} Template * @property {string} id, * @property {string} pathOrHtml, * @property {boolean} cached, * @property {string} html */ // Template registry for caching const templateRegistry = new Map(); const isDev = new URLSearchParams(window.location.search).has('dev'); /** @type {object} */ let templatesJson; /** * Registers a template with the system. In development mode, this loads * and caches the template immediately. In production mode, this loads * from the bundled JSON. * * @param {string} id - Unique template identifier * @param {string} pathOrHtml - Template file path (dev) or HTML content * @returns {Promise} */ export async function registerTemplate(id, pathOrHtml) { if (templateRegistry.has(id)) { console.warn(`Template '${id}' is already registered, overwriting`); } /** @type {Template} */ const template = { id, pathOrHtml, cached: false, html: '' }; // Pre-load the HTML content template.html = await getTemplateHtml(id, template); template.cached = true; templateRegistry.set(id, template); } /** * Loads templates.json in production mode * @returns {Promise} The templates object */ async function loadTemplatesJson() { if (!templatesJson) { try { const response = await fetch('templates.json'); if (!response.ok) { throw new Error(`HTTP ${response.status}: ${response.statusText}`); } templatesJson = await response.json(); } catch (error) { console.error('Failed to load templates.json, falling back to development mode:', error); templatesJson = {}; } } return templatesJson; } /** * Gets HTML content for a template, handling both dev and production modes * * @param {string} id - Template identifier * @param {Template} [template] - Template object (if not provided, looks up in registry) * @returns {Promise} The HTML content */ async function getTemplateHtml(id, template) { if (!template) { template = templateRegistry.get(id); } if (!template) { throw new Error(`Template '${id}' is not registered. Available templates: ${Array.from(templateRegistry.keys()).join(', ')}`); } // Return cached HTML if available if (template.cached && template.html) { return template.html; } let html; if (isDev) { // Development mode: load from file system if (template.pathOrHtml.trim()[0] === '<') { // Literal HTML html = template.pathOrHtml.trim(); } else { // File path - add /src/templates/ prefix if not absolute const path = template.pathOrHtml.startsWith('/') ? template.pathOrHtml : '/src/templates/' + template.pathOrHtml; console.log('Loading template from', path); const response = await fetch(path); if (!response.ok) { throw new Error(`Failed to load template '${id}' from '${path}': ${response.status} ${response.statusText}`); } html = await response.text(); } } else { // Production mode: load from bundled JSON const templates = await loadTemplatesJson(); // @ts-ignore html = templates[id]; if (!html) { throw new Error(`Template '${id}' not found in templates.json. Available templates: ${Object.keys(templates).join(', ')}`); } } return html.trim(); } /** * Replaces template parameters in HTML content using ${param} syntax * * @param {string} html - HTML content with parameters * @param {Object} params - Parameter values to substitute * @returns {string} HTML with parameters replaced */ function replaceTemplateParameters(html, params = {}) { if (!params || Object.keys(params).length === 0) { return html; } return html.replace(/\$\{(\w+)\}/g, (match, paramName) => { if (params.hasOwnProperty(paramName)) { // @ts-ignore return String(params[paramName]); } console.warn(`Template parameter '${paramName}' not provided, leaving as-is`); return match; }); } /** * Creates DOM elements from a registered template with optional parameter substitution. * Template must be registered first with registerTemplate(). If a parentNode is given, * the elements are appended to it. If no parentNode is given, the generated nodes are * returned as an array. * * @param {string} id - Template identifier * @param {Element|Document|null} [parentNode] - If given, appends generated nodes as children * @param {Object} [params] - Parameters for template substitution (e.g., {name: 'icon-name'}) * @returns {ChildNode[]} All the created nodes in an array */ export function createFromTemplate(id, parentNode = null, params = {}) { const template = templateRegistry.get(id); if (!template) { throw new Error(`Template '${id}' is not registered. Available templates: ${Array.from(templateRegistry.keys()).join(', ')}`); } if (!template.cached || !template.html) { throw new Error(`Template '${id}' is not loaded. Make sure to await registerTemplate() before using createFromTemplate().`); } const processedHtml = replaceTemplateParameters(template.html, params); const div = document.createElement('div'); div.innerHTML = processedHtml; const nodes = Array.from(div.childNodes); // If a parent node has been given, add nodes to it and update UI if (parentNode instanceof Element || parentNode instanceof Document) { parentNode.append(...nodes); updateUi(); } return nodes; } /** * Creates a single DOM element from a registered template with optional parameter substitution. * Template must be registered first with registerTemplate(). If the template produces multiple * elements, returns the first one. If a parentNode is given, the element is appended to it. * * @param {string} id - Template identifier * @param {Element|Document|null} [parentNode] - If given, appends generated element as child * @param {Object} [params] - Parameters for template substitution (e.g., {name: 'icon-name'}) * @returns {HTMLElement} The first created element * @throws {Error} If template produces no elements */ export function createSingleFromTemplate(id, parentNode = null, params = {}) { const nodes = createFromTemplate(id, parentNode, params); if (nodes.length === 0) { throw new Error(`Template '${id}' produced no elements.`); } let element = Array.from(nodes).find(elem => elem instanceof HTMLElement); if (element) { return element } throw new Error(`Could not find an html element in the template with id ${id}.`) } /** * Legacy function - creates HTML elements from templates or literal HTML strings. * * @deprecated Use registerTemplate() + createFromTemplate() instead * @param {string} htmlOrFile A literal html string or the name of a file in the 'app/src/templates/' folder * @param {Element|Document|null} [parentNode] If given, appends the generated nodes as children to the parentNode * @returns {Promise} All the created nodes in an array */ export async function createHtmlElements(htmlOrFile, parentNode = null) { let html; if (htmlOrFile.trim()[0] === '<') { // interpret as literal html html = htmlOrFile.trim(); } else { // treat as path const path = '/src/templates/' + htmlOrFile; console.log('Loading HTML from', path); html = await (await fetch(path)).text(); } const div = document.createElement('div'); div.innerHTML = html.trim(); const nodes = Array.from(div.childNodes); // if a parent node has been given, add nodes to it if (parentNode instanceof Element) { parentNode.append(...nodes); } return nodes; } export { createNavigableElement } from './navigable-element.js'; /** * Gets all registered template IDs * @returns {string[]} Array of template IDs */ export function getRegisteredTemplates() { return Array.from(templateRegistry.keys()); } /** * Clears the template cache (useful for testing) */ export function clearTemplateCache() { for (let template of templateRegistry.values()) { template.cached = false; template.html = null; } // @ts-ignore templatesJson = null; }