Skip to content

notes: inline bold, italic, strikethrough and code - #32

Merged
thkleinert merged 5 commits into
mainfrom
feat/note-inline-formatting
Sep 9, 2026
Merged

thkleinert merged 5 commits into
mainfrom
feat/note-inline-formatting

Conversation

@thkleinert

@thkleinert thkleinert commented Sep 9, 2026 •

Copy link
Copy Markdown
Owner

Adds markdown-like inline formatting to note bodies: *italic*, **bold**, ~~strike~~, `code`.

Approach

parseNoteBody was already an inline tokenizer walking the string for mentions and links, so emphasis is four more branches in the same walk rather than a parallel system. The editor is still a plain <textarea> — no rich-text dependency, users type the markers.

Segment model: a flat marks?: ReadonlySet<NoteMark> on each segment, not a tree. Emphasis crosses the other segment types instead of containing them — a bold span holds text, @mentions and links alike, and ***x*** is one run wearing two marks. A tree would make every consumer walk children to find a mention; a mark set leaves the segment list exactly as flat as it was and lets the renderer wrap whatever it was going to draw anyway. Emphasis contents are parsed by re-entering the scanner with an extra mark, so the recursion accumulates marks and never nests segments.

Ordering. Emphasis is tested after the URL branch, for the reason the existing comment gives about @ and one worse: URLs are full of markers, and www.example.com/a*b*c would otherwise lose its middle to an <em> and stop being one link. The emphasis closer scan also steps over links rather than into them. _underscore_ is not supported at all — that hazard is the common case (snake_case, example.com/a_b_c), not the rare one.

An unmatched marker stays the character that was typed. Two rules do most of the work: an opener may not be followed by whitespace and a closer may not be preceded by it (this is what keeps 2 * 3 = 6 * 2 arithmetic), and an empty span is rejected (**** is four asterisks). This preserves the storage philosophy documented at the top of mentions.ts: nothing in the database is a token, so a note always reads as what was typed and a broken marker degrades to visible characters rather than corruption.

Inside `code` nothing else is parsed — no mentions, no links, no nested emphasis.

Inline only, no block-level markdown. The notes ARE an outline — trip_notes rows carry a depth and the list structure is the structure — so headings, list markers and blockquotes would be a second, contradictory hierarchy.

Rendering

NoteBody emits semantic <strong>/<em>/<s>/<code>, not styled spans, in a fixed outermost-first order so the same mark set always produces the same DOM. It is shared by the outliner (NoteList) and the place sheet (PlaceDetailSheet), so both surfaces get this at once. I checked the other views — SharedTripView, TripNotesPage, TripTimeline, PlaceListView — and nothing else renders a note body raw, so there is nowhere that would newly show markers.

CSS: only .note-code gets a rule (neutral color-mix tint off --color-text so it doesn't compete with .mention-chip's primary tint, 0.92em so monospace sits on the same optical line, box-decoration-break: clone like the chip). <strong>, <em> and <s> deliberately ride on browser defaults. No new hex values.

Parser results

Run against a throwaway script (not committed) with places Café Korb and Hotel Wandl. Format is type(marks) "value".

"**bold** text"
    text(bold) "bold"
    text(-) " text"

"an *italic* word"
    text(-) "an "
    text(italic) "italic"
    text(-) " word"

"***bold italic***"
    text(bold+italic) "bold italic"

"**bold with *italic* inside**"
    text(bold) "bold with "
    text(bold+italic) "italic"
    text(bold) " inside"

"~~struck out~~ now"
    text(strike) "struck out"
    text(-) " now"

"run `npm run build` first"
    text(-) "run "
    text(code) "npm run build"
    text(-) " first"

"see www.example.com/a_b_c/x*y*z now"
    text(-) "see "
    url(-) "www.example.com/a_b_c/x*y*z"
    text(-) " now"

"book **@Café Korb** tonight"
    text(-) "book "
    mention(bold) "Café Korb"
    text(-) " tonight"

"a lone * asterisk"
    text(-) "a lone * asterisk"

"2 * 3 = 6"
    text(-) "2 * 3 = 6"

"2 * 3 = 6 * 2"
    text(-) "2 * 3 = 6 * 2"

"wow (**really**), yes!"
    text(-) "wow ("
    text(bold) "really"
    text(-) "), yes!"

"**** empty"
    text(-) "**** empty"

"literal `@Café Korb` here"
    text(-) "literal "
    text(code) "@Café Korb"
    text(-) " here"

"~20 minutes away"
    text(-) "~20 minutes away"

"`**not bold**`"
    text(code) "**not bold**"

