From fe773941e478b6166b0085fadcdf95cbefcff39c Mon Sep 17 00:00:00 2001 From: Younes ENNAJI Date: Sat, 8 Mar 2025 11:34:23 +0000 Subject: [PATCH] chore: prepare flasher to add themes + improve code documentation --- rollup.config.js | 159 +++++- src/Prime/Factory/FlasherFactory.php | 2 +- src/Prime/Resources/assets/exports.ts | 56 +++ src/Prime/Resources/assets/flasher-plugin.ts | 371 +++++++++++--- src/Prime/Resources/assets/flasher.ts | 496 ++++++++++++++++--- src/Prime/Resources/assets/global.d.ts | 25 + src/Prime/Resources/assets/index.ts | 39 +- src/Prime/Resources/assets/plugin.ts | 167 ++++++- src/Prime/Resources/assets/theme.ts | 19 - src/Prime/Resources/assets/types.ts | 372 +++++++++++++- 10 files changed, 1522 insertions(+), 184 deletions(-) create mode 100644 src/Prime/Resources/assets/exports.ts create mode 100644 src/Prime/Resources/assets/global.d.ts delete mode 100644 src/Prime/Resources/assets/theme.ts diff --git a/rollup.config.js b/rollup.config.js index a7fe7e8e..6dc80101 100644 --- a/rollup.config.js +++ b/rollup.config.js @@ -1,27 +1,46 @@ +import path from 'node:path' import process from 'node:process' -import { defineConfig } from 'rollup' -import clear from 'rollup-plugin-clear' -import resolve from '@rollup/plugin-node-resolve' -import cleanup from 'rollup-plugin-cleanup' -import typescript from '@rollup/plugin-typescript' +import { fileURLToPath } from 'node:url' import babel from '@rollup/plugin-babel' +import resolve from '@rollup/plugin-node-resolve' import terser from '@rollup/plugin-terser' -import filesize from 'rollup-plugin-filesize' -import copy from 'rollup-plugin-copy' -import postcss from 'rollup-plugin-postcss' -import cssnano from 'cssnano' +import typescript from '@rollup/plugin-typescript' +import strip from '@rollup/plugin-strip' import autoprefixer from 'autoprefixer' +import cssnano from 'cssnano' +import glob from 'glob' import discardComments from 'postcss-discard-comments' +import { defineConfig } from 'rollup' +import cleanup from 'rollup-plugin-cleanup' +import clear from 'rollup-plugin-clear' +import copy from 'rollup-plugin-copy' +import filesize from 'rollup-plugin-filesize' +import postcss from 'rollup-plugin-postcss' import progress from 'rollup-plugin-progress' const isProduction = process.env.NODE_ENV === 'production' const modules = [ { name: 'flasher', path: 'src/Prime/Resources' }, - { name: 'noty', path: 'src/Noty/Prime/Resources', globals: { noty: 'Noty' }, assets: ['noty/lib/noty.min.js', 'noty/lib/noty.css', 'noty/lib/themes/mint.css'] }, + { + name: 'noty', + path: 'src/Noty/Prime/Resources', + globals: { noty: 'Noty' }, + assets: ['noty/lib/noty.min.js', 'noty/lib/noty.css', 'noty/lib/themes/mint.css'], + }, { name: 'notyf', path: 'src/Notyf/Prime/Resources' }, - { name: 'sweetalert', path: 'src/SweetAlert/Prime/Resources', globals: { sweetalert2: 'Swal' }, assets: ['sweetalert2/dist/sweetalert2.min.js', 'sweetalert2/dist/sweetalert2.min.css'] }, - { name: 'toastr', path: 'src/Toastr/Prime/Resources', globals: { toastr: 'toastr' }, assets: ['jquery/dist/jquery.min.js', 'toastr/build/toastr.min.js', 'toastr/build/toastr.min.css'] }, + { + name: 'sweetalert', + path: 'src/SweetAlert/Prime/Resources', + globals: { sweetalert2: 'Swal' }, + assets: ['sweetalert2/dist/sweetalert2.min.js', 'sweetalert2/dist/sweetalert2.min.css'], + }, + { + name: 'toastr', + path: 'src/Toastr/Prime/Resources', + globals: { toastr: 'toastr' }, + assets: ['jquery/dist/jquery.min.js', 'toastr/build/toastr.min.js', 'toastr/build/toastr.min.css'], + }, ] const postcssPlugins = [ @@ -30,6 +49,8 @@ const postcssPlugins = [ autoprefixer({ overrideBrowserslist: ['> 0%'] }), ] +const externalFlasherId = fileURLToPath(new URL('src/Prime/Resources/assets/index.ts', import.meta.url)) + function commonPlugins(path) { return [ resolve(), @@ -63,16 +84,24 @@ function createPlugins({ name, path, assets }) { const filename = name === 'flasher' ? 'flasher.min.css' : `flasher-${name}.min.css` const copyAssets = assets - ? [copy({ targets: assets.map((asset) => ({ - src: asset.startsWith('node_modules') ? asset : `node_modules/${asset}`, - dest: `${path}/public` })) })] + ? [copy({ + targets: assets.map((asset) => ({ + src: asset.startsWith('node_modules') ? asset : `node_modules/${asset}`, + dest: `${path}/public`, + })), + })] : [] return [ progress(), ...(isProduction ? [clear({ targets: [`${path}/dist`, `${path}/public`] })] : []), - postcss({ extract: filename, plugins: isProduction ? postcssPlugins : [] }), + postcss({ + extract: filename, + plugins: isProduction ? postcssPlugins : [], + use: { sass: { silenceDeprecations: ['legacy-js-api'] } }, + }), ...commonPlugins(path), + ...(isProduction ? [strip()] : []), ...(isProduction ? [cleanup({ comments: 'none' })] : []), ...copyAssets, ] @@ -91,9 +120,11 @@ function createOutput({ name, path, globals }) { const plugins = [ ...(isProduction ? [terser({ format: { comments: false } })] : []), - copy({ targets: [{ src: [`${distPath}/*.min.js`, `${distPath}/*.min.css`], dest: publicPath }], hook: 'writeBundle' }), - ...(isProduction ? [terser({ format: { comments: false } })] : []), - ...(isProduction ? [filesize()] : []), + copy({ + targets: [{ src: [`${distPath}/*.min.js`, `${distPath}/*.min.css`], dest: publicPath }], + hook: 'writeBundle', + }), + ...(isProduction ? [filesize({ showBrotliSize: true })] : []), ] return [ @@ -111,7 +142,14 @@ function createPrimePlugin() { return defineConfig({ input: `${path}/assets/plugin.ts`, - plugins: [resolve(), typescript({ compilerOptions: { outDir: `${path}/dist` }, include: [`${path}/assets/**/**`] })], + plugins: [ + resolve(), + typescript({ + compilerOptions: { + outDir: `${path}/dist`, + }, + include: [`${path}/assets/**/**`], + })], output: [ { format: 'es', file: `${filename}.js` }, // { format: 'cjs', file: `${filename}.cjs.js` }, @@ -119,7 +157,88 @@ function createPrimePlugin() { }) } +function createThemeConfig(file) { + const primePath = 'src/Prime/Resources' + const themeName = path.basename(path.dirname(file)) + + const globals = { + '@flasher/flasher': 'flasher', + [externalFlasherId]: 'flasher', + } + + return defineConfig({ + input: file, + external: Object.keys(globals), + plugins: [ + resolve(), + postcss({ + extract: `${themeName}.min.css`, + plugins: isProduction ? postcssPlugins : [], + use: { sass: { silenceDeprecations: ['legacy-js-api'] } }, + }), + typescript({ + compilerOptions: { + outDir: `${primePath}/dist/themes/${themeName}`, + declaration: false, + }, + include: [ + `${primePath}/assets/**/*.ts`, + ], + }), + babel({ babelHelpers: 'bundled' }), + ...(isProduction ? [cleanup({ comments: 'none' })] : []), + ...(isProduction ? [strip()] : []), + ...(isProduction ? [filesize({ showBrotliSize: true })] : []), + ], + output: [ + { + format: 'umd', + file: `${primePath}/dist/themes/${themeName}/${themeName}.min.js`, + name: `theme.${themeName}`, + globals, + plugins: [ + ...isProduction ? [terser({ format: { comments: false } })] : [], + copy({ + targets: [ + { + src: [ + `${primePath}/dist/themes/${themeName}/${themeName}.min.js`, + `${primePath}/dist/themes/${themeName}/${themeName}.min.css`, + ], + dest: `${primePath}/public/themes/${themeName}`, + }, + ], + hook: 'writeBundle', + verbose: false, + }), + ], + }, + { + format: 'umd', + file: `${primePath}/dist/themes/${themeName}/${themeName}.js`, + name: `theme.${themeName}`, + globals, + }, + { + format: 'es', + file: `${primePath}/dist/themes/${themeName}/${themeName}.esm.js`, + globals, + }, + ], + }) +} + +function createThemesConfigs() { + const primePath = 'src/Prime/Resources' + const themesDir = `${primePath}/assets/themes` + + const themeFiles = glob.sync(`${themesDir}/**/index.ts`).filter((file) => file !== `${themesDir}/index.ts`) + + return themeFiles.map(createThemeConfig) +} + export default [ createPrimePlugin(), ...modules.map(createConfig), + ...createThemesConfigs(), ] diff --git a/src/Prime/Factory/FlasherFactory.php b/src/Prime/Factory/FlasherFactory.php index 8ca77b77..618b033e 100644 --- a/src/Prime/Factory/FlasherFactory.php +++ b/src/Prime/Factory/FlasherFactory.php @@ -14,6 +14,6 @@ final class FlasherFactory extends NotificationFactory implements FlasherFactory { public function createNotificationBuilder(): NotificationBuilderInterface { - return new FlasherBuilder('flasher', $this->storageManager); + return new FlasherBuilder($this->plugin ?? 'flasher', $this->storageManager); } } diff --git a/src/Prime/Resources/assets/exports.ts b/src/Prime/Resources/assets/exports.ts new file mode 100644 index 00000000..7dfb0f53 --- /dev/null +++ b/src/Prime/Resources/assets/exports.ts @@ -0,0 +1,56 @@ +/** + * @file PHPFlasher Type Exports + * @description Re-exports types and interfaces for TypeScript users + * @author yoeunes + */ + +/** + * Re-export all types and interfaces. + * + * This allows TypeScript users to import specific types: + * + * @example + * ```typescript + * import { Options, Envelope } from '@flasher/flasher/exports'; + * ``` + */ +export * from './types' + +/** + * Re-export the AbstractPlugin class. + * + * This is useful for creating custom plugins. + * + * @example + * ```typescript + * import { AbstractPlugin } from '@flasher/flasher/exports'; + * + * class MyPlugin extends AbstractPlugin { + * // Implementation + * } + * ``` + */ +export { AbstractPlugin } from './plugin' + +/** + * Re-export the FlasherPlugin class. + * + * This allows creating custom theme-based plugins. + * + * @example + * ```typescript + * import { FlasherPlugin } from '@flasher/flasher/exports'; + * import myTheme from './my-theme'; + * + * const plugin = new FlasherPlugin(myTheme); + * ``` + */ +export { default as FlasherPlugin } from './flasher-plugin' + +/** + * Re-export the default flasher instance. + * + * This ensures consistency whether importing from the main package + * or from the exports file. + */ +export { default } from './index' diff --git a/src/Prime/Resources/assets/flasher-plugin.ts b/src/Prime/Resources/assets/flasher-plugin.ts index 003ff620..5b62f79e 100644 --- a/src/Prime/Resources/assets/flasher-plugin.ts +++ b/src/Prime/Resources/assets/flasher-plugin.ts @@ -1,155 +1,406 @@ +/** + * @file FlasherPlugin Implementation + * @description Default implementation for displaying notifications using custom themes + * @author yoeunes + */ import './themes/index.scss' import type { Properties } from 'csstype' -import type { Envelope, Options, Theme } from './types' +import type { Envelope, FlasherPluginOptions, Options, Theme } from './types' import { AbstractPlugin } from './plugin' +/** + * Default FlasherPlugin implementation using custom themes. + * + * FlasherPlugin is the built-in renderer for PHPFlasher that creates DOM-based + * notifications with a customizable appearance. It uses themes to determine + * the HTML structure and styling of notifications. + * + * Features: + * - Theme-based notification rendering + * - Container management for different positions + * - Auto-dismissal with progress bars + * - RTL language support + * - HTML escaping for security + * - Mouse-over pause of auto-dismissal + * + * @example + * ```typescript + * // Create a simple theme + * const myTheme: Theme = { + * styles: ['my-theme.css'], + * render: (envelope) => ` + *
+ *

${envelope.title}

+ *

${envelope.message}

+ * + *
+ *
+ * ` + * }; + * + * // Create a plugin with the theme + * const plugin = new FlasherPlugin(myTheme); + * + * // Show a notification + * plugin.success('Operation completed'); + * ``` + */ export default class FlasherPlugin extends AbstractPlugin { + /** + * Theme configuration used for rendering notifications. + * @private + */ private theme: Theme - private options = { + + /** + * Default configuration options for notifications. + * These can be overridden globally or per notification. + * @private + */ + private options: FlasherPluginOptions = { + // Default or type-specific timeout (milliseconds, null = use type-specific) timeout: null, + + // Type-specific timeout durations timeouts: { - success: 5000, - info: 5000, - error: 5000, - warning: 5000, + success: 10000, + info: 10000, + error: 10000, + warning: 10000, }, + + // Animation frames per second (higher = smoother but more CPU intensive) fps: 30, + + // Default position on screen position: 'top-right', + + // Stacking direction (top = newest first, bottom = newest last) direction: 'top', + + // Right-to-left text direction rtl: false, + + // Custom container styles style: {} as Properties, + + // Whether to escape HTML in message content (security feature) escapeHtml: false, } + /** + * Creates a new FlasherPlugin with a specific theme. + * + * @param theme - Theme configuration to use for rendering notifications + * @throws {Error} If theme is missing or invalid + */ constructor(theme: Theme) { super() + if (!theme) { + throw new Error('Theme is required') + } + + if (typeof theme.render !== 'function') { + throw new TypeError('Theme must have a render function') + } + this.theme = theme } + /** + * Renders notification envelopes using the configured theme. + * + * This method creates and displays notifications in the DOM based on + * the provided envelopes and the plugin's theme. + * + * @param envelopes - Array of notification envelopes to render + */ public renderEnvelopes(envelopes: Envelope[]): void { - const render = () => + if (!envelopes?.length) { + return + } + + const render = () => { envelopes.forEach((envelope) => { - // @ts-expect-error - const typeTimeout = this.options.timeout ?? this.options.timeouts[envelope.type] ?? 5000 - const options = { - ...this.options, - ...envelope.options, - timeout: envelope.options.timeout ?? typeTimeout, - escapeHtml: (envelope.options.escapeHtml ?? this.options.escapeHtml) as boolean, + try { + // Get type-specific timeout or default + const typeTimeout = this.options.timeout ?? this.options.timeouts[envelope.type] ?? 10000 + + // Merge default options with envelope-specific options + const mergedOptions = { + ...this.options, + ...envelope.options, + timeout: envelope.options.timeout ?? typeTimeout, + escapeHtml: (envelope.options.escapeHtml ?? this.options.escapeHtml) as boolean, + } + + // Create or get the container for this notification position + const container = this.createContainer(mergedOptions) + + // Extract only the properties that addToContainer expects + const containerOptions = { + direction: mergedOptions.direction, + timeout: Number(mergedOptions.timeout || 0), // Convert null/undefined to 0 + fps: mergedOptions.fps, + rtl: mergedOptions.rtl, + escapeHtml: mergedOptions.escapeHtml, + } + + // Add notification to the container + this.addToContainer(container, envelope, containerOptions) + } catch (error) { + console.error('PHPFlasher: Error rendering envelope', error, envelope) } - - this.addToContainer(this.createContainer(options), envelope, options) }) + } - document.readyState === 'loading' ? document.addEventListener('DOMContentLoaded', render) : render() + // Wait for DOM to be ready if needed + if (document.readyState === 'loading') { + document.addEventListener('DOMContentLoaded', render) + } else { + render() + } } + /** + * Updates the plugin options. + * + * @param options - New options to apply + */ public renderOptions(options: Options): void { + if (!options) { + return + } this.options = { ...this.options, ...options } } + /** + * Creates or gets the container for notifications. + * + * This method ensures that each position has its own container for notifications. + * If a container for the specified position doesn't exist yet, it creates one. + * + * @param options - Options containing position and style information + * @returns The container element + * @private + */ private createContainer(options: { position: string, style: Properties }): HTMLDivElement { + // Look for existing container for this position let container = document.querySelector(`.fl-wrapper[data-position="${options.position}"]`) as HTMLDivElement if (!container) { + // Create new container if none exists container = document.createElement('div') container.className = 'fl-wrapper' container.dataset.position = options.position - Object.entries(options.style).forEach(([key, value]) => container.style.setProperty(key, value)) + + // Apply custom styles + Object.entries(options.style).forEach(([key, value]) => { + if (value !== undefined && value !== null) { + container.style.setProperty(key, String(value)) + } + }) + document.body.appendChild(container) } + // Mark for Turbo/Hotwire preservation if available container.dataset.turboTemporary = '' return container } - private addToContainer(container: HTMLDivElement, envelope: Envelope, options: { direction: string, timeout: number, fps: number, rtl: boolean, escapeHtml: boolean }): void { + /** + * Adds a notification to the container. + * + * This method: + * 1. Creates a notification element using the theme's render function + * 2. Adds necessary classes and event listeners + * 3. Appends it to the container in the right position + * 4. Sets up auto-dismissal if a timeout is specified + * + * @param container - Container to add the notification to + * @param envelope - Notification information + * @param options - Display options + * @private + */ + private addToContainer( + container: HTMLDivElement, + envelope: Envelope, + options: { + direction: string + timeout: number + fps: number + rtl: boolean + escapeHtml: boolean + }, + ): void { + // Sanitize content if needed if (options.escapeHtml) { envelope.title = this.escapeHtml(envelope.title) envelope.message = this.escapeHtml(envelope.message) } + // Create notification element from theme template const notification = this.stringToHTML(this.theme.render(envelope)) - notification.classList.add(...`fl-container${options.rtl ? ' fl-rtl' : ''}`.split(' ')) - options.direction === 'bottom' ? container.append(notification) : container.prepend(notification) + // Add standard classes + notification.classList.add('fl-container') + if (options.rtl) { + notification.classList.add('fl-rtl') + } + // Add to container in the right position (top or bottom) + if (options.direction === 'bottom') { + container.append(notification) + } else { + container.prepend(notification) + } + + // Trigger animation on next frame for better performance requestAnimationFrame(() => notification.classList.add('fl-show')) - notification.querySelector('.fl-close')?.addEventListener('click', (event) => { - event.stopPropagation() - this.removeNotification(notification) - }) + // Add close button functionality + const closeButton = notification.querySelector('.fl-close') + if (closeButton) { + closeButton.addEventListener('click', (event) => { + event.stopPropagation() + this.removeNotification(notification) + }) + } - this.addProgressBar(notification, options) + // Add timer if timeout is specified + if (options.timeout > 0) { + this.addTimer(notification, options) + } } - addProgressBar(notification: HTMLElement, { timeout, fps }: { timeout: number, fps: number }) { - if (timeout <= 0 || fps <= 0) { + /** + * Adds a progress timer to the notification. + * + * This method creates a visual progress bar that shows the remaining time + * before auto-dismissal. The timer pauses when the user hovers over the notification. + * + * @param notification - Notification element + * @param options - Timer options + * @private + */ + private addTimer(notification: HTMLElement, { timeout, fps }: { timeout: number, fps: number }): void { + if (timeout <= 0) { return } - const progressBarContainer = notification.querySelector('.fl-progress-bar') - if (!progressBarContainer) { - return - } - - const progressBar = document.createElement('span') - progressBar.classList.add('fl-progress') - progressBarContainer.append(progressBar) - const lapse = 1000 / fps - let width = 0 - const updateProgress = () => { - width += 1 - const percent = (1 - lapse * (width / timeout)) * 100 - progressBar.style.width = `${percent}%` + let elapsed = 0 + let intervalId: number - if (percent <= 0) { - // eslint-disable-next-line ts/no-use-before-define + const updateTimer = () => { + elapsed += lapse + + const progressBarContainer = notification.querySelector('.fl-progress-bar') + if (progressBarContainer) { + // Create or get progress bar element + let progressBar = progressBarContainer.querySelector('.fl-progress') + if (!progressBar) { + progressBar = document.createElement('span') + progressBar.classList.add('fl-progress') + progressBarContainer.append(progressBar) + } + + // Calculate and set progress (decreasing from 100% to 0%) + const percent = (1 - elapsed / timeout) * 100; + (progressBar as HTMLElement).style.width = `${Math.max(0, percent)}%` + } + + // Close notification when time is up + if (elapsed >= timeout) { clearInterval(intervalId) this.removeNotification(notification) } } - let intervalId: number = window.setInterval(updateProgress, lapse) - notification.addEventListener('mouseout', () => intervalId = window.setInterval(updateProgress, lapse)) + // Start timer + intervalId = window.setInterval(updateTimer, lapse) + + // Pause timer on hover + notification.addEventListener('mouseout', () => { + clearInterval(intervalId) + intervalId = window.setInterval(updateTimer, lapse) + }) notification.addEventListener('mouseover', () => clearInterval(intervalId)) } - private removeNotification(notification: HTMLElement) { + /** + * Removes a notification with animation. + * + * This method: + * 1. Removes the 'show' class to trigger the hide animation + * 2. Waits for the animation to complete + * 3. Removes the notification from the DOM + * 4. Cleans up the container if it's now empty + * + * @param notification - Notification element to remove + * @private + */ + private removeNotification(notification: HTMLElement): void { + if (!notification) { + return + } + notification.classList.remove('fl-show') + + // Clean up empty containers after animation notification.ontransitionend = () => { - !notification.parentElement?.hasChildNodes() && notification.parentElement?.remove() + const parent = notification.parentElement notification.remove() + + if (parent && !parent.hasChildNodes()) { + parent.remove() + } } } + /** + * Converts an HTML string to a DOM element. + * + * @param str - HTML string to convert + * @returns The created DOM element + * @private + */ private stringToHTML(str: string): HTMLElement { const template = document.createElement('template') template.innerHTML = str.trim() return template.content.firstElementChild as HTMLElement } + /** + * Safely escapes HTML special characters. + * + * This method replaces special characters with their HTML entities + * to prevent XSS attacks when displaying user-provided content. + * + * @param str - String to escape + * @returns Escaped string + * @private + */ private escapeHtml(str: string | null | undefined): string { if (str == null) { return '' } - return str.replace(/[&<>"'`=\/]/g, (char) => { - return { - '&': '&', - '<': '<', - '>': '>', - '"': '"', - '\'': ''', - '`': '`', - '=': '=', - '/': '/', - }[char] as string - }) + const htmlEscapes: Record = { + '&': '&', + '<': '<', + '>': '>', + '"': '"', + '\'': ''', + '`': '`', + '=': '=', + '/': '/', + } + + return str.replace(/[&<>"'`=/]/g, (char) => htmlEscapes[char] || char) } } diff --git a/src/Prime/Resources/assets/flasher.ts b/src/Prime/Resources/assets/flasher.ts index 3d5b68e1..aacb3b6d 100644 --- a/src/Prime/Resources/assets/flasher.ts +++ b/src/Prime/Resources/assets/flasher.ts @@ -1,78 +1,324 @@ -import type { Context, Envelope, Options, PluginInterface, Response, Theme } from './types' +/** + * @file Flasher Core + * @description Main orchestration class for the PHPFlasher notification system + * @author yoeunes + */ +import type { Asset, Context, Envelope, Options, PluginInterface, Response, Theme } from './types' import { AbstractPlugin } from './plugin' import FlasherPlugin from './flasher-plugin' +/** + * Main Flasher class that manages plugins, themes, and notifications. + * + * Flasher is the central orchestration class for PHPFlasher. It handles: + * 1. Plugin registration and management + * 2. Theme registration and resolution + * 3. Asset loading (JS and CSS) + * 4. Routing notifications to the appropriate plugin + * 5. Response processing and normalization + * + * This class follows the façade pattern, providing a simple interface to the + * underlying plugin ecosystem. + * + * @example + * ```typescript + * // Create a flasher instance + * const flasher = new Flasher(); + * + * // Register a plugin + * flasher.addPlugin('toastr', new ToastrPlugin()); + * + * // Show a notification + * flasher.use('toastr').success('Operation completed successfully'); + * + * // Process server response + * flasher.render(response); + * ``` + */ export default class Flasher extends AbstractPlugin { + /** + * Default plugin to use when none is specified. + * This plugin will be used when displaying notifications without + * explicitly specifying a plugin. + * + * @private + */ private defaultPlugin = 'flasher' + + /** + * Map of registered plugins. + * Stores plugin instances by name for easy lookup. + * + * @private + */ private plugins: Map = new Map() + + /** + * Map of registered themes. + * Stores theme configurations by name. + * + * @private + */ private themes: Map = new Map() + /** + * Set of assets that have been loaded. + * Used to prevent duplicate loading of the same asset. + * + * @private + */ + private loadedAssets: Set = new Set() + + /** + * Renders notifications from a response. + * + * This method processes a server response containing notifications and configuration. + * It handles asset loading, option application, and notification rendering in a + * coordinated sequence. + * + * @param response - The response containing notifications and configuration + * @returns A promise that resolves when all operations are complete + * + * @example + * ```typescript + * // From an AJAX response + * const response = await fetch('/api/notifications').then(r => r.json()); + * await flasher.render(response); + * + * // With a partial response + * flasher.render({ + * envelopes: [ + * { message: 'Hello world', type: 'info', title: 'Greeting', options: {}, metadata: {} } + * ] + * }); + * ``` + */ public async render(response: Partial): Promise { const resolved = this.resolveResponse(response) - await this.addAssets([ - { - urls: resolved.styles, - nonce: resolved.context.csp_style_nonce as string, - type: 'style', - }, - { - urls: resolved.scripts, - nonce: resolved.context.csp_script_nonce as string, - type: 'script', - }, - ]) + try { + // Load required assets + await this.addAssets([ + { + urls: resolved.styles, + nonce: resolved.context.csp_style_nonce as string, + type: 'style', + }, + { + urls: resolved.scripts, + nonce: resolved.context.csp_script_nonce as string, + type: 'script', + }, + ]) - this.renderOptions(resolved.options) - this.renderEnvelopes(resolved.envelopes) + // Apply options and render notifications + this.renderOptions(resolved.options) + this.renderEnvelopes(resolved.envelopes) + } catch (error) { + console.error('PHPFlasher: Error rendering notifications', error) + } } + /** + * Renders multiple notification envelopes. + * + * This method groups envelopes by plugin and delegates rendering to each plugin. + * This ensures that each notification is processed by the appropriate plugin. + * + * @param envelopes - Array of notification envelopes to render + * + * @example + * ```typescript + * flasher.renderEnvelopes([ + * { + * message: 'Operation completed', + * type: 'success', + * title: 'Success', + * options: {}, + * metadata: { plugin: 'toastr' } + * }, + * { + * message: 'An error occurred', + * type: 'error', + * title: 'Error', + * options: {}, + * metadata: { plugin: 'sweetalert' } + * } + * ]); + * ``` + */ public renderEnvelopes(envelopes: Envelope[]): void { - const map: Record = {} + if (!envelopes?.length) { + return + } + const groupedByPlugin: Record = {} + + // Group envelopes by plugin for batch processing envelopes.forEach((envelope) => { const plugin = this.resolvePluginAlias(envelope.metadata.plugin) - - map[plugin] = map[plugin] || [] - map[plugin].push(envelope) + groupedByPlugin[plugin] = groupedByPlugin[plugin] || [] + groupedByPlugin[plugin].push(envelope) }) - Object.entries(map).forEach(([plugin, envelopes]) => { - this.use(plugin).renderEnvelopes(envelopes) + // Render each group with the appropriate plugin + Object.entries(groupedByPlugin).forEach(([pluginName, pluginEnvelopes]) => { + try { + this.use(pluginName).renderEnvelopes(pluginEnvelopes) + } catch (error) { + console.error(`PHPFlasher: Error rendering envelopes for plugin "${pluginName}"`, error) + } }) } + /** + * Applies options to each plugin. + * + * This method distributes options to the appropriate plugins based on the keys + * in the options object. Each plugin receives only its specific options. + * + * @param options - Object mapping plugin names to their specific options + * + * @example + * ```typescript + * flasher.renderOptions({ + * toastr: { timeOut: 3000, closeButton: true }, + * sweetalert: { confirmButtonColor: '#3085d6' } + * }); + * ``` + */ public renderOptions(options: Options): void { + if (!options) { + return + } + Object.entries(options).forEach(([plugin, option]) => { - // @ts-expect-error - this.use(plugin).renderOptions(option) + try { + // @ts-expect-error - We know this is an Options object + this.use(plugin).renderOptions(option) + } catch (error) { + console.error(`PHPFlasher: Error applying options for plugin "${plugin}"`, error) + } }) } + /** + * Registers a new plugin. + * + * Plugins are the notification renderers that actually display notifications. + * Each plugin typically integrates with a specific notification library like + * Toastr, SweetAlert, etc. + * + * @param name - Unique identifier for the plugin + * @param plugin - Plugin instance that implements the PluginInterface + * @throws {Error} If name or plugin is invalid + * + * @example + * ```typescript + * // Register a custom plugin + * flasher.addPlugin('myplugin', new MyCustomPlugin()); + * + * // Use the registered plugin + * flasher.use('myplugin').info('Hello world'); + * ``` + */ public addPlugin(name: string, plugin: PluginInterface): void { + if (!name || !plugin) { + throw new Error('Both plugin name and instance are required') + } this.plugins.set(name, plugin) } + /** + * Registers a new theme. + * + * Themes define the visual appearance of notifications when using + * the default FlasherPlugin. They provide HTML templates and CSS styles. + * + * @param name - Unique identifier for the theme + * @param theme - Theme configuration object + * @throws {Error} If name or theme is invalid + * + * @example + * ```typescript + * // Register a bootstrap theme + * flasher.addTheme('bootstrap', { + * styles: ['bootstrap.min.css'], + * render: (envelope) => ` + *
+ *

${envelope.title}

+ *

${envelope.message}

+ *
+ * ` + * }); + * + * // Use the theme + * flasher.use('theme.bootstrap').success('Hello world'); + * ``` + */ public addTheme(name: string, theme: Theme): void { + if (!name || !theme) { + throw new Error('Both theme name and definition are required') + } this.themes.set(name, theme) } + /** + * Gets a plugin by name. + * + * This method resolves plugin aliases and creates theme-based plugins + * on demand. If a theme-based plugin is requested but doesn't exist yet, + * it will be created automatically. + * + * @param name - Name of the plugin to retrieve + * @returns The requested plugin instance + * @throws {Error} If the plugin cannot be resolved + * + * @example + * ```typescript + * // Get and use a plugin + * const toastr = flasher.use('toastr'); + * toastr.success('Operation completed'); + * + * // Use a theme as a plugin (automatically creates a FlasherPlugin) + * flasher.use('theme.bootstrap').error('Something went wrong'); + * ``` + */ public use(name: string): PluginInterface { - name = this.resolvePluginAlias(name) - this.resolvePlugin(name) + const resolvedName = this.resolvePluginAlias(name) + this.resolvePlugin(resolvedName) - const plugin = this.plugins.get(name) + const plugin = this.plugins.get(resolvedName) if (!plugin) { - throw new Error(`Unable to resolve "${name}" plugin, did you forget to register it?`) + throw new Error(`Unable to resolve "${resolvedName}" plugin, did you forget to register it?`) } return plugin } + /** + * Alias for use(). + * + * @param name - Name of the plugin to retrieve + * @returns The requested plugin instance + */ public create(name: string): PluginInterface { return this.use(name) } + /** + * Resolves and normalizes a response object. + * + * This method: + * 1. Fills in default values for missing properties + * 2. Resolves plugin aliases for envelopes + * 3. Converts string functions to actual functions + * 4. Adds theme styles to the response + * + * @param response - Partial response object + * @returns Fully resolved response object + * @private + */ private resolveResponse(response: Partial): Response { const resolved = { envelopes: [], @@ -83,13 +329,16 @@ export default class Flasher extends AbstractPlugin { ...response, } as Response + // Process options Object.entries(resolved.options).forEach(([plugin, options]) => { resolved.options[plugin] = this.resolveOptions(options) }) + // Set default CSP nonces if not provided resolved.context.csp_style_nonce = resolved.context.csp_style_nonce || '' resolved.context.csp_script_nonce = resolved.context.csp_script_nonce || '' + // Process envelopes resolved.envelopes.forEach((envelope) => { envelope.metadata = envelope.metadata || {} envelope.metadata.plugin = this.resolvePluginAlias(envelope.metadata.plugin) @@ -101,14 +350,42 @@ export default class Flasher extends AbstractPlugin { return resolved } + /** + * Resolves string functions to actual function objects. + * + * This allows options to include functions serialized as strings, + * which is useful for passing functions from the server to the client. + * + * @param options - Options object that may contain string functions + * @returns Options object with string functions converted to actual functions + * @private + */ private resolveOptions(options: Options): Options { - Object.entries(options).forEach(([key, value]) => { - options[key] = this.resolveFunction(value) + if (!options) { + return {} + } + + const resolved = { ...options } + + Object.entries(resolved).forEach(([key, value]) => { + resolved[key] = this.resolveFunction(value) }) - return options + return resolved } + /** + * Converts a string function representation to an actual function. + * + * Supports both traditional and arrow function syntax: + * - `function(a, b) { return a + b; }` + * - `(a, b) => a + b` + * - `a => a * 2` + * + * @param func - Value to check and potentially convert + * @returns Function if conversion was successful, otherwise the original value + * @private + */ private resolveFunction(func: unknown): unknown { if (typeof func !== 'string') { return func @@ -125,8 +402,7 @@ export default class Flasher extends AbstractPlugin { const args = match[2]?.split(',').map((arg) => arg.trim()) ?? [] let body = match[3].trim() - // Arrow functions with a single expression can omit the curly braces and the return keyword. - // This check is to ensure that the function body is properly wrapped with curly braces. + // Arrow functions with a single expression can omit the curly braces and the return keyword if (!body.startsWith('{')) { body = `{ return ${body}; }` } @@ -135,72 +411,174 @@ export default class Flasher extends AbstractPlugin { // eslint-disable-next-line no-new-func return new Function(...args, body) } catch (e) { - console.error('Error converting string to function:', e) + console.error('PHPFlasher: Error converting string to function:', e) return func } } + /** + * Creates theme-based plugins on demand. + * + * This method automatically creates a FlasherPlugin instance for a theme + * when a theme-based plugin is requested but doesn't exist yet. + * + * @param alias - Plugin alias to resolve + * @private + */ private resolvePlugin(alias: string): void { const factory = this.plugins.get(alias) if (factory || !alias.includes('theme.')) { return } - const view = this.themes.get(alias.replace('theme.', '')) - if (!view) { + const themeName = alias.replace('theme.', '') + const theme = this.themes.get(themeName) + if (!theme) { return } - this.addPlugin(alias, new FlasherPlugin(view)) + // Create and register a FlasherPlugin for this theme + this.addPlugin(alias, new FlasherPlugin(theme)) } + /** + * Resolves a plugin name to its actual implementation name. + * + * This method handles the default plugin and theme aliases. + * + * @param alias - Plugin alias to resolve + * @returns Resolved plugin name + * @private + */ private resolvePluginAlias(alias?: string): string { alias = alias || this.defaultPlugin + // Special case: 'flasher' is aliased to 'theme.flasher' return alias === 'flasher' ? 'theme.flasher' : alias } - private async addAssets(assets: Array<{ urls: string[], nonce: string, type: 'style' | 'script' }>): Promise { - for (const { urls, nonce, type } of assets) { - for (const url of urls) { - await this.loadAsset(url, nonce, type) + /** + * Adds CSS and JavaScript assets to the page. + * + * This method efficiently loads assets, respecting the order for scripts + * which is crucial for libraries with dependencies like jQuery plugins. + * + * @param assets - Array of assets to load + * @returns Promise that resolves when all assets are loaded + * @private + */ + private async addAssets(assets: Asset[]): Promise { + try { + // Process CSS files in parallel (order doesn't matter for CSS) + const styleAssets = assets.filter((asset) => asset.type === 'style') + const stylePromises: Promise[] = [] + + for (const { urls, nonce, type } of styleAssets) { + if (!urls?.length) { + continue + } + + for (const url of urls) { + if (!url || this.loadedAssets.has(url)) { + continue + } + stylePromises.push(this.loadAsset(url, nonce, type)) + this.loadedAssets.add(url) + } } + + // Load all styles in parallel + await Promise.all(stylePromises) + + // Process script files sequentially to respect dependency order + const scriptAssets = assets.filter((asset) => asset.type === 'script') + + for (const { urls, nonce, type } of scriptAssets) { + if (!urls?.length) { + continue + } + + // Load each script URL in the order provided + for (const url of urls) { + if (!url || this.loadedAssets.has(url)) { + continue + } + // Wait for each script to load before proceeding to the next + await this.loadAsset(url, nonce, type) + this.loadedAssets.add(url) + } + } + } catch (error) { + console.error('PHPFlasher: Error loading assets', error) } } - private async loadAsset(url: string, nonce: string, type: 'style' | 'script'): Promise { + /** + * Loads a single asset (CSS or JavaScript) into the document. + * + * @param url - URL of the asset to load + * @param nonce - CSP nonce for the asset + * @param type - Type of asset ('style' or 'script') + * @returns Promise that resolves when the asset is loaded + * @private + */ + private loadAsset(url: string, nonce: string, type: 'style' | 'script'): Promise { + // Check if asset is already loaded if (document.querySelector(`${type === 'style' ? 'link' : 'script'}[src="${url}"]`)) { - return + return Promise.resolve() } - const element = document.createElement(type === 'style' ? 'link' : 'script') as HTMLLinkElement & HTMLScriptElement & { [attrName: string]: string } - if (type === 'style') { - element.rel = 'stylesheet' - element.href = url - } else { - element.type = 'text/javascript' - element.src = url - } - - if (nonce) { - element.setAttribute('nonce', nonce) - } - document.head.appendChild(element) - return new Promise((resolve, reject) => { + const element = document.createElement(type === 'style' ? 'link' : 'script') as HTMLLinkElement & HTMLScriptElement + + if (type === 'style') { + element.rel = 'stylesheet' + element.href = url + } else { + element.type = 'text/javascript' + element.src = url + } + + // Apply CSP nonce if provided + if (nonce) { + element.setAttribute('nonce', nonce) + } + + // Set up load handlers element.onload = () => resolve() element.onerror = () => reject(new Error(`Failed to load ${url}`)) + + // Add to document + document.head.appendChild(element) }) } + /** + * Adds theme styles to the list of assets to load. + * + * This method extracts style URLs from theme definitions and adds them + * to the response styles array. + * + * @param response - Response object to modify + * @param plugin - Plugin name that may reference a theme + * @private + */ private addThemeStyles(response: Response, plugin: string): void { + // Only process theme plugins if (plugin !== 'flasher' && !plugin.includes('theme.')) { return } - plugin = plugin.replace('theme.', '') - const styles = this.themes.get(plugin)?.styles || [] + const themeName = plugin.replace('theme.', '') + const theme = this.themes.get(themeName) + if (!theme?.styles) { + return + } - response.styles = Array.from(new Set([...response.styles, ...styles])) + // Convert single style to array if needed + const themeStyles = Array.isArray(theme.styles) ? theme.styles : [theme.styles] + + // Add styles without duplicates + response.styles = Array.from(new Set([...response.styles, ...themeStyles])) } } diff --git a/src/Prime/Resources/assets/global.d.ts b/src/Prime/Resources/assets/global.d.ts new file mode 100644 index 00000000..0eb3d484 --- /dev/null +++ b/src/Prime/Resources/assets/global.d.ts @@ -0,0 +1,25 @@ +/** + * @file TypeScript Global Declarations + * @description Type definitions for global objects + * @author yoeunes + */ +import type Flasher from './flasher' + +/** + * Extend the Window interface to include the global flasher instance. + * + * This allows TypeScript to recognize window.flasher as a valid property + * with proper type information. + */ +declare global { + interface Window { + /** + * Global PHPFlasher instance. + * + * Available as window.flasher in browser environments. + */ + flasher: Flasher + } +} + +export {} diff --git a/src/Prime/Resources/assets/index.ts b/src/Prime/Resources/assets/index.ts index 90acffed..30f7914b 100644 --- a/src/Prime/Resources/assets/index.ts +++ b/src/Prime/Resources/assets/index.ts @@ -1,7 +1,44 @@ +/** + * @file PHPFlasher Main Entry Point + * @description Creates and exports the default PHPFlasher instance + * @author yoeunes + */ import Flasher from './flasher' -import { flasherTheme } from './themes/flasher' +import { flasherTheme } from './themes' +/** + * Create and configure the default Flasher instance. + * + * This singleton instance is the main entry point for the PHPFlasher library. + * It comes pre-configured with the default theme. + */ const flasher = new Flasher() flasher.addTheme('flasher', flasherTheme) +/** + * Make the flasher instance available globally for browser scripts. + * + * This allows PHPFlasher to be used in vanilla JavaScript without + * module imports. + * + * @example + * ```javascript + * // In a browser script + * window.flasher.success('Operation completed'); + * ``` + */ +if (typeof window !== 'undefined') { + window.flasher = flasher +} + +/** + * Default export of the pre-configured flasher instance. + * + * @example + * ```typescript + * import flasher from '@flasher/flasher'; + * + * flasher.success('Operation completed'); + * ``` + */ export default flasher diff --git a/src/Prime/Resources/assets/plugin.ts b/src/Prime/Resources/assets/plugin.ts index d43e7ae6..2d4405d1 100644 --- a/src/Prime/Resources/assets/plugin.ts +++ b/src/Prime/Resources/assets/plugin.ts @@ -1,56 +1,185 @@ +/** + * @file Abstract Plugin Base Class + * @description Base implementation shared by all notification plugins + * @author yoeunes + */ import type { Envelope, Options, PluginInterface } from './types' +/** + * Base implementation of a notification plugin. + * + * AbstractPlugin provides default implementations for the standard notification + * methods (success, error, info, warning) that delegate to the flash() method. + * This reduces code duplication and ensures consistent behavior across plugins. + * + * Plugin implementations need only implement the abstract renderEnvelopes() + * and renderOptions() methods to integrate with PHPFlasher. + * + * @example + * ```typescript + * class MyPlugin extends AbstractPlugin { + * // Required implementation + * renderEnvelopes(envelopes: Envelope[]): void { + * // Custom rendering logic + * } + * + * renderOptions(options: Options): void { + * // Custom options handling + * } + * + * // Optional: override other methods if needed + * } + * ``` + */ export abstract class AbstractPlugin implements PluginInterface { + /** + * Render multiple notification envelopes. + * + * Must be implemented by concrete plugins to define how notifications + * are displayed using the specific notification library. + * + * @param envelopes - Array of notification envelopes to render + */ abstract renderEnvelopes(envelopes: Envelope[]): void + /** + * Apply plugin-specific options. + * + * Must be implemented by concrete plugins to configure the underlying + * notification library with the provided options. + * + * @param options - Configuration options for the plugin + */ abstract renderOptions(options: Options): void + /** + * Display a success notification. + * + * @param message - Notification content or options object + * @param title - Optional title or options object + * @param options - Optional configuration options + */ public success(message: string | Options, title?: string | Options, options?: Options): void { this.flash('success', message, title, options) } + /** + * Display an error notification. + * + * @param message - Notification content or options object + * @param title - Optional title or options object + * @param options - Optional configuration options + */ public error(message: string | Options, title?: string | Options, options?: Options): void { this.flash('error', message, title, options) } + /** + * Display an information notification. + * + * @param message - Notification content or options object + * @param title - Optional title or options object + * @param options - Optional configuration options + */ public info(message: string | Options, title?: string | Options, options?: Options): void { this.flash('info', message, title, options) } + /** + * Display a warning notification. + * + * @param message - Notification content or options object + * @param title - Optional title or options object + * @param options - Optional configuration options + */ public warning(message: string | Options, title?: string | Options, options?: Options): void { this.flash('warning', message, title, options) } + /** + * Display any type of notification. + * + * This method handles different parameter formats to provide a flexible API: + * - flash(type, message, title, options) + * - flash(type, message, options) - title in options + * - flash(type, options) - message and title in options + * - flash(options) - type, message, and title in options + * + * @param type - Notification type or options object + * @param message - Notification content or options object + * @param title - Optional title or options object + * @param options - Optional configuration options + * + * @throws {Error} If required parameters are missing + */ public flash(type: string | Options, message: string | Options, title?: string | Options, options?: Options): void { + // Handle overloaded parameters + let normalizedType: string + let normalizedMessage: string + let normalizedTitle: string | undefined + let normalizedOptions: Options = {} + + // Case: flash({type, message, title, ...options}) if (typeof type === 'object') { - options = type - type = options.type as unknown as string - message = options.message as unknown as string - title = options.title as unknown as string - } else if (typeof message === 'object') { - options = message - message = options.message as unknown as string - title = options.title as unknown as string - } else if (typeof title === 'object') { - options = title - title = options.title as unknown as string + normalizedOptions = { ...type } + normalizedType = normalizedOptions.type as string + normalizedMessage = normalizedOptions.message as string + normalizedTitle = normalizedOptions.title as string + + // Remove these properties as they're now handled separately + delete normalizedOptions.type + delete normalizedOptions.message + delete normalizedOptions.title + } + // Case: flash(type, {message, title, ...options}) + else if (typeof message === 'object') { + normalizedOptions = { ...message } + normalizedType = type + normalizedMessage = normalizedOptions.message as string + normalizedTitle = normalizedOptions.title as string + + delete normalizedOptions.message + delete normalizedOptions.title + } + // Case: flash(type, message, {title, ...options}) + else if (typeof title === 'object') { + normalizedOptions = { ...title } + normalizedType = type + normalizedMessage = message + normalizedTitle = normalizedOptions.title as string + + delete normalizedOptions.title + } + // Case: flash(type, message, title, options) + else { + normalizedType = type + normalizedMessage = message + normalizedTitle = title + normalizedOptions = options || {} } - if (undefined === message) { - throw new Error('message option is required') + // Validate required parameters + if (!normalizedType) { + throw new Error('Type is required for notifications') } - const envelope = { - type, - message, - title: title || type, - options: options || {}, + if (normalizedMessage === undefined || normalizedMessage === null) { + throw new Error('Message is required for notifications') + } + + // Create standardized envelope + const envelope: Envelope = { + type: normalizedType, + message: normalizedMessage, + title: normalizedTitle || normalizedType, + options: normalizedOptions, metadata: { plugin: '', }, } - this.renderOptions(options || {}) + // Apply options and render the envelope + this.renderOptions(normalizedOptions) this.renderEnvelopes([envelope]) } } diff --git a/src/Prime/Resources/assets/theme.ts b/src/Prime/Resources/assets/theme.ts deleted file mode 100644 index b632f74c..00000000 --- a/src/Prime/Resources/assets/theme.ts +++ /dev/null @@ -1,19 +0,0 @@ -import type { Envelope } from './types' - -export const theme = { - render: (envelope: Envelope): string => { - const { type, title, message } = envelope - - return ` -
-
-
-
- ${title} - ${message} -
-
- -
` - }, -} diff --git a/src/Prime/Resources/assets/types.ts b/src/Prime/Resources/assets/types.ts index 1d20afd9..75653832 100644 --- a/src/Prime/Resources/assets/types.ts +++ b/src/Prime/Resources/assets/types.ts @@ -1,35 +1,397 @@ -export type Options = { [option: string]: unknown } +/** + * @file PHPFlasher Type Definitions + * @description Core types and interfaces for the PHPFlasher notification system. + * @author yoeunes + */ +import type { Properties } from 'csstype' -export type Context = { [key: string]: unknown } +/** + * Generic options object used throughout the application. + * + * This type represents a collection of key-value pairs that can be passed + * to customize the behavior of notifications. + * + * @example + * ```typescript + * const options: Options = { + * timeout: 5000, + * position: 'top-right', + * closeOnClick: true + * }; + * ``` + */ +export type Options = Record +/** + * Context data that can be passed to notifications. + * + * Context provides additional data that can be utilized by notification + * templates or handling logic. + * + * @example + * ```typescript + * const context: Context = { + * userId: 123, + * requestId: 'abc-123', + * csrfToken: 'xyz' + * }; + * ``` + */ +export type Context = Record + +/** + * Represents a notification message to be displayed. + * + * An envelope encapsulates all the information needed to render a notification, + * including its content, type, styling options, and metadata. + * + * @example + * ```typescript + * const envelope: Envelope = { + * message: 'Operation completed successfully', + * title: 'Success', + * type: 'success', + * options: { timeout: 5000 }, + * metadata: { plugin: 'toastr' } + * }; + * ``` + */ export type Envelope = { + /** The main content of the notification */ message: string + + /** + * Optional title for the notification. + * If not provided, defaults to the capitalized notification type. + */ title: string + + /** + * Notification type that determines its appearance and behavior. + * Common types include: success, error, info, warning + */ type: string + + /** + * Additional configuration options specific to this notification. + * These will override global and plugin-specific defaults. + */ options: Options - metadata: { plugin: string } + + /** + * Metadata about the notification, including which plugin should handle it. + * The plugin field determines which renderer will process this notification. + */ + metadata: { + plugin: string + [key: string]: unknown + } + + /** + * Optional context data accessible during notification rendering. + * This can contain request-specific information or user data. + */ context?: Context } +/** + * Response from the server containing notifications and configuration. + * + * This structure is typically returned from backend endpoints and contains + * all the information needed to render notifications, including assets to load. + * + * @example + * ```typescript + * const response: Response = { + * envelopes: [{ message: 'Success', title: 'Done', type: 'success', options: {}, metadata: { plugin: 'toastr' } }], + * options: { toastr: { closeButton: true } }, + * scripts: ['/assets/toastr.min.js'], + * styles: ['/assets/toastr.min.css'], + * context: { csp_nonce: 'random123' } + * }; + * ``` + */ export type Response = { + /** Array of notification envelopes to be displayed */ envelopes: Envelope[] - options: { [plugin: string]: Options } + + /** + * Plugin-specific options that should be applied globally. + * Organized by plugin name for selective application. + */ + options: Record + + /** JavaScript files that should be loaded */ scripts: string[] + + /** CSS files that should be loaded */ styles: string[] + + /** Global context data shared across all notifications */ context: Context } -export type PluginInterface = { +/** + * Core interface that all notification plugins must implement. + * + * This interface defines the contract for any notification library integration. + * Each plugin represents a different notification library (Toastr, SweetAlert, etc.) + * but exposes the same consistent API. + * + * @example + * ```typescript + * class MyCustomPlugin implements PluginInterface { + * // Implementation of required methods + * } + * + * // Usage + * const plugin = new MyCustomPlugin(); + * plugin.success('Operation completed'); + * ``` + */ +export interface PluginInterface { + /** + * Display a success notification. + * + * @param message - Notification content or options object + * @param title - Optional title or options object + * @param options - Optional configuration options + * + * @example + * ```typescript + * // Simple usage + * plugin.success('Data saved successfully'); + * + * // With title + * plugin.success('Changes applied', 'Success'); + * + * // With options + * plugin.success('Profile updated', 'Success', { timeOut: 3000 }); + * + * // Using object syntax + * plugin.success({ + * message: 'Operation completed', + * title: 'Success', + * timeout: 5000 + * }); + * ``` + */ success: (message: string | Options, title?: string | Options, options?: Options) => void + + /** + * Display an error notification. + * + * @param message - Notification content or options object + * @param title - Optional title or options object + * @param options - Optional configuration options + * + * @example + * ```typescript + * // Simple usage + * plugin.error('An error occurred while processing your request'); + * + * // With title + * plugin.error('Could not connect to server', 'Connection Error'); + * + * // With options + * plugin.error('Invalid form data', 'Validation Error', { timeOut: 0 }); + * ``` + */ error: (message: string | Options, title?: string | Options, options?: Options) => void + + /** + * Display an information notification. + * + * @param message - Notification content or options object + * @param title - Optional title or options object + * @param options - Optional configuration options + * + * @example + * ```typescript + * // Simple usage + * plugin.info('Your session will expire in 5 minutes'); + * + * // With title and options + * plugin.info('New updates are available', 'Information', { + * closeButton: true, + * timeOut: 10000 + * }); + * ``` + */ info: (message: string | Options, title?: string | Options, options?: Options) => void + + /** + * Display a warning notification. + * + * @param message - Notification content or options object + * @param title - Optional title or options object + * @param options - Optional configuration options + * + * @example + * ```typescript + * // Simple usage + * plugin.warning('You have unsaved changes'); + * + * // With title + * plugin.warning('Your subscription will expire soon', 'Warning'); + * ``` + */ warning: (message: string | Options, title?: string | Options, options?: Options) => void + + /** + * Display any type of notification. + * + * This is a generic method that allows displaying notifications of any type, + * including custom types beyond the standard success/error/info/warning. + * + * @param type - Notification type or options object + * @param message - Notification content or options object + * @param title - Optional title or options object + * @param options - Optional configuration options + * + * @example + * ```typescript + * // Custom notification type + * plugin.flash('question', 'Do you want to continue?', 'Confirmation'); + * + * // Using object syntax + * plugin.flash({ + * type: 'custom', + * message: 'Something happened', + * title: 'Notice', + * icon: 'bell' + * }); + * ``` + */ flash: (type: string | Options, message: string | Options, title?: string | Options, options?: Options) => void + + /** + * Render multiple notification envelopes. + * + * This is typically used internally to process batches of notifications + * received from the server. + * + * @param envelopes - Array of notification envelopes to render + */ renderEnvelopes: (envelopes: Envelope[]) => void + + /** + * Apply plugin-specific options. + * + * This method configures the underlying notification library + * with the provided options. + * + * @param options - Configuration options for the plugin + */ renderOptions: (options: Options) => void } +/** + * Theme configuration for rendering notifications. + * + * A theme defines how notifications are visually presented to users, + * including HTML structure, styling, and asset dependencies. + * + * @example + * ```typescript + * const bootstrapTheme: Theme = { + * styles: ['bootstrap-notifications.css'], + * render: (envelope) => ` + *
+ * ${envelope.title} + *

${envelope.message}

+ *
+ * ` + * }; + * ``` + */ export type Theme = { + /** + * CSS styles to apply (string URL or array of URLs). + * These will be automatically loaded when the theme is used. + */ styles?: string | string[] + + /** + * Render function that converts an envelope to HTML string. + * This function is responsible for generating the HTML structure + * of the notification. + * + * @param envelope - The notification envelope to render + * @returns HTML string representation of the notification + */ render: (envelope: Envelope) => string } + +/** + * Asset types that can be loaded dynamically. + * Used to distinguish between scripts and stylesheets. + */ +export type AssetType = 'style' | 'script' + +/** + * Configuration for an asset to be loaded. + * Contains all information needed to load external resources. + */ +export type Asset = { + /** URLs to load */ + urls: string[] + + /** Content Security Policy nonce (if required) */ + nonce: string + + /** Type of asset (style or script) */ + type: AssetType +} + +/** + * FlasherPlugin specific options. + * + * These options control the behavior and appearance of notifications + * rendered by the default FlasherPlugin. + * + * @example + * ```typescript + * const options: FlasherPluginOptions = { + * position: 'bottom-left', + * timeout: 8000, + * rtl: true, + * fps: 60 + * }; + * ``` + */ +export type FlasherPluginOptions = { + /** + * Default timeout in milliseconds (0 for no timeout). + * Set to null to use type-specific timeouts. + */ + timeout: number | null + + /** Type-specific timeouts in milliseconds */ + timeouts: Record + + /** Animation frames per second for the progress bar */ + fps: number + + /** + * Notification position on screen. + * Common values: 'top-right', 'top-left', 'bottom-right', 'bottom-left', 'center' + */ + position: string + + /** + * Stacking direction of notifications. + * 'top' means newer notifications appear above older ones. + * 'bottom' means newer notifications appear below older ones. + */ + direction: 'top' | 'bottom' + + /** Right-to-left text direction for RTL languages */ + rtl: boolean + + /** Custom CSS styles applied to the notification container */ + style: Properties + + /** Whether to escape HTML in messages for security */ + escapeHtml: boolean +}