Files
teknav-bak/app/scripts/generate-web-stories.mjs
arsam 5e1fc12600 Import source: app/scripts/generate-web-stories.mjs
Import the existing local project snapshot. This commit organizes existing files; it does not represent newly developed functionality.
2026-09-18 23:23:38 +03:30

256 lines
12 KiB
JavaScript

#!/usr/bin/env node
// Generates CSS scroll-snap Web Stories for each published article.
// Output: dist/stories/{slug}/index.html
// Zero-CDN: inline CSS only, uses local OG images and local fonts.
import { existsSync, mkdirSync, writeFileSync } from 'node:fs';
import { resolve } from 'node:path';
import { TeknavData } from '../teknav-data.js';
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);
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) {
const text = clean(value);
return text.length > max ? `${text.slice(0, max - 1).trim()}…` : text;
}
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;
}
function extractSlides(article) {
const content = String(article.content || '');
const slides = [];
// Lead paragraph
const leadMatch = content.match(/<p[^>]*class="lead"[^>]*>([\s\S]*?)<\/p>/i)
|| content.match(/<p>([\s\S]*?)<\/p>/i);
const lead = leadMatch ? truncate(clean(leadMatch[1]), 220) : truncate(article.summary || '', 220);
if (lead) slides.push({ type: 'lead', text: lead });
// h2 headings + the text right after them
const h2Pattern = /<h2[^>]*>([\s\S]*?)<\/h2>([\s\S]*?)(?=<h2|$)/gi;
let match;
while ((match = h2Pattern.exec(content)) !== null && slides.length < 5) {
const heading = truncate(clean(match[1]), 80);
// grab next paragraph after heading
const bodyMatch = match[2].match(/<p[^>]*>([\s\S]*?)<\/p>/i);
const body = bodyMatch ? truncate(clean(bodyMatch[1]), 200) : '';
if (heading) slides.push({ type: 'section', heading, body });
}
return slides;
}
function slide(id, bgImage, content, slideNum, totalSlides) {
const progress = Math.round((slideNum / totalSlides) * 100);
return ` <section class="story-slide" id="${id}" aria-label="اسلاید ${slideNum + 1}">
<div class="slide-bg" style="background-image:url('${bgImage}')"></div>
<div class="slide-overlay"></div>
<div class="slide-progress"><div class="progress-bar" style="width:${progress}%"></div></div>
<div class="slide-content">${content}</div>
</section>`;
}
function buildStory(article) {
const slug = article.slug;
const title = esc(article.title);
const subtitle = esc(article.subtitle || article.summary || '');
const canonical = `${SITE_URL}/article/${slug}`;
const storyUrl = `${SITE_URL}/stories/${slug}`;
const slugImg = `/images/og/${slug}.jpg`;
const hasSlugImg = existsSync(resolve(`dist${slugImg}`));
const ogImage = hasSlugImg ? slugImg : (article.ogImage || '/images/og/default.jpg');
const category = esc(article.categoryName || '');
const author = esc(article.authorName || '');
const date = esc(article.date || '');
const readTime = article.readTime || 5;
const contentSlides = extractSlides(article);
const totalSlides = 2 + contentSlides.length + 1; // cover + content + CTA
const slides = [];
let slideNum = 0;
// Cover slide
slides.push(slide('cover', ogImage, `
<div class="cover-badge">${category}</div>
<h1 class="cover-title">${title}</h1>
<p class="cover-meta">${author} · ${date} · ${readTime} دقیقه</p>
<div class="swipe-hint">← بکشید</div>`, slideNum++, totalSlides));
// Content slides
for (const s of contentSlides) {
if (s.type === 'lead') {
slides.push(slide(`slide-${slideNum}`, ogImage, `
<p class="content-lead">${esc(s.text)}</p>`, slideNum++, totalSlides));
} else {
slides.push(slide(`slide-${slideNum}`, ogImage, `
<h2 class="content-heading">${esc(s.heading)}</h2>
${s.body ? `<p class="content-body">${esc(s.body)}</p>` : ''}`, slideNum++, totalSlides));
}
}
// CTA slide
slides.push(slide('cta', ogImage, `
<div class="cta-box">
<div class="cta-logo">تکناو</div>
<p class="cta-text">تحلیل کامل را در تکناو بخوانید</p>
<p class="cta-subtitle">${subtitle}</p>
<a class="cta-button" href="${esc(canonical)}" target="_blank" rel="noopener">خواندن مقاله →</a>
</div>`, slideNum++, totalSlides));
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': ['NewsArticle', 'TechArticle'],
headline: clean(article.title).slice(0, 110),
description: clean(article.metaDescription || article.summary || '').slice(0, 160),
image: `${SITE_URL}${ogImage}`,
datePublished: article.dateEn,
author: { '@type': 'Person', name: article.authorName || 'تکناو' },
publisher: { '@id': `${SITE_URL}/#organization` },
mainEntityOfPage: { '@type': 'WebPage', '@id': canonical },
url: storyUrl,
},
],
});
return `<!doctype html>
<html lang="fa" dir="rtl">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width,initial-scale=1">
<meta name="robots" content="index,follow">
<title>${title} | تکناو</title>
<meta name="description" content="${esc(truncate(article.metaDescription || article.summary || article.subtitle || '', 155))}">
<link rel="canonical" href="${esc(storyUrl)}">
<link rel="alternate" href="${esc(canonical)}">
<script type="application/ld+json">${jsonLd}</script>
<!-- Open Graph -->
<meta property="og:type" content="article">
<meta property="og:title" content="${esc(clean(article.title).slice(0,70))}">
<meta property="og:description" content="${esc(truncate(article.metaDescription || article.summary || '', 155))}">
<meta property="og:image" content="${esc(`${SITE_URL}${ogImage}`)}">
<meta property="og:url" content="${esc(storyUrl)}">
<meta property="og:site_name" content="تکناو">
<meta property="article:published_time" content="${esc(article.dateEn)}">
<!-- Twitter Card -->
<meta name="twitter:card" content="summary_large_image">
<meta name="twitter:title" content="${esc(clean(article.title).slice(0,70))}">
<meta name="twitter:description" content="${esc(truncate(article.metaDescription || article.summary || '', 155))}">
<meta name="twitter:image" content="${esc(`${SITE_URL}${ogImage}`)}">
<style>
@font-face{font-family:'Vazirmatn';src:url('/fonts/Vazirmatn-Regular.woff2') format('woff2');font-weight:400;font-display:swap}
@font-face{font-family:'Vazirmatn';src:url('/fonts/Vazirmatn-Bold.woff2') format('woff2');font-weight:700;font-display:swap}
*,*::before,*::after{box-sizing:border-box;margin:0;padding:0}
html,body{height:100%;overflow:hidden;font-family:'Vazirmatn',Tahoma,sans-serif;background:#0C1E22;color:#fff;direction:rtl}
.story-container{height:100vh;overflow-x:scroll;overflow-y:hidden;display:flex;scroll-snap-type:x mandatory;-webkit-overflow-scrolling:touch;scrollbar-width:none}
.story-container::-webkit-scrollbar{display:none}
.story-slide{min-width:100vw;height:100vh;position:relative;scroll-snap-align:start;display:flex;flex-direction:column;justify-content:flex-end;overflow:hidden}
.slide-bg{position:absolute;inset:0;background-size:cover;background-position:center;filter:brightness(0.45)}
.slide-overlay{position:absolute;inset:0;background:linear-gradient(to top,rgba(5,20,25,0.95) 0%,rgba(5,20,25,0.5) 50%,transparent 100%)}
.slide-progress{position:absolute;top:0;left:0;right:0;height:3px;background:rgba(255,255,255,0.2)}
.progress-bar{height:100%;background:#D49A2A;transition:width 0.3s}
.slide-content{position:relative;padding:32px 24px 48px;max-width:600px;width:100%;margin:0 auto}
.cover-badge{display:inline-block;background:#0F6B73;color:#fff;font-size:12px;font-weight:700;padding:4px 12px;border-radius:20px;margin-bottom:16px}
.cover-title{font-size:clamp(20px,5vw,30px);font-weight:700;line-height:1.5;margin-bottom:12px;color:#fff}
.cover-meta{font-size:13px;color:rgba(255,255,255,0.7);margin-bottom:20px}
.swipe-hint{font-size:12px;color:rgba(255,255,255,0.5);letter-spacing:0.5px;animation:pulse 2s infinite}
@keyframes pulse{0%,100%{opacity:0.5}50%{opacity:1}}
.content-lead{font-size:clamp(15px,4vw,18px);line-height:1.8;color:rgba(255,255,255,0.92)}
.content-heading{font-size:clamp(16px,4.5vw,22px);font-weight:700;color:#D49A2A;margin-bottom:16px;line-height:1.5}
.content-body{font-size:clamp(14px,3.5vw,16px);line-height:1.9;color:rgba(255,255,255,0.88)}
.cta-box{background:rgba(15,107,115,0.25);border:1px solid rgba(212,154,42,0.3);border-radius:16px;padding:28px 24px;text-align:center}
.cta-logo{font-size:22px;font-weight:700;color:#D49A2A;margin-bottom:12px;letter-spacing:1px}
.cta-text{font-size:18px;font-weight:700;color:#fff;margin-bottom:8px}
.cta-subtitle{font-size:13px;color:rgba(255,255,255,0.65);margin-bottom:20px;line-height:1.6}
.cta-button{display:inline-block;background:#0F6B73;color:#fff;text-decoration:none;padding:12px 28px;border-radius:8px;font-weight:700;font-size:15px;transition:background 0.2s}
.cta-button:hover{background:#0a5258}
.nav-hint{position:fixed;bottom:24px;left:50%;transform:translateX(-50%);display:flex;gap:8px;z-index:10}
.nav-dot{width:6px;height:6px;border-radius:50%;background:rgba(255,255,255,0.3);transition:background 0.2s;cursor:pointer}
.nav-dot.active{background:#D49A2A}
</style>
</head>
<body>
<div class="story-container" id="story">
${slides.join('\n')}
</div>
<div class="nav-hint" id="dots"></div>
<script>
const container=document.getElementById('story');
const dotsEl=document.getElementById('dots');
const slides=container.querySelectorAll('.story-slide');
slides.forEach((_,i)=>{
const d=document.createElement('div');
d.className='nav-dot'+(i===0?' active':'');
d.onclick=()=>slides[i].scrollIntoView({behavior:'smooth',block:'nearest',inline:'start'});
dotsEl.appendChild(d);
});
const io=new IntersectionObserver(entries=>{
entries.forEach(e=>{
if(e.isIntersecting){
const idx=[...slides].indexOf(e.target);
dotsEl.querySelectorAll('.nav-dot').forEach((d,i)=>d.classList.toggle('active',i===idx));
}
});
},{threshold:0.5});
slides.forEach(s=>io.observe(s));
// Keyboard navigation
document.addEventListener('keydown',e=>{
const idx=[...slides].findIndex(s=>s.getBoundingClientRect().left>=0&&s.getBoundingClientRect().left<window.innerWidth/2);
const cur=idx<0?0:idx;
if(e.key==='ArrowLeft'||e.key==='ArrowDown'){
const next=slides[Math.min(cur+1,slides.length-1)];
next?.scrollIntoView({behavior:'smooth',block:'nearest',inline:'start'});
} else if(e.key==='ArrowRight'||e.key==='ArrowUp'){
const prev=slides[Math.max(cur-1,0)];
prev?.scrollIntoView({behavior:'smooth',block:'nearest',inline:'start'});
}
});
</script>
</body>
</html>`;
}
const publishedArticles = TeknavData.articles.filter(
(a) => isPublishedArticle(a) && isPastOrTodayDate(a.dateEn),
);
let count = 0;
for (const article of publishedArticles) {
const dir = resolve(`dist/stories/${article.slug}`);
mkdirSync(dir, { recursive: true });
writeFileSync(`${dir}/index.html`, buildStory(article), 'utf8');
count++;
}
console.log(`[web-stories] generated ${count} story pages in dist/stories/`);