"**unclosed bold"
    text(-) "**unclosed bold"

"~~**both**~~"
    text(bold+strike) "both"

"Bring adapters*, and *do not* forget the map"
    text(-) "Bring adapters*, and "
    text(italic) "do not"
    text(-) " forget the map"

"Save as IMG*.jpg then *print* it"
    text(-) "Save as IMG*.jpg then "
    text(italic) "print"
    text(-) " it"

Every unmatched or ambiguous case falls back to literal text, and the URL in see www.example.com/a_b_c/x*y*z now survives intact with both its underscores and its asterisks.

npm run build and npm run lint are both clean.

Boundary cases between emphasis and links

The interesting interaction is at a link's edge, since URL_PATTERN takes everything that isn't whitespace and would otherwise swallow a closing marker. findCloser steps over a link (and over a code span) rather than into one, stopping short of a trailing marker:

"*book www.example.com*"
    text(italic) "book "
    url(italic) "www.example.com"

"**see www.example.com**"
    text(bold) "see "
    url(bold) "www.example.com"

"2*3 and http://x.example/*a/b end"
    text(-) "2*3 and "
    url(-) "http://x.example/*a/b"
    text(-) " end"

"https://example.com/a?b=1&c=*"
    url(-) "https://example.com/a?b=1&c=*"

"*price `2*3` here*"
    text(italic) "price "
    text(code+italic) "2*3"
    text(italic) " here"

A marker inside a path never closes a span, and a link in a note with no emphasis in it keeps its href byte for byte — the trim that makes the first two cases work is confined to the closer scan (skipPastLink) rather than folded into trimTrailingPunctuation, because a silently shortened href is a broken link wearing a working link's label.

Fuzz

A throwaway property test (also not committed) ran 200,000 random strings built from an alphabet of *, **, ~, ~~, backtick, @, a place name, a URL containing asterisks, parens, spaces and newlines, asserting that the concatenated segment values are a subsequence of the input dropping only marker characters:

fuzz: 200000 inputs, no character loss, no throws

walk also carries a MAX_DEPTH, past which markers stop being markers — trip_notes.body has no length limit, and without the cap a pasted '*'.repeat(12000) + 'x' + '*'.repeat(12000) overflowed the stack inside the React tree, taking the page down for every viewer of the trip. It now parses at 100k characters in 24ms.

Review

Three review passes, high effort.

Round 1 — four findings. Fixed: a code span was not opaque to an emphasis closer scan, so *price `2*3` here* destroyed it; TRAILING gaining the markers rewrote hrefs unconditionally, and .note-link only shows the host so the user could not see the target had moved; walk had no depth bound, and a pasted '*'.repeat(12000) + 'x' + '*'.repeat(12000) overflowed the stack inside the React tree. Declined: the claim that ****bold**** and **x*** lose characters — every asterisk there is either a delimiter of a matched pair or survives into the span. The header comment was overclaiming, so it now states the guarantee it can keep: the only characters ever removed are markers that found a partner.

Round 2 — five findings across two reviewers, all fixed:

  • HIGH. A stray marker earlier in the line stole the emphasis the writer meant and deleted itself doing it. Bring adapters*, and *do not* forget rendered as Bring adapters, and *do not forget — the first asterisk paired with the opening marker of the real span. openerAt now applies CommonMark's left-flanking rule: a marker facing punctuation only opens if what precedes it is whitespace, punctuation, or the start of the span. Intraword 5*3 is left alone, as CommonMark leaves it.
  • MEDIUM. <strong> and <s> were invisible on the only two things a note contains besides prose. .note-link and .mention-chip set their own font-weight, and an author rule on the element beats the UA's inherited strong { bolder }; the line-through is not overridden but never drawn, because .note-link is inline-flex and text decoration does not propagate into an inline-level atomic box. So **@Café Korb** and ~~www.example.com~~ rendered pixel-identical to the unemphasised versions. Two CSS rules fix it.
  • MEDIUM. findCloser scanned to the end of the string for every opener with no partner — quadratic in marker density, and the triggering input is not adversarial-looking: a pasted bullet list written *Item with no space. 48 KB was 2.4 s of blocked main thread and 120 KB was 61.5 s, for every viewer of the trip. One failed search now settles that marker kind for the rest of the string, since a later opener searches a suffix of a range already proven empty and every test findCloser applies is positional. 61.5 s becomes 33 ms.
  • LOW. skipPastLink handed a link's trailing backtick back to the scan, which read it as a code opener and skipped to a backtick further down the note, stepping over a real closer: *hi www.x.com` there* ok `q` end* lost its closer after there. Those trailing markers may close a span but never open one, since walk keeps them inside the URL; findCloser tracks the link's true end and suppresses only the code branch inside it.
  • LOW. withMarks ended in a bare else that rendered any unrecognised mark as <code>. A Record<NoteMark, …> makes a fifth mark a compile error.

