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.
This commit is contained in:
Teknium 2026-08-12 11:08:55 -07:00 committed by GitHub
parent 62a9c0f0e9
commit f525772725
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
2 changed files with 154 additions and 1 deletions

View File

@ -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)
})
})

View File

@ -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
}