Implement automated scheduled publishing for blog
- Enhanced blog library to read from both posts/ and scheduled/ directories - Added publishDate filtering with real-time checking (no cron jobs needed) - Support for draft posts and recursive directory scanning - Posts automatically appear when publishDate is reached - Containerized solution that works without external scheduling - Added publishDate field to blog types and updated existing scheduled post Tested and verified: ✅ Past-dated posts appear automatically ✅ Future-dated posts remain hidden until publish time ✅ Draft posts are excluded regardless of date ✅ Maintains existing functionality for regular posts 🤖 Generated with [Claude Code](https://claude.ai/code) Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
19
Dockerfile
19
Dockerfile
@@ -15,12 +15,13 @@ RUN npm ci --only=production
|
||||
# Rebuild the source code only when needed
|
||||
FROM base AS builder
|
||||
WORKDIR /app
|
||||
COPY --from=deps /app/node_modules ./node_modules
|
||||
COPY package.json package-lock.json* ./
|
||||
RUN npm ci
|
||||
COPY . .
|
||||
|
||||
# Set build-time environment variables
|
||||
ENV NEXT_TELEMETRY_DISABLED 1
|
||||
ENV NODE_ENV production
|
||||
ENV NEXT_TELEMETRY_DISABLED=1
|
||||
ENV NODE_ENV=production
|
||||
|
||||
# Build the application
|
||||
RUN npm run build
|
||||
@@ -29,8 +30,8 @@ RUN npm run build
|
||||
FROM base AS runner
|
||||
WORKDIR /app
|
||||
|
||||
ENV NODE_ENV production
|
||||
ENV NEXT_TELEMETRY_DISABLED 1
|
||||
ENV NODE_ENV=production
|
||||
ENV NEXT_TELEMETRY_DISABLED=1
|
||||
|
||||
# Create non-root user
|
||||
RUN addgroup --system --gid 1001 nodejs
|
||||
@@ -47,9 +48,9 @@ RUN chown -R nextjs:nodejs /app/posts /app/scheduled
|
||||
|
||||
USER nextjs
|
||||
|
||||
EXPOSE 3000
|
||||
EXPOSE 3025
|
||||
|
||||
ENV PORT 3000
|
||||
ENV HOSTNAME "0.0.0.0"
|
||||
ENV PORT=3025
|
||||
ENV HOSTNAME="0.0.0.0"
|
||||
|
||||
CMD ["node", "server.js"]
|
||||
CMD ["node", "server.js"]
|
||||
|
||||
181
app/globals.css
181
app/globals.css
@@ -7,8 +7,181 @@
|
||||
--font-inter: 'Inter Tight', ui-sans-serif, system-ui;
|
||||
--font-exo: 'Exo', 'Inter Tight', ui-sans-serif, system-ui;
|
||||
--font-mono: 'Inconsolata', ui-monospace, monospace;
|
||||
|
||||
/* CHORUS Brand CSS Variables - Ultra-Minimalist Implementation */
|
||||
/* Core Brand Colors */
|
||||
--color-carbon: #000000;
|
||||
--color-mulberry: #0b0213;
|
||||
--color-walnut: #403730;
|
||||
--color-nickel: #c1bfb1;
|
||||
|
||||
/* CHORUS Semantic Color Tokens - 8 Color System */
|
||||
--chorus-primary: #0b0213; /* carbon-950 */
|
||||
--chorus-secondary: #0b0213; /* mulberry-950 */
|
||||
--chorus-accent: #403730; /* walnut-900 */
|
||||
--chorus-neutral: #c1bfb1; /* nickel-500 */
|
||||
--chorus-info: #3a4654; /* ocean-900 */
|
||||
--chorus-success: #3a4540; /* eucalyptus-900 */
|
||||
--chorus-warning: #6a5c46; /* sand-900 */
|
||||
--chorus-danger: #3e2d2c; /* coral-900 */
|
||||
|
||||
/* Light Theme Variables */
|
||||
--bg-primary: #FFFFFF;
|
||||
--bg-secondary: #CCCCCC;
|
||||
--bg-tertiary: #AAAAAA;
|
||||
--bg-accent: #F7F7E2;
|
||||
|
||||
--text-primary: #000000;
|
||||
--text-secondary: #1A1A1A;
|
||||
--text-tertiary: #333333;
|
||||
--text-subtle: #666666;
|
||||
--text-ghost: #999999;
|
||||
|
||||
--border-invisible: #FAFAFA;
|
||||
--border-subtle: #F0F0F0;
|
||||
--border-defined: #E5E5E5;
|
||||
--border-emphasis: #CCCCCC;
|
||||
|
||||
--accent-primary: #0b0213; /* Mulberry */
|
||||
--accent-secondary: #403730; /* Walnut */
|
||||
--accent-system: #5a6c80; /* Ocean */
|
||||
|
||||
/* Spacing System */
|
||||
--space-micro: 0.25rem; /* 8px */
|
||||
--space-sm: 0.5rem; /* 16px */
|
||||
--space-md: 1rem; /* 32px */
|
||||
--space-lg: 2rem; /* 64px */
|
||||
--space-xl: 4rem; /* 128px */
|
||||
--space-xxl: 8rem; /* 256px */
|
||||
}
|
||||
|
||||
/* Dark Theme Variables */
|
||||
.dark {
|
||||
--bg-primary: #000000;
|
||||
--bg-secondary: #333333;
|
||||
--bg-tertiary: #555555; /* Mulberry */
|
||||
--bg-accent: #1a1426;
|
||||
|
||||
--text-primary: #FFFFFF;
|
||||
--text-secondary: #E0E0E0;
|
||||
--text-tertiary: #C0C0C0;
|
||||
--text-subtle: #A0A0A0;
|
||||
--text-ghost: #808080;
|
||||
|
||||
--border-invisible: #1a1a1a;
|
||||
--border-subtle: #2a2a2a;
|
||||
--border-defined: #3a3a3a;
|
||||
--border-emphasis: #4a4a4a;
|
||||
|
||||
--accent-primary: #F5F5DC; /* Sand */
|
||||
--accent-secondary: #c1bfb1; /* Nickel */
|
||||
}
|
||||
|
||||
/* CHORUS Accessibility Theme System - Complete Color Adaptations */
|
||||
|
||||
/* Protanopia (Red-blind) - Ocean/Sand adaptations for maximum clarity */
|
||||
[data-theme="protanopia"] {
|
||||
/* Logo material override */
|
||||
--color-ring-primary: #1e40af; /* Blue-800 */
|
||||
|
||||
/* Primary brand colors adapted for red-blindness */
|
||||
--chorus-primary: #3a4654; /* ocean-900 replaces carbon */
|
||||
--chorus-secondary: #3a4654; /* ocean-900 replaces mulberry */
|
||||
--chorus-accent: #6a5c46; /* sand-900 warm neutral */
|
||||
--chorus-neutral: #c1bfb1; /* nickel-500 unchanged */
|
||||
--chorus-info: #1e40af; /* ocean-800 enhanced */
|
||||
--chorus-success: #6a5c46; /* sand-900 replaces eucalyptus */
|
||||
--chorus-warning: #D0C9BF; /* sand-400 enhanced */
|
||||
--chorus-danger: #4a5867; /* ocean-800 replaces coral */
|
||||
|
||||
/* Semantic color reassignments */
|
||||
--accent-primary: #3a4654; /* Ocean replaces Mulberry */
|
||||
--accent-secondary: #6a5c46; /* Sand replaces Walnut */
|
||||
--accent-system: #1e40af; /* Enhanced Ocean blue */
|
||||
}
|
||||
|
||||
/* Deuteranopia (Green-blind) - Blue/Yellow enhanced contrast */
|
||||
[data-theme="deuteranopia"] {
|
||||
/* Logo material override */
|
||||
--color-ring-primary: #6b21a8; /* Purple-800 */
|
||||
|
||||
/* Primary brand colors adapted for green-blindness */
|
||||
--chorus-primary: #0b0213; /* mulberry-950 enhanced */
|
||||
--chorus-secondary: #6b21a8; /* purple-800 distinct */
|
||||
--chorus-accent: #1e40af; /* blue-800 replaces walnut */
|
||||
--chorus-neutral: #c1bfb1; /* nickel-500 unchanged */
|
||||
--chorus-info: #1e40af; /* blue-800 enhanced */
|
||||
--chorus-success: #6b21a8; /* purple-800 replaces eucalyptus */
|
||||
--chorus-warning: #F1F0EF; /* sand-100 high contrast */
|
||||
--chorus-danger: #991b1b; /* red-800 enhanced */
|
||||
|
||||
/* Semantic color reassignments */
|
||||
--accent-primary: #6b21a8; /* Purple primary */
|
||||
--accent-secondary: #1e40af; /* Blue secondary */
|
||||
--accent-system: #991b1b; /* Red system alerts */
|
||||
}
|
||||
|
||||
/* Tritanopia (Blue-blind) - Coral/Eucalyptus substitutions */
|
||||
[data-theme="tritanopia"] {
|
||||
/* Logo material override */
|
||||
--color-ring-primary: #991b1b; /* Red-800 */
|
||||
|
||||
/* Primary brand colors adapted for blue-blindness */
|
||||
--chorus-primary: #000000; /* carbon-950 unchanged */
|
||||
--chorus-secondary: #403730; /* walnut-900 enhanced */
|
||||
--chorus-accent: #991b1b; /* red-800 replaces blue elements */
|
||||
--chorus-neutral: #c1bfb1; /* nickel-500 unchanged */
|
||||
--chorus-info: #991b1b; /* red-800 replaces ocean */
|
||||
--chorus-success: #6a5c46; /* sand-900 natural green replacement */
|
||||
--chorus-warning: #D0C9BF; /* sand-400 warm warning */
|
||||
--chorus-danger: #403730; /* walnut-900 dark contrast */
|
||||
|
||||
/* Semantic color reassignments */
|
||||
--accent-primary: #991b1b; /* Red replaces blue elements */
|
||||
--accent-secondary: #6a5c46; /* Sand warm accent */
|
||||
--accent-system: #403730; /* Walnut system elements */
|
||||
}
|
||||
|
||||
/* Achromatopsia (Complete color blindness) - High contrast grayscale only */
|
||||
[data-theme="achromatopsia"] {
|
||||
/* Logo material override */
|
||||
--color-ring-primary: #374151; /* Gray-700 */
|
||||
|
||||
/* Complete grayscale adaptation */
|
||||
--chorus-primary: #000000; /* Pure black */
|
||||
--chorus-secondary: #1a1a1a; /* Carbon-800 */
|
||||
--chorus-accent: #666666; /* Mid gray */
|
||||
--chorus-neutral: #808080; /* Neutral mid-gray */
|
||||
--chorus-info: #374151; /* Gray-700 */
|
||||
--chorus-success: #2a2a2a; /* Dark gray for success */
|
||||
--chorus-warning: #a0a0a0; /* Light gray for warnings */
|
||||
--chorus-danger: #1a1a1a; /* Very dark gray for danger */
|
||||
|
||||
/* High contrast grayscale system */
|
||||
--accent-primary: #000000; /* Pure black */
|
||||
--accent-secondary: #666666; /* Mid-gray */
|
||||
--accent-system: #374151; /* System gray */
|
||||
|
||||
/* Enhanced contrast overrides for all text and UI elements */
|
||||
--bg-primary: #ffffff;
|
||||
--bg-secondary: #f0f0f0;
|
||||
--bg-tertiary: #e0e0e0;
|
||||
--bg-accent: #c0c0c0;
|
||||
|
||||
--text-primary: #000000;
|
||||
--text-secondary: #1a1a1a;
|
||||
--text-tertiary: #333333;
|
||||
--text-subtle: #666666;
|
||||
--text-ghost: #999999;
|
||||
|
||||
--border-invisible: #f8f8f8;
|
||||
--border-subtle: #e0e0e0;
|
||||
--border-defined: #c0c0c0;
|
||||
--border-emphasis: #808080;
|
||||
}
|
||||
|
||||
|
||||
|
||||
html {
|
||||
font-feature-settings: "cv02", "cv03", "cv04", "cv11";
|
||||
scroll-behavior: smooth;
|
||||
@@ -20,6 +193,7 @@ body {
|
||||
-moz-osx-font-smoothing: grayscale;
|
||||
}
|
||||
|
||||
|
||||
/* CHORUS Blog Typography */
|
||||
.prose {
|
||||
@apply max-w-none text-carbon-950 dark:text-carbon-100;
|
||||
@@ -107,10 +281,15 @@ body {
|
||||
@apply fixed top-0 left-0 w-full h-1 bg-ocean-600 dark:bg-mulberry-600 transform origin-left scale-x-0 transition-transform z-50;
|
||||
}
|
||||
|
||||
a {
|
||||
text-decoration: none;
|
||||
text-decoration-line: none;
|
||||
}
|
||||
|
||||
/* Utilities */
|
||||
.line-clamp-3 {
|
||||
overflow: hidden;
|
||||
display: -webkit-box;
|
||||
-webkit-box-orient: vertical;
|
||||
-webkit-line-clamp: 3;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -19,8 +19,8 @@ export const metadata: Metadata = {
|
||||
title: 'CHORUS PING! - Insights on Contextual AI',
|
||||
description: 'Deep dives into contextual AI orchestration, agent coordination, and the future of intelligent systems.',
|
||||
keywords: ['contextual AI', 'agent orchestration', 'enterprise AI', 'AI insights', 'technology blog'],
|
||||
authors: [{ name: 'Anthony Lewis Rawlins', url: 'https://deepblack.cloud' }],
|
||||
creator: 'Deep Black Cloud',
|
||||
authors: [{ name: 'Anthony Rawlins', url: 'https://chorus.services' }],
|
||||
creator: 'Anthony Rawlins',
|
||||
publisher: 'CHORUS Services',
|
||||
metadataBase: new URL('https://blog.chorus.services'),
|
||||
alternates: {
|
||||
@@ -70,14 +70,14 @@ export default function RootLayout({
|
||||
<html lang="en">
|
||||
<head>
|
||||
{/* Google tag (gtag.js) */}
|
||||
<script async src="https://www.googletagmanager.com/gtag/js?id=G-WTFF8JL9SF"></script>
|
||||
<script async src="https://www.googletagmanager.com/gtag/js?id=G-RTYKD3GJ44"></script>
|
||||
<script
|
||||
dangerouslySetInnerHTML={{
|
||||
__html: `
|
||||
window.dataLayer = window.dataLayer || [];
|
||||
function gtag(){dataLayer.push(arguments);}
|
||||
gtag('js', new Date());
|
||||
gtag('config', 'G-WTFF8JL9SF');
|
||||
gtag('config', 'G-RTYKD3GJ44);
|
||||
`,
|
||||
}}
|
||||
/>
|
||||
|
||||
@@ -64,8 +64,8 @@ export default async function PostPage({ params }: PostPageProps) {
|
||||
rehypePlugins: [
|
||||
rehypeHighlight,
|
||||
rehypeSlug,
|
||||
[rehypeAutolinkHeadings, { behavior: 'wrap' }]
|
||||
],
|
||||
[rehypeAutolinkHeadings, { behavior: 'wrap' } as any]
|
||||
] as any,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -17,7 +17,7 @@ export default function BlogFooter() {
|
||||
</div>
|
||||
</div>
|
||||
<p className="text-carbon-600 dark:text-carbon-400 text-sm leading-relaxed mb-6 max-w-md">
|
||||
Insights and deep dives into contextual AI orchestration, agent coordination,
|
||||
Insights and deep dives into Context Engineering for Agentic AI and Orchestration, Agent Collaboration,
|
||||
and the future of intelligent systems.
|
||||
</p>
|
||||
<div className="flex space-x-4">
|
||||
@@ -62,16 +62,6 @@ export default function BlogFooter() {
|
||||
Platform
|
||||
</Link>
|
||||
</li>
|
||||
<li>
|
||||
<Link href="https://chorus.services/about" className="text-carbon-600 dark:text-carbon-400 hover:text-carbon-800 dark:hover:text-carbon-200 transition-colors">
|
||||
About
|
||||
</Link>
|
||||
</li>
|
||||
<li>
|
||||
<Link href="https://chorus.services/contact" className="text-carbon-600 dark:text-carbon-400 hover:text-carbon-800 dark:hover:text-carbon-200 transition-colors">
|
||||
Contact
|
||||
</Link>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -23,7 +23,7 @@ export default function BlogHeader() {
|
||||
? 'bg-white/80 dark:bg-carbon-950/80 backdrop-blur-md border-b border-carbon-200/50 dark:border-carbon-800/50'
|
||||
: 'bg-white/20 dark:bg-carbon-950/20 backdrop-blur-sm'
|
||||
}`}>
|
||||
<nav className="blog-container py-4">
|
||||
<nav className="blog-container py-chorus-lg">
|
||||
<div className="flex items-center justify-between">
|
||||
<Link href="/" className="flex items-center space-x-3 group">
|
||||
<div className="w-14 h-14 group-hover:scale-110 transition-transform">
|
||||
@@ -35,25 +35,29 @@ export default function BlogHeader() {
|
||||
</div>
|
||||
</Link>
|
||||
|
||||
|
||||
|
||||
|
||||
<div className="hidden md:flex items-center space-x-8">
|
||||
<Link
|
||||
href="https://chorus.services"
|
||||
className="text-carbon-600 dark:text-carbon-300 hover:text-carbon-900 dark:hover:text-carbon-100 transition-colors font-semibold"
|
||||
>
|
||||
Home
|
||||
</Link>
|
||||
<Link
|
||||
href="/"
|
||||
className="text-carbon-600 dark:text-carbon-300 hover:text-carbon-900 dark:hover:text-carbon-100 transition-colors font-medium"
|
||||
className="text-carbon-600 dark:text-carbon-300 hover:text-carbon-900 dark:hover:text-carbon-100 transition-colors font-semibold"
|
||||
>
|
||||
All Posts
|
||||
</Link>
|
||||
<Link
|
||||
{/* <Link
|
||||
href="/tags"
|
||||
className="text-carbon-600 dark:text-carbon-300 hover:text-carbon-900 dark:hover:text-carbon-100 transition-colors font-medium"
|
||||
className="text-carbon-600 dark:text-carbon-300 hover:text-carbon-900 dark:hover:text-carbon-100 transition-colors font-semibold"
|
||||
>
|
||||
Topics
|
||||
</Link>
|
||||
<Link
|
||||
href="https://chorus.services"
|
||||
className="text-carbon-600 dark:text-carbon-300 hover:text-carbon-900 dark:hover:text-carbon-100 transition-colors font-medium"
|
||||
>
|
||||
About CHORUS
|
||||
</Link>
|
||||
</Link> */}
|
||||
|
||||
</div>
|
||||
|
||||
<div className="flex items-center space-x-4">
|
||||
|
||||
@@ -16,12 +16,12 @@ export default function PostCard({ post, featured = false }: PostCardProps) {
|
||||
return (
|
||||
<article className={`group ${
|
||||
featured
|
||||
? 'bg-carbon-50 dark:bg-carbon-900 border border-carbon-200 dark:border-carbon-800 rounded-xl p-8 hover:border-carbon-400 dark:hover:border-mulberry-700 transition-all duration-300'
|
||||
: 'bg-carbon-100/50 dark:bg-carbon-900/50 border border-carbon-200/50 dark:border-carbon-800/50 rounded-lg p-6 hover:bg-carbon-50 dark:hover:bg-carbon-900 hover:border-carbon-300 dark:hover:border-carbon-700 transition-all duration-300'
|
||||
? 'bg-gradient-to-b from-sand-200 to-sand-100 dark:from-mulberry-900 to-mulberry-700 border border-carbon-200 dark:border-carbon-800 p-chorus-lg hover:border-carbon-400 dark:hover:border-mulberry-700 transition-all duration-300'
|
||||
: 'bg-gradient-to-b from-sand-100 to-sand-50 dark:from-mulberry-800 to-mulberry-800 border border-carbon-200/50 dark:border-carbon-800/50 p-chorus-lg hover:bg-carbon-50 dark:hover:bg-carbon-900 hover:border-carbon-300 dark:hover:border-carbon-700 transition-all duration-300'
|
||||
}`}>
|
||||
<Link href={`/posts/${post.slug}`} className="block">
|
||||
<div className="mb-4">
|
||||
<div className="flex items-center justify-between mb-3">
|
||||
<div className="mb-chorus-md">
|
||||
<div className="flex items-center justify-between mb-chorus-sm">
|
||||
<div className="blog-meta">
|
||||
<time dateTime={post.date} className="text-carbon-600 dark:text-carbon-500">
|
||||
{formattedDate}
|
||||
@@ -40,7 +40,7 @@ export default function PostCard({ post, featured = false }: PostCardProps) {
|
||||
|
||||
<h2 className={`${
|
||||
featured ? 'text-h3' : 'text-h4'
|
||||
} font-logo text-carbon-950 dark:text-carbon-100 group-hover:text-carbon-700 dark:group-hover:text-mulberry-200 transition-colors mb-3`}>
|
||||
} font-logo text-carbon-950 dark:text-carbon-100 group-hover:text-carbon-700 dark:group-hover:text-mulberry-200 transition-colors mb-chorus-lg`}>
|
||||
{post.title}
|
||||
</h2>
|
||||
|
||||
|
||||
@@ -20,8 +20,8 @@ if [ ! -d "node_modules" ]; then
|
||||
fi
|
||||
|
||||
# Start the development server
|
||||
echo "🚀 Starting Next.js development server on port 3002..."
|
||||
echo "📝 Blog will be available at: http://localhost:3002"
|
||||
echo "🚀 Starting Next.js development server on port 3025..."
|
||||
echo "📝 Blog will be available at: http://localhost:3025"
|
||||
echo "🌍 Production URL will be: https://blog.chorus.services"
|
||||
echo ""
|
||||
echo "Press Ctrl+C to stop the server"
|
||||
|
||||
@@ -2,33 +2,38 @@ version: '3.8'
|
||||
|
||||
services:
|
||||
chorus-blog:
|
||||
build:
|
||||
context: .
|
||||
dockerfile: Dockerfile
|
||||
image: registry.home.deepblack.cloud/tony/chorus-blog:latest
|
||||
ports:
|
||||
- "3002:3000"
|
||||
- "3025:3025"
|
||||
environment:
|
||||
- NODE_ENV=production
|
||||
- NEXT_TELEMETRY_DISABLED=1
|
||||
volumes:
|
||||
- ./posts:/app/posts:ro
|
||||
- ./scheduled:/app/scheduled:ro
|
||||
- /rust/containers/PING/content/posts:/app/content/posts:ro
|
||||
- /rust/containers/PING/content/scheduled:/app/content/scheduled:ro
|
||||
- blog_logs:/app/.next/cache
|
||||
labels:
|
||||
- "traefik.enable=true"
|
||||
- "traefik.docker.network=tengig"
|
||||
- "traefik.http.routers.chorus-blog.rule=Host(`blog.chorus.services`)"
|
||||
- "traefik.http.routers.chorus-blog.entrypoints=web-secured"
|
||||
- "traefik.http.routers.chorus-blog.tls.certresolver=letsencryptresolver"
|
||||
- "traefik.http.services.chorus-blog.loadbalancer.server.port=3000"
|
||||
- "traefik.http.services.chorus-blog.loadbalancer.passhostheader=true"
|
||||
deploy:
|
||||
labels:
|
||||
- "traefik.enable=true"
|
||||
- "traefik.docker.network=tengig"
|
||||
- "traefik.http.routers.chorus-blog.rule=Host(`blog.chorus.services`)"
|
||||
- "traefik.http.routers.chorus-blog.entrypoints=web-secured"
|
||||
- "traefik.http.routers.chorus-blog.tls.certresolver=letsencryptresolver"
|
||||
- "traefik.http.services.chorus-blog.loadbalancer.server.port=3025"
|
||||
- "traefik.http.services.chorus-blog.loadbalancer.passhostheader=true"
|
||||
placement:
|
||||
constraints:
|
||||
- node.hostname == walnut
|
||||
|
||||
networks:
|
||||
- tengig
|
||||
restart: unless-stopped
|
||||
- blog
|
||||
|
||||
volumes:
|
||||
blog_logs:
|
||||
|
||||
networks:
|
||||
tengig:
|
||||
external: true
|
||||
external: true
|
||||
blog:
|
||||
driver: overlay
|
||||
|
||||
164
lib/blog.ts
164
lib/blog.ts
@@ -4,82 +4,116 @@ import matter from 'gray-matter'
|
||||
import { BlogPost, BlogMeta } from '@/types/blog'
|
||||
|
||||
const postsDirectory = path.join(process.cwd(), 'content/posts')
|
||||
const scheduledDirectory = path.join(process.cwd(), 'content/scheduled')
|
||||
|
||||
// Helper function to check if a post should be published
|
||||
function shouldPublishPost(post: BlogMeta): boolean {
|
||||
// If no publishDate is set, use the date field
|
||||
const publishDate = post.publishDate || post.date
|
||||
if (!publishDate) return true
|
||||
|
||||
// Don't publish draft posts
|
||||
if (post.draft === true) return false
|
||||
|
||||
// Check if publish date has passed
|
||||
const publishDateTime = new Date(publishDate)
|
||||
const now = new Date()
|
||||
|
||||
return publishDateTime <= now
|
||||
}
|
||||
|
||||
// Helper function to recursively read markdown files from a directory
|
||||
function readMarkdownFiles(directory: string, basePath: string = ''): BlogPost[] {
|
||||
if (!fs.existsSync(directory)) {
|
||||
return []
|
||||
}
|
||||
|
||||
const items = fs.readdirSync(directory)
|
||||
const posts: BlogPost[] = []
|
||||
|
||||
items.forEach((item) => {
|
||||
const fullPath = path.join(directory, item)
|
||||
const stat = fs.statSync(fullPath)
|
||||
|
||||
if (stat.isDirectory()) {
|
||||
// Recursively read subdirectories
|
||||
const subPosts = readMarkdownFiles(fullPath, path.join(basePath, item))
|
||||
posts.push(...subPosts)
|
||||
} else if (item.endsWith('.md')) {
|
||||
try {
|
||||
// Read markdown file as string
|
||||
const fileContents = fs.readFileSync(fullPath, 'utf8')
|
||||
|
||||
// Use gray-matter to parse the post metadata section
|
||||
const matterResult = matter(fileContents)
|
||||
const meta = matterResult.data as BlogMeta
|
||||
|
||||
// Create slug from filename (remove .md extension)
|
||||
const slug = item.replace(/\.md$/, '')
|
||||
|
||||
// Calculate reading time (average 200 words per minute)
|
||||
const wordCount = matterResult.content.split(/\s+/).length
|
||||
const readingTime = Math.ceil(wordCount / 200)
|
||||
|
||||
const post: BlogPost = {
|
||||
slug,
|
||||
content: matterResult.content,
|
||||
readingTime,
|
||||
...meta,
|
||||
}
|
||||
|
||||
posts.push(post)
|
||||
} catch (error) {
|
||||
console.error(`Error reading markdown file ${fullPath}:`, error)
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
return posts
|
||||
}
|
||||
|
||||
export function getSortedPostsData(): BlogPost[] {
|
||||
// Get file names under /content/posts
|
||||
const fileNames = fs.readdirSync(postsDirectory)
|
||||
const allPostsData = fileNames
|
||||
.filter((fileName) => fileName.endsWith('.md'))
|
||||
.map((fileName) => {
|
||||
// Remove ".md" from file name to get id
|
||||
const slug = fileName.replace(/\.md$/, '')
|
||||
|
||||
// Read markdown file as string
|
||||
const fullPath = path.join(postsDirectory, fileName)
|
||||
const fileContents = fs.readFileSync(fullPath, 'utf8')
|
||||
|
||||
// Use gray-matter to parse the post metadata section
|
||||
const matterResult = matter(fileContents)
|
||||
const meta = matterResult.data as BlogMeta
|
||||
|
||||
// Calculate reading time (average 200 words per minute)
|
||||
const wordCount = matterResult.content.split(/\s+/).length
|
||||
const readingTime = Math.ceil(wordCount / 200)
|
||||
|
||||
return {
|
||||
slug,
|
||||
content: matterResult.content,
|
||||
readingTime,
|
||||
...meta,
|
||||
} as BlogPost
|
||||
})
|
||||
|
||||
// Sort posts by date
|
||||
return allPostsData.sort((a, b) => {
|
||||
if (a.date < b.date) {
|
||||
return 1
|
||||
} else {
|
||||
return -1
|
||||
}
|
||||
// Read posts from both published and scheduled directories
|
||||
const publishedPosts = readMarkdownFiles(postsDirectory)
|
||||
const scheduledPosts = readMarkdownFiles(scheduledDirectory)
|
||||
|
||||
// Combine all posts
|
||||
const allPosts = [...publishedPosts, ...scheduledPosts]
|
||||
|
||||
// Filter posts that should be published
|
||||
const publishablePosts = allPosts.filter(post => shouldPublishPost(post))
|
||||
|
||||
// Sort posts by date (newest first)
|
||||
return publishablePosts.sort((a, b) => {
|
||||
const dateA = new Date(a.date)
|
||||
const dateB = new Date(b.date)
|
||||
return dateB.getTime() - dateA.getTime()
|
||||
})
|
||||
}
|
||||
|
||||
export function getAllPostSlugs() {
|
||||
const fileNames = fs.readdirSync(postsDirectory)
|
||||
return fileNames
|
||||
.filter((fileName) => fileName.endsWith('.md'))
|
||||
.map((fileName) => {
|
||||
return {
|
||||
params: {
|
||||
slug: fileName.replace(/\.md$/, ''),
|
||||
},
|
||||
}
|
||||
})
|
||||
// Get all publishable posts and extract their slugs
|
||||
const posts = getSortedPostsData()
|
||||
return posts.map((post) => {
|
||||
return {
|
||||
params: {
|
||||
slug: post.slug,
|
||||
},
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
export function getPostData(slug: string): BlogPost | null {
|
||||
try {
|
||||
const fullPath = path.join(postsDirectory, `${slug}.md`)
|
||||
const fileContents = fs.readFileSync(fullPath, 'utf8')
|
||||
|
||||
// Use gray-matter to parse the post metadata section
|
||||
const matterResult = matter(fileContents)
|
||||
const meta = matterResult.data as BlogMeta
|
||||
|
||||
// Calculate reading time (average 200 words per minute)
|
||||
const wordCount = matterResult.content.split(/\s+/).length
|
||||
const readingTime = Math.ceil(wordCount / 200)
|
||||
|
||||
return {
|
||||
slug,
|
||||
content: matterResult.content,
|
||||
readingTime,
|
||||
...meta,
|
||||
} as BlogPost
|
||||
} catch (error) {
|
||||
console.error(`Error reading post ${slug}:`, error)
|
||||
// Find the post in our sorted posts data
|
||||
const posts = getSortedPostsData()
|
||||
const post = posts.find(p => p.slug === slug)
|
||||
|
||||
if (!post) {
|
||||
console.error(`Post with slug '${slug}' not found or not yet publishable`)
|
||||
return null
|
||||
}
|
||||
|
||||
return post
|
||||
}
|
||||
|
||||
export function getFeaturedPosts(): BlogPost[] {
|
||||
|
||||
@@ -1,16 +1,16 @@
|
||||
{
|
||||
"name": "chorus-ping",
|
||||
"version": "1.0.0",
|
||||
"version": "1.0.1",
|
||||
"description": "CHORUS PING! - Static blog posts using Next.js",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"dev": "next dev -p 3002",
|
||||
"dev": "next dev -p 3024",
|
||||
"build": "next build",
|
||||
"start": "next start -p 3002",
|
||||
"start": "next start -p 3025",
|
||||
"lint": "next lint",
|
||||
"type-check": "tsc --noEmit",
|
||||
"docker:build": "docker build -t chorus-blog .",
|
||||
"docker:run": "docker run -p 3002:3000 chorus-blog",
|
||||
"docker:run": "docker run -p 3025:3025 chorus-blog",
|
||||
"docker:compose": "docker-compose up -d"
|
||||
},
|
||||
"dependencies": {
|
||||
|
||||
@@ -4,6 +4,8 @@ export interface BlogPost {
|
||||
description: string
|
||||
content: string
|
||||
date: string
|
||||
publishDate?: string
|
||||
draft?: boolean
|
||||
author: {
|
||||
name: string
|
||||
avatar?: string
|
||||
@@ -19,6 +21,8 @@ export interface BlogMeta {
|
||||
title: string
|
||||
description: string
|
||||
date: string
|
||||
publishDate?: string
|
||||
draft?: boolean
|
||||
author: {
|
||||
name: string
|
||||
avatar?: string
|
||||
|
||||
Reference in New Issue
Block a user