Also declined, from a parallel pass: www.example.com*bold* collapsing into one link with a garbled href. Verified byte-for-byte identical on main — it is URL_PATTERN's existing greediness meeting new syntax, not something this branch introduced, and it only fires when a marker abuts a link with no space. The case people do write, a link wrapped in emphasis, is exactly what skipPastLink makes work; the boundary is written down next to the function.

Reuse cleanups both reviewers raised: urlAt absorbs the h/w gate its two callers repeated, codeEnd makes the scanner and the closer scan agree on a span's bounds structurally rather than by comment, runLength replaces two copies of the same loop, and a plain-text segment renders as bare text — nothing styled that <span>, and plain runs are the commonest segment by far.

https://claude.ai/code/session_01GTyi3SDwPUCdn6iXXPHu5E

@cloudflare-workers-and-pages

cloudflare-workers-and-pages Bot commented Sep 9, 2026 •

Copy link
Copy Markdown

Deploying travel-planner with  Cloudflare Pages  Cloudflare Pages

Latest commit: 301e504
Status: ✅  Deploy successful!
Preview URL: https://b3157013.travel-planner-7x6.pages.dev
Branch Preview URL: https://feat-note-inline-formatting.travel-planner-7x6.pages.dev

View logs

thkleinert pushed a commit that referenced this pull request Sep 9, 2026
A code span was not opaque to an emphasis closer scan. "*price `2*3`
here*" closed the italic on the asterisk between the backticks: the code
span vanished, its backticks rendered as literal characters, and an
emphasis run appeared across a boundary nobody wrote. findCloser now
steps over a code span exactly as it steps over a link, using the same
bounds walk uses, so the two always agree on where a span is.

Emphasis markers are out of TRAILING again. Trimming them there was
unconditional, so it rewrote hrefs in notes with no emphasis anywhere in
them — "https://example.com/a?b=1&c=*" lost its last character while the
pill still read "example.com", which is a broken link wearing a working
link's label. The trim only ever existed for the closer scan, so it now
lives there, in skipPastLink, and nowhere else.

walk had no depth bound, and trip_notes.body has no length limit: a
pasted '*'.repeat(12000) + 'x' + '*'.repeat(12000) overflowed the stack,
and since NoteBody renders inside the React tree that takes the page
down for every viewer of the trip rather than only its author. Past
MAX_DEPTH markers stop being markers, which is the same answer
everything else here gives when a marker cannot be honoured.

Declined the fourth finding, that "****bold****" and "**x***" lose
characters. They do not: every asterisk in those is a delimiter of a
matched pair or survives into the span ("**x***" renders x* — the odd
one is kept). What the header comment promised was too strong, though,
so it now states the guarantee it can actually keep: the only characters
ever removed are markers that found a partner.

Claude-Session: https://claude.ai/code/session_01GTyi3SDwPUCdn6iXXPHu5E
thkleinert pushed a commit that referenced this pull request Sep 9, 2026
A code span was not opaque to an emphasis closer scan. "*price `2*3`
here*" closed the italic on the asterisk between the backticks: the code
span vanished, its backticks rendered as literal characters, and an
emphasis run appeared across a boundary nobody wrote. findCloser now
steps over a code span exactly as it steps over a link, using the same
bounds walk uses, so the two always agree on where a span is.

Emphasis markers are out of TRAILING again. Trimming them there was
unconditional, so it rewrote hrefs in notes with no emphasis anywhere in
them — "https://example.com/a?b=1&c=*" lost its last character while the
pill still read "example.com", which is a broken link wearing a working
link's label. The trim only ever existed for the closer scan, so it now
lives there, in skipPastLink, and nowhere else.

walk had no depth bound, and trip_notes.body has no length limit: a
pasted '*'.repeat(12000) + 'x' + '*'.repeat(12000) overflowed the stack,
and since NoteBody renders inside the React tree that takes the page
down for every viewer of the trip rather than only its author. Past
MAX_DEPTH markers stop being markers, which is the same answer
everything else here gives when a marker cannot be honoured.

Declined the fourth finding, that "****bold****" and "**x***" lose
characters. They do not: every asterisk in those is a delimiter of a
matched pair or survives into the span ("**x***" renders x* — the odd
one is kept). What the header comment promised was too strong, though,
so it now states the guarantee it can actually keep: the only characters
ever removed are markers that found a partner.

