Repository navigation
feat(markdown): render frontmatter in file previews #8202
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from 4 commits
39107ac
2fdf40b
46d6a27
30573d1
09e8576
f737f14
999709f
ae9eea8
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,85 @@ | ||
| import type { MarkdownFrontmatterEntry } from "@t3tools/client-runtime/markdown-frontmatter"; | ||
| import { ScrollView, Text as NativeText, View } from "react-native"; | ||
|
|
||
| import { useThemeColor } from "../../lib/useThemeColor"; | ||
|
|
||
| function MarkdownFrontmatterList({ | ||
| items, | ||
| textColor, | ||
| }: { | ||
| readonly items: ReadonlyArray<string>; | ||
| readonly textColor: string; | ||
| }) { | ||
| const occurrences = new Map<string, number>(); | ||
|
|
||
| return ( | ||
| <View className="flex-row flex-wrap gap-1"> | ||
| {items.map((item) => { | ||
| const occurrence = occurrences.get(item) ?? 0; | ||
| occurrences.set(item, occurrence + 1); | ||
|
|
||
| return ( | ||
| <View | ||
| key={`${item}:${occurrence}`} | ||
| className="max-w-full rounded-full border border-secondary-border bg-secondary px-2.5 py-1" | ||
| > | ||
| <NativeText className="font-t3-regular text-sm" style={{ color: textColor }}> | ||
| {item} | ||
| </NativeText> | ||
| </View> | ||
| ); | ||
| })} | ||
| </View> | ||
| ); | ||
| } | ||
|
|
||
| export function MarkdownFrontmatterTable({ | ||
| entries, | ||
| }: { | ||
| readonly entries: ReadonlyArray<MarkdownFrontmatterEntry>; | ||
| }) { | ||
| const textColor = String(useThemeColor("--color-md-body")); | ||
| const strongColor = String(useThemeColor("--color-md-strong")); | ||
| const codeColor = String(useThemeColor("--color-md-code-text")); | ||
|
|
||
| return ( | ||
| <ScrollView | ||
| horizontal | ||
| className="mb-6" | ||
| contentContainerStyle={{ minWidth: "100%" }} | ||
| showsHorizontalScrollIndicator={false} | ||
| > | ||
| <View className="min-w-[560px] flex-1 overflow-hidden border border-border"> | ||
| {entries.map((entry, index) => ( | ||
| <View | ||
| key={entry.key} | ||
| className={index === 0 ? "flex-row" : "flex-row border-t border-border"} | ||
| > | ||
| <View className="w-40 shrink-0 items-end justify-center border-r border-border bg-card px-3 py-2"> | ||
| <NativeText | ||
| numberOfLines={1} | ||
| className="font-t3-bold text-sm" | ||
| style={{ color: strongColor }} | ||
| > | ||
| {entry.key} | ||
| </NativeText> | ||
| </View> | ||
| <View className="min-w-0 flex-1 px-3 py-2"> | ||
| {entry.value.kind === "text" ? ( | ||
| <NativeText className="font-t3-regular text-sm" style={{ color: textColor }}> | ||
| {entry.value.text} | ||
| </NativeText> | ||
| ) : entry.value.kind === "list" ? ( | ||
| <MarkdownFrontmatterList items={entry.value.items} textColor={textColor} /> | ||
| ) : ( | ||
| <NativeText className="font-mono text-xs" style={{ color: codeColor }}> | ||
| {entry.value.source} | ||
| </NativeText> | ||
| )} | ||
| </View> | ||
| </View> | ||
| ))} | ||
| </View> | ||
| </ScrollView> | ||
| ); | ||
| } | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -12,6 +12,7 @@ import { | |
| isAtomCommandInterrupted, | ||
| squashAtomCommandFailure, | ||
| } from "@t3tools/client-runtime/state/runtime"; | ||
| import { parseMarkdownFrontmatter } from "@t3tools/client-runtime/markdown-frontmatter"; | ||
| import { ChevronRight, Code2, Eye, FolderTree, Globe2, LoaderCircle } from "lucide-react"; | ||
| import * as Schema from "effect/Schema"; | ||
| import { useCallback, useEffect, useMemo, useRef, useState } from "react"; | ||
|
|
@@ -58,6 +59,7 @@ import { projectFileCacheKey, projectFileEditorCacheKey } from "./fileContentRev | |
| import { fileBreadcrumbs } from "./filePath"; | ||
| import { isMarkdownPreviewFile, setMarkdownTaskChecked } from "./filePreviewMode"; | ||
| import { FileSaveCoordinator } from "./fileSaveCoordinator"; | ||
| import { MarkdownFrontmatterTable } from "./MarkdownFrontmatterTable"; | ||
| import { | ||
| confirmProjectFileQueryData, | ||
| getOptimisticProjectFileQueryData, | ||
|
|
@@ -724,24 +726,34 @@ function RenderedMarkdownSurface({ | |
| relativePath, | ||
| onPendingChange, | ||
| }); | ||
| const frontmatter = useMemo(() => parseMarkdownFrontmatter(contents), [contents]); | ||
|
|
||
| return ( | ||
| <ScrollArea className="min-h-0 flex-1"> | ||
| <ChatMarkdown | ||
| text={contents} | ||
| cwd={cwd} | ||
| threadRef={threadRef} | ||
| className="mx-auto max-w-4xl px-6 py-5" | ||
| onTaskListChange={({ markerOffset, checked }) => { | ||
| const currentContents = | ||
| getOptimisticProjectFileQueryData(environmentId, cwd, relativePath)?.contents ?? | ||
| contents; | ||
| const nextContents = setMarkdownTaskChecked(currentContents, markerOffset, checked); | ||
| if (nextContents === currentContents) return; | ||
| setProjectFileQueryData(environmentId, cwd, relativePath, nextContents); | ||
| saveCoordinator.change(nextContents); | ||
| }} | ||
| /> | ||
| <div className="mx-auto max-w-4xl px-6 py-5"> | ||
| {frontmatter.entries.length > 0 ? ( | ||
| <MarkdownFrontmatterTable entries={frontmatter.entries} /> | ||
|
Comment on lines
+734
to
+735
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
This introduces user-visible frontmatter rendering across the web, desktop, and mobile file previews, but the commit adds no corresponding AGENTS.md reference: AGENTS.md:L75-L75 Useful? React with 👍 / 👎.
Contributor
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. addressed! |
||
| ) : null} | ||
| <ChatMarkdown | ||
| text={frontmatter.body} | ||
| cwd={cwd} | ||
| threadRef={threadRef} | ||
| className={frontmatter.entries.length > 0 ? "mt-8" : ""} | ||
| onTaskListChange={({ markerOffset, checked }) => { | ||
| const currentContents = | ||
| getOptimisticProjectFileQueryData(environmentId, cwd, relativePath)?.contents ?? | ||
| contents; | ||
| const nextContents = setMarkdownTaskChecked( | ||
| currentContents, | ||
| frontmatter.bodyOffset + markerOffset, | ||
| checked, | ||
| ); | ||
| if (nextContents === currentContents) return; | ||
| setProjectFileQueryData(environmentId, cwd, relativePath, nextContents); | ||
| saveCoordinator.change(nextContents); | ||
| }} | ||
| /> | ||
| </div> | ||
| </ScrollArea> | ||
| ); | ||
| } | ||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,59 @@ | ||
| import type { MarkdownFrontmatterEntry } from "@t3tools/client-runtime/markdown-frontmatter"; | ||
|
|
||
| function MarkdownFrontmatterList({ items }: { readonly items: ReadonlyArray<string> }) { | ||
| const occurrences = new Map<string, number>(); | ||
|
|
||
| return ( | ||
| <span className="flex flex-wrap gap-1"> | ||
| {items.map((item) => { | ||
| const occurrence = occurrences.get(item) ?? 0; | ||
| occurrences.set(item, occurrence + 1); | ||
|
|
||
| return ( | ||
| <span | ||
| key={`${item}:${occurrence}`} | ||
| className="rounded-sm border border-border bg-muted/30 px-2.5 py-0.5" | ||
|
macroscopeapp[bot] marked this conversation as resolved.
Outdated
|
||
| > | ||
| {item} | ||
| </span> | ||
| ); | ||
| })} | ||
| </span> | ||
| ); | ||
| } | ||
|
|
||
| export function MarkdownFrontmatterTable({ | ||
| entries, | ||
| }: { | ||
| readonly entries: ReadonlyArray<MarkdownFrontmatterEntry>; | ||
| }) { | ||
| return ( | ||
| <div className="overflow-x-auto"> | ||
| <table className="w-full border-collapse text-sm text-foreground/80"> | ||
|
macroscopeapp[bot] marked this conversation as resolved.
Outdated
|
||
| <tbody> | ||
| {entries.map((entry) => ( | ||
| <tr key={entry.key}> | ||
| <th | ||
| scope="row" | ||
| className="w-px whitespace-nowrap border border-border bg-muted/30 px-4 py-2 text-right align-top font-semibold text-foreground" | ||
| > | ||
| {entry.key} | ||
| </th> | ||
| <td className="border border-border px-4 py-2 align-top"> | ||
| {entry.value.kind === "text" ? ( | ||
| <span className="whitespace-pre-wrap">{entry.value.text}</span> | ||
| ) : entry.value.kind === "list" ? ( | ||
| <MarkdownFrontmatterList items={entry.value.items} /> | ||
| ) : ( | ||
| <pre className="overflow-x-auto whitespace-pre-wrap font-mono text-xs leading-relaxed"> | ||
| {entry.value.source} | ||
| </pre> | ||
| )} | ||
| </td> | ||
| </tr> | ||
| ))} | ||
| </tbody> | ||
| </table> | ||
| </div> | ||
| ); | ||
| } | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,16 @@ | ||
| # File previews | ||
|
|
||
| Markdown files can switch between source and rendered views on web and desktop. Mobile opens | ||
| Markdown files in the rendered view. | ||
|
|
||
| ## YAML frontmatter | ||
|
|
||
| Rendered Markdown recognizes YAML frontmatter when the file starts with a `---` line, ends the | ||
| frontmatter with another `---` line, and contains a YAML mapping. T3 Code displays the mapping as a | ||
| metadata table above the Markdown body. | ||
|
|
||
| Scalar values appear as text. Arrays containing only scalar values appear as pills. Nested objects | ||
| and arrays remain formatted as YAML. | ||
|
|
||
| Invalid YAML, an unclosed frontmatter block, or a frontmatter value that is not a mapping remains in | ||
| the Markdown body unchanged. |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,99 @@ | ||
| import { fromYaml } from "@t3tools/shared/schemaYaml"; | ||
| import * as Option from "effect/Option"; | ||
| import * as Schema from "effect/Schema"; | ||
|
|
||
| const FrontmatterDocument = fromYaml(Schema.Record(Schema.String, Schema.Json)); | ||
| const decodeFrontmatterDocument = Schema.decodeUnknownOption(FrontmatterDocument); | ||
| const encodeYamlValue = Schema.encodeSync(fromYaml(Schema.Json)); | ||
|
|
||
| export type MarkdownFrontmatterValue = | ||
| | { readonly kind: "text"; readonly text: string } | ||
| | { | ||
| readonly kind: "list"; | ||
| readonly items: ReadonlyArray<string>; | ||
| } | ||
| | { readonly kind: "yaml"; readonly source: string }; | ||
|
|
||
| export interface MarkdownFrontmatterEntry { | ||
| readonly key: string; | ||
| readonly value: MarkdownFrontmatterValue; | ||
| } | ||
|
|
||
| export interface MarkdownFrontmatter { | ||
| readonly body: string; | ||
| readonly bodyOffset: number; | ||
| readonly entries: ReadonlyArray<MarkdownFrontmatterEntry>; | ||
| } | ||
|
|
||
| function displayFrontmatterValue(value: Schema.Json): MarkdownFrontmatterValue { | ||
| if (value === null) { | ||
| return { kind: "text", text: "null" }; | ||
| } | ||
| if (typeof value === "string" || typeof value === "number" || typeof value === "boolean") { | ||
| return { kind: "text", text: String(value) }; | ||
| } | ||
| if (Array.isArray(value) && value.length > 0) { | ||
| const items: Array<string> = []; | ||
| for (const item of value) { | ||
| if (item === null) { | ||
| items.push("null"); | ||
| } else if ( | ||
| typeof item === "string" || | ||
| typeof item === "number" || | ||
| typeof item === "boolean" | ||
| ) { | ||
| items.push(String(item)); | ||
| } else { | ||
| return { kind: "yaml", source: encodeYamlValue(value).trimEnd() }; | ||
| } | ||
| } | ||
| return { kind: "list", items }; | ||
| } | ||
| return { kind: "yaml", source: encodeYamlValue(value).trimEnd() }; | ||
| } | ||
|
|
||
| export function parseMarkdownFrontmatter(markdown: string): MarkdownFrontmatter { | ||
| const unparsed: MarkdownFrontmatter = { body: markdown, bodyOffset: 0, entries: [] }; | ||
| const openingLineEnd = markdown.indexOf("\n"); | ||
| if (openingLineEnd === -1) { | ||
| return unparsed; | ||
| } | ||
|
|
||
| const openingLine = markdown.slice(0, openingLineEnd).replace(/\r$/, ""); | ||
| if (openingLine !== "---") { | ||
| return unparsed; | ||
| } | ||
|
|
||
| const yamlStart = openingLineEnd + 1; | ||
| let lineStart = yamlStart; | ||
| while (lineStart <= markdown.length) { | ||
| const nextLineEnd = markdown.indexOf("\n", lineStart); | ||
| const lineEnd = nextLineEnd === -1 ? markdown.length : nextLineEnd; | ||
| const line = markdown.slice(lineStart, lineEnd).replace(/\r$/, ""); | ||
|
|
||
| if (line === "---") { | ||
| const decoded = decodeFrontmatterDocument(markdown.slice(yamlStart, lineStart)); | ||
| if (Option.isNone(decoded)) { | ||
| return unparsed; | ||
| } | ||
|
|
||
| const entries = Object.entries(decoded.value).map(([key, value]) => ({ | ||
| key, | ||
| value: displayFrontmatterValue(value), | ||
| })); | ||
| const bodyOffset = nextLineEnd === -1 ? lineEnd : nextLineEnd + 1; | ||
| return { | ||
| body: markdown.slice(bodyOffset), | ||
| bodyOffset, | ||
| entries, | ||
| }; | ||
| } | ||
|
|
||
| if (nextLineEnd === -1) { | ||
| break; | ||
| } | ||
| lineStart = nextLineEnd + 1; | ||
| } | ||
|
|
||
| return unparsed; | ||
| } |
Uh oh!
There was an error while loading. Please reload this page.