Import source: app/scripts/generate-comparison-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:
arsam
2026-09-18 23:23:27 +03:30
parent 0bec9a7fa4
commit b114372d4b
+154
View File
@@ -0,0 +1,154 @@
#!/usr/bin/env node
// Generates static comparison pages at dist/compare/{slug}/index.html.
// Each page is pre-rendered for instant load and full SEO indexability.
import { 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 comparisons = TeknavData.comparisons ?? [];
if (!comparisons.length) {
console.log('[comparison-pages] no comparisons defined — skipping');
process.exit(0);
}
const template = readFileSync(resolve('dist/index.html'), 'utf8');
function esc(value) {
return String(value ?? '')
.replace(/&/g, '&')
.replace(/"/g, '"')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;');
}
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 buildPage(comparison) {
const canonical = `${SITE_URL}/compare/${comparison.slug}`;
const title = esc(`${comparison.title} | تکناو`);
const description = esc(truncate(comparison.metaDescription || comparison.title, 160));
const keywords = esc([comparison.item1.name, comparison.item2.name, 'مقایسه', ...(comparison.tags || [])].join(', '));
const image = `${SITE_URL}/images/og/default.jpg`;
const datePublished = comparison.dateEn || new Date().toISOString().slice(0, 10);
// FAQ pairs from useCases + verdict
const faqs = [
...(comparison.useCases || []).map(([q, a]) => ({ q: `چه زمانی از ${comparison.item1.name} استفاده کنیم؟`, a: q })).slice(0, 1),
...(comparison.useCases || []).map(([q, a]) => ({ q: `چه زمانی از ${comparison.item2.name} استفاده کنیم؟`, a })).slice(0, 1),
{ q: `${comparison.item1.name} بهتر است یا ${comparison.item2.name}؟`, a: comparison.verdict || '' },
].filter((f) => f.a);
const jsonLd = JSON.stringify({
'@context': 'https://schema.org',
'@graph': [
{
'@type': ['Organization', 'NewsMediaOrganization'],
'@id': `${SITE_URL}/#organization`,
name: 'تکناو',
url: SITE_URL,
logo: { '@type': 'ImageObject', url: `${SITE_URL}/favicon.png` },
},
{
'@type': 'WebPage',
'@id': canonical,
url: canonical,
name: clean(comparison.title),
description: clean(comparison.metaDescription || comparison.title),
datePublished,
inLanguage: 'fa-IR',
publisher: { '@id': `${SITE_URL}/#organization` },
isPartOf: { '@id': `${SITE_URL}/#website` },
},
{
'@type': 'ItemList',
name: clean(comparison.title),
description: clean(comparison.metaDescription || comparison.title),
numberOfItems: 2,
itemListElement: [
{ '@type': 'ListItem', position: 1, name: comparison.item1.name, description: comparison.item1.summary, url: `${SITE_URL}/glossary/${comparison.item1.slug}` },
{ '@type': 'ListItem', position: 2, name: comparison.item2.name, description: comparison.item2.summary, url: `${SITE_URL}/glossary/${comparison.item2.slug}` },
],
},
faqs.length > 0 ? {
'@type': 'FAQPage',
mainEntity: faqs.map((f) => ({
'@type': 'Question',
name: f.q,
acceptedAnswer: { '@type': 'Answer', text: f.a },
})),
} : null,
{
'@type': 'BreadcrumbList',
itemListElement: [
{ '@type': 'ListItem', position: 1, name: 'تکناو', item: SITE_URL },
{ '@type': 'ListItem', position: 2, name: 'مقایسه‌ها', item: `${SITE_URL}/compare` },
{ '@type': 'ListItem', position: 3, name: clean(comparison.title) },
],
},
].filter(Boolean),
});
let html = template;
html = html.replace(/<html lang="[^"]*" dir="[^"]*">/, '<html lang="fa" dir="rtl">');
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${keywords}$2`);
html = html.replace(/(<meta property="og:locale" content=")[^"]*(")/, '$1fa_IR$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="fa" href="${esc(canonical)}" />`);
html = html.replace(/<script type="application\/ld\+json">[\s\S]*?<\/script>/,
`<script type="application/ld+json">${jsonLd}</script>`);
html = replaceStaticFallback(html, renderStaticFallback({
eyebrow: 'مقایسه فناوری',
title: clean(comparison.title),
description: clean(comparison.metaDescription || comparison.verdict || comparison.title),
facts: [
{ label: comparison.item1.name, value: cleanText(comparison.item1.summary) },
{ label: comparison.item2.name, value: cleanText(comparison.item2.summary) },
comparison.verdict ? { label: 'جمع‌بندی', value: cleanText(comparison.verdict) } : null,
].filter(Boolean),
sections: [
{
title: 'مزایا و محدودیت‌ها',
text: [
`${comparison.item1.name}: ${(comparison.item1.pros || []).join('، ')}`,
`${comparison.item2.name}: ${(comparison.item2.pros || []).join('، ')}`,
].join(' / '),
},
...faqs.map(faq => ({ title: faq.q, text: cleanText(faq.a) })),
],
links: [
{ href: '/compare', label: 'مقایسه‌ها', text: 'همه مقایسه‌های تکناو' },
{ href: '/articles', label: 'مقاله‌های فناوری', text: 'تحلیل‌های بیشتر' },
],
}));
return html;
}
let count = 0;
for (const comparison of comparisons) {
const dir = resolve(`dist/compare/${comparison.slug}`);
mkdirSync(dir, { recursive: true });
writeFileSync(`${dir}/index.html`, buildPage(comparison), 'utf8');
count++;
}
console.log(`[comparison-pages] generated ${count} comparison pages in dist/compare/`);