Claude-Session: https://claude.ai/code/session_01GTyi3SDwPUCdn6iXXPHu5E
thkleinert pushed a commit that referenced this pull request Sep 9, 2026
A stray marker earlier in the line stole the emphasis the writer meant,
and ate itself doing it: "Bring adapters*, and *do not* forget" rendered
as "Bring adapters, and *do not forget" — the first asterisk paired with
the OPENING one of the real span, the emphasis landed on text nobody
wrote, and a character the user typed went missing. "Save as IMG*.jpg
then *print* it" the same. openerAt now applies CommonMark's
left-flanking rule: a marker facing punctuation only opens if what
precedes it is whitespace, punctuation, or the start of the span.
Intraword "5*3" is left alone, which is what CommonMark does too.

Bold and strikethrough were invisible on the only two things a note
contains besides prose. .note-link and .mention-chip set their own
font-weight, and an author rule on the element beats the UA's inherited
strong{bolder}; the line-through is not overridden but never drawn at
all, since .note-link is inline-flex and decoration does not propagate
into an inline-level atomic box. So "**@Café Korb**" and
"~~www.example.com~~" rendered pixel-identical to the unemphasised
versions — the markers did nothing and looked like a bug.

findCloser scanned to the end of the string for every opener that had no
partner, which is quadratic in marker density: a pasted bullet list
written "*Item" with no space is 2.4s of blocked main thread at 48KB and
a minute at 120KB, for every viewer of the trip. One failed search now
settles that marker kind for the rest of the string — a later opener
searches a suffix of a range already proven empty, and every test
findCloser applies is positional. 61.5s becomes 33ms.

skipPastLink handed a link's trailing backtick back to the scan, which
read it as a code opener and skipped to a backtick further down the
note, stepping over a real closer on the way: "*hi www.x.com` there* ok
`q` end*" lost its closer after "there". Those trailing markers may be
read as closers but never as openers, since walk keeps them inside the
URL — findCloser tracks the link's true end and suppresses only the code
branch inside it.

withMarks ended in a bare else that rendered any unrecognised mark as
<code>. A Record over NoteMark makes a fifth mark a compile error.

Also: urlAt absorbs the h/w gate both callers repeated, codeEnd makes
the two passes agree on a span's bounds structurally rather than by
comment, runLength replaces two copies of the same loop, and a plain
text segment renders as bare text — nothing styled that span, and plain
runs are the commonest segment by far.

Claude-Session: https://claude.ai/code/session_01GTyi3SDwPUCdn6iXXPHu5E
@thkleinert
thkleinert force-pushed the feat/note-inline-formatting branch from 8a1bcd9 to 2fbbe6c Compare September 9, 2026 11:19
parseNoteBody was already an inline tokenizer walking the string for
mentions and links, so emphasis is four more branches in the same walk
rather than a second system. Segments gain an optional `marks` set:
flat, because emphasis crosses the other segment types instead of
containing them — a bold span holds text, mentions and links alike, and
a tree would make every consumer walk children to find a mention.

Emphasis is tested after the URL branch, for the reason the comment
there already gives about '@' and one worse: URLs are full of markers,
and www.example.com/a*b*c would otherwise lose its middle to an <em>
and stop being one link. _underscore_ is not supported at all, since
that hazard is the common case rather than the rare one.

An unmatched marker stays the character that was typed. "2 * 3 = 6" is
arithmetic, "****" is four asterisks, "~20 min" is a tilde. That is the
same guarantee mentions already make and the reason the storage format
holds no tokens: a note always reads as what was typed, and a broken
marker degrades to visible characters instead of corruption.

Inline only. The notes ARE an outline — the row's depth carries the
structure — so headings and list markers would be a second,
contradictory hierarchy.

NoteBody renders <strong>/<em>/<s>/<code>, so both surfaces that use it
(the outliner and the place sheet) get this at once; nothing else
renders a note body raw.

Claude-Session: https://claude.ai/code/session_01GTyi3SDwPUCdn6iXXPHu5E
The example given did not hold: a marker at a link's very END is handed
back by trimTrailingPunctuation and can close a span. That is wanted —
it is what makes "*book www.example.com*" work, and it treats a
trailing '*' exactly as a trailing '.' has always been treated. What the
step-over actually guarantees is that nothing INSIDE a path can close,
so the link is never cut in half.

Claude-Session: https://claude.ai/code/session_01GTyi3SDwPUCdn6iXXPHu5E
A code span was not opaque to an emphasis closer scan. "*price `2*3`
here*" closed the italic on the asterisk between the backticks: the code
span vanished, its backticks rendered as literal characters, and an
emphasis run appeared across a boundary nobody wrote. findCloser now
steps over a code span exactly as it steps over a link, using the same
bounds walk uses, so the two always agree on where a span is.

