From f525772725ee23b20dae488de037f3e5df563ce4 Mon Sep 17 00:00:00 2001 From: Teknium <127238744+teknium1@users.noreply.github.com> Date: Wed, 12 Aug 2026 11:08:55 -0700 Subject: [PATCH] fix(desktop): keep config/structured code blocks fenced instead of unwrapping to prose (#84664) * fix(desktop): keep config/structured code blocks fenced instead of unwrapping to prose The desktop markdown preprocessor has a "prose fence" heuristic that strips the fence off blocks it thinks are wrapped prose. Its `proseLines >= 3 && codeSignals === 0` rule fires on ANY 3+ line plaintext block with no JS/SQL tokens -- which is exactly what an SSH config, a .env dump, or any INI/key-value listing looks like. The result was that a fenced ```-block of SSH config rendered as a flat paragraph instead of a code block. Add isLikelyStructuredText() and use it as a veto in both isLikelyProseFence() and isLikelyProseCodeBlock(): a block is treated as structured (and kept fenced) when it has indented continuation lines, or when it has no sentence-ending punctuation and a majority of lines are `Key value` / `Key: value` directives. Real wrapped prose has sentence-shaped lines and no per-line indentation, so it still unwraps as before. The bullet-prose case in isLikelyProseCodeBlock is checked first so markdown bullet lists remain prose. Tests: markdown-code.test.ts gains SSH-config / flat-config / .env regression cases for both functions, plus direct isLikelyStructuredText coverage, and re-asserts that genuine paragraph prose still unwraps. * fix(desktop): tighten config-line detection to not match punctuation-less prose The first CONFIG_LINE_RE matched any 'word word' line, so a wrapped prose fragment with no sentence punctuation (e.g. 'the quick brown fox jumps') was misread as a config directive and its fence kept. Split into an explicit-separator form (Key: value / Key = value) plus a short 2-3 token 'Key value' directive form; a real sentence line has more tokens, so punctuation-less prose is no longer treated as config. --- apps/desktop/src/lib/markdown-code.test.ts | 76 ++++++++++++++++++++- apps/desktop/src/lib/markdown-code.ts | 79 ++++++++++++++++++++++ 2 files changed, 154 insertions(+), 1 deletion(-) diff --git a/apps/desktop/src/lib/markdown-code.test.ts b/apps/desktop/src/lib/markdown-code.test.ts index f71f564c1c2ab..79c5d091beb3c 100644 --- a/apps/desktop/src/lib/markdown-code.test.ts +++ b/apps/desktop/src/lib/markdown-code.test.ts @@ -1,6 +1,6 @@ import { describe, expect, it } from 'vitest' -import { isLikelyProseCodeBlock } from './markdown-code' +import { isLikelyProseCodeBlock, isLikelyProseFence, isLikelyStructuredText } from './markdown-code' describe('isLikelyProseCodeBlock', () => { it('detects prose that Streamdown mislabels as an unknown language', () => { @@ -20,4 +20,78 @@ describe('isLikelyProseCodeBlock', () => { it('keeps real code blocks', () => { expect(isLikelyProseCodeBlock('ts', 'const value = { bunny: true };\nreturn value')).toBe(false) }) + + it('keeps an SSH config block fenced (regression: rendered as flat prose)', () => { + const ssh = ['Host 192.168.0.159', ' HostName 192.168.0.159', ' User teknium', ' Port 22'].join('\n') + + expect(isLikelyProseCodeBlock('', ssh)).toBe(false) + expect(isLikelyProseCodeBlock('text', ssh)).toBe(false) + }) + + it('keeps a flat key-value config fenced', () => { + expect(isLikelyProseCodeBlock('', ['Host myserver', 'User teknium', 'Port 22'].join('\n'))).toBe(false) + }) + + it('keeps an .env-style dump fenced', () => { + expect(isLikelyProseCodeBlock('', ['API_KEY=abc123', 'PORT=8080', 'DEBUG=true'].join('\n'))).toBe(false) + }) +}) + +describe('isLikelyStructuredText', () => { + it('flags indented config stanzas', () => { + expect( + isLikelyStructuredText(['Host x', ' HostName 10.0.0.1', ' Port 22'].join('\n')) + ).toBe(true) + }) + + it('flags flat key-value / settings listings', () => { + expect(isLikelyStructuredText(['Host myserver', 'User teknium', 'Port 22'].join('\n'))).toBe(true) + expect(isLikelyStructuredText(['API_KEY=abc123', 'PORT=8080', 'DEBUG=true'].join('\n'))).toBe(true) + }) + + it('does NOT flag real wrapped prose', () => { + expect( + isLikelyStructuredText( + [ + 'This is the first sentence of a paragraph.', + 'Here is a second line that continues the thought.', + 'And a third concluding line follows here.' + ].join('\n') + ) + ).toBe(false) + }) + + it('does NOT flag prose without a config shape', () => { + expect( + isLikelyStructuredText( + ['the quick brown fox jumps', 'over the lazy sleeping dog', 'while the sun sets slowly'].join('\n') + ) + ).toBe(false) + }) + + it('ignores single-line blocks', () => { + expect(isLikelyStructuredText('Port 22')).toBe(false) + }) +}) + +describe('isLikelyProseFence', () => { + it('keeps an SSH config block fenced', () => { + const ssh = ['Host 192.168.0.159', ' HostName 192.168.0.159', ' User teknium', ' Port 22'].join('\n') + + expect(isLikelyProseFence('', ssh)).toBe(false) + expect(isLikelyProseFence('text', ssh)).toBe(false) + }) + + it('still unwraps a plain-language paragraph fence', () => { + expect( + isLikelyProseFence( + '', + [ + 'This is the first sentence of a paragraph.', + 'Here is a second line that continues the thought.', + 'And a third concluding line follows here.' + ].join('\n') + ) + ).toBe(true) + }) }) diff --git a/apps/desktop/src/lib/markdown-code.ts b/apps/desktop/src/lib/markdown-code.ts index 4b1632b986034..ccad28422873d 100644 --- a/apps/desktop/src/lib/markdown-code.ts +++ b/apps/desktop/src/lib/markdown-code.ts @@ -273,6 +273,69 @@ function codeSignals(body: string): CodeSignals { } } +// A sentence-ending punctuation mark followed by whitespace or end-of-line. +// Real wrapped prose has these; config/structured listings almost never do. +const SENTENCE_PUNCTUATION_RE = /[.!?](?:\s|$)/ +// `Key: value` / `Key = value` — a settings/directive line with an explicit +// separator. Unambiguous config shape. +const CONFIG_SEPARATOR_LINE_RE = /^[A-Za-z0-9_][\w.-]*\s*[:=]\s*\S/ +// A bare identifier that could be a config key (`Host`, `Port`, `HostName`, +// `API_KEY`). Used only for the short `Key value` directive form below. +const CONFIG_KEY_RE = /^[A-Za-z0-9_][\w.-]*$/ + +// True when a single line looks like a config directive rather than prose. +// Either an explicit `Key: value` / `Key = value`, or a short whitespace +// directive of 2-3 tokens led by an identifier (`Host example`, `Port 22`, +// `HostName 10.0.0.1`). The token cap is what separates it from prose — a +// real sentence line has more words than a config directive, so a +// punctuation-less prose fragment like `the quick brown fox jumps` (5 tokens) +// is NOT treated as config. +function isConfigDirectiveLine(line: string): boolean { + const trimmed = line.trim() + + if (CONFIG_SEPARATOR_LINE_RE.test(trimmed)) { + return true + } + + const tokens = trimmed.split(/\s+/) + + return tokens.length >= 2 && tokens.length <= 3 && CONFIG_KEY_RE.test(tokens[0]) +} + +/** + * True when a fenced block looks like structured / config / tabular text + * rather than wrapped prose. Such blocks (SSH config, .env dumps, INI-style + * settings, key/value listings) trip the prose heuristics' "3+ plain lines, + * no JS/SQL tokens" rule and get their fence stripped — rendering as a flat + * paragraph instead of a code block. This veto keeps them fenced. + * + * Two signals, biased toward keeping the fence when ambiguous: + * - ANY indented continuation line (leading whitespace before content): + * prose is not indented line-by-line, but config stanzas are + * (`Host x` / ` HostName y`). + * - No sentence-ending punctuation AND a majority of lines are + * `Key value` / `Key: value` directives (see isConfigDirectiveLine). + */ +export function isLikelyStructuredText(body: string): boolean { + const lines = body.split('\n').filter(line => line.trim()) + + if (lines.length < 2) { + return false + } + + if (lines.some(line => /^\s+\S/.test(line))) { + return true + } + + if (lines.some(line => SENTENCE_PUNCTUATION_RE.test(line.trim()))) { + return false + } + + const configLines = lines.filter(line => isConfigDirectiveLine(line)).length + + return configLines >= Math.max(2, Math.ceil(lines.length * 0.6)) +} + export function isLikelyProseFence(info: string, body: string): boolean { const trimmedInfo = info.trim() const rawInfo = trimmedInfo.toLowerCase() @@ -302,6 +365,13 @@ export function isLikelyProseFence(info: string, body: string): boolean { return false } + // Config / key-value / indented listings are not prose — keep their fence + // so an SSH config, .env, or INI block renders as a code block instead of + // being unwrapped into a paragraph. + if (isLikelyStructuredText(body)) { + return false + } + return ( (signals.bulletLines >= 2 && signals.hasMarkdown && signals.codeSignals <= 2) || (signals.proseLines >= 3 && signals.codeSignals === 0) @@ -316,10 +386,19 @@ export function isLikelyProseCodeBlock(language: string | undefined, code: strin return false } + // A bullet list with markdown emphasis is prose even when it happens to be + // structured; the config veto below is only meant to protect config/kv + // listings, so let the bullet-prose case win first. if (signals.bulletLines >= 1 && (signals.hasMarkdown || signals.proseLines >= 2)) { return true } + // Config / key-value / indented listings are code, not prose — never + // unwrap them (SSH config, .env, INI, key/value tables). + if (isLikelyStructuredText(code || '')) { + return false + } + if (NON_CODE_FENCE_LANGUAGES.has(cleanLanguage)) { return signals.proseLines >= 3 && signals.codeSignals === 0 }