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:
Kev
2026-07-17 15:56:09 -04:00
parent 41fc2b90e2
commit 3b7a1f59bc
8 changed files with 78 additions and 27 deletions
+8
View File
@@ -45,6 +45,14 @@ const nextConfig: NextConfig = {
},
poweredByHeader: false,
reactStrictMode: true,
async redirects() {
return [
// Truth-Everywhere Part 2 (item 8) — the backdated 2026-03-22
// "line-movement-guide" orphan is retired. Any inbound link 301s to the
// real blog index (the 5 committed articles).
{ source: '/blog/line-movement-guide', destination: '/blog', permanent: true },
];
},
async headers() {
return [
{
+5 -4
View File
@@ -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
View File
@@ -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, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;');
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');
}