feat: add version history drawer with release notes

Introduce a footer-triggered Version History drawer with categorized release notes, hardcoded release data, and semver tracking at v0.3.0. Also ships blog blockquote/table styling, site-wide typography, home page image updates, header refinements, and related bug fixes.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
alirez.br
2026-07-02 02:46:20 +03:30
co-authored by Cursor
parent 62163c4ab9
commit a47ec493c5
92 changed files with 4574 additions and 507 deletions
+4
View File
@@ -0,0 +1,4 @@
/** حذف برچسب‌های ویرایشی (#1, #2, ...) از ابتدای متن HTML نقل‌قول. */
export function sanitizeBlockquoteContent(html: string): string {
return html.replace(/(^|>)\s*#\d+\s*/g, "$1").trim();
}
+137 -6
View File
@@ -8,11 +8,22 @@
*/
import * as cheerio from "cheerio";
import type { Element } from "domhandler";
import { sanitizeBlockquoteContent } from "@/lib/blockquote-content";
import {
detectTableVariant,
sanitizeTableCellContent,
} from "@/lib/table-content";
// ─── انواع بلاک (برای گسترش بعدی) ───────────────────────────────────────────
/** نام نوع بلاک؛ با افزودن بلاک جدید این یونیون را گسترش دهید. */
export type BlockType = "collapse" | "blockquote" | "pullquote" | "tip" | "code";
export type BlockType =
| "collapse"
| "blockquote"
| "pullquote"
| "tip"
| "table"
| "code";
/** Payload بلاک collapse (سوالات متداول / accordion). */
export interface CollapseBlockPayload {
@@ -20,11 +31,16 @@ export interface CollapseBlockPayload {
items: { question: string; answer: string }[];
}
/** استایل رنگی نقل‌قول: زرد (accent)، سبز (primary)، طوسی (muted). */
export type BlockquoteVariant = "yellow" | "green" | "gray";
/** Payload بلاک blockquote (نقل‌قول و منبع). content به صورت HTML است و باید با dangerouslySetInnerHTML رندر شود. */
export interface BlockquoteBlockPayload {
/** HTML محتوای نقل‌قول (بدون تگ cite). */
content: string;
citation?: string;
/** استایل رنگی؛ پیش‌فرض طوسی. از کلاس‌های quote-yellow | quote-green | quote-gray در وردپرس. */
variant?: BlockquoteVariant;
}
/** Payload بلاک tip (نکته / اینفو با variant: info, warn, danger). */
@@ -34,6 +50,19 @@ export interface TipBlockPayload {
content: string;
}
/** استایل UI جدول: one (primary/سبز) یا two (accent/زرد) — هر دو با چیدمان کلاسیک. */
export type TableVariant = "one" | "two";
/** Payload بلاک table (جدول وردپرس). */
export interface TableBlockPayload {
variant: TableVariant;
/** HTML محتوای هر ستون در سطر سرستون. */
headers: string[];
/** سطرهای بدنه؛ هر سلول HTML است. */
rows: string[][];
caption?: string;
}
/** Payload بلاک code؛ بعداً پیاده‌سازی می‌شود. */
// export interface CodeBlockPayload { ... }
@@ -41,7 +70,8 @@ export interface TipBlockPayload {
export type BlockPayload =
| CollapseBlockPayload
| BlockquoteBlockPayload
| TipBlockPayload;
| TipBlockPayload
| TableBlockPayload;
// | CodeBlockPayload
// ─── سگمنت خروجی برای لایه رندر ─────────────────────────────────────────────
@@ -311,16 +341,43 @@ function discoverFaqCollapseBlocks(
return faqEntries;
}
const BLOCKQUOTE_VARIANT_CYCLE: BlockquoteVariant[] = ["yellow", "green", "gray"];
function parseExplicitBlockquoteVariant(
classList: string[]
): BlockquoteVariant | undefined {
if (
classList.some(
(c) => c === "quote-yellow" || c === "is-style-yellow"
)
) {
return "yellow";
}
if (
classList.some((c) => c === "quote-green" || c === "is-style-green")
) {
return "green";
}
if (
classList.some((c) => c === "quote-gray" || c === "is-style-gray")
) {
return "gray";
}
return undefined;
}
function extractBlockquotePayload(
$: cheerio.CheerioAPI,
el: Element
): BlockquoteBlockPayload {
const $el = $(el);
const classList = ($el.attr("class") ?? "").split(/\s+/);
const variant = parseExplicitBlockquoteVariant(classList);
const citation = $el.find("cite").first().text().trim() || undefined;
const $body = $el.clone();
$body.find("cite").remove();
const content = ($body.html() ?? "").trim();
return { content, citation };
const content = sanitizeBlockquoteContent($body.html() ?? "");
return { content, citation, ...(variant ? { variant } : {}) };
}
/**
@@ -332,17 +389,71 @@ function extractPullquotePayload(
el: Element
): BlockquoteBlockPayload {
const $el = $(el);
const classList = [
...($el.attr("class") ?? "").split(/\s+/),
...($el.find("blockquote").first().attr("class") ?? "").split(/\s+/),
];
const variant = parseExplicitBlockquoteVariant(classList);
const $blockquote = $el.find("blockquote").first();
const citation = $blockquote.find("cite").first().text().trim() || undefined;
const $body = $blockquote.clone();
$body.find("cite").remove();
const content = ($body.html() ?? "").trim();
return { content, citation };
const content = sanitizeBlockquoteContent($body.html() ?? "");
return { content, citation, ...(variant ? { variant } : {}) };
}
/**
* استخراج از بلاک tip وردپرس: div.wp-block-group.tip با کلاس tip-info | tip-warn | tip-danger.
*/
function extractTablePayload(
$: cheerio.CheerioAPI,
el: Element
): TableBlockPayload {
const $el = $(el);
const variant = detectTableVariant($el.text());
const captionRaw = $el.find("figcaption").first().text().trim();
const caption = captionRaw
? sanitizeTableCellContent(captionRaw)
: undefined;
const $table = $el.find("table").first();
const headers: string[] = [];
const rows: string[][] = [];
$table.find("thead tr").first().find("th, td").each((_, cell) => {
headers.push(sanitizeTableCellContent($(cell).html() ?? ""));
});
const bodyRowEls = $table.find("tbody tr").toArray().filter(isElement);
const allRowEls = bodyRowEls.length
? bodyRowEls
: $table.find("tr").toArray().filter(isElement);
let bodyStart = 0;
if (!headers.length && allRowEls.length) {
$(allRowEls[0])
.find("th, td")
.each((_, cell) => {
headers.push(sanitizeTableCellContent($(cell).html() ?? ""));
});
bodyStart = 1;
}
for (let i = bodyStart; i < allRowEls.length; i++) {
const row: string[] = [];
$(allRowEls[i])
.find("th, td")
.each((_, cell) => {
row.push(sanitizeTableCellContent($(cell).html() ?? ""));
});
if (row.some((cell) => cell.replace(/<[^>]*>/g, "").trim())) {
rows.push(row);
}
}
return { variant, headers, rows, ...(caption ? { caption } : {}) };
}
function extractTipPayload($: cheerio.CheerioAPI, el: Element): TipBlockPayload {
const $el = $(el);
const classList = ($el.attr("class") ?? "").split(/\s+/);
@@ -384,6 +495,11 @@ export const BLOCK_DEFINITIONS: BlockDefinition[] = [
selector: "div.wp-block-group.tip",
extract: extractTipPayload as BlockDefinition["extract"],
},
{
blockType: "table",
selector: "figure.wp-block-table",
extract: extractTablePayload as BlockDefinition["extract"],
},
// { blockType: "code", selector: "pre.wp-block-code", extract: extractCodePayload },
];
@@ -433,6 +549,7 @@ export function discoverBlocks(html: string): DiscoveredContent {
blockquote: [],
pullquote: [],
tip: [],
table: [],
code: [],
};
@@ -459,6 +576,20 @@ export function discoverBlocks(html: string): DiscoveredContent {
// مرتب‌سازی بر اساس ترتیب در سند (از آخر به اول جایگزین می‌کنیم تا ایندکس‌ها جابجا نشوند)
entries.sort((a, b) => orderMap.get(a.el)! - orderMap.get(b.el)!);
// اگر وردپرس کلاس رنگی نفرستاده، به‌ترتیب زرد → سبز → طوسی چرخش می‌دهد
let quoteAutoIndex = 0;
for (const entry of entries) {
if (entry.blockType !== "blockquote" && entry.blockType !== "pullquote") {
continue;
}
const payload = entry.payload as BlockquoteBlockPayload;
if (!payload.variant) {
payload.variant =
BLOCKQUOTE_VARIANT_CYCLE[quoteAutoIndex % BLOCKQUOTE_VARIANT_CYCLE.length];
}
quoteAutoIndex++;
}
// جایگزینی با placeholder از آخر به اول
for (let i = entries.length - 1; i >= 0; i--) {
const { el, blockType, blockId, removeNodes } = entries[i];
+68
View File
@@ -0,0 +1,68 @@
import type { ChangelogSection, ChangelogSectionTitle } from "@/lib/changelog-types";
/** Use a short paragraph when a release has very few notes. */
const PARAGRAPH_ITEM_THRESHOLD = 4;
export function stripChangelogMarkdown(text: string): string {
return text
.replace(/`([^`]+)`/g, "$1")
.replace(/\*\*([^*]+)\*\*/g, "$1")
.replace(/\s+/g, " ")
.trim();
}
export function countReleaseItems(sections: ChangelogSection[]): number {
return sections.reduce((total, section) => total + section.items.length, 0);
}
export function shouldUseParagraphSummary(sections: ChangelogSection[]): boolean {
return countReleaseItems(sections) <= PARAGRAPH_ITEM_THRESHOLD;
}
const SECTION_VERBS: Partial<Record<ChangelogSectionTitle, string>> = {
Added: "introduces",
Changed: "improves",
Fixed: "fixes",
Removed: "removes",
Deprecated: "deprecates",
Security: "patches",
};
function joinNaturalList(items: string[]): string {
if (items.length === 0) return "";
if (items.length === 1) return items[0];
if (items.length === 2) return `${items[0]} and ${items[1]}`;
return `${items.slice(0, -1).join(", ")}, and ${items[items.length - 1]}`;
}
export function buildReleaseParagraph(sections: ChangelogSection[]): string {
const clauses = sections
.map((section) => {
const items = section.items.map((item) =>
stripChangelogMarkdown(item).replace(/\.$/, "").toLowerCase()
);
const verb = SECTION_VERBS[section.title] ?? "updates";
return `${verb} ${joinNaturalList(items)}`;
})
.filter(Boolean);
if (clauses.length === 0) return "";
if (clauses.length === 1) {
return `This release ${clauses[0]}.`;
}
const last = clauses[clauses.length - 1];
const rest = clauses.slice(0, -1);
return `This release ${rest.join(", ")}, and ${last}.`;
}
export function formatReleaseDate(date: string): string {
const parsed = new Date(`${date}T00:00:00`);
if (Number.isNaN(parsed.getTime())) return date;
return new Intl.DateTimeFormat("en-US", {
month: "long",
day: "numeric",
year: "numeric",
}).format(parsed);
}
+90
View File
@@ -0,0 +1,90 @@
import type { ChangelogRelease } from "@/lib/changelog-types";
/**
* Hardcoded release notes shown in the Version History drawer.
* Update this file when shipping a new version — always prepend; never overwrite.
*/
export const CHANGELOG_RELEASES: ChangelogRelease[] = [
{
version: "0.3.0",
date: "2026-07-02",
major: 0,
minor: 3,
patch: 0,
sections: [
{
title: "Added",
items: [
"Introduced the Version History drawer, opened from the footer version label.",
"Added categorized release notes with Added, Improved, and Fixed sections.",
"Added a current version indicator on the latest release card.",
"Added hardcoded release data in lib/changelog-releases.ts for reliable drawer content.",
],
},
{
title: "Changed",
items: [
"Refined the drawer layout, card typography, and spacing between release sections.",
"Improved release date formatting and left-aligned changelog text for readability.",
"Moved the drawer close button to the left edge for RTL-friendly interaction.",
],
},
{
title: "Fixed",
items: [
"Fixed empty version descriptions by serving release notes from static release data.",
"Fixed scrolling issues inside the version history drawer.",
"Fixed server-only fs import errors by loading changelog data on the server layout.",
],
},
],
},
{
version: "0.2.0",
date: "2026-07-02",
major: 0,
minor: 2,
patch: 0,
sections: [
{
title: "Added",
items: [
"Built blog blockquote templates with yellow, green, and gray color variants.",
"Shipped styled WordPress table blocks with glass headers and mobile scroll.",
"Introduced a site-wide typography scale for consistent headings and body text.",
"Added centralized .blog-content prose styles for single post pages.",
],
},
{
title: "Changed",
items: [
"Replaced home page hero, features accordion, and ecosystem images with updated assets.",
"Refactored single blog post pages with centralized prose styles in globals.css.",
"Redesigned the site header with centered navigation and aligned container width.",
"Updated hero and integration call-to-action links to point to the solutions page.",
],
},
{
title: "Fixed",
items: [
"Fixed pullquotes not rendering on single blog post pages.",
"Fixed background scrolling while the mobile navigation menu was open.",
"Fixed header width and padding misalignment at several breakpoints.",
"Fixed related posts crashing the page when the WordPress API returned an error.",
"Fixed featured image aspect ratio and top spacing on mobile blog posts.",
"Fixed SEO metadata, Open Graph tags, and 404 page layout.",
],
},
],
},
{
version: "0.1.0",
date: "2026-06-01",
major: 0,
minor: 1,
patch: 0,
sections: [],
summary:
"Initial public release of the application, including the homepage, navigation, article pages, and the first version of the overall user interface.",
},
];
+83
View File
@@ -0,0 +1,83 @@
const SECTION_HEADINGS = [
"Added",
"Changed",
"Fixed",
"Removed",
"Deprecated",
"Security",
] as const;
export type ChangelogSectionTitle = (typeof SECTION_HEADINGS)[number];
export type ChangelogSection = {
title: ChangelogSectionTitle;
items: string[];
};
export type ChangelogRelease = {
version: string;
date: string;
major: number;
minor: number;
patch: number;
/** Categorized release notes. */
sections: ChangelogSection[];
/** Optional paragraph summary for major or initial releases. */
summary?: string;
};
export const CHANGELOG_SECTION_LABELS: Record<ChangelogSectionTitle, string> = {
Added: "Added",
Changed: "Improved",
Fixed: "Fixed",
Removed: "Removed",
Deprecated: "Deprecated",
Security: "Security",
};
export function parseChangelog(markdown: string): ChangelogRelease[] {
const releases: ChangelogRelease[] = [];
const lines = markdown.split("\n");
let i = 0;
while (i < lines.length) {
const versionMatch = lines[i].match(/^## \[([\d.]+)\] - (.+)$/);
if (!versionMatch) {
i++;
continue;
}
const version = versionMatch[1];
const date = versionMatch[2];
const [major = 0, minor = 0, patch = 0] = version.split(".").map(Number);
i++;
const sections: ChangelogSection[] = [];
while (i < lines.length && !lines[i].match(/^## \[/)) {
const sectionMatch = lines[i].match(/^### (.+)$/);
if (
sectionMatch &&
SECTION_HEADINGS.includes(sectionMatch[1] as ChangelogSectionTitle)
) {
const title = sectionMatch[1] as ChangelogSectionTitle;
i++;
const items: string[] = [];
while (i < lines.length && lines[i].startsWith("- ")) {
items.push(lines[i].slice(2).trim());
i++;
}
if (items.length > 0) {
sections.push({ title, items });
}
continue;
}
i++;
}
releases.push({ version, date, major, minor, patch, sections });
}
return releases;
}
+6
View File
@@ -0,0 +1,6 @@
import { CHANGELOG_RELEASES } from "@/lib/changelog-releases";
import type { ChangelogRelease } from "@/lib/changelog-types";
export function getChangelog(): ChangelogRelease[] {
return CHANGELOG_RELEASES;
}
+90
View File
@@ -0,0 +1,90 @@
import fs from "fs";
import path from "path";
export type LocalImage = {
src: string;
width: number;
height: number;
};
function readJpegSize(buffer: Buffer): { width: number; height: number } {
let i = 2;
while (i < buffer.length) {
if (buffer[i] !== 0xff) break;
const marker = buffer[i + 1];
if (marker === 0xc0 || marker === 0xc1 || marker === 0xc2) {
return {
height: buffer.readUInt16BE(i + 5),
width: buffer.readUInt16BE(i + 7),
};
}
i += 2 + buffer.readUInt16BE(i + 2);
}
throw new Error("Could not read JPEG dimensions");
}
function localImage(publicPath: string, publicUrl?: string): LocalImage {
const filePath = path.join(process.cwd(), "public", publicPath.replace(/^\//, ""));
const buffer = fs.readFileSync(filePath);
const { width, height } = readJpegSize(buffer);
return {
src: publicUrl ?? publicPath,
width,
height,
};
}
function sectionImage(filename: string): LocalImage {
return localImage(
`/assets/main-pic/4section/${filename}`,
`/assets/main-pic/4section/${encodeURIComponent(filename)}`,
);
}
export type FeatureImageKey = "item-1" | "item-2" | "item-3" | "item-4";
export type FeatureImageSet = Record<
FeatureImageKey,
{
image: LocalImage;
imageDark: LocalImage;
alt: string;
}
>;
export function getHomeImages() {
return {
hero: {
backgroundDark: localImage("/assets/main-pic/1dark.jpg"),
main: localImage("/assets/main-pic/1.jpg"),
mainDark: localImage("/assets/main-pic/1dark.jpg"),
},
features: {
"item-1": {
image: sectionImage("Systems Integration.jpg"),
imageDark: sectionImage("Systems Integration-dark.jpg"),
alt: "یکپارچه‌سازی سیستم‌ها",
},
"item-2": {
image: sectionImage("Powerful Management panels.jpg"),
imageDark: sectionImage("Powerful Management panels-dark.jpg"),
alt: "پنل‌های مدیریتی قدرتمند",
},
"item-3": {
image: sectionImage("Interaction with Social Networks.jpg"),
imageDark: sectionImage("Interaction with Social Networks_dark.jpg"),
alt: "تعامل با شبکه‌های اجتماعی",
},
"item-4": {
image: sectionImage("Process Automation.jpg"),
imageDark: sectionImage("Process Automation-dark.jpg"),
alt: "اتوماسیون فرآیندها",
},
} satisfies FeatureImageSet,
ecosystem: {
light: localImage("/assets/main-pic/intelligent-workflow-control-center.jpg"),
dark: localImage("/assets/main-pic/intelligent-workflow-control-center-dark.jpg"),
},
};
}
+11
View File
@@ -0,0 +1,11 @@
/** حذف برچسب‌های انتخاب UI (#one, #two) از محتوای سلول جدول. */
export function sanitizeTableCellContent(html: string): string {
return html.replace(/#(?:one|two)\b/gi, "").trim();
}
/** تشخیص variant جدول از متن خام (قبل از sanitize). */
export function detectTableVariant(text: string): "one" | "two" {
if (/#two\b/i.test(text)) return "two";
if (/#one\b/i.test(text)) return "one";
return "one";
}
+130
View File
@@ -0,0 +1,130 @@
import { cva, type VariantProps } from "class-variance-authority";
import { cn } from "@/lib/utils";
/**
* Site-wide typography scale optimized for Persian (RTL) readability.
* Use via `typography({ variant })`, `type.*` shorthand, or `<Typography>`.
*/
export const typographyVariants = cva("tracking-normal", {
variants: {
variant: {
/** Large marketing hero titles */
display:
"text-3xl font-semibold leading-relaxed text-balance sm:text-4xl lg:text-5xl",
/** Main page titles */
h1: "text-3xl font-bold leading-snug text-balance md:text-4xl lg:text-5xl",
/** Major section headings */
h2: "text-2xl font-semibold leading-snug text-balance md:text-3xl lg:text-4xl",
/** Subsection / feature block headings */
h3: "text-lg font-semibold leading-snug md:text-xl",
/** Compact block headings, mini sections */
h4: "text-base font-semibold leading-snug md:text-lg",
/** Prominent intro paragraphs */
"body-lg": "text-lg leading-relaxed md:text-xl",
/** Standard paragraphs */
body: "text-base leading-relaxed",
/** Secondary / denser copy */
"body-sm": "text-sm leading-relaxed",
/** Metadata, timestamps, footnotes */
caption: "text-xs leading-normal text-muted-foreground",
/** Section eyebrow / badge labels */
eyebrow: "text-sm font-medium leading-normal text-muted-foreground",
/** Form labels, settings labels */
label: "text-sm font-medium leading-none",
/** Header / menu items (desktop) */
nav: "text-sm font-medium",
/** Mobile navigation items */
"nav-mobile": "text-base font-medium",
/** Titles inside cards and panels */
"card-title": "text-base font-semibold leading-snug",
/** Supporting copy inside cards */
"card-desc": "text-sm leading-relaxed text-muted-foreground",
/** Chat message bubbles */
chat: "text-sm leading-relaxed",
/** Log panels, JSON, technical output */
code: "font-mono text-xs leading-relaxed",
/** Large stat numbers */
stat: "text-4xl font-bold leading-none text-primary md:text-5xl",
/** Inline nav / footer links */
link: "text-sm text-muted-foreground",
/** Section intro paragraphs (muted) */
"section-intro": "text-lg leading-relaxed text-muted-foreground",
/** Pull quotes / emphasis blocks */
emphasis: "text-base font-medium leading-relaxed text-foreground",
/** Accordion / list item titles */
"list-title": "text-base font-medium leading-snug",
/** CTA section headings (slightly larger than h2) */
"cta-title":
"text-2xl font-semibold leading-snug text-balance md:text-3xl lg:text-4xl",
/** Decorative error page numbers */
"display-mono":
"text-[5rem] font-black leading-none sm:text-[6rem] md:text-[8rem]",
},
},
defaultVariants: {
variant: "body",
},
});
export type TypographyVariant = NonNullable<
VariantProps<typeof typographyVariants>["variant"]
>;
/** Shorthand class strings for inline use: `className={type.h2}` */
export const type: Record<TypographyVariant, string> = {
display:
"text-3xl font-semibold leading-relaxed tracking-normal text-balance sm:text-4xl lg:text-5xl",
h1: "text-3xl font-bold leading-snug tracking-normal text-balance md:text-4xl lg:text-5xl",
h2: "text-2xl font-semibold leading-snug tracking-normal text-balance md:text-3xl lg:text-4xl",
h3: "text-lg font-semibold leading-snug tracking-normal md:text-xl",
h4: "text-base font-semibold leading-snug tracking-normal md:text-lg",
"body-lg": "text-lg leading-relaxed tracking-normal md:text-xl",
body: "text-base leading-relaxed tracking-normal",
"body-sm": "text-sm leading-relaxed tracking-normal",
caption: "text-xs leading-normal tracking-normal text-muted-foreground",
eyebrow: "text-sm font-medium leading-normal tracking-normal text-muted-foreground",
label: "text-sm font-medium leading-none tracking-normal",
nav: "text-sm font-medium tracking-normal",
"nav-mobile": "text-base font-medium tracking-normal",
"card-title": "text-base font-semibold leading-snug tracking-normal",
"card-desc": "text-sm leading-relaxed tracking-normal text-muted-foreground",
chat: "text-sm leading-relaxed tracking-normal",
code: "font-mono text-xs leading-relaxed tracking-normal",
stat: "text-4xl font-bold leading-none tracking-normal text-primary md:text-5xl",
link: "text-sm tracking-normal text-muted-foreground",
"section-intro": "text-lg leading-relaxed tracking-normal text-muted-foreground",
emphasis: "text-base font-medium leading-relaxed tracking-normal text-foreground",
"list-title": "text-base font-medium leading-snug tracking-normal",
"cta-title":
"text-2xl font-semibold leading-snug tracking-normal text-balance md:text-3xl lg:text-4xl",
"display-mono":
"text-[5rem] font-black leading-none tracking-tighter sm:text-[6rem] md:text-[8rem]",
};
/** Text-related spacing tied to typography hierarchy */
export const typeSpacing = {
/** Eyebrow → heading */
eyebrowToHeading: "mt-2",
/** Heading → supporting paragraph */
headingToBody: "mt-3",
/** Section title block */
sectionStack: "space-y-4",
/** Page hero block */
heroStack: "space-y-6",
/** Card title → description */
cardStack: "gap-1.5",
/** Stacked paragraphs */
paragraphStack: "space-y-4",
/** Label → input */
labelToControl: "gap-1.5",
/** Message → timestamp in chat */
chatMeta: "space-y-1",
} as const;
export function typography(
variant: TypographyVariant,
className?: string
): string {
return cn(typographyVariants({ variant }), className);
}
+4
View File
@@ -0,0 +1,4 @@
import packageJson from "../package.json";
/** Application version from package.json (semver). */
export const APP_VERSION = packageJson.version;