diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml index ee4d3cc..3d7b303 100644 --- a/.github/workflows/deploy.yml +++ b/.github/workflows/deploy.yml @@ -1,15 +1,13 @@ name: Deploy to Cloudflare Pages on: - push: - branches: [main] pull_request: types: [closed] branches: [main] jobs: deploy: - if: github.event_name == 'push' || (github.event_name == 'pull_request' && github.event.pull_request.merged == true) + if: github.event.pull_request.merged == true runs-on: ubuntu-latest environment: production permissions: @@ -33,11 +31,6 @@ jobs: - name: Install dependencies run: pnpm install --frozen-lockfile - - name: Build - run: pnpm generate:og - env: - NODE_VERSION: '22' - - name: Build run: pnpm build env: diff --git a/.github/workflows/generator.yml b/.github/workflows/generator.yml new file mode 100644 index 0000000..97a5c02 --- /dev/null +++ b/.github/workflows/generator.yml @@ -0,0 +1,45 @@ +name: Generate Data and OG Images + +on: + pull_request: + branches: [main] + +jobs: + generate-and-commit: + runs-on: ubuntu-latest + permissions: + contents: write + steps: + - name: Checkout + uses: actions/checkout@v4 + with: + ref: ${{ github.head_ref }} + + - name: Setup pnpm + uses: pnpm/action-setup@v4 + with: + version: 10 + + - name: Setup Node.js + uses: actions/setup-node@v4 + with: + node-version: '22' + cache: pnpm + + - name: Install dependencies + run: pnpm install --frozen-lockfile + + - name: Generate Data + run: pnpm generate:data + env: + NODE_VERSION: '22' + + - name: Generate OG Images + run: pnpm generate:og + env: + NODE_VERSION: '22' + + - name: Commit and Push changes + uses: stefanzweifel/git-auto-commit-action@v5 + with: + commit_message: "chore: auto-generate data and og images" diff --git a/.github/workflows/update-data.yml b/.github/workflows/update-data.yml deleted file mode 100644 index d217391..0000000 --- a/.github/workflows/update-data.yml +++ /dev/null @@ -1,64 +0,0 @@ -name: Update Data Files - -on: - push: - branches: [main] - paths: - - 'src/content/articles/**/*.md' - workflow_dispatch: - -jobs: - update: - runs-on: ubuntu-latest - permissions: - contents: write - pull-requests: write - - steps: - - name: Checkout repository - uses: actions/checkout@v3 - with: - persist-credentials: false - - - name: Setup pnpm - uses: pnpm/action-setup@v4 - with: - version: 10 - - - name: Set up Node - uses: actions/setup-node@v3 - with: - node-version: 22 - cache: pnpm - - - name: Install dependencies - run: pnpm install --frozen-lockfile - - - name: Run update script - env: - GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} - run: node scripts/update-data.mjs - - - name: Commit & push changes - run: | - git config user.name "github-actions[bot]" - git config user.email "41898282+github-actions[bot]@users.noreply.github.com" - git add src/data/tags.json src/data/categories.json src/data/articles.json - if ! git diff --cached --quiet; then - git commit -m "chore(data): auto‑update tags, categories & articles" - git push origin HEAD:${{ github.ref }} - fi - - - name: Create PR if needed - if: github.ref != 'refs/heads/main' - uses: peter-evans/create-pull-request@v5 - with: - token: ${{ secrets.GITHUB_TOKEN }} - commit-message: 'chore(data): auto‑update tags, categories & articles' - branch: update-data-${{ github.run_id }} - title: 'Auto‑generated data updates' - body: | - This PR contains updates to `tags.json`, `categories.json` and `articles.json` - generated from the latest markdown content. - New tags have empty descriptions – please fill them in. - New categories have empty descriptions and colors – please fill them in (colors are used for OG images). diff --git a/.gitignore b/.gitignore index c036c59..1ea7c45 100644 --- a/.gitignore +++ b/.gitignore @@ -15,5 +15,3 @@ pnpm-debug.log* .DS_Store .idea/ - -public/og/* \ No newline at end of file diff --git a/package.json b/package.json index b9bd7b6..85e4257 100644 --- a/package.json +++ b/package.json @@ -17,7 +17,7 @@ "prepare": "husky", "check": "astro check", "generate:og": "node scripts/generate-og.mjs", - "generate:data": "node scripts/genedate-data.mjs" + "generate:data": "node scripts/update-data.mjs --lang=tr && node scripts/update-data.mjs --lang=en" }, "dependencies": { "@astrojs/partytown": "^2.1.5", diff --git a/public/og/en/.gitkeep b/public/og/en/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/public/og/en/index.png b/public/og/en/index.png new file mode 100644 index 0000000..448f310 Binary files /dev/null and b/public/og/en/index.png differ diff --git a/public/og/tr/.gitkeep b/public/og/tr/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/public/og/tr/index.png b/public/og/tr/index.png new file mode 100644 index 0000000..54912b8 Binary files /dev/null and b/public/og/tr/index.png differ diff --git a/scripts/generate-og.mjs b/scripts/generate-og.mjs index c7eaf28..bb83e1e 100644 --- a/scripts/generate-og.mjs +++ b/scripts/generate-og.mjs @@ -11,9 +11,32 @@ const fontBold = await fetch( 'https://fonts.gstatic.com/s/inter/v20/UcCO3FwrK3iLTeHuS_nVMrMxCp50SjIw2boKoduKmMEVuFuYMZg.ttf', ).then((res) => res.arrayBuffer()) -const DEFAULT_COLORS = { bg: '#1a1a2e', text: '#e2e8f0' } +const repoRoot = path.join(process.cwd()) +const logoDark = fs.readFileSync(path.join(repoRoot, 'src/assets/logo-dark.png')).toString('base64') -async function makeSvg(title, color, isArticle) { +const DEFAULT_COLORS = { bg: '#18181b', text: '#e5e7eb' } + +const LANG_TEXTS = { + tr: { + explore: 'Makaleleri Keşfet', + category: 'KATEGORİ', + article: 'MAKALE', + tagline: 'Sinir ağları, transformerlar, pekiştirmeli öğrenme ve daha fazlası', + description: 'Makine öğrenimi kavramlarının net açıklamaları', + suffix: 'ML Kavramları Açıklandı', + }, + en: { + explore: 'Explore Articles', + category: 'CATEGORY', + article: 'ARTICLE', + tagline: 'Neural networks, transformers, reinforcement learning & more', + description: 'Clear explanations of machine learning concepts', + suffix: 'ML Concepts Explained', + }, +} + +async function makeSvg(title, color, isArticle, lang = 'en', logo) { + const texts = LANG_TEXTS[lang] || LANG_TEXTS.en const bg = color || DEFAULT_COLORS.bg const svg = await satori( { @@ -26,7 +49,7 @@ async function makeSvg(title, color, isArticle) { flexDirection: 'column', justifyContent: 'space-between', padding: '60px 80px', - background: `linear-gradient(135deg, ${bg} 0%, #0a0a1a 100%)`, + background: `linear-gradient(180deg, ${bg} 0%, #0d0d0d 100%)`, fontFamily: 'Inter', }, children: [ @@ -41,36 +64,12 @@ async function makeSvg(title, color, isArticle) { style: { display: 'flex', alignItems: 'center', gap: '16px' }, children: [ { - type: 'div', + type: 'img', props: { - style: { - width: '48px', - height: '48px', - borderRadius: '12px', - background: '#22d3ee', - display: 'flex', - alignItems: 'center', - justifyContent: 'center', - color: '#0a0a1a', - fontSize: '24px', - fontWeight: '700', - fontFamily: 'Inter', - }, - children: '4', + src: `data:image/png;base64,${logo}`, + height: 48 }, - }, - { - type: 'span', - props: { - style: { - fontSize: '28px', - fontWeight: '700', - color: '#f8fafc', - fontFamily: 'Inter', - }, - children: '4byte.dev', - }, - }, + } ], }, }, @@ -79,11 +78,11 @@ async function makeSvg(title, color, isArticle) { props: { style: { fontSize: '16px', - color: '#94a3b8', + color: '#8b949e', fontWeight: '500', fontFamily: 'Inter', }, - children: isArticle ? 'Machine Learning & AI' : 'Explore Articles', + children: isArticle ? 'Machine Learning & AI' : texts.explore, }, }, ], @@ -99,13 +98,13 @@ async function makeSvg(title, color, isArticle) { props: { style: { fontSize: '14px', - color: '#e94560', + color: '#388bfd', fontWeight: '600', textTransform: 'uppercase', letterSpacing: '2px', fontFamily: 'Inter', }, - children: isArticle ? 'ARTICLE' : 'LEARN MACHINE LEARNING', + children: isArticle ? texts.article : texts.category, }, }, { @@ -114,7 +113,7 @@ async function makeSvg(title, color, isArticle) { style: { fontSize: title.length > 60 ? '42px' : '56px', fontWeight: '700', - color: '#f8fafc', + color: '#e5e7eb', lineHeight: 1.2, fontFamily: 'Inter', maxWidth: '900px', @@ -127,14 +126,12 @@ async function makeSvg(title, color, isArticle) { props: { style: { fontSize: '20px', - color: '#94a3b8', + color: '#8b949e', lineHeight: 1.6, fontFamily: 'Inter', maxWidth: '800px', }, - children: isArticle - ? 'Clear explanations of machine learning concepts' - : 'Neural networks, transformers, reinforcement learning & more', + children: isArticle ? texts.description : texts.tagline, }, }, ], @@ -146,29 +143,17 @@ async function makeSvg(title, color, isArticle) { style: { display: 'flex', alignItems: 'center', gap: '12px' }, children: [ { - type: 'div', + type: 'img', props: { - style: { - width: '40px', - height: '40px', - borderRadius: '50%', - background: '#22d3ee', - display: 'flex', - alignItems: 'center', - justifyContent: 'center', - fontSize: '18px', - fontWeight: '700', - color: '#0a0a1a', - fontFamily: 'Inter', - }, - children: '4', + src: `data:image/png;base64,${logo}`, + height: 48 }, }, { type: 'span', props: { - style: { fontSize: '18px', color: '#94a3b8', fontFamily: 'Inter' }, - children: '4byte.dev — ML Concepts Explained', + style: { fontSize: '18px', color: '#8b949e', fontFamily: 'Inter' }, + children: texts.suffix, }, }, ], @@ -189,32 +174,46 @@ async function makeSvg(title, color, isArticle) { return svg } -const outDir = path.join(process.cwd(), 'public', 'og') +const outDir = path.join(repoRoot, 'public', 'og') fs.mkdirSync(outDir, { recursive: true }) -const dataDir = path.join(process.cwd(), 'src', 'data') -const categories = JSON.parse(fs.readFileSync(path.join(dataDir, 'categories.json'), 'utf8')) -const articles = JSON.parse(fs.readFileSync(path.join(dataDir, 'articles.json'), 'utf8')) +const SUPPORTED_LANGS = ['tr', 'en'] -const categoryColorMap = {} -for (const cat of categories) { - categoryColorMap[cat.name] = cat.color || null -} +for (const lang of SUPPORTED_LANGS) { + const langOutDir = path.join(outDir, lang) + fs.mkdirSync(langOutDir, { recursive: true }) + + const dataDir = path.join(repoRoot, 'src', 'data', lang) + let categories = [] + let articles = [] + + try { + categories = JSON.parse(fs.readFileSync(path.join(dataDir, 'categories.json'), 'utf8')) + articles = JSON.parse(fs.readFileSync(path.join(dataDir, 'articles.json'), 'utf8')) + } catch { + console.log(`No data found for language: ${lang}`) + } + + const categoryColorMap = {} + for (const cat of categories) { + categoryColorMap[cat.name] = cat.color || null + } -const svgIndex = await makeSvg('4byte.dev', null, false) -const resvgIndex = new Resvg(svgIndex, { fitTo: { mode: 'width', value: 1200 } }) -fs.writeFileSync(path.join(outDir, 'index.png'), Buffer.from(resvgIndex.render().asPng())) -console.log('Generated public/og/index.png') + const svgIndex = await makeSvg('4byte.dev', null, false, lang, logoDark) + const resvgIndex = new Resvg(svgIndex, { fitTo: { mode: 'width', value: 1200 } }) + fs.writeFileSync(path.join(langOutDir, 'index.png'), Buffer.from(resvgIndex.render().asPng())) + console.log(`Generated public/og/${lang}/index.png`) -if (articles.length === 0) { - console.log('No articles found – skipping article OG images.') -} else { - for (const { slug, title, category } of articles) { - const color = categoryColorMap[category] || null - const svgArticle = await makeSvg(title, color, true) - const resvgArticle = new Resvg(svgArticle, { fitTo: { mode: 'width', value: 1200 } }) - fs.writeFileSync(path.join(outDir, `${slug}.png`), Buffer.from(resvgArticle.render().asPng())) - console.log(`Generated public/og/${slug}.png`) + if (articles.length === 0) { + console.log(`No articles for ${lang} – skipping article OG images.`) + } else { + for (const { slug, title, category } of articles) { + const color = categoryColorMap[category] || null + const svgArticle = await makeSvg(title, color, true, lang, logoDark) + const resvgArticle = new Resvg(svgArticle, { fitTo: { mode: 'width', value: 1200 } }) + fs.writeFileSync(path.join(langOutDir, `${slug}.png`), Buffer.from(resvgArticle.render().asPng())) + console.log(`Generated public/og/${lang}/${slug}.png`) + } } } diff --git a/scripts/update-data.mjs b/scripts/update-data.mjs index 15b5232..c9e9039 100755 --- a/scripts/update-data.mjs +++ b/scripts/update-data.mjs @@ -7,13 +7,22 @@ import slugify from 'slugify' const readJSON = async (file) => JSON.parse(await fs.readFile(file, 'utf8')) const writeJSON = async (file, data) => await fs.writeFile(file, JSON.stringify(data, null, 2), 'utf8') +const langArg = process.argv.find((arg) => arg.startsWith('--lang=')) +const targetLang = langArg ? langArg.split('=')[1] : 'tr' + +const SUPPORTED_LANGS = ['tr', 'en'] +if (!SUPPORTED_LANGS.includes(targetLang)) { + console.error(`Unsupported language: ${targetLang}. Supported: ${SUPPORTED_LANGS.join(', ')}`) + process.exit(1) +} + ;(async () => { const __dirname = new URL('.', import.meta.url).pathname const repoRoot = path.resolve(__dirname, '..') - const contentDir = path.join(repoRoot, 'src/content/articles') - const tagsPath = path.join(repoRoot, 'src/data/tags.json') - const categoriesPath = path.join(repoRoot, 'src/data/categories.json') - const articlesPath = path.join(repoRoot, 'src/data/articles.json') + const contentDir = path.join(repoRoot, 'src/content/articles', targetLang) + const tagsPath = path.join(repoRoot, 'src/data', targetLang, 'tags.json') + const categoriesPath = path.join(repoRoot, 'src/data', targetLang, 'categories.json') + const articlesPath = path.join(repoRoot, 'src/data', targetLang, 'articles.json') const tags = (await readJSON(tagsPath)) ?? [] const categories = (await readJSON(categoriesPath)) ?? [] @@ -24,7 +33,14 @@ const writeJSON = async (file, data) => await fs.writeFile(file, JSON.stringify( const addedTags = new Set() const addedCategories = new Set() - const mdFiles = (await fs.readdir(contentDir)).filter((f) => f.endsWith('.md')) + let mdFiles = [] + try { + mdFiles = (await fs.readdir(contentDir)).filter((f) => f.endsWith('.md')) + } catch { + console.log(`No content directory for language '${targetLang}': ${contentDir}`) + console.log('Skipping article generation.') + } + const articles = [] for (const file of mdFiles) { @@ -80,18 +96,23 @@ const writeJSON = async (file, data) => await fs.writeFile(file, JSON.stringify( await writeJSON(categoriesPath, filteredCategories) await writeJSON(articlesPath, articles) + console.log(`Updated data for language: ${targetLang}`) + console.log(` Articles: ${articles.length}`) + console.log(` Categories: ${filteredCategories.length}`) + console.log(` Tags: ${filteredTags.length}`) + if (process.env.GITHUB_EVENT_PATH) { const event = JSON.parse(await fs.readFile(process.env.GITHUB_EVENT_PATH, 'utf8')) const prNumber = event.pull_request?.number if (prNumber) { const lines = [] if (addedTags.size) { - lines.push('## 📌 New tags added (please add descriptions)') + lines.push(`## New tags added for ${targetLang} (please add descriptions)`) for (const t of addedTags) lines.push(`- \`${t}\``) lines.push('') } if (addedCategories.size) { - lines.push('## 📂 New categories added (please add descriptions)') + lines.push(`## New categories added for ${targetLang} (please add descriptions)`) for (const c of addedCategories) lines.push(`- \`${c}\``) lines.push('') } diff --git a/src/components/LanguageSelector.astro b/src/components/LanguageSelector.astro new file mode 100644 index 0000000..00881b4 --- /dev/null +++ b/src/components/LanguageSelector.astro @@ -0,0 +1,99 @@ +--- +import { LANGUAGES } from '../helper.mjs' + +interface Props { + currentLang: string + currentPath: string + labels: Record + direction?: 'up' | 'down' +} + +const { currentLang, currentPath, labels, direction = 'down' } = Astro.props + +const langLabels = labels.lang as Record | undefined + +function getOtherLangPath(lang: string, path: string): string { + if (lang === 'en') { + return path.replace(/^\/en/, '') || '/' + } + return '/en' + path +} + +const otherLangPath = getOtherLangPath(currentLang, currentPath) + +const isUp = direction === 'up' +const dropdownClass = isUp + ? 'hidden absolute bottom-full left-0 mb-1 w-32' + : 'hidden absolute top-full left-0 mt-1 w-32' +--- + +
+ + +
+ { + Object.entries(LANGUAGES).map(([code, config]) => ( + + {langLabels?.[code] || code.toUpperCase()} + {config.name} + + )) + } +
+
+ + diff --git a/src/components/ShareButtons.astro b/src/components/ShareButtons.astro index e6aff47..fa0e27a 100644 --- a/src/components/ShareButtons.astro +++ b/src/components/ShareButtons.astro @@ -1,10 +1,13 @@ --- +import { t } from '../i18n/index.ts' + interface Props { title: string url: string + labels?: Record } -const { title, url } = Astro.props +const { title, url, labels = {} } = Astro.props const encodedTitle = encodeURIComponent(title) const encodedUrl = encodeURIComponent(url) @@ -14,7 +17,7 @@ const encodedUrl = encodeURIComponent(url) - Share + {t(labels, 'share.label')} +} + +const { lang, currentPath, labels } = Astro.props + +const categories = lang === 'en' ? categoriesEn : categoriesTr +const tags = lang === 'en' ? tagsEn : tagsTr + +const navLabels = labels.nav as Record | undefined +const themeLabels = labels.theme as Record | undefined const navItems = [ - { title: 'Home', url: '/' }, - { title: 'Articles', url: '/articles' }, - { title: 'Categories', url: '/categories' }, - { title: 'Tags', url: '/tags' }, + { title: navLabels?.home || 'Home', url: lang === 'en' ? '/en/' : '/' }, + { title: navLabels?.articles || 'Articles', url: lang === 'en' ? '/en/articles' : '/articles' }, + { title: navLabels?.categories || 'Categories', url: lang === 'en' ? '/en/categories' : '/categories' }, + { title: navLabels?.tags || 'Tags', url: lang === 'en' ? '/en/tags' : '/tags' }, ] -const currentPath = Astro.url.pathname - function isActive(path: string): boolean { - if (path === '/') return currentPath === '/' + if (path === '/' || path === '/en/') return currentPath === path return currentPath.startsWith(path) } const topCategories = [...categories].sort((a, b) => b.articleCount - a.articleCount).slice(0, 5) - const topTags = [...tags].sort((a, b) => b.articleCount - a.articleCount).slice(0, 8) import logoDark from '../assets/logo-dark.png' @@ -29,7 +44,7 @@ import logoLight from '../assets/logo-light.png' class="w-64 shrink-0 border-r border-border dark:border-border-dark bg-background dark:bg-background-dark hidden lg:flex flex-col h-screen sticky top-0" >
- + 4byte logo @@ -39,7 +54,7 @@ import logoLight from '../assets/logo-light.png'
- Navigation + {t(labels, 'nav.home')}
{ navItems.map((item) => ( @@ -51,10 +66,10 @@ import logoLight from '../assets/logo-light.png' : 'text-muted-foreground dark:text-muted-dark-foreground hover:bg-muted dark:hover:bg-muted-dark hover:text-foreground dark:hover:text-foreground-dark' }`} > - {item.title === 'Home' && } - {item.title === 'Articles' && } - {item.title === 'Categories' && } - {item.title === 'Tags' && } + {item.title === (navLabels?.home || 'Home') && } + {item.title === (navLabels?.articles || 'Articles') && } + {item.title === (navLabels?.categories || 'Categories') && } + {item.title === (navLabels?.tags || 'Tags') && } {item.title} )) @@ -63,14 +78,14 @@ import logoLight from '../assets/logo-light.png'
- Categories + {t(labels, 'nav.categories') || 'Categories'}
{ topCategories.map((cat) => ( - Tags + {t(labels, 'nav.tags') || 'Tags'}
{ topTags.map((tag) => ( - -
+
+ +
diff --git a/src/pages/en/articles/[slug].astro b/src/pages/en/articles/[slug].astro new file mode 100644 index 0000000..df91de4 --- /dev/null +++ b/src/pages/en/articles/[slug].astro @@ -0,0 +1,222 @@ +--- +import DocsLayout from '../../../layouts/DocsLayout.astro' +import ShareButtons from '../../../components/ShareButtons.astro' +import { getCollection } from 'astro:content' +import { SITE } from '../../../config.mjs' +import { t } from '../../../i18n/index.ts' + +const lang = 'en' +const labels = await import(`../../../data/${lang}/labels.json`).then((m) => m.default) + +export async function getStaticPaths() { + const allArticles = await getCollection('articles') + return allArticles + .filter((a) => a.data.lang === 'en') + .map((article) => ({ + params: { slug: article.data.slug }, + props: { article }, + })) +} + +const { article } = Astro.props + +if (!article || article.data.status !== 'Published') { + return Astro.redirect('/en/404') +} + +const categorySlug = article.data.category.toLowerCase().replace(/\s+/g, '-') +const articleUrl = `${SITE.origin}/en/articles/${article.data.slug}` +const ogImageUrl = `/og/${article.data.slug}.png` + +const publishedDate = new Date(article.data.date) +const isoDate = publishedDate.toISOString() + +const sections = (article.body ?? '') + .split('\n') + .filter((line) => line.startsWith('## ')) + .map((line) => line.replace('## ', '')) + +const jsonLdArticle = { + '@context': 'https://schema.org', + '@type': 'Article', + headline: article.data.title, + description: article.data.excerpt, + image: `${SITE.origin}${ogImageUrl}`, + url: articleUrl, + datePublished: isoDate, + dateModified: isoDate, + author: { + '@type': 'Person', + name: article.data.author, + url: SITE.origin, + }, + publisher: { + '@type': 'Organization', + name: SITE.name, + url: SITE.origin, + }, + inLanguage: 'en', + keywords: article.data.tags.join(', '), + mainEntityOfPage: { + '@type': 'WebPage', + '@id': articleUrl, + }, +} + +const homeLabel = t(labels, 'article.home') +const articlesLabel = t(labels, 'nav.articles') +const byLabel = t(labels, 'article.by') +const onThisPageLabel = t(labels, 'article.onThisPage') +--- + + +