Initial commit: Hedena Gold monorepo.
First commit of the Hedena Gold product (separate from gold-bank): branding, package names, and deploy defaults aligned to the new brand; legacy Henza trees stay local-only via .gitignore.
This commit is contained in:
@@ -0,0 +1,14 @@
|
||||
import type { Plugin } from 'vite';
|
||||
|
||||
export interface AppVersionInfo {
|
||||
app: string;
|
||||
build: number;
|
||||
version: string;
|
||||
commit: string | null;
|
||||
builtAt: string;
|
||||
history: { build: number; date: string; notes: string[] }[];
|
||||
}
|
||||
|
||||
export function notesFor(app: string, message: string): string[];
|
||||
export function computeAppVersion(app: string, cwd?: string): AppVersionInfo;
|
||||
export function appVersion(app: 'user-app' | 'admin-panel'): Plugin;
|
||||
@@ -0,0 +1,87 @@
|
||||
// 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) });
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,69 @@
|
||||
#!/usr/bin/env node
|
||||
// For each npm package, prints its four newest major lines: first release,
|
||||
// latest version and when that latest version was published. A line with no
|
||||
// release for a long time has probably stopped receiving patches.
|
||||
//
|
||||
// Backs the version table in docs/GO-LIVE-PLAN.md §4. Re-run it before acting
|
||||
// on that table — the numbers there are only true for the day they were taken.
|
||||
//
|
||||
// node scripts/check-majors.mjs # the packages the plan tracks
|
||||
// node scripts/check-majors.mjs vite pinia # any packages
|
||||
//
|
||||
// Goes through the npm CLI rather than fetch() so the machine's registry or
|
||||
// mirror settings (.npmrc) apply.
|
||||
import { execSync } from 'node:child_process';
|
||||
|
||||
const TRACKED = [
|
||||
'vue', 'vite', '@vitejs/plugin-vue', 'vite-plugin-pwa', 'vue-router', 'pinia',
|
||||
'typescript', 'vue-tsc', 'tailwindcss', 'ant-design-vue', 'vant', '@vueuse/core',
|
||||
'@nestjs/core', 'prisma',
|
||||
];
|
||||
// Package names only — they are interpolated into a shell command.
|
||||
const PACKAGE_NAME = /^(@[a-z0-9][\w.-]*\/)?[a-z0-9][\w.-]*$/i;
|
||||
const STALE_AFTER_DAYS = 180;
|
||||
|
||||
const pkgs = process.argv.length > 2 ? process.argv.slice(2) : TRACKED;
|
||||
const now = Date.now();
|
||||
|
||||
for (const pkg of pkgs) {
|
||||
if (!PACKAGE_NAME.test(pkg)) {
|
||||
console.log(`\n${pkg}: not a package name, skipped`);
|
||||
continue;
|
||||
}
|
||||
|
||||
let published;
|
||||
try {
|
||||
published = JSON.parse(
|
||||
execSync(`npm view ${pkg} time --json`, {
|
||||
encoding: 'utf8',
|
||||
timeout: 60_000,
|
||||
stdio: ['ignore', 'pipe', 'ignore'],
|
||||
}),
|
||||
);
|
||||
} catch {
|
||||
console.log(`\n${pkg}: npm view failed`);
|
||||
continue;
|
||||
}
|
||||
|
||||
const lines = new Map();
|
||||
for (const [version, date] of Object.entries(published)) {
|
||||
// Stable releases only; this also drops the `created` / `modified` keys.
|
||||
if (!/^\d+\.\d+\.\d+$/.test(version)) continue;
|
||||
const major = Number(version.split('.')[0]);
|
||||
const line = lines.get(major) ?? { firstVersion: version, first: date, lastVersion: version, last: date };
|
||||
if (date < line.first) Object.assign(line, { firstVersion: version, first: date });
|
||||
if (date > line.last) Object.assign(line, { lastVersion: version, last: date });
|
||||
lines.set(major, line);
|
||||
}
|
||||
|
||||
console.log(`\n${pkg}`);
|
||||
const newestFirst = [...lines].sort(([a], [b]) => b - a).slice(0, 4);
|
||||
for (const [major, line] of newestFirst) {
|
||||
const idleDays = Math.floor((now - Date.parse(line.last)) / 86_400_000);
|
||||
const stale = idleDays > STALE_AFTER_DAYS ? ` <- no release for ${idleDays} days` : '';
|
||||
console.log(
|
||||
` v${String(major).padEnd(3)} first ${line.firstVersion.padEnd(9)} ${line.first.slice(0, 10)}` +
|
||||
` latest ${line.lastVersion.padEnd(9)} ${line.last.slice(0, 10)}${stale}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
from PIL import Image
|
||||
from pathlib import Path
|
||||
|
||||
# Point `src` at your Hedena Gold master logo, then run from the repo root.
|
||||
src = Path(r"path/to/hedena-gold-logo.webp")
|
||||
img = Image.open(src).convert("RGBA")
|
||||
print("source size", img.size)
|
||||
|
||||
root = Path(__file__).resolve().parents[1]
|
||||
(root / "apps/user-app/public/img").mkdir(parents=True, exist_ok=True)
|
||||
(root / "apps/user-app/src/assets/img").mkdir(parents=True, exist_ok=True)
|
||||
(root / "apps/admin-panel/src/assets/images/logo").mkdir(parents=True, exist_ok=True)
|
||||
|
||||
img.save(root / "apps/user-app/public/img/brand-logo.webp", "WEBP")
|
||||
img.save(root / "apps/user-app/public/img/brand-logo.png", "PNG")
|
||||
img.save(root / "apps/user-app/src/assets/img/brand-logo.png", "PNG")
|
||||
img.save(root / "apps/admin-panel/src/assets/images/logo/brand-icon.webp", "WEBP")
|
||||
img.save(root / "apps/admin-panel/src/assets/images/logo/brand-icon.png", "PNG")
|
||||
img.save(root / "apps/admin-panel/src/assets/images/logo/brand-horizontal.png", "PNG")
|
||||
|
||||
for size, path in [
|
||||
(192, root / "apps/user-app/public/icon-192.png"),
|
||||
(512, root / "apps/user-app/public/icon-512.png"),
|
||||
(192, root / "apps/admin-panel/public/icon-192.png"),
|
||||
(512, root / "apps/admin-panel/public/icon-512.png"),
|
||||
]:
|
||||
icon = Image.new("RGBA", (size, size), (0, 0, 0, 0))
|
||||
fitted = img.copy()
|
||||
fitted.thumbnail((size, size), Image.Resampling.LANCZOS)
|
||||
x = (size - fitted.width) // 2
|
||||
y = (size - fitted.height) // 2
|
||||
icon.paste(fitted, (x, y), fitted)
|
||||
icon.save(path, "PNG")
|
||||
print("wrote", path)
|
||||
Reference in New Issue
Block a user