Files
hapi/web/src/lib/file-markdown-preview.ts
T
a82dd49049 feat(web): markdown Source | Preview toggle in session file pane (#957)
* feat(web): markdown Source | Preview toggle in session file pane

Add Source | Preview toggle for .md/.mdx files in the session file route,
defaulting to preview with localStorage persistence. Reuse chat markdown
pipeline via MarkdownRenderer standalone mode (no assistant-ui thread).

Includes unit tests, Playwright smoke, and e2e fixture.

Closes tiann/hapi#954

Co-authored-by: Cursor <cursoragent@cursor.com>

* fix(web): cast standalone MarkdownRenderer components for react-markdown

Soup verify gate: defaultComponents merge type is wider than
react-markdown Components; standalone file-pane path needs explicit cast.

* fix(web): route file-pane markdown fences through SyntaxHighlighter

Standalone file preview now mirrors chat code-block rendering: fenced
blocks use SyntaxHighlighter and MARKDOWN_COMPONENTS_BY_LANGUAGE (mermaid
included) without requiring ThreadPrimitive context.

Addresses HAPI Bot Major on tiann/hapi#957.

Co-authored-by: Cursor <cursoragent@cursor.com>

* fix(web): detect fenced vs inline code in standalone markdown preview

Move block detection to the pre override (react-markdown v10 does not pass
inline to custom code components). Add inline-code regression test.

Co-authored-by: Cursor <cursoragent@cursor.com>

---------

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-11 11:06:38 +08:00

70 lines
1.7 KiB
TypeScript

export type MarkdownPreviewMode = 'source' | 'preview'
export const MARKDOWN_PREVIEW_MODE_STORAGE_KEY = 'hapi.filePreview.markdownMode.v1'
export const DEFAULT_MARKDOWN_PREVIEW_MODE: MarkdownPreviewMode = 'preview'
function isBrowser(): boolean {
return typeof window !== 'undefined' && typeof document !== 'undefined'
}
function safeGetItem(key: string): string | null {
if (!isBrowser()) {
return null
}
try {
return localStorage.getItem(key)
} catch {
return null
}
}
function safeSetItem(key: string, value: string): void {
if (!isBrowser()) {
return
}
try {
localStorage.setItem(key, value)
} catch {
// Ignore storage errors
}
}
function safeRemoveItem(key: string): void {
if (!isBrowser()) {
return
}
try {
localStorage.removeItem(key)
} catch {
// Ignore storage errors
}
}
export function isMarkdownFile(path: string): boolean {
const parts = path.split('.')
if (parts.length <= 1) {
return false
}
const ext = parts[parts.length - 1]?.toLowerCase()
return ext === 'md' || ext === 'mdx'
}
function parseMarkdownPreviewMode(raw: string | null): MarkdownPreviewMode {
if (raw === 'source' || raw === 'preview') {
return raw
}
return DEFAULT_MARKDOWN_PREVIEW_MODE
}
export function getInitialMarkdownPreviewMode(): MarkdownPreviewMode {
return parseMarkdownPreviewMode(safeGetItem(MARKDOWN_PREVIEW_MODE_STORAGE_KEY))
}
export function persistMarkdownPreviewMode(mode: MarkdownPreviewMode): void {
if (mode === DEFAULT_MARKDOWN_PREVIEW_MODE) {
safeRemoveItem(MARKDOWN_PREVIEW_MODE_STORAGE_KEY)
return
}
safeSetItem(MARKDOWN_PREVIEW_MODE_STORAGE_KEY, mode)
}