diff --git a/app/scripts/generate-comparison-pages.mjs b/app/scripts/generate-comparison-pages.mjs new file mode 100644 index 0000000..ba0c556 --- /dev/null +++ b/app/scripts/generate-comparison-pages.mjs @@ -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, '>'); +} + +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 = html.replace(/