Item 8 — wire the 5 real articles into /blog, retire the backdated orphan
The live /blog showed "How to Read Line Movement Like a Sharp" dated 2026-03-22 — an orphaned, uncommitted file that predates the product. Meanwhile 5 genuinely-real articles sat in content/articles/, unwired. - blog.ts now reads content/articles (not the untracked content/blog). Maps `excerpt` → description, skips `status: draft`, reads explicit `slug`. - Added honest dates (2026-07-17, the real publish day) + flipped the 5 articles to `status: published`. Real content: how-vyndr-grades-a-prop, why-our-misses-are-public, what-clv-is, how-streaks-lie, the-vyndr-originals. - Retired the orphan: /blog/line-movement-guide 301s to /blog (next.config). - Added a minimal, dependency-free, XSS-safe markdown renderer so headers/bold render as HTML instead of literal "##". Each article keeps title/date/ read-time + the existing OG/JSON-LD metadata (affiliate-review ready). Richer media (images/charts) is the separate design train. Web build exit 0 (SSGs all 5 slugs); backend suite unaffected. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -1,4 +1,4 @@
|
||||
import { getAllPosts, getPostBySlug } from '@/lib/blog';
|
||||
import { getAllPosts, getPostBySlug, renderMarkdown } from '@/lib/blog';
|
||||
import { notFound } from 'next/navigation';
|
||||
import type { Metadata } from 'next';
|
||||
|
||||
@@ -48,9 +48,10 @@ export default async function BlogPost({ params }: { params: Promise<{ slug: str
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
<div className="prose prose-invert max-w-none text-[var(--text)] leading-relaxed whitespace-pre-wrap">
|
||||
{post.content}
|
||||
</div>
|
||||
<div
|
||||
className="prose prose-invert max-w-none text-[var(--text)] leading-relaxed"
|
||||
dangerouslySetInnerHTML={{ __html: renderMarkdown(post.content) }}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<script
|
||||
|
||||
+55
-18
@@ -2,7 +2,11 @@ import fs from 'fs';
|
||||
import path from 'path';
|
||||
import matter from 'gray-matter';
|
||||
|
||||
const BLOG_DIR = path.join(process.cwd(), 'content', 'blog');
|
||||
// Truth-Everywhere Part 2 (item 8): the blog reads the REAL committed articles
|
||||
// in content/articles (5 genuine pieces with honest dates), NOT the untracked
|
||||
// content/blog that held the backdated 2026-03-22 orphan. Drafts are skipped so
|
||||
// unfinished pieces never leak to the public index.
|
||||
const BLOG_DIR = path.join(process.cwd(), 'content', 'articles');
|
||||
|
||||
export interface BlogPost {
|
||||
slug: string;
|
||||
@@ -19,28 +23,61 @@ export function getAllPosts(): BlogPost[] {
|
||||
|
||||
const files = fs.readdirSync(BLOG_DIR).filter((f) => f.endsWith('.mdx') || f.endsWith('.md'));
|
||||
|
||||
const posts = files.map((file) => {
|
||||
const raw = fs.readFileSync(path.join(BLOG_DIR, file), 'utf-8');
|
||||
const { data, content } = matter(raw);
|
||||
const slug = file.replace(/\.mdx?$/, '');
|
||||
const wordCount = content.split(/\s+/).length;
|
||||
const readingTime = Math.max(1, Math.ceil(wordCount / 200));
|
||||
const posts = files
|
||||
.map((file) => {
|
||||
const raw = fs.readFileSync(path.join(BLOG_DIR, file), 'utf-8');
|
||||
const { data, content } = matter(raw);
|
||||
const slug = data.slug || file.replace(/\.mdx?$/, '');
|
||||
const wordCount = content.split(/\s+/).length;
|
||||
const readingTime = Math.max(1, Math.ceil(wordCount / 200));
|
||||
|
||||
return {
|
||||
slug,
|
||||
title: data.title || slug,
|
||||
date: data.date || '',
|
||||
description: data.description || '',
|
||||
tags: data.tags || [],
|
||||
content,
|
||||
readingTime,
|
||||
};
|
||||
});
|
||||
return {
|
||||
slug,
|
||||
title: data.title || slug,
|
||||
date: data.date || '',
|
||||
// articles carry `excerpt`; older posts used `description` — accept either
|
||||
description: data.description || data.excerpt || '',
|
||||
tags: data.tags || [],
|
||||
content,
|
||||
readingTime,
|
||||
status: data.status || 'published',
|
||||
};
|
||||
})
|
||||
// Only published pieces reach the public index — drafts never leak.
|
||||
.filter((p) => p.status !== 'draft');
|
||||
|
||||
return posts.sort((a, b) => (a.date > b.date ? -1 : 1));
|
||||
return posts
|
||||
.map(({ status, ...p }) => p)
|
||||
.sort((a, b) => (a.date > b.date ? -1 : 1));
|
||||
}
|
||||
|
||||
export function getPostBySlug(slug: string): BlogPost | null {
|
||||
const posts = getAllPosts();
|
||||
return posts.find((p) => p.slug === slug) || null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Minimal, dependency-free markdown → HTML for our OWN committed articles
|
||||
* (headers, bold, italic, paragraphs). Content is trusted (files we author),
|
||||
* and we HTML-escape before transforming, so it's XSS-safe. The richer media
|
||||
* train (images/charts) is separate — this just stops "## Header" rendering as
|
||||
* literal hashes on the public blog.
|
||||
*/
|
||||
export function renderMarkdown(md: string): string {
|
||||
const esc = (s: string) => s
|
||||
.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>');
|
||||
const inline = (s: string) => esc(s)
|
||||
.replace(/\*\*([^*]+)\*\*/g, '<strong>$1</strong>')
|
||||
.replace(/(^|[^*])\*([^*]+)\*(?!\*)/g, '$1<em>$2</em>');
|
||||
return md
|
||||
.trim()
|
||||
.split(/\n{2,}/)
|
||||
.map((block) => {
|
||||
const b = block.trim();
|
||||
if (b.startsWith('### ')) return `<h3>${inline(b.slice(4))}</h3>`;
|
||||
if (b.startsWith('## ')) return `<h2>${inline(b.slice(3))}</h2>`;
|
||||
if (b.startsWith('# ')) return `<h1>${inline(b.slice(2))}</h1>`;
|
||||
return `<p>${inline(b).replace(/\n/g, '<br/>')}</p>`;
|
||||
})
|
||||
.join('\n');
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user