Import source: app/scripts/generate-article-pages.mjs
Import the existing local project snapshot. This commit organizes existing files; it does not represent newly developed functionality.
This commit is contained in:
@@ -0,0 +1,399 @@
|
|||||||
|
import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs';
|
||||||
|
import { resolve } from 'node:path';
|
||||||
|
import { TeknavData } from '../teknav-data.js';
|
||||||
|
import { cleanText, renderStaticFallback, replaceStaticFallback } from './static-fallback.mjs';
|
||||||
|
|
||||||
|
const SITE_URL = (process.env.TEKNAV_SITE_URL || process.env.VITE_SITE_URL || 'https://www.teknav.ir').replace(/\/$/, '');
|
||||||
|
const today = new Date().toISOString().slice(0, 10);
|
||||||
|
|
||||||
|
const template = readFileSync(resolve('dist/index.html'), 'utf8');
|
||||||
|
|
||||||
|
function esc(value) {
|
||||||
|
return String(value ?? '')
|
||||||
|
.replace(/&/g, '&')
|
||||||
|
.replace(/"/g, '"')
|
||||||
|
.replace(/</g, '<')
|
||||||
|
.replace(/>/g, '>');
|
||||||
|
}
|
||||||
|
|
||||||
|
function clean(value) {
|
||||||
|
return String(value ?? '').replace(/<[^>]+>/g, ' ').replace(/\s+/g, ' ').trim();
|
||||||
|
}
|
||||||
|
|
||||||
|
function truncate(value, max = 155) {
|
||||||
|
const text = clean(value);
|
||||||
|
return text.length > max ? `${text.slice(0, max - 1).trim()}…` : text;
|
||||||
|
}
|
||||||
|
|
||||||
|
function uniqueList(values, limit = 18) {
|
||||||
|
const seen = new Set();
|
||||||
|
return values
|
||||||
|
.map((value) => clean(value))
|
||||||
|
.filter(Boolean)
|
||||||
|
.filter((value) => {
|
||||||
|
const key = value.toLocaleLowerCase('fa-IR');
|
||||||
|
if (seen.has(key)) return false;
|
||||||
|
seen.add(key);
|
||||||
|
return true;
|
||||||
|
})
|
||||||
|
.slice(0, limit);
|
||||||
|
}
|
||||||
|
|
||||||
|
function articleKeywords(article, category, author) {
|
||||||
|
return uniqueList([
|
||||||
|
...(article.keywords || []),
|
||||||
|
...(article.tags || []),
|
||||||
|
article.title,
|
||||||
|
article.subtitle,
|
||||||
|
article.categoryName || category?.name,
|
||||||
|
article.type,
|
||||||
|
author?.specialty,
|
||||||
|
'تحلیل فارسی فناوری',
|
||||||
|
'تکناو',
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
function articleEntities(article, category) {
|
||||||
|
return uniqueList([
|
||||||
|
article.categoryName || category?.name,
|
||||||
|
...(article.tags || []),
|
||||||
|
...(article.keywords || []),
|
||||||
|
], 24).map((name) => ({ '@type': 'Thing', name }));
|
||||||
|
}
|
||||||
|
|
||||||
|
function articleSections(content) {
|
||||||
|
return [...String(content || '').matchAll(/<h2[^>]*>(.*?)<\/h2>/g)]
|
||||||
|
.map((match) => clean(match[1]))
|
||||||
|
.filter(Boolean)
|
||||||
|
.slice(0, 12);
|
||||||
|
}
|
||||||
|
|
||||||
|
function articleCitationUrls(article) {
|
||||||
|
return uniqueList(article?.citationUrls || [], 20);
|
||||||
|
}
|
||||||
|
|
||||||
|
function academicMetaTags(article, author, canonical, keywords) {
|
||||||
|
const published = article.publishedAt || article.dateEn;
|
||||||
|
const modified = article.dateModified || article.updatedAt || published;
|
||||||
|
const values = {
|
||||||
|
citation_title: clean(article.title),
|
||||||
|
citation_author: clean(article.authorName || author?.name || ''),
|
||||||
|
citation_publication_date: published,
|
||||||
|
citation_online_date: published,
|
||||||
|
citation_lastdate: modified,
|
||||||
|
citation_journal_title: 'Teknav',
|
||||||
|
citation_language: article.language || article.inLanguage || 'fa-IR',
|
||||||
|
citation_public_url: canonical,
|
||||||
|
citation_keywords: keywords.join('; '),
|
||||||
|
'DC.title': clean(article.title),
|
||||||
|
'DC.creator': clean(article.authorName || author?.name || ''),
|
||||||
|
'DC.date': published,
|
||||||
|
'DC.language': article.language || article.inLanguage || 'fa-IR',
|
||||||
|
'DC.identifier': article.identifier || canonical,
|
||||||
|
};
|
||||||
|
return [
|
||||||
|
...Object.entries(values)
|
||||||
|
.filter(([, value]) => value)
|
||||||
|
.map(([name, value]) => `<meta name="${esc(name)}" content="${esc(value)}" />`),
|
||||||
|
...articleCitationUrls(article).map((url) => `<meta name="citation_reference" content="${esc(url)}" />`),
|
||||||
|
].join('\n ');
|
||||||
|
}
|
||||||
|
|
||||||
|
function articleFaqJsonLd(article, canonical) {
|
||||||
|
if (!article?.faqs?.length) return null;
|
||||||
|
return {
|
||||||
|
'@type': 'FAQPage',
|
||||||
|
'@id': `${canonical}#faq`,
|
||||||
|
mainEntity: article.faqs.map(([question, answer]) => ({
|
||||||
|
'@type': 'Question',
|
||||||
|
name: clean(question),
|
||||||
|
acceptedAnswer: {
|
||||||
|
'@type': 'Answer',
|
||||||
|
text: clean(answer),
|
||||||
|
},
|
||||||
|
})),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function articleBenchmarkJsonLd(article, author, canonical) {
|
||||||
|
if (!article?.identifier && !article?.version && !article?.researchQuestions?.length) return null;
|
||||||
|
return {
|
||||||
|
'@type': 'CreativeWork',
|
||||||
|
'@id': `${canonical}#benchmark`,
|
||||||
|
name: `${clean(article.title)} benchmark protocol`,
|
||||||
|
alternateName: clean(article.slug),
|
||||||
|
identifier: article.identifier,
|
||||||
|
version: article.version,
|
||||||
|
url: canonical,
|
||||||
|
inLanguage: article.language || article.inLanguage || 'fa-IR',
|
||||||
|
creator: {
|
||||||
|
'@type': 'Person',
|
||||||
|
name: article.authorName || author?.name,
|
||||||
|
url: author ? `${SITE_URL}/author/${author.slug}` : undefined,
|
||||||
|
},
|
||||||
|
about: (article.researchQuestions || []).map((question) => ({
|
||||||
|
'@type': 'Question',
|
||||||
|
name: clean(question),
|
||||||
|
})),
|
||||||
|
conditionsOfAccess: 'Protocol article; empirical dataset release depends on copyright and licensing clearance.',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function isPastOrTodayDate(date) {
|
||||||
|
const value = String(date ?? '').slice(0, 10);
|
||||||
|
return /^\d{4}-\d{2}-\d{2}$/.test(value) && value <= today;
|
||||||
|
}
|
||||||
|
|
||||||
|
function isPublishedArticle(article) {
|
||||||
|
if (article.status === 'منتشرشده') return true;
|
||||||
|
if (article.status === 'پیشنویس' || article.status === 'در انتظار بررسی') return false;
|
||||||
|
if (['rust-systems', 'venture-capital-ai'].includes(article.slug)) return false;
|
||||||
|
return !!article.dateEn;
|
||||||
|
}
|
||||||
|
|
||||||
|
const publishedArticles = TeknavData.articles.filter((article) => isPublishedArticle(article) && isPastOrTodayDate(article.dateEn));
|
||||||
|
const categories = TeknavData.categories ?? [];
|
||||||
|
const authors = TeknavData.authors ?? [];
|
||||||
|
|
||||||
|
let count = 0;
|
||||||
|
for (const article of publishedArticles) {
|
||||||
|
const path = article.canonicalPath || `/article/${article.slug}`;
|
||||||
|
const canonical = `${SITE_URL}${path}`;
|
||||||
|
const rawTitle = article.seoTitle || article.ogTitle || `${article.title} | تکناو`;
|
||||||
|
const rawDesc = truncate(article.metaDescription || article.ogDescription || article.summary || article.subtitle || '', 160);
|
||||||
|
const slugImg = `/images/og/${article.slug}.jpg`;
|
||||||
|
const hasSlugImg = existsSync(resolve(`dist${slugImg}`));
|
||||||
|
const rawOgImage = article.ogImage || (hasSlugImg ? slugImg : null);
|
||||||
|
const image = rawOgImage ? `${SITE_URL}${rawOgImage}` : `${SITE_URL}/images/og/default.jpg`;
|
||||||
|
|
||||||
|
const title = esc(rawTitle);
|
||||||
|
const description = esc(rawDesc);
|
||||||
|
|
||||||
|
const category = categories.find(c =>
|
||||||
|
c.id === article.category || c.slug === article.category ||
|
||||||
|
c.slug === article.categorySlug || c.name === article.categoryName
|
||||||
|
);
|
||||||
|
const author = authors.find(a =>
|
||||||
|
a.id === article.authorId || a.slug === article.authorSlug || a.name === article.authorName
|
||||||
|
);
|
||||||
|
const keywords = articleKeywords(article, category, author);
|
||||||
|
const entities = articleEntities(article, category);
|
||||||
|
const sections = articleSections(article.content);
|
||||||
|
const citations = articleCitationUrls(article);
|
||||||
|
const articleTypes = article.identifier || citations.length
|
||||||
|
? ['NewsArticle', 'TechArticle', 'ScholarlyArticle']
|
||||||
|
: ['NewsArticle', 'TechArticle'];
|
||||||
|
const language = article.language || article.inLanguage || 'fa-IR';
|
||||||
|
const htmlLang = language.toLocaleLowerCase('en-US').startsWith('en') ? 'en' : 'fa';
|
||||||
|
const htmlDir = htmlLang === 'en' ? 'ltr' : 'rtl';
|
||||||
|
const ogLocale = htmlLang === 'en' ? 'en_US' : 'fa_IR';
|
||||||
|
|
||||||
|
const authorSameAs = author?.social ? Object.values(author.social).filter(v => v && v !== '#') : [];
|
||||||
|
if (author?.alumniOf?.wikiUrl) authorSameAs.push(author.alumniOf.wikiUrl);
|
||||||
|
if (author?.alumniOf?.sameAs) authorSameAs.push(author.alumniOf.sameAs);
|
||||||
|
if (author?.alumniOf?.wikidata) authorSameAs.push(author.alumniOf.wikidata);
|
||||||
|
|
||||||
|
const authorImage = author?.avatarUrl ? (author.avatarUrl.startsWith('http') ? author.avatarUrl : `${SITE_URL}${author.avatarUrl}`) : undefined;
|
||||||
|
const authorAlternateName = author?.username ? `@${author.username}` : undefined;
|
||||||
|
|
||||||
|
const authorInteractionStatistic = [];
|
||||||
|
if (author?.articleCount) {
|
||||||
|
authorInteractionStatistic.push({
|
||||||
|
'@type': 'InteractionCounter',
|
||||||
|
interactionType: 'https://schema.org/WriteAction',
|
||||||
|
userInteractionCount: author.articleCount,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
const authorFollowerCount = author?.followerCount || 1284;
|
||||||
|
authorInteractionStatistic.push({
|
||||||
|
'@type': 'InteractionCounter',
|
||||||
|
interactionType: 'https://schema.org/FollowAction',
|
||||||
|
userInteractionCount: authorFollowerCount,
|
||||||
|
});
|
||||||
|
|
||||||
|
const authorJson = {
|
||||||
|
'@type': 'Person',
|
||||||
|
name: article.authorName || author?.name,
|
||||||
|
url: author ? `${SITE_URL}/author/${author.slug}` : undefined,
|
||||||
|
jobTitle: author?.specialty,
|
||||||
|
description: author?.bio,
|
||||||
|
knowsAbout: author?.expertise,
|
||||||
|
sameAs: authorSameAs,
|
||||||
|
...(authorImage ? { image: authorImage } : {}),
|
||||||
|
...(authorAlternateName ? { alternateName: authorAlternateName } : {}),
|
||||||
|
interactionStatistic: authorInteractionStatistic,
|
||||||
|
};
|
||||||
|
|
||||||
|
if (author?.alumniOf) {
|
||||||
|
authorJson.alumniOf = {
|
||||||
|
'@type': 'EducationalOrganization',
|
||||||
|
name: author.alumniOf.name,
|
||||||
|
url: author.alumniOf.wikiUrl || author.alumniOf.sameAs,
|
||||||
|
sameAs: author.alumniOf.wikidata,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
if (author?.credential) {
|
||||||
|
authorJson.hasCredential = [
|
||||||
|
{
|
||||||
|
'@type': 'EducationalOccupationalCredential',
|
||||||
|
name: author.credential.name,
|
||||||
|
credentialCategory: author.credential.credentialCategory || author.credential.category || 'degree',
|
||||||
|
recognizedBy: author.alumniOf ? {
|
||||||
|
'@type': 'EducationalOrganization',
|
||||||
|
name: author.alumniOf.name,
|
||||||
|
url: author.alumniOf.wikiUrl || author.alumniOf.sameAs,
|
||||||
|
sameAs: author.alumniOf.wikidata,
|
||||||
|
} : undefined,
|
||||||
|
}
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
const jsonLd = JSON.stringify({
|
||||||
|
'@context': 'https://schema.org',
|
||||||
|
'@graph': [
|
||||||
|
{
|
||||||
|
'@type': ['Organization', 'NewsMediaOrganization'],
|
||||||
|
'@id': `${SITE_URL}/#organization`,
|
||||||
|
name: 'تکناو',
|
||||||
|
alternateName: ['Teknav', 'تکنّاو'],
|
||||||
|
url: SITE_URL,
|
||||||
|
description: 'تکناو رسانه فارسی تحلیل فناوری، هوش مصنوعی، علم داده، امنیت سایبری، نرمافزار، سختافزار و آینده تکنولوژی است.',
|
||||||
|
logo: { '@type': 'ImageObject', '@id': `${SITE_URL}/#logo`, url: `${SITE_URL}/favicon.png`, width: 512, height: 512 },
|
||||||
|
image: { '@id': `${SITE_URL}/#logo` },
|
||||||
|
publishingPrinciples: `${SITE_URL}/about`,
|
||||||
|
masthead: `${SITE_URL}/authors`,
|
||||||
|
areaServed: { '@type': 'Country', name: 'Iran' },
|
||||||
|
audience: { '@type': 'Audience', audienceType: 'فارسیزبانان علاقهمند به فناوری' },
|
||||||
|
sameAs: ['https://twitter.com/teknavir', 'https://x.com/teknavir', 'https://linkedin.com/company/teknav'],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
'@type': 'WebSite',
|
||||||
|
'@id': `${SITE_URL}/#website`,
|
||||||
|
url: SITE_URL,
|
||||||
|
name: 'تکناو',
|
||||||
|
alternateName: 'Teknav',
|
||||||
|
description: 'تکناو رسانه فارسی تحلیل فناوری، هوش مصنوعی، علم داده، امنیت سایبری، نرمافزار، سختافزار و آینده تکنولوژی است.',
|
||||||
|
inLanguage: 'fa-IR',
|
||||||
|
publisher: { '@id': `${SITE_URL}/#organization` },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
'@type': articleTypes,
|
||||||
|
'@id': `${canonical}#article`,
|
||||||
|
mainEntityOfPage: { '@type': 'WebPage', '@id': canonical },
|
||||||
|
headline: clean(article.title).slice(0, 110),
|
||||||
|
alternativeHeadline: clean(article.subtitle || article.summary).slice(0, 110) || undefined,
|
||||||
|
description: rawDesc,
|
||||||
|
abstract: clean(article.summary || article.subtitle || rawDesc),
|
||||||
|
articleBody: truncate(article.content, 5000),
|
||||||
|
image: { '@type': 'ImageObject', url: image, width: 1200, height: 630 },
|
||||||
|
datePublished: article.publishedAt || article.dateEn,
|
||||||
|
dateModified: article.dateModified || article.updatedAt || article.dateEn,
|
||||||
|
inLanguage: language,
|
||||||
|
articleSection: article.categoryName || category?.name,
|
||||||
|
keywords,
|
||||||
|
wordCount: clean(article.content).split(/\s+/).filter(Boolean).length || undefined,
|
||||||
|
timeRequired: article.readTime ? `PT${article.readTime}M` : undefined,
|
||||||
|
genre: article.type,
|
||||||
|
teaches: sections,
|
||||||
|
identifier: article.identifier,
|
||||||
|
version: article.version,
|
||||||
|
citation: citations,
|
||||||
|
author: authorJson,
|
||||||
|
publisher: { '@id': `${SITE_URL}/#organization` },
|
||||||
|
about: [
|
||||||
|
category ? { '@type': 'Thing', name: category.name, url: `${SITE_URL}/topics/${category.slug}` } : null,
|
||||||
|
...entities.slice(0, 5),
|
||||||
|
].filter(Boolean),
|
||||||
|
mentions: entities,
|
||||||
|
isPartOf: [
|
||||||
|
{ '@id': `${SITE_URL}/#website` },
|
||||||
|
category ? { '@type': 'CollectionPage', '@id': `${SITE_URL}/topics/${category.slug}` } : null,
|
||||||
|
].filter(Boolean),
|
||||||
|
speakable: {
|
||||||
|
'@type': 'SpeakableSpecification',
|
||||||
|
xpath: ['/html/head/title', '/html/head/meta[@name="description"]/@content'],
|
||||||
|
},
|
||||||
|
isAccessibleForFree: true,
|
||||||
|
educationalLevel: 'Intermediate',
|
||||||
|
...(article.factCheckedAt ? {
|
||||||
|
reviewedBy: { '@id': `${SITE_URL}/#organization` },
|
||||||
|
lastReviewed: article.factCheckedAt,
|
||||||
|
} : {}),
|
||||||
|
},
|
||||||
|
articleBenchmarkJsonLd(article, author, canonical),
|
||||||
|
articleFaqJsonLd(article, canonical),
|
||||||
|
{
|
||||||
|
'@type': 'BreadcrumbList',
|
||||||
|
itemListElement: [
|
||||||
|
{ '@type': 'ListItem', position: 1, name: 'تکناو', item: SITE_URL },
|
||||||
|
{ '@type': 'ListItem', position: 2, name: 'مقالهها', item: `${SITE_URL}/articles` },
|
||||||
|
...(category ? [{ '@type': 'ListItem', position: 3, name: category.name, item: `${SITE_URL}/category/${category.slug}` }] : []),
|
||||||
|
{ '@type': 'ListItem', position: category ? 4 : 3, name: clean(article.title) },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
].filter(Boolean),
|
||||||
|
});
|
||||||
|
|
||||||
|
let html = template;
|
||||||
|
html = html.replace(/<html lang="[^"]*" dir="[^"]*">/, `<html lang="${htmlLang}" dir="${htmlDir}">`);
|
||||||
|
html = html.replace(/<title>[^<]*<\/title>/, `<title>${title}</title>`);
|
||||||
|
html = html.replace(/(<meta name="description" content=")[^"]*(")/, `$1${description}$2`);
|
||||||
|
html = html.replace(/(<meta name="keywords" content=")[^"]*(")/, `$1${esc(keywords.join(', '))}$2`);
|
||||||
|
html = html.replace(/(<meta name="language" content=")[^"]*(")/, `$1${esc(language)}$2`);
|
||||||
|
html = html.replace(/(<meta property="og:locale" content=")[^"]*(")/, `$1${ogLocale}$2`);
|
||||||
|
html = html.replace(/(<meta property="og:title" content=")[^"]*(")/, `$1${title}$2`);
|
||||||
|
html = html.replace(/(<meta property="og:description" content=")[^"]*(")/, `$1${description}$2`);
|
||||||
|
html = html.replace(/(<meta property="og:url" content=")[^"]*(")/, `$1${esc(canonical)}$2`);
|
||||||
|
html = html.replace(/(<meta property="og:image" content=")[^"]*(")/, `$1${esc(image)}$2`);
|
||||||
|
html = html.replace(/(<meta name="twitter:title" content=")[^"]*(")/, `$1${title}$2`);
|
||||||
|
html = html.replace(/(<meta name="twitter:description" content=")[^"]*(")/,`$1${description}$2`);
|
||||||
|
html = html.replace(/(<meta name="twitter:image" content=")[^"]*(")/, `$1${esc(image)}$2`);
|
||||||
|
html = html.replace(/(<link rel="canonical" href=")[^"]*(")/, `$1${esc(canonical)}$2`);
|
||||||
|
html = html.replace(/<link rel="alternate" hreflang="[^"]+" href="[^"]+" \/>/, `<link rel="alternate" hreflang="${htmlLang}" href="${esc(canonical)}" />\n <link rel="alternate" type="text/html" href="${esc(`${SITE_URL}/stories/${article.slug}`)}" title="${esc(article.title)} — Web Story" />`);
|
||||||
|
html = html.replace(/(<script type="application\/ld\+json">)/, `${academicMetaTags(article, author, canonical, keywords)}\n $1`);
|
||||||
|
html = html.replace(/<script type="application\/ld\+json">[\s\S]*?<\/script>/,
|
||||||
|
`<script type="application/ld+json">${jsonLd}</script>`);
|
||||||
|
html = replaceStaticFallback(html, renderStaticFallback({
|
||||||
|
eyebrow: category?.name || 'مقاله تکناو',
|
||||||
|
title: clean(article.title),
|
||||||
|
description: clean(article.summary || article.subtitle || rawDesc),
|
||||||
|
facts: [
|
||||||
|
author ? { label: 'نویسنده', value: author.name } : null,
|
||||||
|
article.dateFa || article.dateEn ? { label: 'تاریخ انتشار', value: article.dateFa || article.dateEn } : null,
|
||||||
|
sections.length ? { label: 'بخشهای مقاله', value: sections.join('، ') } : null,
|
||||||
|
].filter(Boolean),
|
||||||
|
bodyHtml: article.content || `<p>${esc(cleanText(article.summary || rawDesc))}</p>`,
|
||||||
|
links: [
|
||||||
|
...relatedArticles(article, publishedArticles).map((r) => ({
|
||||||
|
href: `/article/${r.slug}`,
|
||||||
|
label: clean(r.title),
|
||||||
|
text: 'مقاله مرتبط',
|
||||||
|
})),
|
||||||
|
category ? { href: `/category/${category.slug}`, label: category.name, text: 'مقالههای مرتبط این دسته' } : null,
|
||||||
|
author?.username ? { href: `/profile/@${author.username}`, label: author.name, text: 'پروفایل نویسنده' } : null,
|
||||||
|
{ href: '/articles', label: 'همه مقالهها', text: 'آرشیو تکناو' },
|
||||||
|
].filter(Boolean),
|
||||||
|
}));
|
||||||
|
|
||||||
|
const dir = resolve(`dist/article/${article.slug}`);
|
||||||
|
mkdirSync(dir, { recursive: true });
|
||||||
|
writeFileSync(`${dir}/index.html`, html, 'utf8');
|
||||||
|
count++;
|
||||||
|
}
|
||||||
|
|
||||||
|
function relatedArticles(article, pool, limit = 4) {
|
||||||
|
const tags = new Set(article.tags || []);
|
||||||
|
return pool
|
||||||
|
.filter((a) => a.slug !== article.slug)
|
||||||
|
.map((a) => ({
|
||||||
|
a,
|
||||||
|
score: (a.tags || []).filter((t) => tags.has(t)).length + (a.category === article.category ? 1 : 0),
|
||||||
|
}))
|
||||||
|
.filter(({ score }) => score > 0)
|
||||||
|
.sort((x, y) => y.score - x.score || String(y.a.dateEn || '').localeCompare(String(x.a.dateEn || '')))
|
||||||
|
.slice(0, limit)
|
||||||
|
.map(({ a }) => a);
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log(`[article-pages] generated ${count} static article HTML pages`);
|
||||||
Reference in New Issue
Block a user