diff --git a/packages/framework/src/content.test.ts b/packages/framework/src/content.test.ts new file mode 100644 index 0000000..b8f7d6a --- /dev/null +++ b/packages/framework/src/content.test.ts @@ -0,0 +1,57 @@ +import fs from 'node:fs'; +import os from 'node:os'; +import path from 'node:path'; +import { afterEach, beforeEach, describe, expect, it } from 'vitest'; +import { loadBlogPosts, loadChangelogEntries } from './content'; + +let tmpRoot: string; +let prevContentRoot: string | undefined; + +beforeEach(() => { + tmpRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'inkform-content-test-')); + prevContentRoot = process.env.DOCS_CONTENT_ROOT; + process.env.DOCS_CONTENT_ROOT = tmpRoot; +}); + +afterEach(() => { + fs.rmSync(tmpRoot, { recursive: true, force: true }); + if (prevContentRoot === undefined) delete process.env.DOCS_CONTENT_ROOT; + else process.env.DOCS_CONTENT_ROOT = prevContentRoot; +}); + +function writeMdx(dir: string, file: string, frontmatter: string, body = 'Body.') { + fs.mkdirSync(dir, { recursive: true }); + fs.writeFileSync(path.join(dir, file), `---\n${frontmatter}\n---\n${body}\n`); +} + +describe('loadBlogPosts date parsing', () => { + it('reads an unquoted YAML date (parsed as a Date, not a string)', () => { + const blogDir = path.join(tmpRoot, 'blog'); + writeMdx(blogDir, 'unquoted.mdx', 'title: Unquoted\ndate: 2026-01-25\nstatus: published'); + writeMdx(blogDir, 'quoted.mdx', "title: Quoted\ndate: '2026-01-20'\nstatus: published"); + + const posts = loadBlogPosts(); + const bySlug = new Map(posts.map((p) => [p.slug, p])); + + expect(bySlug.get('unquoted')?.date).toBe('2026-01-25'); + expect(bySlug.get('quoted')?.date).toBe('2026-01-20'); + // Newest first — regression check for the '' sorting-last bug. + expect(posts.map((p) => p.slug)).toEqual(['unquoted', 'quoted']); + }); + + it('falls back to an empty string for a genuinely missing date', () => { + const blogDir = path.join(tmpRoot, 'blog'); + writeMdx(blogDir, 'no-date.mdx', 'title: No date\nstatus: published'); + const posts = loadBlogPosts(); + expect(posts[0].date).toBe(''); + }); +}); + +describe('loadChangelogEntries date parsing', () => { + it('reads an unquoted YAML date the same way as blog posts', () => { + const changelogDir = path.join(tmpRoot, 'changelog'); + writeMdx(changelogDir, '2026-02-10-v2.mdx', 'title: v2\ndate: 2026-02-10\nstatus: published'); + const entries = loadChangelogEntries(); + expect(entries[0].date).toBe('2026-02-10'); + }); +}); diff --git a/packages/framework/src/content.ts b/packages/framework/src/content.ts index 9016488..7a2dbb4 100644 --- a/packages/framework/src/content.ts +++ b/packages/framework/src/content.ts @@ -56,6 +56,19 @@ export function readingTimeMinutes(content: string): number { return Math.max(1, Math.round(words / 200)); } +/** + * Frontmatter `date` as `YYYY-MM-DD`. YAML parses an unquoted date like + * `date: 2026-01-25` as a `Date` at UTC midnight, not a string — gray-matter + * passes that through as-is, so a plain `typeof === 'string'` check drops it + * to `''` (sorts last, renders "Invalid Date"). Read it off the `Date` in UTC + * so the day doesn't shift for authors west of UTC. + */ +function toDateString(v: unknown): string { + if (typeof v === 'string') return v.slice(0, 10); + if (v instanceof Date && !Number.isNaN(v.getTime())) return v.toISOString().slice(0, 10); + return ''; +} + function plainExcerpt(content: string, max = 200): string { const text = content .replace(/^---[\s\S]*?---/, '') @@ -91,7 +104,7 @@ function toBlogPost(fileSlug: string, data: Record, content: st return { slug: str(data.slug) ?? fileSlug, title: str(data.title) ?? 'Untitled', - date: typeof data.date === 'string' ? data.date.slice(0, 10) : '', + date: toDateString(data.date), author: str(data.author), tags: Array.isArray(data.tags) ? (data.tags.filter((t) => typeof t === 'string') as string[]) : [], status: str(data.status) ?? 'published', @@ -166,7 +179,7 @@ export function loadChangelogEntries(dir = 'changelog', includeDrafts = false): if (!parsed) return null; const d = parsed.data; const str = (v: unknown) => (typeof v === 'string' && v.trim() ? v : null); - const date = typeof d.date === 'string' ? d.date.slice(0, 10) : ''; + const date = toDateString(d.date); const status = str(d.status) ?? 'published'; return { slug: f.replace(/\.mdx$/, '').replace(/^\d{4}-\d{2}-\d{2}-/, ''),