diff --git a/app/globals.css b/app/globals.css index 0285df3..6558202 100644 --- a/app/globals.css +++ b/app/globals.css @@ -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; } \ No newline at end of file diff --git a/components/blog-toc.tsx b/components/blog-toc.tsx index bebe4eb..198e52c 100644 --- a/components/blog-toc.tsx +++ b/components/blog-toc.tsx @@ -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 )} > diff --git a/components/changelog-version-card.tsx b/components/changelog-version-card.tsx index a85e3f2..4ebafa6 100644 --- a/components/changelog-version-card.tsx +++ b/components/changelog-version-card.tsx @@ -43,6 +43,7 @@ export function ChangelogVersionCard({ release }: ChangelogVersionCardProps) { diff --git a/lib/blockquote-variant.fixtures.ts b/lib/blockquote-variant.fixtures.ts new file mode 100644 index 0000000..5a8e6f6 --- /dev/null +++ b/lib/blockquote-variant.fixtures.ts @@ -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: + "

جایی که Agent ارزش می‌آفریند و جایی که فقط پیچیدگی اضافه می‌کند، مرز موفقیت پروژه‌های هوش مصنوعی سازمانی را مشخص می‌کند.

", + citation: "مارتین فاولر", + expected: "gray", + }, + { + label: "هشدار درباره پیچیدگی اضافی (زرد)", + content: + "

اگر فرآیند همیشه یک مسیر مشخص را طی می‌کند، اضافه کردن Agent معمولاً فقط هزینه، پیچیدگی و نگهداری سیستم را افزایش می‌دهد؛ در چنین شرایطی یک Workflow ساده، شفاف و قابل‌اعتماد انتخاب منطقی‌تر و کارآمدتری خواهد بود.

", + expected: "yellow", + }, + { + label: "نکته کلیدی درباره ارزش Agent (سبز)", + content: + "

نکته کلیدی: Agent زمانی ارزشمند می‌شود که سیستم مجبور باشد از میان چند گزینه، بهترین انتخاب را انجام دهد؛ نه زمانی که فقط یک مسیر مشخص را بارها و بارها تکرار می‌کند.

", + expected: "green", + }, + { + label: "جمع‌بندی مثبت (سبز)", + content: + "

موفق‌ترین سازمان‌ها الزاماً آن‌هایی نیستند که بیشترین Agent را می‌سازند؛ بلکه آن‌هایی هستند که دقیقاً می‌دانند در کدام نقطه، Agent می‌تواند ارزش واقعی ایجاد کند و در کدام نقطه یک Workflow ساده انتخاب منطقی‌تر، کم‌هزینه‌تر و مؤثرتر است.

", + expected: "green", + }, + { + label: "پیام خنثی بدون سیگنال قوی (طوسی)", + content: + "

هدف از به‌کارگیری Agent، هوشمندتر کردن همه فرآیندها نیست؛ هدف این است که تصمیم‌هایی را هوشمند کنیم که بیشترین تأثیر را بر عملکرد، بهره‌وری و تجربه مشتری دارند.

", + expected: "gray", + }, +]; diff --git a/lib/blockquote-variant.ts b/lib/blockquote-variant.ts new file mode 100644 index 0000000..441d13f --- /dev/null +++ b/lib/blockquote-variant.ts @@ -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"; +} diff --git a/lib/blog-blocks.ts b/lib/blog-blocks.ts index 1efe66c..966a6f7 100644 --- a/lib/blog-blocks.ts +++ b/lib/blog-blocks.ts @@ -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]; diff --git a/lib/changelog-display.ts b/lib/changelog-display.ts index 4771faf..47282f0 100644 --- a/lib/changelog-display.ts +++ b/lib/changelog-display.ts @@ -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); } diff --git a/lib/content-tone.ts b/lib/content-tone.ts new file mode 100644 index 0000000..8ec781c --- /dev/null +++ b/lib/content-tone.ts @@ -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"; +} diff --git a/lib/table-content.ts b/lib/table-content.ts index adffc35..51d5360 100644 --- a/lib/table-content.ts +++ b/lib/table-content.ts @@ -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"; } diff --git a/scripts/blockquote-variant-demo.ts b/scripts/blockquote-variant-demo.ts new file mode 100644 index 0000000..3afe0ed --- /dev/null +++ b/scripts/blockquote-variant-demo.ts @@ -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); diff --git a/scripts/table-variant-demo.ts b/scripts/table-variant-demo.ts new file mode 100644 index 0000000..e4c0599 --- /dev/null +++ b/scripts/table-variant-demo.ts @@ -0,0 +1,58 @@ +import { detectTableVariant } from "../lib/table-content"; + +const VARIANT_LABEL = { + one: "سبز (one)", + two: "زرد (two)", +} as const; + +const FIXTURES = [ + { + label: "جدول مزایا (سبز)", + input: { + headers: ["

ویژگی

", "

توضیح

"], + rows: [ + ["

سرعت

", "

اجرای سریع فرآیند

"], + ["

انعطاف

", "

قابلیت سفارشی‌سازی

"], + ], + }, + expected: "one" as const, + }, + { + label: "جدول معایب و محدودیت (زرد)", + input: { + headers: ["

محدودیت

", "

توضیح

"], + rows: [ + ["

هزینه

", "

افزایش هزینه نگهداری

"], + ["

پیچیدگی

", "

نیاز به آموزش تیم

"], + ], + }, + expected: "two" as const, + }, + { + label: "جدول مقایسه خنثی (سبز)", + input: { + headers: ["

گزینه

", "

کاربرد

"], + rows: [ + ["

Workflow

", "

مسیر ثابت

"], + ["

Agent

", "

تصمیم‌گیری

"], + ], + }, + 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);