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

+6
View File
@@ -181,6 +181,12 @@ longer than 200 characters. The board has
`:board`, `:card <title>` (`:card!` links the open document to it),
`:due <when|->`, `:repeat <rule|->`, `:done` and `:list <name>` (`:list!` deletes one).
A single line break in a document is a line break, the way it was typed. A
paragraph an editor wrapped at a fixed width — an import, a save over the mount
from vim with `textwidth` — is the exception: every line but the last is long
and each break comes where the next word would not have fitted, so it is read
as running text. Two spaces or a `\` at the end of a line always break it.
On a touch device modal editing is off by default (`:set vim` turns it on) and
the same actions are buttons: the `⋯` on a row — or a long press — opens what
the keys do to it: rename, move, share, seal, delete. A move is picked up
+5 -1
View File
@@ -46,7 +46,11 @@ Each save keeps the text it replaces, so nothing written is lost. See
## Markdown
Documents are GitHub-flavoured markdown: headings, lists, task lists, tables,
quotes, code blocks and links. A single line break is kept as a line break.
quotes, code blocks and links. A single line break is kept as a line break,
the way it was typed, with one exception: a paragraph that an editor wrapped
at a fixed width, as imported notes and files saved over the SFTP mount often
are, is read as the running text it is. A break asked for outright, with two
spaces or a `\` at the end of the line, is always kept.
Math is set by KaTeX:
+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)
}