Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
58 changes: 58 additions & 0 deletions app/globals.css
Original file line number Diff line number Diff line change
Expand Up @@ -781,6 +781,64 @@ pre, code {
padding: 0;
}

/* YAML frontmatter metadata card (file preview) */
.markdown-frontmatter {
margin-bottom: 20px;
padding: 14px 16px 12px;
border: 1px solid var(--border);
border-radius: 8px;
background: var(--bg-panel);
}
.markdown-frontmatter-title {
font-size: 1.3em;
font-weight: 600;
line-height: 1.35;
color: var(--text);
}
.markdown-frontmatter-tags {
display: flex;
flex-wrap: wrap;
gap: 6px;
margin-top: 8px;
}
.markdown-frontmatter-tag {
font-size: 12px;
line-height: 1.6;
color: var(--text-muted);
background: var(--bg-subtle);
border: 1px solid var(--border);
border-radius: 999px;
padding: 0 10px;
}
.markdown-frontmatter-rows {
display: grid;
grid-template-columns: auto 1fr;
gap: 5px 14px;
margin: 10px 0 0;
font-size: 13px;
line-height: 1.55;
}
.markdown-frontmatter-row {
display: contents;
}
.markdown-frontmatter-row dt {
color: var(--text-muted);
font-family: var(--font-mono);
font-size: 0.92em;
white-space: nowrap;
}
.markdown-frontmatter-row dd {
margin: 0;
color: var(--text);
min-width: 0;
word-break: break-word;
}
.markdown-frontmatter-row dd a {
color: var(--accent);
text-decoration: underline;
text-underline-offset: 2px;
}

/* syntax highlighter line numbers */
.markdown-body span.linenumber,
span.linenumber {
Expand Down
21 changes: 20 additions & 1 deletion bun.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

8 changes: 8 additions & 0 deletions components/FileViewer.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -19,8 +19,10 @@ import {
} from "@/lib/file-types";
import { encodeFilePathForApi, getFileDirectory, getFileName, getRelativeFilePath } from "@/lib/file-paths";
import { resolveLocalFileHref } from "@/lib/file-links";
import { parseFrontmatter } from "@/lib/frontmatter";
import { markdownPreviewRehypePlugins, markdownPreviewRemarkPlugins, normalizeDisplayMath } from "@/lib/markdown";
import { CodeBlock, MermaidBlock } from "./MermaidBlock";
import { FrontmatterCard } from "./FrontmatterCard";
import { parseUnifiedPatch } from "@/lib/patch";
import type { GitFileDiffResponse } from "@/lib/git-types";
import { useI18n } from "@/hooks/useI18n";
Expand Down Expand Up @@ -944,6 +946,11 @@ function TextFileViewer({ filePath, cwd, sourceSessionId, onOpenFile, onMentionL
[data],
);

const frontmatter = useMemo(
() => (data?.language === "markdown" ? parseFrontmatter(data.content) : null),
[data],
);

useEffect(() => {
const updateSelectedLineRange = () => {
const root = contentRef.current;
Expand Down Expand Up @@ -1158,6 +1165,7 @@ function TextFileViewer({ filePath, cwd, sourceSessionId, onOpenFile, onMentionL
className="markdown-body markdown-file-preview"
style={{ padding: "24px 32px" }}
>
{frontmatter?.data && <FrontmatterCard data={frontmatter.data} />}
<ReactMarkdown
remarkPlugins={markdownPreviewRemarkPlugins}
rehypePlugins={markdownPreviewRehypePlugins}
Expand Down
70 changes: 70 additions & 0 deletions components/FrontmatterCard.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,70 @@
"use client";

import type { ReactNode } from "react";
import { formatFrontmatterValue, getFrontmatterTitle } from "@/lib/frontmatter";

interface FrontmatterCardProps {
data: Record<string, unknown> | null;
}

const TAG_KEYS = ["tags", "categories", "keywords", "tag", "category"];

function isUrl(value: string): boolean {
return /^(https?:\/\/|mailto:)/i.test(value);
}

function renderValue(value: unknown): ReactNode {
const text = formatFrontmatterValue(value);
if (!text) return null;
if (typeof value === "string" && isUrl(value)) {
// Only safe schemes — values come from the user's own file but stay escaped
// by React regardless; this just prevents javascript: hrefs.
return (
<a href={value} target="_blank" rel="noopener noreferrer">
{text}
</a>
);
}
// Arrays are rendered as inline text; anything else keeps its plain text form.
return text;
}

export function FrontmatterCard({ data }: FrontmatterCardProps) {
if (!data) return null;
const entries = Object.entries(data);
if (entries.length === 0) return null;

const title = getFrontmatterTitle(data.title);

const tagKey = TAG_KEYS.find((key) => Array.isArray(data[key]));
const tags = tagKey
? (data[tagKey] as unknown[]).map(formatFrontmatterValue).filter(Boolean)
: [];

const rows = entries.filter(([key]) => key !== tagKey && (key !== "title" || !title));

return (
<div className="markdown-frontmatter">
{title && <div className="markdown-frontmatter-title">{title}</div>}
{tags.length > 0 && (
<div className="markdown-frontmatter-tags">
{tags.map((tag, index) => (
<span className="markdown-frontmatter-tag" key={`${tag}-${index}`}>
{tag}
</span>
))}
</div>
)}
{rows.length > 0 && (
<dl className="markdown-frontmatter-rows">
{rows.map(([key, value]) => (
<div className="markdown-frontmatter-row" key={key}>
<dt>{key}</dt>
<dd>{renderValue(value)}</dd>
</div>
))}
</dl>
)}
</div>
);
}
55 changes: 55 additions & 0 deletions lib/frontmatter.test.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,55 @@
import assert from "node:assert/strict";
import { describe, it } from "node:test";

import {
formatFrontmatterValue,
getFrontmatterTitle,
parseFrontmatter,
} from "./frontmatter.ts";

describe("parseFrontmatter", () => {
it("accepts the same common fence variants as remark-frontmatter", () => {
const cases = [
"---\ntitle: Demo\n---\nbody",
"\uFEFF---\ntitle: Demo\n---\nbody",
"--- \ntitle: Demo\n---\t\nbody",
"---\r\ntitle: Demo\r\n---\r\nbody",
"---\rtitle: Demo\r---\rbody",
];

for (const markdown of cases) {
assert.deepEqual(parseFrontmatter(markdown), {
data: { title: "Demo" },
rest: "body",
});
}
});

it("removes an empty or malformed fenced block from the returned body", () => {
assert.deepEqual(parseFrontmatter("---\n---\nbody"), { data: null, rest: "body" });
assert.deepEqual(parseFrontmatter("---\n[invalid\n---\nbody"), {
data: null,
rest: "body",
});
});

it("leaves documents without a leading frontmatter block untouched", () => {
const markdown = "body\n---\ntitle: Not frontmatter\n---";
assert.deepEqual(parseFrontmatter(markdown), { data: null, rest: markdown });
});
});

describe("frontmatter value formatting", () => {
it("does not recurse forever through YAML aliases", () => {
const { data } = parseFrontmatter("---\nloop: &loop\n - *loop\n---\n");
assert.ok(data);
assert.equal(formatFrontmatterValue(data.loop), "[Circular]");
});

it("uses scalar titles and leaves structured titles for the metadata rows", () => {
assert.equal(getFrontmatterTitle(" Demo "), "Demo");
assert.equal(getFrontmatterTitle(1984), "1984");
assert.equal(getFrontmatterTitle(false), "false");
assert.equal(getFrontmatterTitle(["Demo"]), null);
});
});
Loading