fix: improve markdown rendering and bullet alignment (#530)

* fix: pin bullet markers to top of multi-line list items

- Add `items-start` to list item flex container so markers align to the
  first line instead of centering vertically across the full item height
- Use consistent bullet glyph (•) across all nesting levels
- Add test fixture 10 covering all bullet/list types and inline gap cases

For provenance purposes, this commit was AI assisted.

* fix: improve inline markdown rendering

- Add strikethrough support (~~text~~)
- Fix ***bold italic*** by checking triple-asterisk before double
- Nudge checkbox icons down 3px to align with text baseline

For provenance purposes, this commit was AI assisted.

* fix: add backslash escaping to inline markdown renderer

- Add \* \_ \` \[ \~ \\ escape sequences — consume backslash and emit literal character
- Add \ to nextSpecial scanner so the escape handler fires correctly mid-text

For provenance purposes, this commit was AI assisted.

* fix: add autolink support to inline markdown renderer

- Handle <https://url> as clickable external links
- Handle <email@domain.com> as mailto links
- Add < to nextSpecial scanner so autolinks are detected mid-text

For provenance purposes, this commit was AI assisted.

* chore: remove HTML entities from test fixture

Not a supported feature — matches Obsidian behavior.

For provenance purposes, this commit was AI assisted.

* fix: prevent empty DocBadges wrapper in sticky header row layout

In row layout, check hasPreviousVersion && planDiffStats (what
PlanDiffBadge actually needs) instead of onPlanDiffToggle (always
truthy). Prevents an empty wrapper div that created phantom width
via gap-x-3 in the sticky bar.

For provenance purposes, this commit was AI assisted.
This commit is contained in:
Michael Ramos
2026-04-09 16:44:31 -07:00
committed by GitHub
parent bac424d056
commit 062106ff90
4 changed files with 239 additions and 10 deletions
+8 -4
View File
@@ -37,12 +37,16 @@ export const DocBadges: React.FC<DocBadgesProps> = ({
archiveInfo,
linkedDocInfo,
}) => {
const anything =
repoInfo || hasPreviousVersion || showDemoBadge || linkedDocInfo || archiveInfo;
if (!anything) return null;
const isRow = layout === 'row';
// In row layout, only PlanDiffBadge (when it has stats to show) and
// archiveInfo actually render — everything else is hidden. Check what
// will truly produce visible output to avoid an empty wrapper div.
const anything = isRow
? (!linkedDocInfo && ((hasPreviousVersion && planDiffStats) || archiveInfo))
: repoInfo || hasPreviousVersion || showDemoBadge || linkedDocInfo || archiveInfo;
if (!anything) return null;
// Row layout: single horizontal line. Column layout: stacked rows.
const outerClass = isRow
? 'flex flex-row items-center gap-1.5 text-[9px] text-muted-foreground/70 font-mono'
+3 -3
View File
@@ -25,7 +25,7 @@ interface ListMarkerProps {
onToggle?: () => void;
}
const BULLET_BY_LEVEL = ['\u2022', '\u25E6', '\u25AA'];
const BULLET_BY_LEVEL = ['\u2022', '\u2022', '\u2022'];
export const ListMarker: React.FC<ListMarkerProps> = ({
level,
@@ -57,11 +57,11 @@ export const ListMarker: React.FC<ListMarkerProps> = ({
)}
{isCheckbox ? (
checked ? (
<svg className="w-4 h-4 text-success" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={2.5}>
<svg className="w-4 h-4 text-success mt-[3px]" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={2.5}>
<path strokeLinecap="round" strokeLinejoin="round" d="M9 12.75L11.25 15 15 9.75M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
</svg>
) : (
<svg className={`w-4 h-4 text-muted-foreground/50${interactive ? ' hover:text-muted-foreground transition-colors' : ''}`} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={2}>
<svg className={`w-4 h-4 text-muted-foreground/50 mt-[3px]${interactive ? ' hover:text-muted-foreground transition-colors' : ''}`} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={2}>
<circle cx="12" cy="12" r="9" />
</svg>
)
+48 -3
View File
@@ -739,8 +739,53 @@ const InlineMarkdown: React.FC<{ text: string; onOpenLinkedDoc?: (path: string)
let previousChar = '';
while (remaining.length > 0) {
// Backslash escaping: \* \_ \` \[ \~ etc. — emit literal char, hide backslash
let match = remaining.match(/^\\([*_`\[\]~!\\])/);
if (match) {
parts.push(match[1]);
remaining = remaining.slice(2);
previousChar = match[1];
continue;
}
// Autolinks: <https://url> or <email@domain.com>
match = remaining.match(/^<(https?:\/\/[^>]+)>/);
if (match) {
const url = match[1];
parts.push(<a key={key++} href={url} target="_blank" rel="noopener noreferrer" className="text-primary underline underline-offset-2 hover:text-primary/80">{url}</a>);
remaining = remaining.slice(match[0].length);
previousChar = '>';
continue;
}
match = remaining.match(/^<([^@>\s]+@[^>\s]+)>/);
if (match) {
const email = match[1];
parts.push(<a key={key++} href={`mailto:${email}`} className="text-primary underline underline-offset-2 hover:text-primary/80">{email}</a>);
remaining = remaining.slice(match[0].length);
previousChar = '>';
continue;
}
// Strikethrough: ~~text~~
match = remaining.match(/^~~([\s\S]+?)~~/);
if (match) {
parts.push(<del key={key++}><InlineMarkdown imageBaseDir={imageBaseDir} onImageClick={onImageClick} text={match[1]} onOpenLinkedDoc={onOpenLinkedDoc} /></del>);
remaining = remaining.slice(match[0].length);
previousChar = match[0][match[0].length - 1] || previousChar;
continue;
}
// Bold + italic: ***text***
match = remaining.match(/^\*\*\*([\s\S]+?)\*\*\*/);
if (match) {
parts.push(<strong key={key++} className="font-semibold"><em><InlineMarkdown imageBaseDir={imageBaseDir} onImageClick={onImageClick} text={match[1]} onOpenLinkedDoc={onOpenLinkedDoc} /></em></strong>);
remaining = remaining.slice(match[0].length);
previousChar = match[0][match[0].length - 1] || previousChar;
continue;
}
// Bold: **text** ([\s\S]+? allows matching across hard line breaks)
let match = remaining.match(/^\*\*([\s\S]+?)\*\*/);
match = remaining.match(/^\*\*([\s\S]+?)\*\*/);
if (match) {
parts.push(<strong key={key++} className="font-semibold"><InlineMarkdown imageBaseDir={imageBaseDir} onImageClick={onImageClick} text={match[1]} onOpenLinkedDoc={onOpenLinkedDoc} /></strong>);
remaining = remaining.slice(match[0].length);
@@ -906,7 +951,7 @@ const InlineMarkdown: React.FC<{ text: string; onOpenLinkedDoc?: (path: string)
}
// Find next special character or consume one regular character
const nextSpecial = remaining.slice(1).search(/[\*_`\[!]/);
const nextSpecial = remaining.slice(1).search(/[\*_`\[!~\\<]/);
if (nextSpecial === -1) {
parts.push(remaining);
previousChar = remaining[remaining.length - 1] || previousChar;
@@ -1020,7 +1065,7 @@ const BlockRenderer: React.FC<{
const isInteractive = isCheckbox && !!onToggleCheckbox;
return (
<div
className="flex gap-3 my-1.5"
className="flex items-start gap-3 my-1.5"
data-block-id={block.id}
style={{ marginLeft: `${indent}rem` }}
>
@@ -0,0 +1,180 @@
# Inline Gaps & Bullet List Coverage
## Inline Formatting — Known Gaps
### Strikethrough
This feature is ~~deprecated~~ and should be removed.
The old ~~`legacyMode` flag~~ has been replaced by `modernMode`.
### Nested and Combined Emphasis
This is ***bold and italic*** at the same time.
Here is _**underscore italic wrapping bold**_ text.
And **_bold wrapping underscore italic_** text.
Single *italic* and **bold** beside each other with no space: *italic***bold**.
### Backslash Escaping
Literal asterisk: \*not italic\*
Literal underscore: \_not italic\_
Literal backtick: \`not code\`
Literal bracket: \[not a link\]
### Autolinks
Visit <https://plannotator.ai> for more info.
Send mail to <hello@plannotator.ai>.
### Link Reference Definitions
Here is a [link to the docs][docs] and another [link to the repo][repo].
Also a [bare reference][docs] used twice.
[docs]: https://plannotator.ai/docs
[repo]: https://github.com/backnotprop/plannotator
---
## Bullet Lists — All Types
### Unordered — Dash
- First item
- Second item
- Third item
### Unordered — Asterisk
* Alpha
* Beta
* Gamma
### Ordered — Basic
1. First step
2. Second step
3. Third step
### Ordered — Arbitrary Numbers (should renumber 1, 2, 3)
1. First
99. Second
3. Third
### Ordered — Starting at Non-1
5. Item five
6. Item six
7. Item seven
### Checkboxes — Unchecked
- [ ] Write tests
- [ ] Review PR
- [ ] Deploy to staging
### Checkboxes — Mixed
- [x] Install dependencies
- [x] Configure environment
- [ ] Run integration tests
- [ ] Write release notes
### Ordered + Checkbox (GitHub style)
1. [x] Design spec approved
2. [x] Implementation complete
3. [ ] QA signoff
4. [ ] Shipped
---
## Nested Bullets
### Two Levels — Unordered
- Top level item A with enough text that it will wrap across multiple lines when viewed in a normal browser window at typical zoom levels
- Nested under A, also with a longer description so we can confirm the bullet stays pinned to the top of the text and does not drift to the vertical center of the whole item
- Another nested item under A with similar verbosity to make the wrapping behavior clearly visible during manual testing
- Top level item B which also has a fair amount of text to ensure it wraps and we can see how the marker aligns relative to the content block beneath it
- Nested under B with extra detail: this item intentionally runs long so the bullet alignment fix is obvious even at wide viewport widths
### Three Levels — Unordered
- Level one — this is a long item that spans multiple lines so we can verify the top-alignment fix holds at the outermost nesting level, not just for deeply nested content
- Level two — also written with enough words to cause line wrapping, confirming the fix applies consistently across all indentation levels and is not just a one-off
- Level three — the deepest level in this section, with enough prose to wrap at least once so the bullet position relative to the first line is clearly observable
- Another level three item with similar length to the one above, included so we have two consecutive wrapping items at the deepest level
- Back to level two with a longer description than before so this item also wraps and we can spot any regression between siblings at the same nesting depth
- Another level one item, intentionally verbose so it wraps and gives us a second data point at the top level for the alignment check
### Mixed Ordered and Unordered
1. First ordered item written with enough detail to push it past a single line, confirming that the top-alignment fix works for ordered markers as well as plain bullets
- Unordered child with a long enough description that it wraps, so we can see the bullet sit at the top of the wrapped content rather than floating in the middle
- Another child item, equally verbose, to verify consistent alignment across sibling items within the same parent
2. Second ordered item, also long enough to wrap, giving us a second ordered marker to inspect for correct top alignment
- Child of second ordered item, written long so the bullet wraps and the alignment is testable without squinting
3. Third ordered item to round out the list with a similarly verbose description for consistency
### Ordered Nested
1. Phase one — introductory work that sets up the environment, configures dependencies, and establishes the baseline from which all subsequent phases will build
1. Task A involves researching the current implementation and documenting all the edge cases that need to be addressed before moving forward
2. Task B covers writing the initial draft of the solution, including inline comments explaining the rationale behind each non-obvious decision
2. Phase two — the main implementation phase where the bulk of the work happens and the majority of the codebase changes are introduced
1. Task C is the core refactor, touching the parser, the viewer, and the list marker component in a coordinated way to avoid regressions
2. Task D is the follow-up cleanup pass that removes dead code, updates tests, and ensures the diff engine still produces correct output after the changes
1. Sub-task D1: update parser tests to cover the new list continuation logic added during the refactor
2. Sub-task D2: rebuild all build targets in the correct order and run a full manual smoke test against the test fixtures before merging
### Deep Nesting with Checkboxes
- [ ] Top-level task with a long description that wraps to confirm the checkbox icon stays at the top of the item rather than centering itself relative to all lines of text
- [x] Subtask that has been completed, written with extra detail so the checked state and the strikethrough styling are both visible across multiple lines of wrapped content
- [ ] Subtask still pending, also written long enough to wrap so we can compare the visual alignment of checked versus unchecked items at the same nesting level
- [ ] Sub-subtask at the deepest level, long enough to wrap and confirm the fix holds even at three levels of nesting with checkbox markers
- [x] Another sub-subtask that is done, included so we have both checked and unchecked examples at this depth for a thorough visual comparison
- [ ] Another subtask at level two, written verbosely to wrap and complete the set of alignment test cases for this nesting structure
- [x] Another top-level task that is complete, with a long description so the strikethrough and muted text styles are visible across multiple wrapped lines
### Multi-line List Items (continuation lines)
- This is a list item with a genuinely long description that continues across several lines. It covers enough ground that even on a wide screen it should wrap at least once, giving us a real-world example of the bullet alignment fix in action without relying on artificial line breaks.
- Short item.
- Another item with continuation that also runs long enough to wrap naturally in the browser, so we can confirm the fix applies to continuation-style items the same way it applies to items written as a single long string.
### Bullets with Inline Formatting
- Item with **bold text** that also runs long enough to wrap so we can confirm the bullet aligns to the top even when the first line contains a bold span that changes the line's visual weight
- Item with *italic text* and enough surrounding prose that the line wraps and the bullet position relative to the first line is clearly observable during the manual test
- Item with `inline code` embedded partway through a longer sentence so the item wraps and we can see that the code span does not affect bullet alignment in any unexpected way
- Item with a [link to plannotator.ai](https://plannotator.ai) embedded in a longer description that wraps, confirming that anchor elements inside list items do not disrupt the top-alignment behavior
- Item with ~~strikethrough~~ text in a long enough sentence that the item wraps and the strikethrough styling is visible on the first line while the bullet stays pinned to the top
- Item with ***bold italic*** combined emphasis inside a sentence that continues long enough to wrap, giving us a combined formatting case to inspect for alignment
- **Bold label:** a longer description following the bold label, intentionally verbose so the item wraps and we can see the bold marker sit correctly at the top of the block
### Nested Bullets with Inline Formatting
- **Phase 1:** Initial setup — this phase covers installing all required runtimes, verifying the environment, and ensuring every developer on the team can reproduce the build locally without additional configuration steps
- Install `bun` runtime by following the official installation guide; confirm the version matches the one pinned in `.tool-versions` or the project README before proceeding
- Run `bun install` in the monorepo root to pull all workspace dependencies in a single pass; this may take a minute on a cold cache but subsequent runs will be fast
- Verify with `bun --version` that the installed version is correct and matches CI; mismatches here have historically caused subtle build failures that are hard to diagnose
- **Phase 2:** Configuration — set all required environment variables and confirm that the local server starts cleanly before attempting any integration or end-to-end tests
- Set `PLANNOTATOR_PORT=19432` to match the port expected by the VS Code extension and the remote tunnel configuration used in devcontainer environments
- Export `PLANNOTATOR_REMOTE=1` when running inside a devcontainer or over SSH; without this flag the server will attempt to open a browser on the remote host which will silently fail
- Confirm the SSH tunnel is *active* and forwarding the correct port before running any test that depends on the browser opening automatically on the local machine
- **Phase 3:** Deployment — build all targets in the correct order, verify the output, and notify the team before tagging the release so there is time to catch any last-minute issues
- Run `bun run build` from the monorepo root, which executes the review build first and then the hook build in the correct sequence to avoid stale HTML being copied into the dist folder
- ~~Upload to S3~~ (replaced by CDN push via GitHub Actions on merge to main; manual uploads are no longer part of the release process and should not be performed directly)
- Notify the team via **Slack** in the `#releases` channel with the version number, a link to the changelog, and a brief summary of what changed so reviewers know what to look for