diff --git a/.github/workflows/static.yml b/.github/workflows/static.yml index 205842c..9fdf185 100644 --- a/.github/workflows/static.yml +++ b/.github/workflows/static.yml @@ -54,6 +54,13 @@ jobs: - name: Install Node.js dependencies run: "[[ -f package-lock.json || -f npm-shrinkwrap.json ]] && npm ci || true" + - name: Aggregate external blog posts + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + run: | + echo "🔄 Aggregating external blog posts..." + npm run aggregate-posts || echo "⚠️ Post aggregation failed, continuing with build" + - name: Initialize Hugo modules run: hugo mod get diff --git a/assets/css/external-posts.css b/assets/css/external-posts.css new file mode 100644 index 0000000..8db85e5 --- /dev/null +++ b/assets/css/external-posts.css @@ -0,0 +1,60 @@ +/* External post card styling */ +.external-post-card { + position: relative; +} + +.external-post-card .card { + border-left: 4px solid #007bff; + background: linear-gradient(145deg, #ffffff, #f8f9fa); +} + +.external-post-badge { + position: absolute; + top: 10px; + right: 10px; + background: rgba(0, 123, 255, 0.9); + color: white; + padding: 4px 8px; + border-radius: 12px; + font-size: 0.75rem; + font-weight: 500; + z-index: 10; +} + +.external-post-badge i { + margin-right: 4px; +} + +.external-post-source { + margin-top: 10px; + padding-top: 8px; + border-top: 1px solid #dee2e6; +} + +.external-post-source i { + margin-right: 4px; +} + +.tag-badge { + margin-right: 4px; + margin-bottom: 4px; + font-size: 0.7rem; +} + +.tags { + margin-top: 8px; +} + +/* Dark theme support */ +[data-theme="dark"] .external-post-card .card { + background: linear-gradient(145deg, #2d3748, #1a202c); + border-left-color: #4299e1; +} + +[data-theme="dark"] .external-post-badge { + background: rgba(66, 153, 225, 0.9); +} + +[data-theme="dark"] .external-post-source { + border-top-color: #4a5568; +} \ No newline at end of file diff --git a/data/en/external-posts.yaml b/data/en/external-posts.yaml new file mode 100644 index 0000000..cc090f3 --- /dev/null +++ b/data/en/external-posts.yaml @@ -0,0 +1 @@ +posts: [] diff --git a/data/en/external_posts.yaml b/data/en/external_posts.yaml new file mode 100644 index 0000000..870284d --- /dev/null +++ b/data/en/external_posts.yaml @@ -0,0 +1,11 @@ +posts: + - title: "Sample External Post" + date: "2024-01-15T00:00:00Z" + summary: "This is a sample external post to demonstrate the aggregation functionality." + url: "https://wesleycamargo.github.io/sample-post" + source: + repository: "wesleycamargo/wesleycamargo.github.io" + author: "wesleycamargo" + originalUrl: "https://github.com/wesleycamargo/wesleycamargo.github.io/blob/main/content/posts/sample.md" + tags: ["sample", "demo"] + external: true diff --git a/layouts/_default/list.html b/layouts/_default/list.html new file mode 100644 index 0000000..2e49991 --- /dev/null +++ b/layouts/_default/list.html @@ -0,0 +1,60 @@ +{{ define "navbar" }} + {{ partial "navigators/navbar.html" . }} +{{ end }} + +{{ define "sidebar" }} + {{ $homePage:="#" }} + {{ if hugo.IsMultilingual }} + {{ $homePage = (path.Join (cond ( eq .Language.Lang "en") "" .Language.Lang) .Type) }} + {{ end }} + + +{{ end }} + +{{ define "content" }} +
+
+
+ + {{ $posts := where .RegularPagesRecursive "Layout" "!=" "search" }} + {{ $numShow := site.Params.features.pagination.maxPostsPerPage | default 12}} + {{ $paginator := .Paginate $posts $numShow }} + {{ range $paginator.Pages }} + {{ if .Layout }} + {{/* ignore the search.md file*/}} + {{ else }} + {{ partial "cards/post.html" . }} + {{ end }} + {{ end }} + + + {{ with site.Data.en.external_posts }} + {{ if .posts }} + {{ range .posts }} + {{ partial "cards/external-post.html" . }} + {{ end }} + {{ end }} + {{ end }} +
+
+ {{ partial "pagination.html" . }} +
+
+
+{{ end }} \ No newline at end of file diff --git a/layouts/partials/cards/external-post.html b/layouts/partials/cards/external-post.html new file mode 100644 index 0000000..300cbb4 --- /dev/null +++ b/layouts/partials/cards/external-post.html @@ -0,0 +1,42 @@ +
+
+ +
+ +
{{ .title }}
+

{{ .summary }}

+
+ {{ if .tags }} +
+ {{ range .tags }} + {{ . }} + {{ end }} +
+ {{ end }} +
+ + From {{ .source.author }} + +
+
+ +
+
\ No newline at end of file diff --git a/layouts/partials/header.html b/layouts/partials/header.html index c112702..b9277d5 100644 --- a/layouts/partials/header.html +++ b/layouts/partials/header.html @@ -6,6 +6,7 @@ {{/* Custom site styles */}} + {{/* add favicon only if the site author has provided the the favicon */}} diff --git a/layouts/partials/sections/recent-posts.html b/layouts/partials/sections/recent-posts.html new file mode 100644 index 0000000..9e55af3 --- /dev/null +++ b/layouts/partials/sections/recent-posts.html @@ -0,0 +1,45 @@ +{{ $sectionID := replace (lower .section.name) " " "-" }} +{{ if .section.id }} + {{ $sectionID = .section.id }} +{{ end }} + +{{ $numShow := 3}} +{{ if .section.numShow }} + {{ $numShow = .section.numShow }} +{{ end }} + +
+ {{ if not (.section.hideTitle) }} +

+ {{ .section.name }}

+ {{ else }} +

+ {{ .section.name }}

+ {{ end }} +
+
+ + {{ range first $numShow (where (where site.RegularPages.ByDate.Reverse "Type" "posts" ) "Layout" "!=" "search") }} + {{ partial "cards/post.html" . }} + {{ end }} + + + {{ with site.Data.en.external_posts }} + {{ if .posts }} + {{ range first $numShow .posts }} + {{ partial "cards/external-post.html" . }} + {{ end }} + {{ end }} + {{ end }} +
+
+ {{ if (.section.showMoreButton) }} +
+ + {{ i18n "show_more"}} +
+ {{ end }} +
\ No newline at end of file diff --git a/package-lock.json b/package-lock.json index 352d503..66994d0 100644 --- a/package-lock.json +++ b/package-lock.json @@ -20,6 +20,7 @@ "imagesloaded": "^5.0.0", "include-media": "^1.4.10", "ityped": "^1.0.3", + "js-yaml": "^4.1.0", "katex": "^0.16.11", "mark.js": "^8.11.1", "mermaid": "^11.6.0", @@ -519,6 +520,13 @@ "node": ">= 8" } }, + "node_modules/argparse": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/argparse/-/argparse-2.0.1.tgz", + "integrity": "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==", + "dev": true, + "license": "Python-2.0" + }, "node_modules/autoprefixer": { "version": "10.4.21", "resolved": "https://registry.npmjs.org/autoprefixer/-/autoprefixer-10.4.21.tgz", @@ -1725,6 +1733,19 @@ "dev": true, "license": "MIT" }, + "node_modules/js-yaml": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.1.0.tgz", + "integrity": "sha512-wpxZs9NoxZaJESJGIZTyDEaYpl0FKSA+FB9aJiyemKhMwkxQg63h4T1KJgUGHpTqPDNRcmmYLugrRjJlBtWvRA==", + "dev": true, + "license": "MIT", + "dependencies": { + "argparse": "^2.0.1" + }, + "bin": { + "js-yaml": "bin/js-yaml.js" + } + }, "node_modules/jsonfile": { "version": "6.1.0", "resolved": "https://registry.npmjs.org/jsonfile/-/jsonfile-6.1.0.tgz", diff --git a/package.json b/package.json index f671f4d..f3432c0 100644 --- a/package.json +++ b/package.json @@ -14,6 +14,7 @@ "imagesloaded": "^5.0.0", "include-media": "^1.4.10", "ityped": "^1.0.3", + "js-yaml": "^4.1.0", "katex": "^0.16.11", "mark.js": "^8.11.1", "mermaid": "^11.6.0", @@ -21,5 +22,8 @@ "popper.js": "^1.16.1", "postcss": "^8.4.41", "postcss-cli": "^11.0.0" + }, + "scripts": { + "aggregate-posts": "node scripts/aggregate-posts.js" } } \ No newline at end of file diff --git a/scripts/aggregate-posts.js b/scripts/aggregate-posts.js new file mode 100755 index 0000000..5649a11 --- /dev/null +++ b/scripts/aggregate-posts.js @@ -0,0 +1,331 @@ +#!/usr/bin/env node + +/** + * External Blog Post Aggregator + * + * Fetches blog posts from external GitHub repositories using GitHub API + * (without RSS feeds) and stores them in Hugo data files. + * + * Target repositories: + * - devjev/devjev.nl (or similar) + * - bearman-nl (or similar structure) + * - wesleycamargo/wesleycamargo.github.io + */ + +const fs = require('fs').promises; +const path = require('path'); +const https = require('https'); +const yaml = require('js-yaml'); + +// Configuration for target repositories +const TARGET_REPOS = [ + { + owner: 'wesleycamargo', + repo: 'wesleycamargo.github.io', + postPaths: ['content/posts', 'content/blog', '_posts', 'posts'], // Try multiple common paths + branch: 'main' + }, + { + owner: 'devjev', + repo: 'devjev.nl', + postPaths: ['content/posts', 'content/blog', '_posts', 'posts'], + branch: 'main' + }, + { + owner: 'bearman-nl', + repo: 'bearman.nl', + postPaths: ['content/posts', 'content/blog', '_posts', 'posts'], + branch: 'main' + } + // Note: Actual repo names may need to be discovered and updated +]; + +/** + * Make GitHub API request + */ +function makeGitHubRequest(url) { + return new Promise((resolve, reject) => { + const options = { + headers: { + 'User-Agent': 'thecloudexplorers-blog-aggregator', + 'Accept': 'application/vnd.github.v3+json' + } + }; + + // Add GitHub token if available + if (process.env.GITHUB_TOKEN) { + options.headers['Authorization'] = `token ${process.env.GITHUB_TOKEN}`; + } + + https.get(url, options, (res) => { + let data = ''; + res.on('data', chunk => data += chunk); + res.on('end', () => { + if (res.statusCode === 200) { + try { + resolve(JSON.parse(data)); + } catch (error) { + reject(new Error(`Failed to parse JSON: ${error.message}`)); + } + } else { + reject(new Error(`GitHub API error: ${res.statusCode} ${data}`)); + } + }); + }).on('error', reject); + }); +} + +/** + * Get files from repository directory + */ +async function getRepoFiles(owner, repo, path, branch = 'main') { + const url = `https://api.github.com/repos/${owner}/${repo}/contents/${path}?ref=${branch}`; + console.log(`Fetching files from: ${url}`); + + try { + return await makeGitHubRequest(url); + } catch (error) { + console.warn(`Failed to fetch files from ${owner}/${repo}/${path}: ${error.message}`); + return []; + } +} + +/** + * Get file content from repository + */ +async function getFileContent(owner, repo, path, branch = 'main') { + const url = `https://api.github.com/repos/${owner}/${repo}/contents/${path}?ref=${branch}`; + + try { + const response = await makeGitHubRequest(url); + if (response.content && response.encoding === 'base64') { + return Buffer.from(response.content, 'base64').toString('utf-8'); + } + return null; + } catch (error) { + console.warn(`Failed to fetch file content from ${owner}/${repo}/${path}: ${error.message}`); + return null; + } +} + +/** + * Parse Hugo front matter from markdown content + */ +function parseFrontMatter(content) { + const frontMatterRegex = /^---\s*\n([\s\S]*?)\n---\s*\n([\s\S]*)$/; + const match = content.match(frontMatterRegex); + + if (!match) { + return null; + } + + try { + const frontMatter = yaml.load(match[1]); + const body = match[2]; + return { frontMatter, body }; + } catch (error) { + console.warn(`Failed to parse front matter: ${error.message}`); + return null; + } +} + +/** + * Extract summary from content + */ +function extractSummary(content, maxLength = 200) { + // Remove markdown formatting and get plain text + const plainText = content + .replace(/#{1,6}\s+/g, '') // Remove headers + .replace(/\*\*([^*]+)\*\*/g, '$1') // Remove bold + .replace(/\*([^*]+)\*/g, '$1') // Remove italic + .replace(/\[([^\]]+)\]\([^)]+\)/g, '$1') // Remove links + .replace(/```[\s\S]*?```/g, '') // Remove code blocks + .replace(/`([^`]+)`/g, '$1') // Remove inline code + .trim(); + + if (plainText.length <= maxLength) { + return plainText; + } + + // Find the last complete sentence within the limit + const truncated = plainText.substring(0, maxLength); + const lastSentence = truncated.lastIndexOf('.'); + + if (lastSentence > maxLength * 0.7) { + return truncated.substring(0, lastSentence + 1); + } + + return truncated + '...'; +} + +/** + * Try to find posts in a repository by checking multiple common paths + */ +async function findPostsPath(owner, repo, branch = 'main') { + const commonPaths = ['content/posts', 'content/blog', '_posts', 'posts', 'blog']; + + for (const path of commonPaths) { + try { + const files = await getRepoFiles(owner, repo, path, branch); + if (files.length > 0) { + console.log(`Found posts path: ${path} (${files.length} items)`); + return path; + } + } catch (error) { + // Continue to next path + continue; + } + } + + console.warn(`No posts found in common paths for ${owner}/${repo}`); + return null; +} + +/** + * Process posts from a single repository + */ +async function processRepository(repoConfig) { + console.log(`Processing repository: ${repoConfig.owner}/${repoConfig.repo}`); + + // Try to find the posts directory + let postsPath = null; + if (repoConfig.postPaths) { + for (const path of repoConfig.postPaths) { + try { + const files = await getRepoFiles(repoConfig.owner, repoConfig.repo, path, repoConfig.branch); + if (files.length > 0) { + postsPath = path; + console.log(`Found posts in: ${path}`); + break; + } + } catch (error) { + continue; + } + } + } else { + postsPath = await findPostsPath(repoConfig.owner, repoConfig.repo, repoConfig.branch); + } + + if (!postsPath) { + console.warn(`No posts found for ${repoConfig.owner}/${repoConfig.repo}`); + return []; + } + + const files = await getRepoFiles(repoConfig.owner, repoConfig.repo, postsPath, repoConfig.branch); + const posts = []; + + for (const file of files) { + if (file.type === 'file' && file.name.endsWith('.md') && !file.name.startsWith('_')) { + console.log(`Processing post: ${file.name}`); + + const content = await getFileContent(repoConfig.owner, repoConfig.repo, file.path, repoConfig.branch); + if (!content) continue; + + const parsed = parseFrontMatter(content); + if (!parsed || !parsed.frontMatter) continue; + + const { frontMatter, body } = parsed; + + // Skip drafts + if (frontMatter.draft === true) { + console.log(`Skipping draft: ${file.name}`); + continue; + } + + // Generate post URL based on repository structure + let postUrl = `https://${repoConfig.owner}.github.io/`; + if (postsPath.includes('posts') || postsPath.includes('blog')) { + postUrl += `${postsPath.split('/').pop()}/${file.name.replace('.md', '')}/`; + } else { + postUrl += file.name.replace('.md', '/'); + } + + // Create external post object + const post = { + title: frontMatter.title || file.name.replace('.md', ''), + date: frontMatter.date || frontMatter.publishDate || frontMatter.created || new Date().toISOString(), + summary: frontMatter.summary || frontMatter.description || frontMatter.excerpt || extractSummary(body), + url: postUrl, + source: { + repository: `${repoConfig.owner}/${repoConfig.repo}`, + author: repoConfig.owner, + originalUrl: `https://github.com/${repoConfig.owner}/${repoConfig.repo}/blob/${repoConfig.branch}/${file.path}` + }, + tags: frontMatter.tags || frontMatter.categories || [], + external: true + }; + + posts.push(post); + } + } + + console.log(`Found ${posts.length} posts in ${repoConfig.owner}/${repoConfig.repo}`); + return posts; +} + +/** + * Remove duplicate posts based on title and date + */ +function deduplicatePosts(posts) { + const seen = new Set(); + return posts.filter(post => { + const key = `${post.title.toLowerCase()}-${post.date}`; + if (seen.has(key)) { + console.log(`Removing duplicate post: ${post.title}`); + return false; + } + seen.add(key); + return true; + }); +} + +/** + * Main aggregation function + */ +async function aggregatePosts() { + console.log('Starting external blog post aggregation...'); + + let allPosts = []; + + for (const repoConfig of TARGET_REPOS) { + try { + const posts = await processRepository(repoConfig); + allPosts = allPosts.concat(posts); + } catch (error) { + console.error(`Error processing repository ${repoConfig.owner}/${repoConfig.repo}:`, error.message); + } + } + + // Deduplicate and sort by date + allPosts = deduplicatePosts(allPosts); + allPosts.sort((a, b) => new Date(b.date) - new Date(a.date)); + + console.log(`Total external posts found: ${allPosts.length}`); + + // Save to Hugo data file + const dataPath = path.join(__dirname, '..', 'data', 'en', 'external-posts.yaml'); + const yamlContent = yaml.dump({ posts: allPosts }, { + defaultFlowStyle: false, + lineWidth: -1 + }); + + await fs.writeFile(dataPath, yamlContent, 'utf-8'); + console.log(`External posts saved to: ${dataPath}`); + + return allPosts; +} + +// Run if called directly +if (require.main === module) { + aggregatePosts() + .then(posts => { + console.log(`✅ Successfully aggregated ${posts.length} external posts`); + process.exit(0); + }) + .catch(error => { + console.error('❌ Aggregation failed:', error.message); + process.exit(1); + }); +} + +module.exports = { aggregatePosts }; \ No newline at end of file diff --git a/static/css/external-posts.css b/static/css/external-posts.css new file mode 100644 index 0000000..8db85e5 --- /dev/null +++ b/static/css/external-posts.css @@ -0,0 +1,60 @@ +/* External post card styling */ +.external-post-card { + position: relative; +} + +.external-post-card .card { + border-left: 4px solid #007bff; + background: linear-gradient(145deg, #ffffff, #f8f9fa); +} + +.external-post-badge { + position: absolute; + top: 10px; + right: 10px; + background: rgba(0, 123, 255, 0.9); + color: white; + padding: 4px 8px; + border-radius: 12px; + font-size: 0.75rem; + font-weight: 500; + z-index: 10; +} + +.external-post-badge i { + margin-right: 4px; +} + +.external-post-source { + margin-top: 10px; + padding-top: 8px; + border-top: 1px solid #dee2e6; +} + +.external-post-source i { + margin-right: 4px; +} + +.tag-badge { + margin-right: 4px; + margin-bottom: 4px; + font-size: 0.7rem; +} + +.tags { + margin-top: 8px; +} + +/* Dark theme support */ +[data-theme="dark"] .external-post-card .card { + background: linear-gradient(145deg, #2d3748, #1a202c); + border-left-color: #4299e1; +} + +[data-theme="dark"] .external-post-badge { + background: rgba(66, 153, 225, 0.9); +} + +[data-theme="dark"] .external-post-source { + border-top-color: #4a5568; +} \ No newline at end of file