feat: auto-detect blockquote and table colors from content

Replace manual hashtags and color cycling with content-tone heuristics for blockquotes and tables, and refine blockquote layout with a centered icon, larger sizing, and clearer contrast over background decorations.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
ar.badiei
2026-07-12 01:55:50 +03:30
co-authored by Cursor
parent a47ec493c5
commit 6cb9e2aac5
11 changed files with 370 additions and 64 deletions
+38 -36
View File
@@ -403,12 +403,15 @@
.blog-blockquote {
position: relative;
overflow: hidden;
display: flex;
flex-direction: column;
align-items: center;
border-radius: var(--radius-blog);
border: 1px solid;
padding-block: 1.5rem;
padding-inline-end: 1.75rem;
padding-inline-start: 4rem;
box-shadow: 0 1px 2px rgb(0 0 0 / 0.05);
padding-block: 1.8rem;
padding-inline: 2.1rem;
box-shadow: 0 2px 4px rgb(0 0 0 / 0.06);
isolation: isolate;
}
.blog-blockquote::before {
@@ -421,9 +424,9 @@
}
.blog-blockquote--yellow {
border-color: color-mix(in srgb, var(--accent) 45%, var(--border));
background: color-mix(in srgb, var(--accent) 18%, var(--background));
box-shadow: 0 2px 8px color-mix(in srgb, var(--accent) 14%, transparent);
border-color: color-mix(in srgb, var(--accent) 55%, var(--border));
background: color-mix(in srgb, var(--accent) 24%, var(--background));
box-shadow: 0 4px 14px color-mix(in srgb, var(--accent) 18%, transparent);
}
.blog-blockquote--yellow::before {
@@ -431,7 +434,7 @@
}
.blog-blockquote--yellow .blog-blockquote__icon {
color: color-mix(in srgb, var(--accent) 55%, transparent);
color: color-mix(in srgb, var(--accent) 82%, var(--foreground));
}
.blog-blockquote--yellow .blog-blockquote__footer {
@@ -439,9 +442,9 @@
}
.blog-blockquote--green {
border-color: color-mix(in srgb, var(--primary) 45%, var(--border));
background: color-mix(in srgb, var(--primary) 12%, var(--background));
box-shadow: 0 2px 8px color-mix(in srgb, var(--primary) 12%, transparent);
border-color: color-mix(in srgb, var(--primary) 55%, var(--border));
background: color-mix(in srgb, var(--primary) 18%, var(--background));
box-shadow: 0 4px 14px color-mix(in srgb, var(--primary) 16%, transparent);
}
.blog-blockquote--green::before {
@@ -449,7 +452,7 @@
}
.blog-blockquote--green .blog-blockquote__icon {
color: color-mix(in srgb, var(--primary) 50%, transparent);
color: color-mix(in srgb, var(--primary) 80%, var(--foreground));
}
.blog-blockquote--green .blog-blockquote__footer {
@@ -457,16 +460,17 @@
}
.blog-blockquote--gray {
border-color: var(--border);
background: color-mix(in srgb, var(--muted) 65%, var(--background));
border-color: color-mix(in srgb, var(--border) 90%, var(--muted-foreground));
background: color-mix(in srgb, var(--muted) 72%, var(--background));
box-shadow: 0 4px 12px rgb(0 0 0 / 0.06);
}
.blog-blockquote--gray::before {
background: color-mix(in srgb, var(--muted-foreground) 38%, transparent);
background: color-mix(in srgb, var(--muted-foreground) 50%, transparent);
}
.blog-blockquote--gray .blog-blockquote__icon {
color: color-mix(in srgb, var(--muted-foreground) 42%, transparent);
color: color-mix(in srgb, var(--muted-foreground) 78%, var(--foreground));
}
.blog-blockquote--gray .blog-blockquote__footer {
@@ -474,13 +478,12 @@
}
.blog-blockquote__icon {
position: absolute;
inset-inline-start: 1.25rem;
top: 1.5rem;
width: 2rem;
height: 2rem;
position: relative;
z-index: 2;
width: 2.4rem;
height: 2.4rem;
flex-shrink: 0;
z-index: 1;
margin-bottom: 0.9rem;
}
.blog-blockquote__decorations {
@@ -490,7 +493,7 @@
height: 100%;
pointer-events: none;
z-index: 0;
opacity: 0.15;
opacity: 0.07;
/* Keep decorative shapes out of the quote-icon corner (inline-start). */
clip-path: polygon(
0 0,
@@ -519,15 +522,16 @@
.blog-blockquote__content {
position: relative;
z-index: 1;
z-index: 2;
isolation: isolate;
font-size: 1.125rem;
width: 100%;
font-size: 1.35rem;
line-height: 1.75;
text-align: justify;
}
.blog-blockquote__content p {
margin-block: 0.5rem;
margin-block: 0.6rem;
}
.blog-blockquote__content a {
@@ -541,26 +545,24 @@
.blog-blockquote__footer {
position: relative;
z-index: 1;
margin-top: 1rem;
padding-top: 1rem;
z-index: 2;
width: 100%;
margin-top: 1.2rem;
padding-top: 1.2rem;
border-top: 1px solid;
}
.blog-pullquote {
text-align: center;
padding-inline: 1.5rem;
padding-inline: 1.8rem;
}
.blog-pullquote .blog-blockquote__icon {
position: static;
margin-inline: auto;
margin-bottom: 0.75rem;
width: 1.75rem;
height: 1.75rem;
width: 2.1rem;
height: 2.1rem;
}
.blog-pullquote .blog-blockquote__content {
font-size: 1.25rem;
font-size: 1.5rem;
font-weight: 500;
}
+1 -1
View File
@@ -231,7 +231,7 @@ export function BlogToc({ items, className }: BlogTocProps) {
open={mobileOpen}
onOpenChange={setMobileOpen}
className={cn(
"lg:hidden w-full sticky top-14 z-10 bg-background",
"lg:hidden w-full sticky top-[calc(max(0.5rem,env(safe-area-inset-top,0px))+4.5rem)] z-10 bg-background",
className
)}
>
+1
View File
@@ -43,6 +43,7 @@ export function ChangelogVersionCard({ release }: ChangelogVersionCardProps) {
<time
className="mt-1.5 block text-xs text-muted-foreground"
dateTime={release.date}
dir="ltr"
>
{formatReleaseDate(release.date)}
</time>
+44
View File
@@ -0,0 +1,44 @@
import type { BlockquoteVariant } from "@/lib/blog-blocks";
/**
* پنج بلاک‌اوت آخر از مقاله «چرا همه درباره Agent صحبت می‌کنند؟»
* برای آزمایش تشخیص خودکار variant اصلاح شده‌اند.
*/
export const BLOCKQUOTE_VARIANT_FIXTURES: {
label: string;
content: string;
citation?: string;
expected: BlockquoteVariant;
}[] = [
{
label: "نقل‌قول با منبع (طوسی)",
content:
"<p>جایی که Agent ارزش می‌آفریند و جایی که فقط پیچیدگی اضافه می‌کند، مرز موفقیت پروژه‌های هوش مصنوعی سازمانی را مشخص می‌کند.</p>",
citation: "مارتین فاولر",
expected: "gray",
},
{
label: "هشدار درباره پیچیدگی اضافی (زرد)",
content:
"<p>اگر فرآیند همیشه یک مسیر مشخص را طی می‌کند، اضافه کردن Agent معمولاً فقط هزینه، پیچیدگی و نگهداری سیستم را افزایش می‌دهد؛ در چنین شرایطی یک Workflow ساده، شفاف و قابل‌اعتماد انتخاب منطقی‌تر و کارآمدتری خواهد بود.</p>",
expected: "yellow",
},
{
label: "نکته کلیدی درباره ارزش Agent (سبز)",
content:
"<p>نکته کلیدی: Agent زمانی ارزشمند می‌شود که سیستم مجبور باشد از میان چند گزینه، بهترین انتخاب را انجام دهد؛ نه زمانی که فقط یک مسیر مشخص را بارها و بارها تکرار می‌کند.</p>",
expected: "green",
},
{
label: "جمع‌بندی مثبت (سبز)",
content:
"<p>موفق‌ترین سازمان‌ها الزاماً آن‌هایی نیستند که بیشترین Agent را می‌سازند؛ بلکه آن‌هایی هستند که دقیقاً می‌دانند در کدام نقطه، Agent می‌تواند ارزش واقعی ایجاد کند و در کدام نقطه یک Workflow ساده انتخاب منطقی‌تر، کم‌هزینه‌تر و مؤثرتر است.</p>",
expected: "green",
},
{
label: "پیام خنثی بدون سیگنال قوی (طوسی)",
content:
"<p>هدف از به‌کارگیری Agent، هوشمندتر کردن همه فرآیندها نیست؛ هدف این است که تصمیم‌هایی را هوشمند کنیم که بیشترین تأثیر را بر عملکرد، بهره‌وری و تجربه مشتری دارند.</p>",
expected: "gray",
},
];
+22
View File
@@ -0,0 +1,22 @@
import type { BlockquoteVariant } from "@/lib/blog-blocks";
import { detectContentTone, stripHtml } from "@/lib/content-tone";
/**
* تشخیص variant نقل‌قول از روی متن و منبع.
* اولویت: منبع → هشدار → نکته مثبت → خنثی.
*/
export function detectBlockquoteVariant(
contentHtml: string,
citation?: string
): BlockquoteVariant {
if (citation?.trim()) {
return "gray";
}
const text = stripHtml(contentHtml);
const tone = detectContentTone(text);
if (tone === "warning") return "yellow";
if (tone === "positive") return "green";
return "gray";
}
+20 -22
View File
@@ -9,6 +9,7 @@
import * as cheerio from "cheerio";
import type { Element } from "domhandler";
import { sanitizeBlockquoteContent } from "@/lib/blockquote-content";
import { detectBlockquoteVariant } from "@/lib/blockquote-variant";
import {
detectTableVariant,
sanitizeTableCellContent,
@@ -39,7 +40,7 @@ export interface BlockquoteBlockPayload {
/** HTML محتوای نقل‌قول (بدون تگ cite). */
content: string;
citation?: string;
/** استایل رنگی؛ پیش‌فرض طوسی. از کلاس‌های quote-yellow | quote-green | quote-gray در وردپرس. */
/** استایل رنگی؛ از کلاس وردپرس یا تشخیص خودکار متن. */
variant?: BlockquoteVariant;
}
@@ -341,8 +342,6 @@ function discoverFaqCollapseBlocks(
return faqEntries;
}
const BLOCKQUOTE_VARIANT_CYCLE: BlockquoteVariant[] = ["yellow", "green", "gray"];
function parseExplicitBlockquoteVariant(
classList: string[]
): BlockquoteVariant | undefined {
@@ -366,18 +365,28 @@ function parseExplicitBlockquoteVariant(
return undefined;
}
function resolveBlockquoteVariant(
content: string,
citation: string | undefined,
explicit?: BlockquoteVariant
): BlockquoteVariant {
if (explicit) return explicit;
return detectBlockquoteVariant(content, citation);
}
function extractBlockquotePayload(
$: cheerio.CheerioAPI,
el: Element
): BlockquoteBlockPayload {
const $el = $(el);
const classList = ($el.attr("class") ?? "").split(/\s+/);
const variant = parseExplicitBlockquoteVariant(classList);
const explicitVariant = parseExplicitBlockquoteVariant(classList);
const citation = $el.find("cite").first().text().trim() || undefined;
const $body = $el.clone();
$body.find("cite").remove();
const content = sanitizeBlockquoteContent($body.html() ?? "");
return { content, citation, ...(variant ? { variant } : {}) };
const variant = resolveBlockquoteVariant(content, citation, explicitVariant);
return { content, citation, variant };
}
/**
@@ -393,13 +402,14 @@ function extractPullquotePayload(
...($el.attr("class") ?? "").split(/\s+/),
...($el.find("blockquote").first().attr("class") ?? "").split(/\s+/),
];
const variant = parseExplicitBlockquoteVariant(classList);
const explicitVariant = 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 = sanitizeBlockquoteContent($body.html() ?? "");
return { content, citation, ...(variant ? { variant } : {}) };
const variant = resolveBlockquoteVariant(content, citation, explicitVariant);
return { content, citation, variant };
}
/**
@@ -410,7 +420,7 @@ function extractTablePayload(
el: Element
): TableBlockPayload {
const $el = $(el);
const variant = detectTableVariant($el.text());
const rawText = $el.text();
const captionRaw = $el.find("figcaption").first().text().trim();
const caption = captionRaw
? sanitizeTableCellContent(captionRaw)
@@ -451,6 +461,8 @@ function extractTablePayload(
}
}
const variant = detectTableVariant({ rawText, caption, headers, rows });
return { variant, headers, rows, ...(caption ? { caption } : {}) };
}
@@ -576,20 +588,6 @@ 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];
+13 -1
View File
@@ -56,13 +56,25 @@ export function buildReleaseParagraph(sections: ChangelogSection[]): string {
return `This release ${rest.join(", ")}, and ${last}.`;
}
function toLatinDigits(value: string): string {
return value.replace(/[\u0660-\u0669\u06f0-\u06f9]/g, (digit) =>
String(
digit.charCodeAt(0) -
(digit <= "\u0669" ? 0x0660 : 0x06f0)
)
);
}
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", {
const formatted = new Intl.DateTimeFormat("en-US", {
month: "long",
day: "numeric",
year: "numeric",
numberingSystem: "latn",
}).format(parsed);
return toLatinDigits(formatted);
}
+83
View File
@@ -0,0 +1,83 @@
/** هشدار، احتیاط، یا پیام منفی/ریسکی. */
export const WARNING_PATTERNS: RegExp[] = [
/هشدار/,
/دقت کنید/,
/توجه داشته باشید/,
/مراقب باشید/,
/خطر/,
/\bنباید\b/,
/\bهرگز\b/,
/اشتباه رایج/,
/از دست ندهید/,
/پیش از آنکه/,
/ممکن است منجر/,
/فقط هزینه/,
/پیچیدگی.*افزایش/,
/افزایش می‌دهد/,
/در چنین شرایطی/,
/ریسک/,
/مضر/,
/نامناسب/,
];
/** نکته کلیدی، جمع‌بندی مثبت، یا پیام ارزشمند. */
export const POSITIVE_PATTERNS: RegExp[] = [
/نکته کلیدی/,
/نکته مهم/,
/نکته اصلی/,
/جمع.?بندی/,
/پیشنهاد می/,
/توصیه می/,
/بهترین انتخاب/,
/بهترین راه/,
/\bحتماً\b/,
/ارزش می‌آفریند/,
/ارزشمند/,
/ارزش واقعی/,
/موفق.?ترین/,
/هدف اصلی/,
/نتیجه.?گیری/,
/کلید موفقیت/,
];
export type ContentTone = "warning" | "positive" | "neutral";
export function stripHtml(html: string): string {
return html
.replace(/<[^>]+>/g, " ")
.replace(/&nbsp;/gi, " ")
.replace(/\s+/g, " ")
.trim();
}
export function countPatternMatches(text: string, patterns: RegExp[]): number {
return patterns.reduce(
(count, pattern) => (pattern.test(text) ? count + 1 : count),
0
);
}
/** تشخیص لحن متن: هشدار، مثبت، یا خنثی. */
export function detectContentTone(
text: string,
extra?: { warning?: RegExp[]; positive?: RegExp[] }
): ContentTone {
if (!text.trim()) {
return "neutral";
}
const warningPatterns = [...WARNING_PATTERNS, ...(extra?.warning ?? [])];
const positivePatterns = [...POSITIVE_PATTERNS, ...(extra?.positive ?? [])];
const warningScore = countPatternMatches(text, warningPatterns);
const positiveScore = countPatternMatches(text, positivePatterns);
if (warningScore > 0 && warningScore >= positiveScore) {
return "warning";
}
if (positiveScore > 0) {
return "positive";
}
return "neutral";
}
+66 -4
View File
@@ -1,11 +1,73 @@
import type { TableVariant } from "@/lib/blog-blocks";
import { detectContentTone, stripHtml } from "@/lib/content-tone";
/** حذف برچسب‌های انتخاب 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";
/** سیگنال‌های هشدار مخصوص جدول (مقایسه، معایب، محدودیت). */
const TABLE_WARNING_PATTERNS: RegExp[] = [
/معایب/,
/محدودیت/,
/نکات منفی/,
/هزینه.?ها/,
/چالش.?ها/,
/مشکلات/,
/ضعف/,
/منفی/,
/اجتناب/,
/اجتناب کنید/,
];
/** سیگنال‌های مثبت مخصوص جدول (مزایا، ویژگی‌ها، پیشنهاد). */
const TABLE_POSITIVE_PATTERNS: RegExp[] = [
/مزایا/,
/مزیت/,
/ویژگی.?ها/,
/قابلیت.?ها/,
/پیشنهاد/,
/بهترین/,
/مزیت رقابتی/,
/خلاصه/,
];
function buildTableText(parts: {
caption?: string;
headers?: string[];
rows?: string[][];
}): string {
const chunks = [
parts.caption ?? "",
...(parts.headers ?? []),
...(parts.rows ?? []).flat(),
];
return stripHtml(chunks.join(" "));
}
export interface DetectTableVariantInput {
/** متن خام figure (برای override با #one/#two در محتوای قدیمی). */
rawText?: string;
caption?: string;
headers?: string[];
rows?: string[][];
}
/**
* تشخیص variant جدول: one = سبز (primary)، two = زرد (accent).
* اولویت: hashtag صریح → هشدار → مثبت/خنثی (سبز).
*/
export function detectTableVariant(input: DetectTableVariantInput): TableVariant {
const raw = input.rawText ?? "";
if (/#two\b/i.test(raw)) return "two";
if (/#one\b/i.test(raw)) return "one";
const text = buildTableText(input);
const tone = detectContentTone(text, {
warning: TABLE_WARNING_PATTERNS,
positive: TABLE_POSITIVE_PATTERNS,
});
if (tone === "warning") return "two";
return "one";
}
+24
View File
@@ -0,0 +1,24 @@
import { detectBlockquoteVariant } from "../lib/blockquote-variant";
import { BLOCKQUOTE_VARIANT_FIXTURES } from "../lib/blockquote-variant.fixtures";
const VARIANT_LABEL = {
yellow: "زرد (هشدار)",
green: "سبز (ارزشمند)",
gray: "طوسی (خنثی)",
} as const;
let passed = 0;
for (const [index, fixture] of BLOCKQUOTE_VARIANT_FIXTURES.entries()) {
const detected = detectBlockquoteVariant(fixture.content, fixture.citation);
const ok = detected === fixture.expected;
if (ok) passed += 1;
console.log(`--- ${index + 1}. ${fixture.label}`);
console.log(`انتظار: ${VARIANT_LABEL[fixture.expected]}`);
console.log(`تشخیص: ${VARIANT_LABEL[detected]} ${ok ? "OK" : "FAIL"}`);
console.log("");
}
console.log(`نتیجه: ${passed}/${BLOCKQUOTE_VARIANT_FIXTURES.length} درست`);
process.exit(passed === BLOCKQUOTE_VARIANT_FIXTURES.length ? 0 : 1);
+58
View File
@@ -0,0 +1,58 @@
import { detectTableVariant } from "../lib/table-content";
const VARIANT_LABEL = {
one: "سبز (one)",
two: "زرد (two)",
} as const;
const FIXTURES = [
{
label: "جدول مزایا (سبز)",
input: {
headers: ["<p>ویژگی</p>", "<p>توضیح</p>"],
rows: [
["<p>سرعت</p>", "<p>اجرای سریع فرآیند</p>"],
["<p>انعطاف</p>", "<p>قابلیت سفارشی‌سازی</p>"],
],
},
expected: "one" as const,
},
{
label: "جدول معایب و محدودیت (زرد)",
input: {
headers: ["<p>محدودیت</p>", "<p>توضیح</p>"],
rows: [
["<p>هزینه</p>", "<p>افزایش هزینه نگهداری</p>"],
["<p>پیچیدگی</p>", "<p>نیاز به آموزش تیم</p>"],
],
},
expected: "two" as const,
},
{
label: "جدول مقایسه خنثی (سبز)",
input: {
headers: ["<p>گزینه</p>", "<p>کاربرد</p>"],
rows: [
["<p>Workflow</p>", "<p>مسیر ثابت</p>"],
["<p>Agent</p>", "<p>تصمیم‌گیری</p>"],
],
},
expected: "one" as const,
},
];
let passed = 0;
for (const [index, fixture] of FIXTURES.entries()) {
const detected = detectTableVariant(fixture.input);
const ok = detected === fixture.expected;
if (ok) passed += 1;
console.log(`--- ${index + 1}. ${fixture.label}`);
console.log(`انتظار: ${VARIANT_LABEL[fixture.expected]}`);
console.log(`تشخیص: ${VARIANT_LABEL[detected]} ${ok ? "OK" : "FAIL"}`);
console.log("");
}
console.log(`نتیجه: ${passed}/${FIXTURES.length} درست`);
process.exit(passed === FIXTURES.length ? 0 : 1);