Skip to content

Commit 0926837

Browse files
backnotpropclaude
andauthored
Render YAML frontmatter as styled metadata card (#45)
Fixes #43 - YAML frontmatter was rendering as ugly text because the parser treated it as regular markdown content. Changes: - Added extractFrontmatter() to parser that parses YAML key-value pairs - Added FrontmatterCard component that renders frontmatter nicely - Supports string values and arrays (rendered as tags) - Card appears at top of plan with muted background Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
1 parent 963332c commit 0926837

File tree

5 files changed

+110
-4
lines changed

5 files changed

+110
-4
lines changed

.gitignore

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -30,3 +30,5 @@ apps/opencode-plugin/plannotator.html
3030
*.sw?
3131
opencode.json
3232
plannotator-local
33+
# Local research/reference docs (not for repo)
34+
reference/

packages/editor/App.tsx

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
import React, { useState, useEffect, useMemo, useRef } from 'react';
2-
import { parseMarkdownToBlocks, exportDiff } from '@plannotator/ui/utils/parser';
2+
import { parseMarkdownToBlocks, exportDiff, extractFrontmatter, Frontmatter } from '@plannotator/ui/utils/parser';
33
import { Viewer, ViewerHandle } from '@plannotator/ui/components/Viewer';
44
import { AnnotationPanel } from '@plannotator/ui/components/AnnotationPanel';
55
import { ExportModal } from '@plannotator/ui/components/ExportModal';
@@ -301,6 +301,7 @@ const App: React.FC = () => {
301301
const [annotations, setAnnotations] = useState<Annotation[]>([]);
302302
const [selectedAnnotationId, setSelectedAnnotationId] = useState<string | null>(null);
303303
const [blocks, setBlocks] = useState<Block[]>([]);
304+
const [frontmatter, setFrontmatter] = useState<Frontmatter | null>(null);
304305
const [showExport, setShowExport] = useState(false);
305306
const [showFeedbackPrompt, setShowFeedbackPrompt] = useState(false);
306307
const [showClaudeCodeWarning, setShowClaudeCodeWarning] = useState(false);
@@ -384,6 +385,8 @@ const App: React.FC = () => {
384385
}, [isLoadingShared, isSharedSession]);
385386

386387
useEffect(() => {
388+
const { frontmatter: fm } = extractFrontmatter(markdown);
389+
setFrontmatter(fm);
387390
setBlocks(parseMarkdownToBlocks(markdown));
388391
}, [markdown]);
389392

@@ -678,6 +681,7 @@ const App: React.FC = () => {
678681
ref={viewerRef}
679682
blocks={blocks}
680683
markdown={markdown}
684+
frontmatter={frontmatter}
681685
annotations={annotations}
682686
onAddAnnotation={handleAddAnnotation}
683687
onSelectAnnotation={setSelectedAnnotationId}

packages/ui/components/Viewer.tsx

Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ import Highlighter from 'web-highlighter';
44
import hljs from 'highlight.js';
55
import 'highlight.js/styles/github-dark.css';
66
import { Block, Annotation, AnnotationType, EditorMode } from '../types';
7+
import { Frontmatter } from '../utils/parser';
78
import { Toolbar } from './Toolbar';
89
import { TaterSpriteSitting } from './TaterSpriteSitting';
910
import { AttachmentsButton } from './AttachmentsButton';
@@ -12,6 +13,7 @@ import { getIdentity } from '../utils/identity';
1213
interface ViewerProps {
1314
blocks: Block[];
1415
markdown: string;
16+
frontmatter?: Frontmatter | null;
1517
annotations: Annotation[];
1618
onAddAnnotation: (ann: Annotation) => void;
1719
onSelectAnnotation: (id: string | null) => void;
@@ -29,9 +31,43 @@ export interface ViewerHandle {
2931
applySharedAnnotations: (annotations: Annotation[]) => void;
3032
}
3133

34+
/**
35+
* Renders YAML frontmatter as a styled metadata card.
36+
*/
37+
const FrontmatterCard: React.FC<{ frontmatter: Frontmatter }> = ({ frontmatter }) => {
38+
const entries = Object.entries(frontmatter);
39+
if (entries.length === 0) return null;
40+
41+
return (
42+
<div className="mt-4 mb-6 p-4 bg-muted/30 border border-border/50 rounded-lg">
43+
<div className="grid gap-2 text-sm">
44+
{entries.map(([key, value]) => (
45+
<div key={key} className="flex gap-2">
46+
<span className="font-medium text-muted-foreground min-w-[80px]">{key}:</span>
47+
<span className="text-foreground">
48+
{Array.isArray(value) ? (
49+
<span className="flex flex-wrap gap-1">
50+
{value.map((v, i) => (
51+
<span key={i} className="px-1.5 py-0.5 bg-primary/10 text-primary rounded text-xs">
52+
{v}
53+
</span>
54+
))}
55+
</span>
56+
) : (
57+
value
58+
)}
59+
</span>
60+
</div>
61+
))}
62+
</div>
63+
</div>
64+
);
65+
};
66+
3267
export const Viewer = forwardRef<ViewerHandle, ViewerProps>(({
3368
blocks,
3469
markdown,
70+
frontmatter,
3571
annotations,
3672
onAddAnnotation,
3773
onSelectAnnotation,
@@ -622,6 +658,7 @@ export const Viewer = forwardRef<ViewerHandle, ViewerProps>(({
622658
)}
623659
</button>
624660
</div>
661+
{frontmatter && <FrontmatterCard frontmatter={frontmatter} />}
625662
{blocks.map(block => (
626663
block.type === 'code' ? (
627664
<CodeBlock

packages/ui/utils/parser.ts

Lines changed: 64 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,75 @@
11
import { Block } from '../types';
22

3+
/**
4+
* Parsed YAML frontmatter as key-value pairs.
5+
*/
6+
export interface Frontmatter {
7+
[key: string]: string | string[];
8+
}
9+
10+
/**
11+
* Extract YAML frontmatter from markdown if present.
12+
* Returns both the parsed frontmatter and the remaining markdown.
13+
*/
14+
export function extractFrontmatter(markdown: string): { frontmatter: Frontmatter | null; content: string } {
15+
const trimmed = markdown.trimStart();
16+
if (!trimmed.startsWith('---')) {
17+
return { frontmatter: null, content: markdown };
18+
}
19+
20+
// Find the closing ---
21+
const endIndex = trimmed.indexOf('\n---', 3);
22+
if (endIndex === -1) {
23+
return { frontmatter: null, content: markdown };
24+
}
25+
26+
// Extract frontmatter content (between the --- delimiters)
27+
const frontmatterRaw = trimmed.slice(4, endIndex).trim();
28+
const afterFrontmatter = trimmed.slice(endIndex + 4).trimStart();
29+
30+
// Parse simple YAML (key: value pairs)
31+
const frontmatter: Frontmatter = {};
32+
let currentKey: string | null = null;
33+
let currentArray: string[] | null = null;
34+
35+
for (const line of frontmatterRaw.split('\n')) {
36+
const trimmedLine = line.trim();
37+
38+
// Array item (- value)
39+
if (trimmedLine.startsWith('- ') && currentKey) {
40+
const value = trimmedLine.slice(2).trim();
41+
if (!currentArray) {
42+
currentArray = [];
43+
frontmatter[currentKey] = currentArray;
44+
}
45+
currentArray.push(value);
46+
continue;
47+
}
48+
49+
// Key: value pair
50+
const colonIndex = trimmedLine.indexOf(':');
51+
if (colonIndex > 0) {
52+
currentKey = trimmedLine.slice(0, colonIndex).trim();
53+
const value = trimmedLine.slice(colonIndex + 1).trim();
54+
currentArray = null;
55+
56+
if (value) {
57+
frontmatter[currentKey] = value;
58+
}
59+
}
60+
}
61+
62+
return { frontmatter, content: afterFrontmatter };
63+
}
64+
365
/**
466
* A simplified markdown parser that splits content into linear blocks.
567
* For a production app, we would use a robust AST walker (remark),
668
* but for this demo, we want predictable text-anchoring.
769
*/
870
export const parseMarkdownToBlocks = (markdown: string): Block[] => {
9-
const lines = markdown.split('\n');
71+
const { content: cleanMarkdown } = extractFrontmatter(markdown);
72+
const lines = cleanMarkdown.split('\n');
1073
const blocks: Block[] = [];
1174
let currentId = 0;
1275

tests/manual/local/test-hook.sh

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -28,11 +28,11 @@ echo "Starting hook server..."
2828
echo "Browser should open automatically. Approve or deny the plan."
2929
echo ""
3030

31-
# Sample plan with code blocks (for tag extraction testing)
31+
# Sample plan with YAML frontmatter and code blocks
3232
PLAN_JSON=$(cat << 'EOF'
3333
{
3434
"tool_input": {
35-
"plan": "# Implementation Plan: User Authentication\n\n## Overview\nAdd secure user authentication using JWT tokens and bcrypt password hashing.\n\n## Phase 1: Database Schema\n\n```sql\nCREATE TABLE users (\n id UUID PRIMARY KEY,\n email VARCHAR(255) UNIQUE NOT NULL,\n password_hash VARCHAR(255) NOT NULL,\n created_at TIMESTAMP DEFAULT NOW()\n);\n```\n\n## Phase 2: API Endpoints\n\n```typescript\n// POST /auth/register\napp.post('/auth/register', async (req, res) => {\n const { email, password } = req.body;\n const hash = await bcrypt.hash(password, 10);\n // ... create user\n});\n\n// POST /auth/login\napp.post('/auth/login', async (req, res) => {\n // ... verify credentials\n const token = jwt.sign({ userId }, SECRET);\n res.json({ token });\n});\n```\n\n## Checklist\n\n- [ ] Set up database migrations\n- [ ] Implement password hashing\n- [ ] Add JWT token generation\n- [ ] Create login/register endpoints\n- [x] Design database schema\n\n---\n\n**Target:** Complete by end of sprint"
35+
"plan": "---\ntitle: User Authentication Plan\nauthor: Claude\ndate: 2026-01-09\ntags:\n - auth\n - security\n - backend\n---\n\n# Implementation Plan: User Authentication\n\n## Overview\nAdd secure user authentication using JWT tokens and bcrypt password hashing.\n\n## Phase 1: Database Schema\n\n```sql\nCREATE TABLE users (\n id UUID PRIMARY KEY,\n email VARCHAR(255) UNIQUE NOT NULL,\n password_hash VARCHAR(255) NOT NULL,\n created_at TIMESTAMP DEFAULT NOW()\n);\n```\n\n## Phase 2: API Endpoints\n\n```typescript\n// POST /auth/register\napp.post('/auth/register', async (req, res) => {\n const { email, password } = req.body;\n const hash = await bcrypt.hash(password, 10);\n // ... create user\n});\n\n// POST /auth/login\napp.post('/auth/login', async (req, res) => {\n // ... verify credentials\n const token = jwt.sign({ userId }, SECRET);\n res.json({ token });\n});\n```\n\n## Checklist\n\n- [ ] Set up database migrations\n- [ ] Implement password hashing\n- [ ] Add JWT token generation\n- [ ] Create login/register endpoints\n- [x] Design database schema\n\n---\n\n**Target:** Complete by end of sprint"
3636
}
3737
}
3838
EOF

0 commit comments

Comments
 (0)