Skip to content
Closed
Show file tree
Hide file tree
Changes from 4 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
10 changes: 8 additions & 2 deletions apps/mobile/src/features/files/FileMarkdownPreview.tsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import { useCallback, useMemo, useState } from "react";
import { parseMarkdownFrontmatter } from "@t3tools/client-runtime/markdown-frontmatter";
import {
Markdown,
type CustomRenderers,
Expand All @@ -20,6 +21,7 @@ import {
SelectableMarkdownText,
type NativeMarkdownTextStyle,
} from "../../native/SelectableMarkdownText";
import { MarkdownFrontmatterTable } from "./MarkdownFrontmatterTable";

interface MarkdownPreviewStyles {
readonly theme: PartialMarkdownTheme;
Expand Down Expand Up @@ -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");
}, []);
Expand All @@ -205,9 +208,12 @@ export function FileMarkdownPreview(props: {
}
>
<View className="mx-auto w-full max-w-[760px]">
{frontmatter.entries.length > 0 ? (
<MarkdownFrontmatterTable entries={frontmatter.entries} />
) : null}
{hasNativeSelectableMarkdownText() ? (
<SelectableMarkdownText
markdown={props.markdown}
markdown={frontmatter.body}
onLinkPress={onLinkPress}
textStyle={styles.nativeTextStyle}
/>
Expand All @@ -218,7 +224,7 @@ export function FileMarkdownPreview(props: {
styles={styles.styles}
theme={styles.theme}
>
{props.markdown}
{frontmatter.body}
</Markdown>
)}
</View>
Expand Down
85 changes: 85 additions & 0 deletions apps/mobile/src/features/files/MarkdownFrontmatterTable.tsx
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}
Comment thread
cursor[bot] marked this conversation as resolved.
</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>
);
}
42 changes: 27 additions & 15 deletions apps/web/src/components/files/FilePreviewPanel.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Document the new frontmatter preview behavior

This introduces user-visible frontmatter rendering across the web, desktop, and mobile file previews, but the commit adds no corresponding docs/user/ documentation. Add shipped-product documentation explaining when leading YAML frontmatter is rendered and which values remain unchanged.

AGENTS.md reference: AGENTS.md:L75-L75

Useful? React with 👍 / 👎.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The 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>
);
}
Expand Down
59 changes: 59 additions & 0 deletions apps/web/src/components/files/MarkdownFrontmatterTable.tsx
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"
Comment thread
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">
Comment thread
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>
);
}
16 changes: 16 additions & 0 deletions docs/user/file-previews.md
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.
4 changes: 4 additions & 0 deletions packages/client-runtime/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down
99 changes: 99 additions & 0 deletions packages/client-runtime/src/markdownFrontmatter.ts
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;
}
Loading