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.
This commit is contained in:
@@ -0,0 +1,255 @@
|
|||||||
|
#!/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, '<')
|
||||||
|
.replace(/>/g, '>');
|
||||||
|
}
|
||||||
|
|
||||||
|
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/`);
|
||||||
Reference in New Issue
Block a user