From 115a0cd0f5954889fcdcc0f081b17959bc91d261 Mon Sep 17 00:00:00 2001 From: Taras Date: Tue, 25 Aug 2026 17:01:49 +0300 Subject: [PATCH 1/6] feat(markdown): render frontmatter in file previews --- .../features/files/FileMarkdownPreview.tsx | 10 +- .../files/MarkdownFrontmatterTable.tsx | 84 ++++++++++++++++ .../src/components/files/FilePreviewPanel.tsx | 42 +++++--- .../files/MarkdownFrontmatterTable.tsx | 59 +++++++++++ packages/client-runtime/package.json | 4 + .../client-runtime/src/markdownFrontmatter.ts | 99 +++++++++++++++++++ 6 files changed, 281 insertions(+), 17 deletions(-) create mode 100644 apps/mobile/src/features/files/MarkdownFrontmatterTable.tsx create mode 100644 apps/web/src/components/files/MarkdownFrontmatterTable.tsx create mode 100644 packages/client-runtime/src/markdownFrontmatter.ts diff --git a/apps/mobile/src/features/files/FileMarkdownPreview.tsx b/apps/mobile/src/features/files/FileMarkdownPreview.tsx index 8b5892f3a..c9788d906 100644 --- a/apps/mobile/src/features/files/FileMarkdownPreview.tsx +++ b/apps/mobile/src/features/files/FileMarkdownPreview.tsx @@ -1,4 +1,5 @@ import { useCallback, useMemo, useState } from "react"; +import { parseMarkdownFrontmatter } from "@t3tools/client-runtime/markdown-frontmatter"; import { Markdown, type CustomRenderers, @@ -20,6 +21,7 @@ import { SelectableMarkdownText, type NativeMarkdownTextStyle, } from "../../native/SelectableMarkdownText"; +import { MarkdownFrontmatterTable } from "./MarkdownFrontmatterTable"; interface MarkdownPreviewStyles { readonly theme: PartialMarkdownTheme; @@ -187,6 +189,7 @@ export function FileMarkdownPreview(props: { } }, [props.onRefresh]); const styles = useMarkdownPreviewStyles(); + const frontmatter = useMemo(() => parseMarkdownFrontmatter(props.markdown), [props.markdown]); const onLinkPress = useCallback((href: string) => { void tryOpenExternalUrl(href, "markdown-link"); }, []); @@ -205,9 +208,12 @@ export function FileMarkdownPreview(props: { } > + {frontmatter.entries.length > 0 ? ( + + ) : null} {hasNativeSelectableMarkdownText() ? ( @@ -218,7 +224,7 @@ export function FileMarkdownPreview(props: { styles={styles.styles} theme={styles.theme} > - {props.markdown} + {frontmatter.body} )} diff --git a/apps/mobile/src/features/files/MarkdownFrontmatterTable.tsx b/apps/mobile/src/features/files/MarkdownFrontmatterTable.tsx new file mode 100644 index 000000000..5ae1dd394 --- /dev/null +++ b/apps/mobile/src/features/files/MarkdownFrontmatterTable.tsx @@ -0,0 +1,84 @@ +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, + backgroundColor, +}: { + readonly items: ReadonlyArray; + readonly textColor: string; + readonly backgroundColor: string; +}) { + const occurrences = new Map(); + + return ( + + {items.map((item) => { + const occurrence = occurrences.get(item) ?? 0; + occurrences.set(item, occurrence + 1); + + return ( + + + {item} + + + ); + })} + + ); +} + +export function MarkdownFrontmatterTable({ + entries, +}: { + readonly entries: ReadonlyArray; +}) { + const textColor = String(useThemeColor("--color-md-body")); + const strongColor = String(useThemeColor("--color-md-strong")); + const mutedBackgroundColor = String(useThemeColor("--color-md-blockquote-bg")); + const codeColor = String(useThemeColor("--color-md-code-text")); + + return ( + + {entries.map((entry, index) => ( + + + + {entry.key} + + + + {entry.value.kind === "text" ? ( + + {entry.value.text} + + ) : entry.value.kind === "list" ? ( + + ) : ( + + + {entry.value.source} + + + )} + + + ))} + + ); +} diff --git a/apps/web/src/components/files/FilePreviewPanel.tsx b/apps/web/src/components/files/FilePreviewPanel.tsx index a8c364763..7fbf561a0 100644 --- a/apps/web/src/components/files/FilePreviewPanel.tsx +++ b/apps/web/src/components/files/FilePreviewPanel.tsx @@ -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 ( - { - 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); - }} - /> +
+ {frontmatter.entries.length > 0 ? ( + + ) : null} + 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); + }} + /> +
); } diff --git a/apps/web/src/components/files/MarkdownFrontmatterTable.tsx b/apps/web/src/components/files/MarkdownFrontmatterTable.tsx new file mode 100644 index 000000000..221bd1215 --- /dev/null +++ b/apps/web/src/components/files/MarkdownFrontmatterTable.tsx @@ -0,0 +1,59 @@ +import type { MarkdownFrontmatterEntry } from "@t3tools/client-runtime/markdown-frontmatter"; + +function MarkdownFrontmatterList({ items }: { readonly items: ReadonlyArray }) { + const occurrences = new Map(); + + return ( + + {items.map((item) => { + const occurrence = occurrences.get(item) ?? 0; + occurrences.set(item, occurrence + 1); + + return ( + + {item} + + ); + })} + + ); +} + +export function MarkdownFrontmatterTable({ + entries, +}: { + readonly entries: ReadonlyArray; +}) { + return ( +
+ + + {entries.map((entry) => ( + + + + + ))} + +
+ {entry.key} + + {entry.value.kind === "text" ? ( + {entry.value.text} + ) : entry.value.kind === "list" ? ( + + ) : ( +
+                    {entry.value.source}
+                  
+ )} +
+
+ ); +} diff --git a/packages/client-runtime/package.json b/packages/client-runtime/package.json index abed33998..1775ee8ee 100644 --- a/packages/client-runtime/package.json +++ b/packages/client-runtime/package.json @@ -19,6 +19,10 @@ "types": "./src/markdownImages.ts", "default": "./src/markdownImages.ts" }, + "./markdown-frontmatter": { + "types": "./src/markdownFrontmatter.ts", + "default": "./src/markdownFrontmatter.ts" + }, "./errors": { "types": "./src/errors/index.ts", "default": "./src/errors/index.ts" diff --git a/packages/client-runtime/src/markdownFrontmatter.ts b/packages/client-runtime/src/markdownFrontmatter.ts new file mode 100644 index 000000000..d04b53c16 --- /dev/null +++ b/packages/client-runtime/src/markdownFrontmatter.ts @@ -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; + } + | { 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; +} + +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 = []; + 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; +} From 2b3fb35f807497f52c5b806a95c701ba2dd8d696 Mon Sep 17 00:00:00 2001 From: Taras Date: Tue, 25 Aug 2026 17:18:24 +0300 Subject: [PATCH 2/6] fix(markdown): address frontmatter review feedback --- .../features/files/MarkdownFrontmatterTable.tsx | 2 +- docs/user/file-previews.md | 16 ++++++++++++++++ 2 files changed, 17 insertions(+), 1 deletion(-) create mode 100644 docs/user/file-previews.md diff --git a/apps/mobile/src/features/files/MarkdownFrontmatterTable.tsx b/apps/mobile/src/features/files/MarkdownFrontmatterTable.tsx index 5ae1dd394..71169c75d 100644 --- a/apps/mobile/src/features/files/MarkdownFrontmatterTable.tsx +++ b/apps/mobile/src/features/files/MarkdownFrontmatterTable.tsx @@ -23,7 +23,7 @@ function MarkdownFrontmatterList({ return ( diff --git a/docs/user/file-previews.md b/docs/user/file-previews.md new file mode 100644 index 000000000..217225f78 --- /dev/null +++ b/docs/user/file-previews.md @@ -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. From 33c10d698df3c8eb5c17ab31970dfcf05707fb2b Mon Sep 17 00:00:00 2001 From: Taras Date: Tue, 25 Aug 2026 19:27:43 +0300 Subject: [PATCH 3/6] fix(mobile): improve frontmatter table layout --- .../files/MarkdownFrontmatterTable.tsx | 67 ++++++++++--------- 1 file changed, 34 insertions(+), 33 deletions(-) diff --git a/apps/mobile/src/features/files/MarkdownFrontmatterTable.tsx b/apps/mobile/src/features/files/MarkdownFrontmatterTable.tsx index 71169c75d..3765614ba 100644 --- a/apps/mobile/src/features/files/MarkdownFrontmatterTable.tsx +++ b/apps/mobile/src/features/files/MarkdownFrontmatterTable.tsx @@ -6,11 +6,9 @@ import { useThemeColor } from "../../lib/useThemeColor"; function MarkdownFrontmatterList({ items, textColor, - backgroundColor, }: { readonly items: ReadonlyArray; readonly textColor: string; - readonly backgroundColor: string; }) { const occurrences = new Map(); @@ -23,8 +21,7 @@ function MarkdownFrontmatterList({ return ( {item} @@ -43,42 +40,46 @@ export function MarkdownFrontmatterTable({ }) { const textColor = String(useThemeColor("--color-md-body")); const strongColor = String(useThemeColor("--color-md-strong")); - const mutedBackgroundColor = String(useThemeColor("--color-md-blockquote-bg")); const codeColor = String(useThemeColor("--color-md-code-text")); return ( - - {entries.map((entry, index) => ( - - - - {entry.key} - - - - {entry.value.kind === "text" ? ( - - {entry.value.text} + + + {entries.map((entry, index) => ( + + + + {entry.key} - ) : entry.value.kind === "list" ? ( - - ) : ( - + + + {entry.value.kind === "text" ? ( + + {entry.value.text} + + ) : entry.value.kind === "list" ? ( + + ) : ( {entry.value.source} - - )} + )} + - - ))} - + ))} + + ); } From 7bcd312ecc4a52b58e6b2bd00149fc3be6d2f0e2 Mon Sep 17 00:00:00 2001 From: Taras Date: Tue, 25 Aug 2026 20:46:18 +0300 Subject: [PATCH 4/6] fix(markdown): align frontmatter tables with clients --- .../files/MarkdownFrontmatterTable.tsx | 4 +-- .../files/MarkdownFrontmatterTable.tsx | 25 +++++++++++-------- apps/web/src/index.css | 1 + 3 files changed, 18 insertions(+), 12 deletions(-) diff --git a/apps/mobile/src/features/files/MarkdownFrontmatterTable.tsx b/apps/mobile/src/features/files/MarkdownFrontmatterTable.tsx index 3765614ba..d2cb33753 100644 --- a/apps/mobile/src/features/files/MarkdownFrontmatterTable.tsx +++ b/apps/mobile/src/features/files/MarkdownFrontmatterTable.tsx @@ -20,7 +20,7 @@ function MarkdownFrontmatterList({ return ( @@ -55,7 +55,7 @@ export function MarkdownFrontmatterTable({ key={entry.key} className={index === 0 ? "flex-row" : "flex-row border-t border-border"} > - + }) { const occurrences = new Map(); @@ -10,12 +13,9 @@ function MarkdownFrontmatterList({ items }: { readonly items: ReadonlyArray + {item} - + ); })} @@ -28,18 +28,23 @@ export function MarkdownFrontmatterTable({ readonly entries: ReadonlyArray; }) { return ( -
- + +
{entries.map((entry) => ( -
{entry.key} + {entry.value.kind === "text" ? ( {entry.value.text} ) : entry.value.kind === "list" ? ( @@ -54,6 +59,6 @@ export function MarkdownFrontmatterTable({ ))}
-
+ ); } diff --git a/apps/web/src/index.css b/apps/web/src/index.css index d43475f90..d7cc874d8 100644 --- a/apps/web/src/index.css +++ b/apps/web/src/index.css @@ -2365,6 +2365,7 @@ code { white-space: nowrap; } +.chat-markdown tbody th, .chat-markdown tbody td { border-bottom: 1px solid color-mix(in srgb, var(--contrast-border) 60%, transparent); } From 17a96e7e1d2f5f922206dcb44e82fddaa2e12051 Mon Sep 17 00:00:00 2001 From: Taras Date: Tue, 25 Aug 2026 21:00:36 +0300 Subject: [PATCH 5/6] fix(markdown): address table layout feedback --- .../files/MarkdownFrontmatterTable.tsx | 41 ++++++++++++++++++- .../files/MarkdownFrontmatterTable.tsx | 4 +- apps/web/src/index.css | 14 +++++-- 3 files changed, 51 insertions(+), 8 deletions(-) diff --git a/apps/mobile/src/features/files/MarkdownFrontmatterTable.tsx b/apps/mobile/src/features/files/MarkdownFrontmatterTable.tsx index d2cb33753..09e181061 100644 --- a/apps/mobile/src/features/files/MarkdownFrontmatterTable.tsx +++ b/apps/mobile/src/features/files/MarkdownFrontmatterTable.tsx @@ -1,8 +1,12 @@ import type { MarkdownFrontmatterEntry } from "@t3tools/client-runtime/markdown-frontmatter"; +import { useState } from "react"; import { ScrollView, Text as NativeText, View } from "react-native"; import { useThemeColor } from "../../lib/useThemeColor"; +const MIN_KEY_COLUMN_WIDTH = 160; +const MIN_VALUE_COLUMN_WIDTH = 400; + function MarkdownFrontmatterList({ items, textColor, @@ -41,6 +45,18 @@ export function MarkdownFrontmatterTable({ const textColor = String(useThemeColor("--color-md-body")); const strongColor = String(useThemeColor("--color-md-strong")); const codeColor = String(useThemeColor("--color-md-code-text")); + const [keyWidths, setKeyWidths] = useState>(() => new Map()); + let measuredKeyColumnWidth = MIN_KEY_COLUMN_WIDTH; + let hasEveryKeyWidth = true; + for (const entry of entries) { + const keyWidth = keyWidths.get(entry.key); + if (keyWidth === undefined) { + hasEveryKeyWidth = false; + break; + } + measuredKeyColumnWidth = Math.max(measuredKeyColumnWidth, keyWidth); + } + const keyColumnWidth = hasEveryKeyWidth ? measuredKeyColumnWidth : null; return ( - + {entries.map((entry, index) => ( - + { + const measuredWidth = Math.ceil(event.nativeEvent.layout.width); + setKeyWidths((current) => { + if (current.get(entry.key) === measuredWidth) { + return current; + } + const next = new Map(current); + next.set(entry.key, measuredWidth); + return next; + }); + } + : undefined + } + > - +
{entries.map((entry) => ( diff --git a/apps/web/src/index.css b/apps/web/src/index.css index d7cc874d8..cbf5b37c0 100644 --- a/apps/web/src/index.css +++ b/apps/web/src/index.css @@ -2343,7 +2343,8 @@ code { wrapping rules (overflow-wrap: anywhere) would let columns shrink to single characters and defeat the overflow — restore word-boundary wrapping so the min column width is the longest word. */ -.chat-markdown table { +.chat-markdown table, +.markdown-table { width: 100%; min-width: max-content; border-collapse: collapse; @@ -2353,12 +2354,15 @@ code { } .chat-markdown th, -.chat-markdown td { +.chat-markdown td, +.markdown-table th, +.markdown-table td { padding: 0.45rem 0.75rem; text-align: left; } -.chat-markdown thead th { +.chat-markdown thead th, +.markdown-table thead th { border-bottom: 1px solid color-mix(in srgb, var(--contrast-border) 60%, transparent); padding-block: 0.55rem; font-weight: 600; @@ -2366,7 +2370,9 @@ code { } .chat-markdown tbody th, -.chat-markdown tbody td { +.chat-markdown tbody td, +.markdown-table tbody th, +.markdown-table tbody td { border-bottom: 1px solid color-mix(in srgb, var(--contrast-border) 60%, transparent); } From 37acf335e1708ac1a3ad3e3edac42e2e89190ac0 Mon Sep 17 00:00:00 2001 From: Taras Date: Tue, 25 Aug 2026 21:05:53 +0300 Subject: [PATCH 6/6] fix(web): keep frontmatter values wrapping --- .../components/files/MarkdownFrontmatterTable.tsx | 2 +- apps/web/src/index.css | 12 ++++++++++-- 2 files changed, 11 insertions(+), 3 deletions(-) diff --git a/apps/web/src/components/files/MarkdownFrontmatterTable.tsx b/apps/web/src/components/files/MarkdownFrontmatterTable.tsx index d8dfbddaf..f85555e47 100644 --- a/apps/web/src/components/files/MarkdownFrontmatterTable.tsx +++ b/apps/web/src/components/files/MarkdownFrontmatterTable.tsx @@ -40,7 +40,7 @@ export function MarkdownFrontmatterTable({ diff --git a/apps/web/src/index.css b/apps/web/src/index.css index cbf5b37c0..dff778b61 100644 --- a/apps/web/src/index.css +++ b/apps/web/src/index.css @@ -2346,11 +2346,14 @@ code { .chat-markdown table, .markdown-table { width: 100%; - min-width: max-content; border-collapse: collapse; + font-size: 0.75rem; +} + +.chat-markdown table { + min-width: max-content; overflow-wrap: normal; word-break: normal; - font-size: 0.75rem; } .chat-markdown th, @@ -2358,6 +2361,11 @@ code { .markdown-table th, .markdown-table td { padding: 0.45rem 0.75rem; +} + +.chat-markdown th, +.chat-markdown td, +.markdown-table td { text-align: left; }
{entry.key}