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:
@@ -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",
|
||||
},
|
||||
];
|
||||
@@ -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
@@ -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];
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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(/ /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
@@ -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";
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user