markdown: a paragraph an editor wrapped reads as running text

`breaks` is on because the diary is written a line at a time and a line typed
in the browser is meant. Markdown written elsewhere is usually wrapped at a
width instead — an import, a save over the SFTP mount from vim with
`textwidth`, a pasted README — and kept its breaks, one in the middle of every
sentence, worst on a phone.

`hardWrapped` tells the two apart by what a wrapping editor leaves behind:
every line but the last is long, each break comes where the next word would
not have fitted on the line before, and the lines seldom end where a sentence
does. A paragraph that looks like that has its soft breaks read as spaces; a
break asked for outright, two spaces or a backslash at the end of the line,
stays. Lines broken by hand — a short sentence and then another, a list of
things that happened, a verse — keep their breaks as before.
This commit is contained in:
nvrl committed 2026-10-21 12:38:26 +02:00
1 parent a59ba4af1b
commit 767dd076dd
4 files changed
+53 -2

No files matched your search

+18 -1
View File
@@ -1,7 +1,7 @@
import DOMPurify from 'dompurify'
import { marked, type TokenizerAndRendererExtension } from 'marked'
import { mathEngine } from './math.svelte'
import { titleHref } from './util'
import { hardWrapped, titleHref } from './util'
marked.setOptions({ gfm: true, breaks: true })
@@ -68,8 +68,25 @@ const wikilink: TokenizerAndRendererExtension = {
},
}
/*
* A line break typed in the browser is meant: the diary is written a line at a
* time, so `breaks` is on. A paragraph an editor wrapped at its width is the
* exception, and its breaks are read as the spaces they stand for. A break
* asked for outright — two spaces or a `\` at the end of the line — stays.
*/
type Inline = { type: string; raw: string; tokens?: Inline[] }
function unwrap(tokens: Inline[]) {
for (const [at, token] of tokens.entries()) {
if (token.type === 'br' && /^ ?\n$/.test(token.raw)) tokens[at] = { type: 'text', raw: token.raw, text: ' ' } as Inline
else if (token.tokens) unwrap(token.tokens)
}
}
marked.use({
extensions: [mathBlock, mathInline, wikilink],
walkTokens(token) {
if (token.type === 'paragraph' && hardWrapped(token.raw)) unwrap(token.tokens as Inline[])
},
renderer: {
code(token) {
return token.lang === 'math' ? tex(token.text, true) : false
+24
View File
@@ -322,3 +322,27 @@ export function neighbour(rects: Rect[], from: number, direction: Direction): nu
})
return best
}
/**
* Whether a paragraph was wrapped by an editor at a fixed width rather than
* broken by hand: every line but the last is long, and each break comes
* where the next word would not have fitted on the line before. Markdown
* written elsewhere — an import, a save over the SFTP mount from vim with
* `textwidth`, a pasted README — is wrapped that way, and keeping its breaks
* puts one in the middle of every sentence. Lines broken by hand rarely pass:
* a short sentence followed by a short word would have fitted, and a line
* broken by hand mostly ends where a sentence does, which a wrapped one
* seldom does.
*/
export function hardWrapped(paragraph: string): boolean {
const lines = paragraph.trimEnd().split('\n').map((line) => line.trimEnd())
if (lines.length < 2) return false
const body = lines.slice(0, -1)
const width = Math.max(...lines.map((line) => line.length))
const fits = body.every((line, at) => {
const next = lines[at + 1].trimStart().split(/\s/, 1)[0]
return line.length >= 40 && line.length + 1 + next.length > width
})
const sentences = body.filter((line) => /[.!?:;]$/.test(line)).length
return fits && sentences <= Math.floor((body.length - 1) / 3)
}