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:
parent
62a9c0f0e9
commit
f525772725
|
|
@ -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)
|
||||
})
|
||||
})
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in New Issue