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..09e181061 --- /dev/null +++ b/apps/mobile/src/features/files/MarkdownFrontmatterTable.tsx @@ -0,0 +1,122 @@ +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, +}: { + readonly items: ReadonlyArray; + readonly textColor: 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 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 + } + > + + {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..f85555e47 --- /dev/null +++ b/apps/web/src/components/files/MarkdownFrontmatterTable.tsx @@ -0,0 +1,64 @@ +import type { MarkdownFrontmatterEntry } from "@t3tools/client-runtime/markdown-frontmatter"; + +import { Badge } from "~/components/ui/badge"; +import { ScrollArea } from "~/components/ui/scroll-area"; + +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/apps/web/src/index.css b/apps/web/src/index.css index d43475f90..dff778b61 100644 --- a/apps/web/src/index.css +++ b/apps/web/src/index.css @@ -2343,29 +2343,44 @@ 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; + font-size: 0.75rem; +} + +.chat-markdown table { + min-width: max-content; overflow-wrap: normal; word-break: normal; - font-size: 0.75rem; } .chat-markdown th, -.chat-markdown td { +.chat-markdown td, +.markdown-table th, +.markdown-table td { padding: 0.45rem 0.75rem; +} + +.chat-markdown th, +.chat-markdown td, +.markdown-table td { 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; white-space: nowrap; } -.chat-markdown tbody td { +.chat-markdown tbody th, +.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); } 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. 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; +}