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>${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`);