Files
mdm-ordinis/ordinis-admin-ui/src/components/version/useChangelog.ts
T
2026-05-12 17:05:24 +00:00

225 lines
7.6 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { useMemo } from 'react'
// Public CHANGELOG (human-readable, без commit hashes / scope prefixes,
// curated handler-friendly text). Lives в `docs/changelog.md` чтобы single
// source of truth для public-facing changelog был один (docs build тоже
// его публикует на /docs/changelog.html через Diplodoc).
//
// Internal CHANGELOG (semantic-release auto-generated, с commit hashes для
// engineering team) — `ordinis-admin-ui/CHANGELOG.md`. Не bundled в UI.
// eslint-disable-next-line import/no-unresolved -- Vite ?raw query resolved at build
import changelogRaw from '../../../../docs/changelog.md?raw'
/**
* Parsed CHANGELOG entry — one version block from semantic-release output.
*
* <p>Source format (semantic-release default):
* <pre>
* ## [2.14.0](https://git.nstart.cloud/.../compare/v2.13.0...v2.14.0) (2026-05-12)
*
* ### ✨ Features
*
* * **webhook:** time-series histogram stats endpoint ([5479142](...))
*
* ### 🐛 Fixes
*
* * **ux:** friendly error UI вместо raw AxiosError ([3984d99](...))
* </pre>
*
* <p>Parsed → structured for «What's New» modal rendering.
*/
export type ChangelogEntry = {
/** Version string без 'v' префикса (e.g. "2.14.0"). */
version: string
/** Compare link к git diff между previous и этой версии. */
compareUrl?: string
/** Release date (ISO yyyy-mm-dd) если parsed из header'а. */
date?: string
/** Категория → bullets. Категории: "Features", "Fixes", "Performance", etc. */
sections: ChangelogSection[]
}
export type ChangelogSection = {
/** Display title без emoji (e.g. "Features"). */
title: string
/** Emoji preserved if present (e.g. "✨"). */
emoji?: string
items: ChangelogItem[]
}
export type ChangelogItem = {
/** Scope если был в commit'е (e.g. "webhook", "ui"). */
scope?: string
/** Description без scope + без commit hash link. */
description: string
/** Git commit hash (8 chars). */
commit?: string
/** Full commit URL. */
commitUrl?: string
}
/**
* Parse CHANGELOG.md text → structured ChangelogEntry[].
*
* <p>Format assumptions (semantic-release default):
* <ul>
* <li>Version header: {@code ## [2.14.0](url) (yyyy-mm-dd)}</li>
* <li>Section header: {@code ### ✨ Features} (emoji optional)</li>
* <li>Item: {@code * **scope:** description ([hash](url))}</li>
* </ul>
*
* <p>Robust against missing parts: пустые scope, missing commit URL,
* missing date. Returns entries в порядке появления (newest first для
* semantic-release output).
*/
export function parseChangelog(raw: string): ChangelogEntry[] {
const entries: ChangelogEntry[] = []
// Split на version headers — каждый ## [x.y.z] block.
const versionRegex = /^##\s+\[([^\]]+)\](?:\(([^)]+)\))?\s*(?:\(([\d-]+)\))?/gm
const matches: { start: number; end: number; version: string; url?: string; date?: string }[] = []
let m: RegExpExecArray | null
while ((m = versionRegex.exec(raw)) !== null) {
matches.push({
start: m.index,
end: m.index, // tentative, finalized below
version: m[1],
url: m[2],
date: m[3],
})
}
// Finalize end indices — каждый block заканчивается где начинается следующий
for (let i = 0; i < matches.length; i++) {
matches[i].end = i + 1 < matches.length ? matches[i + 1].start : raw.length
}
for (const block of matches) {
const body = raw.substring(block.start, block.end)
const sections = parseSections(body)
entries.push({
version: block.version,
compareUrl: block.url,
date: block.date,
sections,
})
}
return entries
}
function parseSections(body: string): ChangelogSection[] {
const sectionRegex = /^###\s+(?:(\p{Emoji_Presentation}|\p{Extended_Pictographic})\s+)?(.+)$/gmu
const sections: ChangelogSection[] = []
const matches: { start: number; end: number; emoji?: string; title: string }[] = []
let m: RegExpExecArray | null
while ((m = sectionRegex.exec(body)) !== null) {
matches.push({
start: m.index,
end: m.index,
emoji: m[1],
title: m[2].trim(),
})
}
for (let i = 0; i < matches.length; i++) {
matches[i].end = i + 1 < matches.length ? matches[i + 1].start : body.length
}
for (const s of matches) {
const sectionBody = body.substring(s.start, s.end)
const items = parseItems(sectionBody)
sections.push({ title: s.title, emoji: s.emoji, items })
}
return sections
}
const ITEM_REGEX = /^\*\s+(?:\*\*([^:]+?):\*\*\s+)?(.+?)(?:\s+\(\[([a-f0-9]{7,})\]\(([^)]+)\)\))?\s*$/gm
function parseItems(sectionBody: string): ChangelogItem[] {
const items: ChangelogItem[] = []
let m: RegExpExecArray | null
const regex = new RegExp(ITEM_REGEX.source, ITEM_REGEX.flags)
while ((m = regex.exec(sectionBody)) !== null) {
items.push({
scope: m[1]?.trim(),
description: m[2].trim(),
commit: m[3],
commitUrl: m[4],
})
}
return items
}
/**
* Hook: returns parsed CHANGELOG entries. Memoized — parse runs once per
* page load (CHANGELOG bundled into build, не fetched runtime).
*
* <p>Entries sorted newest-first (semantic-release output order).
*/
export function useChangelog(): ChangelogEntry[] {
return useMemo(() => parseChangelog(changelogRaw), [])
}
/**
* LocalStorage key для tracking last-seen version. Per-user (browser-scoped).
* Если key empty / missing — пользователь никогда не открывал What's New →
* unread = ALL entries (limited к recent N в UI).
*/
const LS_KEY = 'ord-whatsnew-last-seen'
/**
* Returns version string from localStorage, или null если не set.
*/
export function readLastSeenVersion(): string | null {
try {
return localStorage.getItem(LS_KEY)
} catch {
return null
}
}
/**
* Persist last-seen version. Called когда user открывает What's New modal
* AND видит latest entry. Throttle через just writing once per session
* (modal close persists current latest version → next session checks delta).
*/
export function writeLastSeenVersion(version: string): void {
try {
localStorage.setItem(LS_KEY, version)
} catch {
// localStorage может быть disabled (Safari private mode) — fail silently
}
}
/**
* Compare versions для unread detection. Semantic versioning major.minor.patch.
* Returns true если {@code candidate} newer than {@code seen}.
*
* <p>Robust к unknown formats: «dev», «local», missing parts. Returns false
* (treat as not-newer) для unparseable.
*/
export function isVersionNewer(candidate: string, seen: string | null): boolean {
if (!seen) return true // нет baseline → все entries unread
const parse = (v: string): number[] | null => {
const m = v.match(/^v?(\d+)\.(\d+)\.(\d+)/)
if (!m) return null
return [parseInt(m[1], 10), parseInt(m[2], 10), parseInt(m[3], 10)]
}
const a = parse(candidate)
const b = parse(seen)
if (!a || !b) return false
for (let i = 0; i < 3; i++) {
if (a[i] > b[i]) return true
if (a[i] < b[i]) return false
}
return false
}
/**
* Compute count of entries newer than last seen — для notification dot
* на «What's New» button.
*/
export function useUnreadCount(): number {
const entries = useChangelog()
return useMemo(() => {
const seen = readLastSeenVersion()
return entries.filter((e) => isVersionNewer(e.version, seen)).length
// eslint-disable-next-line react-hooks/exhaustive-deps -- seen из localStorage, не trigger'ит re-render автоматически
}, [entries])
}