diff --git a/app/scripts/generate-article-pages.mjs b/app/scripts/generate-article-pages.mjs
new file mode 100644
index 0000000..6f155d0
--- /dev/null
+++ b/app/scripts/generate-article-pages.mjs
@@ -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, '>');
+}
+
+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>/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]) => ``),
+ ...articleCitationUrls(article).map((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 = html.replace(/[^<]*<\/title>/, `${title}`);
+ html = html.replace(/(/, `\n `);
+ html = html.replace(/(`);
+ 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 || `${esc(cleanText(article.summary || rawDesc))}
`,
+ 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`);