A system for building static webapps
0
fork

Configure Feed

Select the types of activity you want to include in your feed.

feat: update to use meta.json for version check

+157 -108
+2 -1
deno.json
··· 29 29 } 30 30 }, 31 31 "tasks": { 32 - "cli": "deno install cli/main.ts -Afg --name=civility --config ./deno.json" 32 + "cli": "deno install packages/cli/main.ts -Afg --name=civ --config ./deno.json", 33 + "test": "deno fmt && deno lint && deno test -A --coverage" 33 34 }, 34 35 "imports": { 35 36 "@astral/astral": "jsr:@astral/astral@^0.5.5",
+1 -1
docs/ROUTING.md
··· 38 38 Enable hashbang routing with one option: 39 39 40 40 ```typescript 41 - import { Router } from '@bpev/civility' 41 + import { Router } from '@civility/ui' 42 42 43 43 this.router = Router.new({ 44 44 selectorAttrib: 'data-route',
+31 -9
packages/cli/commands/build/pwa.ts
··· 10 10 config: Record<string, unknown> 11 11 buildOptions: esbuild.BuildOptions 12 12 civilityConfig: CivilityConfig & { entryPoints: string[] } 13 + version: string 13 14 } 14 15 15 16 export async function createBuildConfig( ··· 39 40 }, 40 41 } 41 42 42 - return { configPath, config, buildOptions, civilityConfig } 43 + return { 44 + configPath, 45 + config, 46 + buildOptions, 47 + civilityConfig, 48 + version: config.version as string, 49 + } 50 + } 51 + 52 + async function writeMetaJson(outdir: string, version: string): Promise<void> { 53 + await Deno.writeTextFile( 54 + `${outdir}/meta.json`, 55 + JSON.stringify({ version, builtAt: new Date().toISOString() }), 56 + ) 43 57 } 44 58 45 59 export async function buildOnce( 46 60 buildOptions: esbuild.BuildOptions, 61 + version: string, 47 62 ): Promise<void> { 48 63 try { 49 64 const start = performance.now() 50 65 await esbuild.build(buildOptions) 66 + await writeMetaJson(buildOptions.outdir as string, version) 51 67 const duration = Math.round(performance.now() - start) 52 68 53 69 logSuccess(`Build completed in ${colors.bold(duration + 'ms')}`) ··· 62 78 63 79 export async function buildWatch( 64 80 buildOptions: esbuild.BuildOptions, 81 + version: string, 65 82 ): Promise<esbuild.BuildContext> { 66 83 try { 67 84 const buildWithCallback: esbuild.BuildOptions = { ··· 75 92 build.onStart(() => { 76 93 if (!isFirstBuild) logInfo('Rebuilding...') 77 94 }) 78 - build.onEnd(({ errors, warnings }: esbuild.BuildResult) => { 95 + build.onEnd(async ({ errors, warnings }: esbuild.BuildResult) => { 79 96 if (errors.length > 0) { 80 97 logError(`Build failed with ${errors.length} error(s)`) 81 - } else if (warnings.length > 0) { 82 - logWarning(`Build completed with ${warnings.length} warning(s)`) 83 98 } else { 84 - logSuccess( 85 - isFirstBuild 86 - ? 'Initial build completed' 87 - : 'Rebuild completed', 88 - ) 99 + await writeMetaJson(buildOptions.outdir as string, version) 100 + if (warnings.length > 0) { 101 + logWarning( 102 + `Build completed with ${warnings.length} warning(s)`, 103 + ) 104 + } else { 105 + logSuccess( 106 + isFirstBuild 107 + ? 'Initial build completed' 108 + : 'Rebuild completed', 109 + ) 110 + } 89 111 } 90 112 91 113 if (isFirstBuild) {
+10 -6
packages/cli/main.ts
··· 113 113 ) 114 114 logInfo(`Output directory: ${theme.bold(outputDir)}`) 115 115 116 - const { buildOptions } = await createBuildConfig(resolvedConfig) 116 + const { buildOptions, version } = await createBuildConfig( 117 + resolvedConfig, 118 + ) 117 119 118 120 if (options.watch) { 119 121 logInfo('Starting build in watch mode...') 120 - const ctx = await pwaBuildWatch(buildOptions) 122 + const ctx = await pwaBuildWatch(buildOptions, version) 121 123 122 124 const cleanup = async () => { 123 125 logInfo('Stopping build watch...') ··· 132 134 await new Promise(() => {}) 133 135 } else { 134 136 logInfo('Building application...') 135 - await pwaBuildOnce(buildOptions) 137 + await pwaBuildOnce(buildOptions, version) 136 138 logSuccess('Build completed successfully') 137 139 } 138 140 } ··· 242 244 ) 243 245 logInfo(`Output directory: ${theme.bold(outputDir)}`) 244 246 245 - const { buildOptions } = await createBuildConfig(resolvedConfig) 247 + const { buildOptions, version } = await createBuildConfig( 248 + resolvedConfig, 249 + ) 246 250 247 251 if (options.watch) { 248 252 logInfo('Starting build in watch mode...') 249 - const ctx = await pwaBuildWatch(buildOptions) 253 + const ctx = await pwaBuildWatch(buildOptions, version) 250 254 251 255 const cleanup = async () => { 252 256 logInfo('Stopping build watch...') ··· 261 265 await new Promise(() => {}) 262 266 } else { 263 267 logInfo('Building application...') 264 - await pwaBuildOnce(buildOptions) 268 + await pwaBuildOnce(buildOptions, version) 265 269 logSuccess('Build completed successfully') 266 270 } 267 271 }
+3 -1
packages/cli/stubs/pwa/deno.json
··· 15 15 "dev": "civility start" 16 16 }, 17 17 "imports": { 18 - "@bpev/civility": "jsr:@bpev/civility@^0.0.5" 18 + "@civility/store": "jsr:@civility/store@^0.1.2", 19 + "@civility/ui": "jsr:@civility/ui@^0.1.2", 20 + "@civility/workers": "jsr:@civility/workers@^0.1.2" 19 21 } 20 22 }
+40 -40
packages/cli/stubs/pwa/www/index.ts
··· 1 - import { Router } from '@bpev/civility' 2 - import { client } from '@bpev/civility/workers' 3 - import './routes/clock.ts' 4 - import './routes/stopwatch.ts' 1 + import { Router } from "@civility/ui"; 2 + import { client } from "@civility/workers"; 3 + import "./routes/clock.ts"; 4 + import "./routes/stopwatch.ts"; 5 5 6 - client.init 6 + client.init; 7 7 8 8 export class App { 9 - private router: ReturnType<typeof Router.new> 10 - private mainElement: HTMLElement | null = null 9 + private router: ReturnType<typeof Router.new>; 10 + private mainElement: HTMLElement | null = null; 11 11 12 12 constructor() { 13 13 this.router = Router.new({ 14 - selectorAttrib: 'data-route', 14 + selectorAttrib: "data-route", 15 15 useHash: true, 16 - defaultHandler: () => this.router.route('/'), 17 - }) 16 + defaultHandler: () => this.router.route("/"), 17 + }); 18 18 19 - this.setupRoutes() 19 + this.setupRoutes(); 20 20 } 21 21 22 22 private setupRoutes(): void { 23 - this.router.on('/', { 23 + this.router.on("/", { 24 24 on: () => { 25 - this.renderPage('clock-page') 26 - this.updateActiveNavLink('/') 27 - this.updatePageTitle('Clock') 25 + this.renderPage("clock-page"); 26 + this.updateActiveNavLink("/"); 27 + this.updatePageTitle("Clock"); 28 28 }, 29 - }) 29 + }); 30 30 31 - this.router.on('/stopwatch', { 31 + this.router.on("/stopwatch", { 32 32 on: () => { 33 - this.renderPage('stopwatch-page') 34 - this.updateActiveNavLink('/stopwatch') 35 - this.updatePageTitle('Stopwatch') 33 + this.renderPage("stopwatch-page"); 34 + this.updateActiveNavLink("/stopwatch"); 35 + this.updatePageTitle("Stopwatch"); 36 36 }, 37 - }) 37 + }); 38 38 } 39 39 40 40 private renderPage(componentTag: string): void { 41 - this.mainElement = document.querySelector('main') 42 - if (!this.mainElement) return 41 + this.mainElement = document.querySelector("main"); 42 + if (!this.mainElement) return; 43 43 44 44 // Clear current content 45 - this.mainElement.innerHTML = '' 45 + this.mainElement.innerHTML = ""; 46 46 47 47 // Create and append new component 48 - const component = document.createElement(componentTag) 49 - this.mainElement.appendChild(component) 48 + const component = document.createElement(componentTag); 49 + this.mainElement.appendChild(component); 50 50 } 51 51 52 52 private updateActiveNavLink(hash: string): void { 53 53 // Remove aria-current from all nav links 54 - document.querySelectorAll('ui-bottom-bar a').forEach((link) => { 55 - link.removeAttribute('aria-current') 56 - }) 54 + document.querySelectorAll("ui-bottom-bar a").forEach((link) => { 55 + link.removeAttribute("aria-current"); 56 + }); 57 57 58 58 // Add aria-current to active link 59 59 const activeLink = document.querySelector( 60 60 `ui-bottom-bar a[href="${hash}"]`, 61 - ) 61 + ); 62 62 if (activeLink) { 63 - activeLink.setAttribute('aria-current', 'page') 63 + activeLink.setAttribute("aria-current", "page"); 64 64 } 65 65 } 66 66 67 67 private updatePageTitle(title: string): void { 68 - const titleElement = document.querySelector('#page-title') 68 + const titleElement = document.querySelector("#page-title"); 69 69 if (titleElement) { 70 - titleElement.textContent = title 70 + titleElement.textContent = title; 71 71 } 72 72 } 73 73 74 74 init(): void { 75 75 // Wait for DOM to be ready 76 - if (document.readyState === 'loading') { 77 - document.addEventListener('DOMContentLoaded', () => { 78 - this.router.ready() 79 - }) 76 + if (document.readyState === "loading") { 77 + document.addEventListener("DOMContentLoaded", () => { 78 + this.router.ready(); 79 + }); 80 80 } else { 81 - this.router.ready() 81 + this.router.ready(); 82 82 } 83 83 } 84 84 } 85 85 86 - const app = new App() 87 - app.init() 86 + const app = new App(); 87 + app.init();
+4 -9
packages/cli/stubs/pwa/www/utils/updates.ts
··· 14 14 15 15 navigator.serviceWorker.addEventListener('message', (event) => { 16 16 const { data } = event 17 - const { type, version, newVersion, currentVersion } = data 18 - 19 - if (type === 'SW_ACTIVATED') { 20 - console.log(`Service Worker activated: ${version}`) 21 - } else if (type === 'UPDATE_AVAILABLE') { 22 - console.log( 23 - `Update available: ${newVersion} (current: ${currentVersion})`, 24 - ) 25 - showUpdatePrompt(currentVersion, newVersion) 17 + if (data?.type === 'VERSION_STATUS') { 18 + if (data.updateAvailable) { 19 + showUpdatePrompt(data.currentVersion, data.serverVersion) 20 + } 26 21 } 27 22 }) 28 23 }
+2 -1
packages/cli/stubs/pwa/www/worker.js
··· 4 4 withFetchStrategy, 5 5 withPrecache, 6 6 withUpdatePolling, 7 - } from '@bpev/civility/workers' 7 + } from '@civility/workers' 8 8 9 9 init([ 10 10 withPrecache([ ··· 13 13 '/static/civility.css', 14 14 '/static/theme.css', 15 15 '/dist/index.js', 16 + '/dist/meta.json', 16 17 '/manifest.json', 17 18 ]), 18 19 withCleanup(),
+3
packages/store/mod.ts
··· 1 1 export * from './state.ts' 2 2 export * from './storage.ts' 3 + 4 + export { default as State } from './state.ts' 5 + export { default as Storage } from './storage.ts'
+24 -6
packages/ui/components/ui-pwa-version.ts
··· 3 3 export class UiPwaVersion extends LitElement { 4 4 static override properties = { 5 5 version: { type: String }, 6 - _pendingVersion: { state: true }, 6 + _checked: { state: true }, 7 + _serverVersion: { state: true }, 8 + _updateAvailable: { state: true }, 7 9 } 8 10 9 11 version: string = 10 12 (globalThis as { __APP_VERSION__?: string }).__APP_VERSION__ ?? 'unknown' 11 - _pendingVersion: string | null = null 13 + _checked = false 14 + _serverVersion: string | null = null 15 + _updateAvailable = false 12 16 13 17 #onSwMessage: ((event: MessageEvent) => void) | null = null 14 18 ··· 20 24 super.connectedCallback() 21 25 if ('serviceWorker' in navigator) { 22 26 this.#onSwMessage = ({ data }: MessageEvent) => { 23 - if (data?.type === 'UPDATE_AVAILABLE') { 24 - this._pendingVersion = data.newVersion 27 + if (data?.type === 'VERSION_STATUS') { 28 + this._checked = true 29 + this._serverVersion = data.serverVersion 30 + this._updateAvailable = data.updateAvailable 25 31 } 26 32 } 27 33 navigator.serviceWorker.addEventListener('message', this.#onSwMessage) ··· 44 50 } 45 51 46 52 override render() { 47 - if (this._pendingVersion) { 53 + if (this._updateAvailable) { 48 54 return html` 49 55 <p>Current: <code>${this.version}</code></p> 50 - <p>New version available: <code>${this._pendingVersion}</code></p> 56 + <p>New version available: <code>${this._serverVersion}</code></p> 51 57 <button @click="${() => void this.#applyUpdate()}">Update now</button> 58 + ` 59 + } 60 + if (!this._checked) { 61 + return html` 62 + <p>Current: <code>${this.version}</code></p> 63 + <p>Checking for updates...</p> 64 + ` 65 + } 66 + if (this._serverVersion === null) { 67 + return html` 68 + <p>Current: <code>${this.version}</code></p> 69 + <p>Version check failed</p> 52 70 ` 53 71 } 54 72 return html`
+8 -8
packages/ui/dist/civility.css
··· 28 28 -webkit-text-size-adjust: 100%; 29 29 /* 2. Prevent adjustments of font size after orientation changes in iOS. */ 30 30 -moz-tab-size: 4; 31 - -o-tab-size: 4; 32 - tab-size: 4; 31 + -o-tab-size: 4; 32 + tab-size: 4; 33 33 /* 3. Use a more readable tab size (opinionated). */ 34 34 } 35 35 ··· 1577 1577 width: 100%; 1578 1578 height: 100%; 1579 1579 -o-object-fit: cover; 1580 - object-fit: cover; 1580 + object-fit: cover; 1581 1581 } 1582 1582 1583 1583 /* UI Dropdown */ ··· 1910 1910 padding: 0; 1911 1911 margin: 0; 1912 1912 -webkit-appearance: textfield; 1913 - appearance: textfield; 1913 + appearance: textfield; 1914 1914 -moz-appearance: textfield; 1915 1915 } 1916 1916 ··· 2204 2204 2205 2205 .input-reset { 2206 2206 -webkit-appearance: none; 2207 - -moz-appearance: none; 2208 - appearance: none; 2207 + -moz-appearance: none; 2208 + appearance: none; 2209 2209 background: transparent; 2210 2210 } 2211 2211 ··· 2248 2248 .no-select { 2249 2249 cursor: default; 2250 2250 -webkit-user-select: none; 2251 - -moz-user-select: none; 2252 - user-select: none; 2251 + -moz-user-select: none; 2252 + user-select: none; 2253 2253 } 2254 2254 2255 2255 /* Position utilities */
+4 -4
packages/ui/dist/utilities.css
··· 282 282 283 283 .input-reset { 284 284 -webkit-appearance: none; 285 - -moz-appearance: none; 286 - appearance: none; 285 + -moz-appearance: none; 286 + appearance: none; 287 287 background: transparent; 288 288 } 289 289 ··· 326 326 .no-select { 327 327 cursor: default; 328 328 -webkit-user-select: none; 329 - -moz-user-select: none; 330 - user-select: none; 329 + -moz-user-select: none; 330 + user-select: none; 331 331 } 332 332 333 333 /* Position utilities */
+1 -1
packages/ui/modules/layout_router.ts
··· 8 8 * 9 9 * @example Basic usage 10 10 * ```ts 11 - * import { createLayoutRouter } from '@bpev/civility' 11 + * import { createLayoutRouter } from '@civility/ui' 12 12 * 13 13 * interface NavMeta { title?: string; navActive?: string } 14 14 *
+23 -20
packages/workers/plugins/updates.ts
··· 4 4 5 5 export interface SwUpdateOptions { 6 6 /** 7 - * URL of a JS file to fetch and inspect for a version string. 8 - * Defaults to `'/dist/index.js'`. 7 + * URL of the build metadata JSON file (must contain a `version` field). 8 + * Defaults to `'/dist/meta.json'`. 9 9 */ 10 10 checkUrl?: string 11 11 /** ··· 24 24 * Plugin: on `activate`, starts a version-polling loop and wires up 25 25 * `SKIP_WAITING` / `CHECK_UPDATE` message handling. 26 26 * 27 - * Fetches `checkUrl` on each tick, extracts `__APP_VERSION__` from the source, 28 - * and broadcasts `UPDATE_AVAILABLE` to all clients when a newer version is found. 27 + * Fetches `checkUrl` (a JSON file with a `version` field) on each tick and 28 + * broadcasts `VERSION_STATUS` to all clients after every check, whether or not 29 + * an update is available. `serverVersion` will be `null` if the fetch failed 30 + * or the response had no `version` field. 29 31 * 30 32 * @example 31 33 * ```ts ··· 35 37 export function withUpdatePolling(options: SwUpdateOptions = {}): WorkerPlugin { 36 38 return ({ version }: WorkerContext) => { 37 39 const { 38 - checkUrl = '/dist/index.js', 40 + checkUrl = '/dist/meta.json', 39 41 intervalMs = 5 * 60 * 1000, 40 42 initialDelayMs = 10000, 41 43 } = options 42 44 43 45 async function checkForUpdates(): Promise<void> { 46 + let serverVersion: string | null = null 44 47 try { 45 48 const response = await fetch(checkUrl, { 46 49 cache: 'no-cache', 47 50 headers: { 'Cache-Control': 'no-cache' }, 48 51 }) 49 - if (!response.ok) return 50 - 51 - const text = await response.text() 52 - const match = text.match(/__APP_VERSION__\s*=\s*["']([^"']+)["']/) 53 - const serverVersion = match?.[1] 54 - 55 - if (serverVersion && serverVersion !== version) { 56 - const clients = await self.clients.matchAll() 57 - clients.forEach((client) => 58 - client.postMessage({ 59 - type: 'UPDATE_AVAILABLE', 60 - currentVersion: version, 61 - newVersion: serverVersion, 62 - }) 63 - ) 52 + if (response.ok) { 53 + const data = await response.json() 54 + serverVersion = typeof data?.version === 'string' 55 + ? data.version 56 + : null 64 57 } 65 58 } catch (error) { 66 59 console.log('[SW] Update check failed:', (error as Error).message) 67 60 } 61 + 62 + const clients = await self.clients.matchAll() 63 + clients.forEach((client) => 64 + client.postMessage({ 65 + type: 'VERSION_STATUS', 66 + currentVersion: version, 67 + serverVersion, 68 + updateAvailable: serverVersion !== null && serverVersion !== version, 69 + }) 70 + ) 68 71 } 69 72 70 73 self.addEventListener('activate', () => {
+1 -1
packages/workers/worker.ts
··· 47 47 * withFetchStrategy, 48 48 * withPrecache, 49 49 * withUpdatePolling, 50 - * } from '@bpev/civility/workers' 50 + * } from '@civility/workers' 51 51 * 52 52 * init([ 53 53 * withPrecache(['/', '/index.html', '/dist/index.js', '/manifest.json']),