Import the existing local project snapshot. This commit organizes existing files; it does not represent newly developed functionality.
350 lines
13 KiB
JavaScript
350 lines
13 KiB
JavaScript
import { describe, it } from 'node:test';
|
||
import assert from 'node:assert/strict';
|
||
|
||
function cleanText(value) {
|
||
return String(value ?? '')
|
||
.replace(/<[^>]+>/g, ' ')
|
||
.replace(/\s+/g, ' ')
|
||
.trim();
|
||
}
|
||
|
||
function truncateText(value, max) {
|
||
const text = cleanText(value);
|
||
return text.length > max ? text.slice(0, Math.max(0, max - 1)).trim() : text;
|
||
}
|
||
|
||
function internalLinkCount(content) {
|
||
return (content.match(/href=["']\/(?:article|topics|category|series|author)\//g) ?? []).length;
|
||
}
|
||
|
||
function hasPlaceholderContent(content) {
|
||
return /placeholder|lorem|در دسترس است|بهزودی|بزودی|نمونه محتوا|متن آزمایشی/i.test(content);
|
||
}
|
||
|
||
function publishQualityIssues(article) {
|
||
const checks = {
|
||
titleLength: article.title.length >= 20 && article.title.length <= 100,
|
||
metaDescription: !!article.metaDescription && article.metaDescription.length >= 80 && article.metaDescription.length <= 160,
|
||
canonicalPath: !!article.canonicalPath && article.canonicalPath.startsWith('/article/'),
|
||
ogImage: !!article.ogImage && !/^https?:\/\//i.test(article.ogImage),
|
||
category: !!article.category?.slug,
|
||
internalLinks: internalLinkCount(article.content) >= 2,
|
||
noPlaceholder: !hasPlaceholderContent(article.content),
|
||
noRemoteImages: !(article.content.match(/<img[^>]+src=["'](https?:\/\/|\/\/)/gi) ?? []).length,
|
||
sourceNotes: !!article.sourceNotes?.trim(),
|
||
factChecked: !!article.factCheckedAt,
|
||
};
|
||
return Object.entries(checks).filter(([, ok]) => !ok).map(([key]) => key);
|
||
}
|
||
|
||
function completeSeoData(input) {
|
||
const tags = (input.tags ?? []).filter(Boolean);
|
||
const keywords = input.keywords?.length
|
||
? input.keywords
|
||
: [...new Set([input.category?.name, input.category?.slug, input.type, ...tags, input.title].filter(Boolean))].slice(0, 12);
|
||
const description = truncateText(input.metaDescription || input.summary || input.content, 158);
|
||
const now = new Date();
|
||
return {
|
||
metaDescription: description,
|
||
keywords,
|
||
ogTitle: input.ogTitle?.trim() || `${truncateText(input.title, 84)} | تکنـاو`,
|
||
ogDescription: truncateText(input.ogDescription || description, 280),
|
||
ogImage: input.ogImage?.trim() || `/images/og/${input.slug}.jpg`,
|
||
canonicalPath: input.canonicalPath?.trim() || `/article/${input.slug}`,
|
||
sourceNotes: input.sourceNotes?.trim() || `یادداشت منابع و راستیآزمایی این مقاله در پنل تحریریه تکنـاو ثبت شده است. دسته: ${input.category?.name ?? 'فناوری'}.`,
|
||
factCheckedAt: input.factCheckedAt ?? now,
|
||
dateModified: input.dateModified ?? now,
|
||
contentFreshnessStatus: input.contentFreshnessStatus ?? 'current',
|
||
};
|
||
}
|
||
|
||
function normalizeFa(input) {
|
||
return input
|
||
.replace(/[\u200c\u0640]/g, '')
|
||
.replace(/[\u064b-\u0652]/g, '')
|
||
.replace(/[يى]/g, 'ی')
|
||
.replace(/ك/g, 'ک')
|
||
.replace(/[أإآٱ]/g, 'ا')
|
||
.replace(/\s+/g, ' ')
|
||
.trim()
|
||
.toLowerCase();
|
||
}
|
||
|
||
function computeStreak(streakCount, lastReadAt, now) {
|
||
if (!lastReadAt) return 1;
|
||
const lastDate = new Date(lastReadAt);
|
||
lastDate.setHours(0, 0, 0, 0);
|
||
const today = new Date(now);
|
||
today.setHours(0, 0, 0, 0);
|
||
const diffDays = Math.floor((today.getTime() - lastDate.getTime()) / (1000 * 60 * 60 * 24));
|
||
if (diffDays === 1) return streakCount + 1;
|
||
if (diffDays > 1) return 1;
|
||
return streakCount;
|
||
}
|
||
|
||
function currentStreakIfAlive(streakCount, lastReadAt, now) {
|
||
if (!lastReadAt) return 0;
|
||
const lastDate = new Date(lastReadAt);
|
||
lastDate.setHours(0, 0, 0, 0);
|
||
const today = new Date(now);
|
||
today.setHours(0, 0, 0, 0);
|
||
const diffDays = Math.floor((today.getTime() - lastDate.getTime()) / (1000 * 60 * 60 * 24));
|
||
if (diffDays > 1) return 0;
|
||
return streakCount;
|
||
}
|
||
|
||
describe('cleanText', () => {
|
||
it('strips HTML tags and collapses whitespace', () => {
|
||
assert.equal(cleanText('<p>Hello <b>world</b></p>'), 'Hello world');
|
||
});
|
||
it('handles null/undefined', () => {
|
||
assert.equal(cleanText(null), '');
|
||
assert.equal(cleanText(undefined), '');
|
||
});
|
||
it('trims the result', () => {
|
||
assert.equal(cleanText(' hello '), 'hello');
|
||
});
|
||
});
|
||
|
||
describe('truncateText', () => {
|
||
it('returns text unchanged when within limit', () => {
|
||
assert.equal(truncateText('hello', 10), 'hello');
|
||
});
|
||
it('truncates to max-1 and trims', () => {
|
||
assert.equal(truncateText('abcdefghij', 5), 'abcd');
|
||
});
|
||
it('handles zero max', () => {
|
||
assert.equal(truncateText('hello', 0), '');
|
||
});
|
||
it('strips HTML before truncating', () => {
|
||
assert.equal(truncateText('<p>abcdef</p>', 4), 'abc');
|
||
});
|
||
});
|
||
|
||
describe('internalLinkCount', () => {
|
||
it('counts internal links', () => {
|
||
assert.equal(internalLinkCount('<a href="/article/x">1</a><a href="/article/y">2</a>'), 2);
|
||
});
|
||
it('counts topic/category/series/author links', () => {
|
||
assert.equal(internalLinkCount('<a href="/topics/ai">t</a><a href="/category/s">c</a><a href="/series/s1">s</a><a href="/author/a">a</a>'), 4);
|
||
});
|
||
it('ignores external links', () => {
|
||
assert.equal(internalLinkCount('<a href="https://example.com">ext</a>'), 0);
|
||
});
|
||
it('returns 0 for empty content', () => {
|
||
assert.equal(internalLinkCount(''), 0);
|
||
});
|
||
});
|
||
|
||
describe('hasPlaceholderContent', () => {
|
||
it('detects "placeholder"', () => {
|
||
assert.equal(hasPlaceholderContent('This is placeholder text'), true);
|
||
});
|
||
it('detects "lorem"', () => {
|
||
assert.equal(hasPlaceholderContent('lorem ipsum dolor sit amet'), true);
|
||
});
|
||
it('detects Persian placeholders', () => {
|
||
assert.equal(hasPlaceholderContent('این محتوا بهزودی اضافه میشود'), true);
|
||
assert.equal(hasPlaceholderContent('بزودی'), true);
|
||
assert.equal(hasPlaceholderContent('نمونه محتوا'), true);
|
||
assert.equal(hasPlaceholderContent('متن آزمایشی'), true);
|
||
});
|
||
it('returns false for real content', () => {
|
||
assert.equal(hasPlaceholderContent('هوش مصنوعی تحولی در صنعت فناوری ایجاد کرده است'), false);
|
||
});
|
||
});
|
||
|
||
describe('publishQualityIssues', () => {
|
||
const baseArticle = {
|
||
title: 'A'.repeat(50),
|
||
content: '<a href="/article/x">1</a><a href="/article/y">2</a>Real content here.',
|
||
metaDescription: 'A'.repeat(120),
|
||
canonicalPath: '/article/test-article',
|
||
ogImage: '/images/og/test.jpg',
|
||
category: { slug: 'ai' },
|
||
sourceNotes: 'Verified sources',
|
||
factCheckedAt: new Date(),
|
||
};
|
||
|
||
it('returns empty array for a fully valid article', () => {
|
||
assert.deepEqual(publishQualityIssues(baseArticle), []);
|
||
});
|
||
it('detects title too short', () => {
|
||
assert.ok(publishQualityIssues({ ...baseArticle, title: 'Short' }).includes('titleLength'));
|
||
});
|
||
it('detects title too long', () => {
|
||
assert.ok(publishQualityIssues({ ...baseArticle, title: 'A'.repeat(101) }).includes('titleLength'));
|
||
});
|
||
it('detects missing metaDescription', () => {
|
||
assert.ok(publishQualityIssues({ ...baseArticle, metaDescription: null }).includes('metaDescription'));
|
||
});
|
||
it('detects invalid canonicalPath', () => {
|
||
assert.ok(publishQualityIssues({ ...baseArticle, canonicalPath: '/blog/test' }).includes('canonicalPath'));
|
||
});
|
||
it('detects CDN ogImage', () => {
|
||
assert.ok(publishQualityIssues({ ...baseArticle, ogImage: 'https://cdn.example.com/og.jpg' }).includes('ogImage'));
|
||
});
|
||
it('detects missing category', () => {
|
||
assert.ok(publishQualityIssues({ ...baseArticle, category: null }).includes('category'));
|
||
});
|
||
it('detects insufficient internal links', () => {
|
||
assert.ok(publishQualityIssues({ ...baseArticle, content: 'No links here' }).includes('internalLinks'));
|
||
});
|
||
it('detects placeholder content', () => {
|
||
assert.ok(publishQualityIssues({ ...baseArticle, content: 'placeholder <a href="/article/x">1</a><a href="/article/y">2</a>' }).includes('noPlaceholder'));
|
||
});
|
||
it('detects remote images', () => {
|
||
assert.ok(publishQualityIssues({ ...baseArticle, content: '<img src="https://example.com/img.jpg"/><a href="/article/x">1</a><a href="/article/y">2</a>' }).includes('noRemoteImages'));
|
||
});
|
||
it('detects missing sourceNotes', () => {
|
||
assert.ok(publishQualityIssues({ ...baseArticle, sourceNotes: null }).includes('sourceNotes'));
|
||
});
|
||
it('detects missing factCheckedAt', () => {
|
||
assert.ok(publishQualityIssues({ ...baseArticle, factCheckedAt: null }).includes('factChecked'));
|
||
});
|
||
});
|
||
|
||
describe('completeSeoData', () => {
|
||
it('fills all defaults from basic input', () => {
|
||
const result = completeSeoData({
|
||
slug: 'test-article',
|
||
title: 'Test Article Title That Is Long Enough',
|
||
summary: 'This is a summary of the article that should be used as meta description.',
|
||
content: 'Full article content goes here.',
|
||
});
|
||
assert.ok(result.metaDescription.length > 0);
|
||
assert.ok(result.canonicalPath.startsWith('/article/'));
|
||
assert.ok(result.ogImage.startsWith('/images/og/'));
|
||
assert.ok(result.ogTitle.includes('تکنـاو'));
|
||
assert.ok(result.keywords.length > 0);
|
||
assert.ok(result.factCheckedAt instanceof Date);
|
||
assert.ok(result.dateModified instanceof Date);
|
||
assert.equal(result.contentFreshnessStatus, 'current');
|
||
});
|
||
|
||
it('preserves provided values', () => {
|
||
const date = new Date('2025-01-01');
|
||
const result = completeSeoData({
|
||
slug: 'test',
|
||
title: 'Test',
|
||
summary: 'Summary',
|
||
content: 'Content',
|
||
metaDescription: 'Custom description that is long enough for meta purposes here',
|
||
ogTitle: 'Custom OG Title',
|
||
ogImage: '/custom/image.jpg',
|
||
canonicalPath: '/article/custom',
|
||
sourceNotes: 'Custom notes',
|
||
factCheckedAt: date,
|
||
dateModified: date,
|
||
contentFreshnessStatus: 'stale',
|
||
});
|
||
assert.equal(result.metaDescription, 'Custom description that is long enough for meta purposes here');
|
||
assert.equal(result.ogTitle, 'Custom OG Title');
|
||
assert.equal(result.ogImage, '/custom/image.jpg');
|
||
assert.equal(result.canonicalPath, '/article/custom');
|
||
assert.equal(result.sourceNotes, 'Custom notes');
|
||
assert.deepEqual(result.factCheckedAt, date);
|
||
assert.equal(result.contentFreshnessStatus, 'stale');
|
||
});
|
||
|
||
it('generates keywords from category and tags', () => {
|
||
const result = completeSeoData({
|
||
slug: 'test',
|
||
title: 'AI Article',
|
||
summary: 'Summary',
|
||
content: 'Content',
|
||
type: 'تحلیل عمیق',
|
||
category: { slug: 'ai', name: 'هوش مصنوعی' },
|
||
tags: ['machine-learning', 'deep-learning'],
|
||
});
|
||
assert.ok(result.keywords.includes('هوش مصنوعی'));
|
||
assert.ok(result.keywords.includes('ai'));
|
||
assert.ok(result.keywords.includes('machine-learning'));
|
||
});
|
||
|
||
it('limits keywords to 12', () => {
|
||
const result = completeSeoData({
|
||
slug: 'test',
|
||
title: 'Title',
|
||
summary: 'Summary',
|
||
content: 'Content',
|
||
tags: Array.from({ length: 20 }, (_, i) => `tag-${i}`),
|
||
});
|
||
assert.ok(result.keywords.length <= 12);
|
||
});
|
||
});
|
||
|
||
describe('normalizeFa', () => {
|
||
it('strips ZWNJ', () => {
|
||
assert.equal(normalizeFa('می\u200cروم'), 'میروم');
|
||
});
|
||
it('strips Arabic tatweel and normalizes ي', () => {
|
||
assert.equal(normalizeFa('ج\u0640ميل'), 'جمیل');
|
||
});
|
||
it('strips Arabic diacritics', () => {
|
||
assert.equal(normalizeFa('كِتَابٌ'), 'کتاب');
|
||
});
|
||
it('normalizes Arabic ي and ى to Persian ی', () => {
|
||
assert.equal(normalizeFa('يوسف'), 'یوسف');
|
||
assert.equal(normalizeFa('موسي'), 'موسی');
|
||
});
|
||
it('normalizes Arabic ك to Persian ک', () => {
|
||
assert.equal(normalizeFa('كتاب'), 'کتاب');
|
||
});
|
||
it('normalizes alef variants to plain ا', () => {
|
||
assert.equal(normalizeFa('أحمد'), 'احمد');
|
||
assert.equal(normalizeFa('إبراهيم'), 'ابراهیم');
|
||
assert.equal(normalizeFa('آرام'), 'ارام');
|
||
});
|
||
it('collapses whitespace, trims, lowercases', () => {
|
||
assert.equal(normalizeFa(' Hello World '), 'hello world');
|
||
});
|
||
it('handles empty string', () => {
|
||
assert.equal(normalizeFa(''), '');
|
||
});
|
||
});
|
||
|
||
describe('computeStreak', () => {
|
||
const makeDate = (daysAgo) => {
|
||
const d = new Date();
|
||
d.setHours(12, 0, 0, 0);
|
||
d.setDate(d.getDate() - daysAgo);
|
||
return d;
|
||
};
|
||
|
||
it('returns 1 when no previous read', () => {
|
||
assert.equal(computeStreak(0, null, new Date()), 1);
|
||
});
|
||
it('increments streak when last read was yesterday', () => {
|
||
assert.equal(computeStreak(5, makeDate(1), new Date()), 6);
|
||
});
|
||
it('resets to 1 when gap > 1 day', () => {
|
||
assert.equal(computeStreak(10, makeDate(3), new Date()), 1);
|
||
});
|
||
it('keeps streak when same day', () => {
|
||
assert.equal(computeStreak(7, new Date(), new Date()), 7);
|
||
});
|
||
});
|
||
|
||
describe('currentStreakIfAlive', () => {
|
||
const makeDate = (daysAgo) => {
|
||
const d = new Date();
|
||
d.setHours(12, 0, 0, 0);
|
||
d.setDate(d.getDate() - daysAgo);
|
||
return d;
|
||
};
|
||
|
||
it('returns 0 when no previous read', () => {
|
||
assert.equal(currentStreakIfAlive(5, null, new Date()), 0);
|
||
});
|
||
it('returns streak when last read today', () => {
|
||
assert.equal(currentStreakIfAlive(7, new Date(), new Date()), 7);
|
||
});
|
||
it('returns streak when last read yesterday', () => {
|
||
assert.equal(currentStreakIfAlive(5, makeDate(1), new Date()), 5);
|
||
});
|
||
it('returns 0 when gap > 1 day', () => {
|
||
assert.equal(currentStreakIfAlive(10, makeDate(3), new Date()), 0);
|
||
});
|
||
});
|