|
| 1 | +import type { Logger, Plugin } from 'vite'; |
| 2 | + |
| 3 | +export interface SplashLogoOptions { |
| 4 | + /** |
| 5 | + * URL for the splash logo (served from the app `public/` folder by default). |
| 6 | + * @default '/logo-loading.svg' |
| 7 | + */ |
| 8 | + src?: string; |
| 9 | + /** |
| 10 | + * Accessible name for the logo. When set, the image is exposed to assistive tech |
| 11 | + * (and `aria-hidden` is not applied). |
| 12 | + */ |
| 13 | + alt?: string; |
| 14 | + /** |
| 15 | + * @default 192 |
| 16 | + */ |
| 17 | + width?: number; |
| 18 | + /** |
| 19 | + * @default 64 |
| 20 | + */ |
| 21 | + height?: number; |
| 22 | +} |
| 23 | + |
| 24 | +export interface AppSplashPluginOptions { |
| 25 | + /** |
| 26 | + * Logo dimensions and accessibility (`src` defaults to `/logo-loading.svg` when omitted). |
| 27 | + */ |
| 28 | + logo?: SplashLogoOptions; |
| 29 | + /** |
| 30 | + * Short tagline shown under the logo on the splash screen (static HTML text). |
| 31 | + * The status line under the progress bar still follows `app-load-progress` events. |
| 32 | + */ |
| 33 | + description?: string; |
| 34 | + /** |
| 35 | + * When false, the plugin does not modify `index.html`. |
| 36 | + * @default true |
| 37 | + */ |
| 38 | + enabled?: boolean; |
| 39 | +} |
| 40 | + |
| 41 | +function escapeHtml(s: string): string { |
| 42 | + return s |
| 43 | + .replace(/&/g, '&') |
| 44 | + .replace(/</g, '<') |
| 45 | + .replace(/>/g, '>') |
| 46 | + .replace(/"/g, '"'); |
| 47 | +} |
| 48 | + |
| 49 | +function resolveLogo( |
| 50 | + options: AppSplashPluginOptions, |
| 51 | +): { src: string; alt: string; width: number; height: number } { |
| 52 | + const logo = options.logo ?? {}; |
| 53 | + const src = logo.src ?? '/logo-loading.svg'; |
| 54 | + const width = logo.width ?? 192; |
| 55 | + const height = logo.height ?? 64; |
| 56 | + const alt = logo.alt ?? ''; |
| 57 | + return { src, alt, width, height }; |
| 58 | +} |
| 59 | + |
| 60 | +function buildSplashFragment( |
| 61 | + logo: { src: string; alt: string; width: number; height: number }, |
| 62 | + description: string | undefined, |
| 63 | +): string { |
| 64 | + const w = logo.width; |
| 65 | + const h = logo.height; |
| 66 | + const imgAttrs = |
| 67 | + logo.alt.length > 0 |
| 68 | + ? `src="${escapeHtml(logo.src)}" alt="${escapeHtml(logo.alt)}" width="${w}" height="${h}" decoding="async" style="display:block;width:${w}px;height:${h}px;max-width:${w}px;max-height:${h}px;min-width:${w}px;min-height:${h}px;flex-shrink:0;"` |
| 69 | + : `src="${escapeHtml(logo.src)}" alt="" width="${w}" height="${h}" decoding="async" style="display:block;width:${w}px;height:${h}px;max-width:${w}px;max-height:${h}px;min-width:${w}px;min-height:${h}px;flex-shrink:0;" aria-hidden="true"`; |
| 70 | + |
| 71 | + const desc = |
| 72 | + description !== undefined && description.trim().length > 0 |
| 73 | + ? ` |
| 74 | + <p id="app-splash-description" style="margin:0;font-size:0.875rem;color:var(--wa-text-secondary,#888);text-align:center;max-width:min(360px,90vw);line-height:1.4;">${escapeHtml(description.trim())}</p>` |
| 75 | + : ''; |
| 76 | + |
| 77 | + return ` |
| 78 | + <div id="app-loading-overlay" style="position:fixed;inset:0;z-index:1000;background:var(--wa-background,#1a1a1a);pointer-events:none;display:flex;flex-direction:column;align-items:center;justify-content:center;gap:2rem;"> |
| 79 | + <img ${imgAttrs}/> |
| 80 | + ${desc} |
| 81 | + <div style="width:240px;height:3px;background:var(--wa-layer-2,#2a2a2a);border-radius:2px;overflow:hidden;"> |
| 82 | + <div style="width:40%;height:100%;background:var(--wa-color-primary,#2299dd);border-radius:2px;animation:app-loading-bar 1.5s ease-in-out infinite;" aria-hidden="true"></div> |
| 83 | + </div> |
| 84 | + <div id="app-load-progress" style="font-size:0.8125rem;color:var(--wa-text-secondary,#888);" aria-live="polite">Starting…</div> |
| 85 | + </div> |
| 86 | + <script> |
| 87 | + window.addEventListener('app-load-progress', function(e) { |
| 88 | + var el = document.getElementById('app-load-progress'); |
| 89 | + if (el && e.detail && e.detail.message) el.textContent = e.detail.message; |
| 90 | + }); |
| 91 | + window.addEventListener('app-loaded', function() { |
| 92 | + var el = document.getElementById('app-loading-overlay'); |
| 93 | + if (el) { el.style.opacity = '0'; el.style.transition = 'opacity 0.15s'; setTimeout(function() { el.remove(); }, 160); } |
| 94 | + }, { once: true }); |
| 95 | + </script>`; |
| 96 | +} |
| 97 | + |
| 98 | +const APP_ROOT_RE = |
| 99 | + /<div\b[^>]*\bid=["']app-root["'][^>]*>\s*<\/div>/i; |
| 100 | + |
| 101 | +const KEYFRAMES_STYLE = `<style>@keyframes app-loading-bar{0%{transform:translateX(0)}50%{transform:translateX(144px)}100%{transform:translateX(0)}}</style>`; |
| 102 | + |
| 103 | +/** |
| 104 | + * Injects the Docks startup splash (overlay, progress line, `app-load-progress` / `app-loaded` listeners) |
| 105 | + * into `index.html` after `#app-root`, and adds the loading-bar keyframes in `<head>` when missing. |
| 106 | + * |
| 107 | + * Downstream apps only need a `<div id="app-root"></div>` and this plugin; branding stays in `public/logo-loading.svg` (or `logo`). |
| 108 | + */ |
| 109 | +export function appSplashPlugin(options: AppSplashPluginOptions = {}): Plugin { |
| 110 | + const enabled = options.enabled ?? true; |
| 111 | + let logger: Logger | undefined; |
| 112 | + |
| 113 | + return { |
| 114 | + name: 'eclipse-docks-app-splash', |
| 115 | + configResolved(config) { |
| 116 | + logger = config.logger; |
| 117 | + }, |
| 118 | + transformIndexHtml: { |
| 119 | + order: 'pre', |
| 120 | + handler(html) { |
| 121 | + if (!enabled) return html; |
| 122 | + if (/id=["']app-loading-overlay["']/.test(html)) return html; |
| 123 | + |
| 124 | + if (!APP_ROOT_RE.test(html)) { |
| 125 | + logger?.warn( |
| 126 | + '[@eclipse-docks/app-splash] No <div id="app-root"></div> found; splash not injected.', |
| 127 | + ); |
| 128 | + return html; |
| 129 | + } |
| 130 | + |
| 131 | + const logo = resolveLogo(options); |
| 132 | + let out = html; |
| 133 | + if (!out.includes('app-loading-bar')) { |
| 134 | + out = out.replace(/<\/head>/i, `${KEYFRAMES_STYLE}</head>`); |
| 135 | + } |
| 136 | + out = out.replace(APP_ROOT_RE, (m) => `${m}${buildSplashFragment(logo, options.description)}`); |
| 137 | + return out; |
| 138 | + }, |
| 139 | + }, |
| 140 | + }; |
| 141 | +} |
0 commit comments