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
+2 -1
View File
@@ -1,9 +1,10 @@
---
title: How Streaks Lie
slug: how-streaks-lie
date: '2026-07-17'
excerpt: Most hot streaks are soft schedules wearing a costume. How to read a streak against what it was built on, the way the VYNDR lens does.
tags: [streaks, the-read-room]
status: draft
status: published
---
Most hot streaks are soft schedules wearing a costume.
+2 -1
View File
@@ -1,9 +1,10 @@
---
title: How VYNDR Grades a Prop
slug: how-vyndr-grades-a-prop
date: '2026-07-17'
excerpt: From the line feed to the letter. What actually happens inside the pipeline, including the part where the model refuses.
tags: [methodology, the-read-room]
status: draft
status: published
---
Most grading products are a vibe wearing a letter. Here is what ours actually does.
+2 -1
View File
@@ -1,9 +1,10 @@
---
title: "The VYNDR Originals: 41 Archetypes and Why They Exist"
slug: the-vyndr-originals
date: '2026-07-17'
excerpt: TORCH, BOMBER, GHOST, CONDUCTOR. What the archetype system is, how a player earns one, and why it changes which props you should trust.
tags: [archetypes, the-read-room]
status: draft
status: published
---
Every player profile on VYNDR carries a word like TORCH or BOMBER. This is not decoration. It is the compressed answer to a question the box score never asks: how does this player produce, and which of his props can you actually trust.
+2 -1
View File
@@ -1,9 +1,10 @@
---
title: "CLV: The Only Number That Proves an Edge"
slug: what-clv-is
date: '2026-07-17'
excerpt: Closing line value in plain language. Why beating the close matters more than winning the bet, and how VYNDR captures it on every grade.
tags: [clv, methodology, the-read-room]
status: draft
status: published
---
You can win a bet with a bad read and lose one with a great read. That is variance, and it is why the win-loss column — the number everyone stares at — is the slowest, noisiest possible way to judge a model. There is a faster, cleaner number. The industry calls it CLV: closing line value.
@@ -1,9 +1,10 @@
---
title: Why Our Misses Are Public
slug: why-our-misses-are-public
date: '2026-07-17'
excerpt: Every miss stays on the Ledger, by name, in the same format as the wins, permanently. The two reasons, and why nobody else in the industry can copy this.
tags: [the-ledger, methodology]
status: draft
status: published
---
Open the VYNDR Ledger on any morning and you will find last night's misses listed by name, in the same format, the same placement, and the same energy as the wins. Wilson over 23.5 settled at 19. It is on the record. It stays on the record.
+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
+43 -6
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,10 +23,11 @@ export function getAllPosts(): BlogPost[] {
const files = fs.readdirSync(BLOG_DIR).filter((f) => f.endsWith('.mdx') || f.endsWith('.md'));
const posts = files.map((file) => {
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 slug = data.slug || file.replace(/\.mdx?$/, '');
const wordCount = content.split(/\s+/).length;
const readingTime = Math.max(1, Math.ceil(wordCount / 200));
@@ -30,17 +35,49 @@ export function getAllPosts(): BlogPost[] {
slug,
title: data.title || slug,
date: data.date || '',
description: data.description || '',
// 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');
}