// App version + «what changed» notes, baked into each front-end at build time. // // Version: the build number is the commit count of HEAD, so it only ever goes // up and needs no file to bump. It is compiled into the bundle (__APP_BUILD__) // and written to dist/version.json; the running app polls that file and, when // the server's build is newer than its own, shows the update modal. // // Notes come from commit messages. Technical text is never shown: only lines // that start with a Persian tag, written for the person using that part: // // اپ: … the customer app // اپ۱: … user-app (alias of اپ) // پنل: … the admin panel // // A commit may carry several such lines. Each app shows only its own. import { execFileSync } from 'node:child_process'; const TAGS = { 'user-app': ['اپ', 'اپ۱', 'اپ1'], 'admin-panel': ['پنل'], }; /** How many released builds with notes the file keeps. */ const HISTORY = 30; function git(args, cwd) { return execFileSync('git', args, { cwd, encoding: 'utf8', stdio: ['ignore', 'pipe', 'ignore'] }); } /** Pure, for testing: the note lines in one commit message that belong to `app`. */ export function notesFor(app, message) { const tags = TAGS[app] ?? []; const out = []; for (const raw of message.split(/\r?\n/)) { const m = /^\s*([^\s::]+)\s*[::]\s*(.+?)\s*$/.exec(raw); // The note must be Persian: a wrapped English line that happens to begin // with a tag (e.g. in a commit body explaining the tags) is not a note. if (m && tags.includes(m[1]) && /[؀-ۿ]/.test(m[2])) out.push(m[2]); } return out; } export function computeAppVersion(app, cwd = process.cwd()) { const builtAt = new Date().toISOString(); try { const build = Number(git(['rev-list', '--count', 'HEAD'], cwd).trim()); const commit = git(['rev-parse', '--short', 'HEAD'], cwd).trim(); // Newest first; a record separator between commits, unit separator between fields. const log = git(['log', '-n', '400', '--format=%ct%x1f%B%x1e'], cwd); const history = []; log.split('\x1e').forEach((rec, i) => { const [ts, body] = rec.replace(/^\s+/, '').split('\x1f'); if (!body) return; const notes = notesFor(app, body); if (!notes.length) return; history.push({ build: build - i, date: new Date(Number(ts) * 1000).toISOString(), notes }); }); return { app, build, version: `1.${build}`, commit, builtAt, history: history.slice(0, HISTORY) }; } catch { // Not a git checkout (e.g. a copied tree): still a valid, if note-less, build. const build = Math.floor(Date.now() / 1000); return { app, build, version: `1.${build}`, commit: null, builtAt, history: [] }; } } /** * Vite plugin: defines __APP_BUILD__ / __APP_VERSION__ and emits version.json. * The dev server has no version.json, so the update check stays quiet there. */ export function appVersion(app) { const info = computeAppVersion(app); return { name: 'app-version', config() { return { define: { __APP_BUILD__: JSON.stringify(info.build), __APP_VERSION__: JSON.stringify(info.version), }, }; }, generateBundle() { this.emitFile({ type: 'asset', fileName: 'version.json', source: JSON.stringify(info, null, 2) }); }, }; }