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:
@@ -0,0 +1,4 @@
|
||||
/** حذف برچسبهای ویرایشی (#1, #2, ...) از ابتدای متن HTML نقلقول. */
|
||||
export function sanitizeBlockquoteContent(html: string): string {
|
||||
return html.replace(/(^|>)\s*#\d+\s*/g, "$1").trim();
|
||||
}
|
||||
+137
-6
@@ -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];
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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.",
|
||||
},
|
||||
];
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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"),
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -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";
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -0,0 +1,4 @@
|
||||
import packageJson from "../package.json";
|
||||
|
||||
/** Application version from package.json (semver). */
|
||||
export const APP_VERSION = packageJson.version;
|
||||
Reference in New Issue
Block a user