diff --git a/app/scripts/generate-author-pages.mjs b/app/scripts/generate-author-pages.mjs new file mode 100644 index 0000000..531fabb --- /dev/null +++ b/app/scripts/generate-author-pages.mjs @@ -0,0 +1,220 @@ +import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs'; +import { resolve } from 'node:path'; +import { TeknavData } from '../teknav-data.js'; +import { 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 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 imageUrl(path) { + if (!path) return `${SITE_URL}/favicon.png`; + if (path.startsWith('http')) return path; + return `${SITE_URL}${path.startsWith('/') ? '' : '/'}${path}`; +} + +const authors = TeknavData.authors ?? []; +let count = 0; + +for (const author of authors) { + const name = author.name; + const description = truncate(author.bio || `آرشیو مقاله‌های ${name} در تکناو.`, 155); + const avatarUrl = author.avatarUrl; + const keywords = `${name}, نویسنده فناوری, تحلیل فناوری, تکناو`; + + const sameAs = author.social ? Object.values(author.social).filter(v => v && v !== '#') : []; + if (author.alumniOf?.wikiUrl) sameAs.push(author.alumniOf.wikiUrl); + if (author.alumniOf?.sameAs) sameAs.push(author.alumniOf.sameAs); + if (author.alumniOf?.wikidata) sameAs.push(author.alumniOf.wikidata); + + const interactionStatistic = []; + if (author.articleCount) { + interactionStatistic.push({ + '@type': 'InteractionCounter', + interactionType: 'https://schema.org/WriteAction', + userInteractionCount: author.articleCount, + }); + } + const followerCount = author.followerCount || 1284; + interactionStatistic.push({ + '@type': 'InteractionCounter', + interactionType: 'https://schema.org/FollowAction', + userInteractionCount: followerCount, + }); + + const person = { + '@type': 'Person', + name, + url: `${SITE_URL}/author/${author.slug}`, + jobTitle: author.specialty, + description: author.bio, + knowsAbout: author.expertise, + sameAs, + ...(avatarUrl ? { image: imageUrl(avatarUrl) } : {}), + ...(author.username ? { alternateName: `@${author.username}` } : {}), + interactionStatistic, + }; + + if (author.alumniOf) { + person.alumniOf = { + '@type': 'EducationalOrganization', + name: author.alumniOf.name, + url: author.alumniOf.wikiUrl || author.alumniOf.sameAs, + sameAs: author.alumniOf.wikidata, + }; + } + + if (author.credential) { + person.hasCredential = [ + { + '@type': 'EducationalOccupationalCredential', + name: author.credential.name, + credentialCategory: author.credential.credentialCategory || 'degree', + recognizedBy: author.alumniOf ? { + '@type': 'EducationalOrganization', + name: author.alumniOf.name, + url: author.alumniOf.wikiUrl || author.alumniOf.sameAs, + sameAs: author.alumniOf.wikidata, + } : undefined, + } + ]; + } + + // Base Organization & WebSite + const org = { + '@type': ['Organization', 'NewsMediaOrganization'], + '@id': `${SITE_URL}/#organization`, + name: 'تکناو', + alternateName: ['Teknav', 'تکنّاو'], + url: SITE_URL, + logo: { + '@type': 'ImageObject', + '@id': `${SITE_URL}/#logo`, + url: `${SITE_URL}/favicon.png`, + width: 512, + height: 512, + }, + image: { '@id': `${SITE_URL}/#logo` }, + }; + + const website = { + '@type': 'WebSite', + '@id': `${SITE_URL}/#website`, + url: SITE_URL, + name: 'تکناو', + alternateName: 'Teknav', + inLanguage: 'fa-IR', + publisher: { '@id': `${SITE_URL}/#organization` }, + }; + + const generatePage = (routeUrl, isProfile = false) => { + const title = `${name} | ${isProfile ? 'پروفایل تکناو' : 'نویسنده تکناو'}`; + const pageKeywords = isProfile ? `${name}, @${author.username}, پروفایل تکناو, نویسنده فناوری` : keywords; + const authorArticles = (TeknavData.articles ?? []) + .filter(article => ( + (article.authorId === author.id || article.authorName === author.name || article.authorUsername === author.username) + && article.status === 'منتشرشده' + )) + .slice(0, 12); + const jsonLd = JSON.stringify({ + '@context': 'https://schema.org', + '@graph': [ + org, + website, + { + '@type': 'ProfilePage', + name: isProfile ? name : `${name} در تکناو`, + url: routeUrl, + inLanguage: 'fa-IR', + ...(avatarUrl ? { primaryImageOfPage: imageUrl(avatarUrl) } : {}), + mainEntity: { + ...person, + url: routeUrl, + }, + }, + { + '@type': 'BreadcrumbList', + itemListElement: [ + { '@type': 'ListItem', position: 1, name: 'تکناو', item: SITE_URL }, + { '@type': 'ListItem', position: 2, name: isProfile ? 'نویسندگان' : 'نویسندگان تکناو', item: `${SITE_URL}/authors` }, + { '@type': 'ListItem', position: 3, name }, + ], + }, + ], + }); + + let html = template; + html = html.replace(//, ``); + html = html.replace(/[^<]*<\/title>/, `<title>${title}`); + html = html.replace(/(/, ``); + html = html.replace(/`); + html = replaceStaticFallback(html, renderStaticFallback({ + eyebrow: isProfile ? 'پروفایل نویسنده' : 'نویسنده تکناو', + title, + description: author.bio || description, + facts: [ + author.specialty ? { label: 'تخصص', value: author.specialty } : null, + author.expertise?.length ? { label: 'حوزه‌ها', value: author.expertise.join('، ') } : null, + author.education ? { label: 'تحصیلات', value: author.education } : null, + ].filter(Boolean), + links: [ + ...authorArticles.map(article => ({ + href: article.canonicalPath || `/article/${article.slug}`, + label: article.title, + text: article.summary, + })), + { href: '/authors', label: 'همه نویسندگان', text: 'آشنایی با تیم تحریریه تکناو' }, + ], + })); + + return html; + }; + + // 1. Generate /author/${slug}/index.html + const authorHtml = generatePage(`${SITE_URL}/author/${author.slug}`, false); + const authorDir = resolve(`dist/author/${author.slug}`); + mkdirSync(authorDir, { recursive: true }); + writeFileSync(`${authorDir}/index.html`, authorHtml, 'utf8'); + count++; + + // 2. Generate /profile/@${username}/index.html + if (author.username) { + const profileHtml = generatePage(`${SITE_URL}/profile/@${author.username}`, true); + const profileDir = resolve(`dist/profile/@${author.username}`); + mkdirSync(profileDir, { recursive: true }); + writeFileSync(`${profileDir}/index.html`, profileHtml, 'utf8'); + count++; + } +} + +console.log(`[author-pages] generated ${count} static author/profile HTML pages`);