Emphasis markers are out of TRAILING again. Trimming them there was
unconditional, so it rewrote hrefs in notes with no emphasis anywhere in
them — "https://example.com/a?b=1&c=*" lost its last character while the
pill still read "example.com", which is a broken link wearing a working
link's label. The trim only ever existed for the closer scan, so it now
lives there, in skipPastLink, and nowhere else.

walk had no depth bound, and trip_notes.body has no length limit: a
pasted '*'.repeat(12000) + 'x' + '*'.repeat(12000) overflowed the stack,
and since NoteBody renders inside the React tree that takes the page
down for every viewer of the trip rather than only its author. Past
MAX_DEPTH markers stop being markers, which is the same answer
everything else here gives when a marker cannot be honoured.

Declined the fourth finding, that "****bold****" and "**x***" lose
characters. They do not: every asterisk in those is a delimiter of a
matched pair or survives into the span ("**x***" renders x* — the odd
one is kept). What the header comment promised was too strong, though,
so it now states the guarantee it can actually keep: the only characters
ever removed are markers that found a partner.

Claude-Session: https://claude.ai/code/session_01GTyi3SDwPUCdn6iXXPHu5E
A second review pass flagged "www.example.com*bold*" collapsing into one
link with a garbled href. Verified against main: byte-for-byte the same
output there, so it is URL_PATTERN's existing greediness meeting new
syntax rather than anything this branch introduced. Declined as a case
nobody writes — a link and an emphasis run with no space between them —
where the case people do write, a link wrapped in emphasis, is exactly
what skipPastLink makes work. Written down so the next reader does not
have to re-derive it.

Claude-Session: https://claude.ai/code/session_01GTyi3SDwPUCdn6iXXPHu5E
A stray marker earlier in the line stole the emphasis the writer meant,
and ate itself doing it: "Bring adapters*, and *do not* forget" rendered
as "Bring adapters, and *do not forget" — the first asterisk paired with
the OPENING one of the real span, the emphasis landed on text nobody
wrote, and a character the user typed went missing. "Save as IMG*.jpg
then *print* it" the same. openerAt now applies CommonMark's
left-flanking rule: a marker facing punctuation only opens if what
precedes it is whitespace, punctuation, or the start of the span.
Intraword "5*3" is left alone, which is what CommonMark does too.

Bold and strikethrough were invisible on the only two things a note
contains besides prose. .note-link and .mention-chip set their own
font-weight, and an author rule on the element beats the UA's inherited
strong{bolder}; the line-through is not overridden but never drawn at
all, since .note-link is inline-flex and decoration does not propagate
into an inline-level atomic box. So "**@Café Korb**" and
"~~www.example.com~~" rendered pixel-identical to the unemphasised
versions — the markers did nothing and looked like a bug.

findCloser scanned to the end of the string for every opener that had no
partner, which is quadratic in marker density: a pasted bullet list
written "*Item" with no space is 2.4s of blocked main thread at 48KB and
a minute at 120KB, for every viewer of the trip. One failed search now
settles that marker kind for the rest of the string — a later opener
searches a suffix of a range already proven empty, and every test
findCloser applies is positional. 61.5s becomes 33ms.

skipPastLink handed a link's trailing backtick back to the scan, which
read it as a code opener and skipped to a backtick further down the
note, stepping over a real closer on the way: "*hi www.x.com` there* ok
`q` end*" lost its closer after "there". Those trailing markers may be
read as closers but never as openers, since walk keeps them inside the
URL — findCloser tracks the link's true end and suppresses only the code
branch inside it.

withMarks ended in a bare else that rendered any unrecognised mark as
<code>. A Record over NoteMark makes a fifth mark a compile error.

Also: urlAt absorbs the h/w gate both callers repeated, codeEnd makes
the two passes agree on a span's bounds structurally rather than by
comment, runLength replaces two copies of the same loop, and a plain
text segment renders as bare text — nothing styled that span, and plain
runs are the commonest segment by far.

Claude-Session: https://claude.ai/code/session_01GTyi3SDwPUCdn6iXXPHu5E
@thkleinert
thkleinert force-pushed the feat/note-inline-formatting branch from 2fbbe6c to 301e504 Compare September 9, 2026 11:21
@thkleinert
thkleinert merged commit 968fdb5 into main Sep 9, 2026
3 checks passed
@thkleinert
thkleinert deleted the feat/note-inline-formatting branch September 9, 2026 11:22
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants