Skip to content

Conversation

@Kiarokh
Copy link
Contributor

@Kiarokh Kiarokh commented Nov 7, 2025

When an email is imported into the CRM, the spacing between paragraphs in the CRM appears to be much larger than the spacing between paragraphs in emails, created in Outlook. This is visible in the Activity Feed, and creates a lot of additional vertical scrolling for users.

This issue stems from how email clients like Outlook handle paragraphs. Unlike standard text editing programs like Word, Outlook (or Gmail) doesn’t follow the standard typographic rules. In Word, there’s usually a greater space between paragraphs than between lines. However, users often hit the Enter key twice to create a gap between lines in while they are typing their emails.

When these emails are imported into the CRM, our standard paragraph styling adds even more space, creating the appearance of double line spacing because there are extra empty paragraphs in the actual imported email.

We don’t want to have a non-standard typographic style in CRM. So we can't simply remove our default styling for margins between paragraphs.

So we need to remove the empty paragraphs from the input value that has HTML format. The issue is that the depending on the email client which is used, the empty paragraphs would be different. They could be anything like:

<p></p>
<p><p></p></p>
<p>&nbsp;</p>
<p class="something"><br></p>
<p><span style="font-size:10.5pt; color:#333333">&nbsp;</span></p>
<p><span></span></p>
<p> </p>

Therefore, we can’t do this purely with CSS in a fully reliable way, since CSS selectors can’t inspect text content. So something like:

p:empty {
  display: none;
}
// or
p:has(:only-child:empty),
p:has(br:only-child),
p:has(:not(:empty)) {
  display: none;
}

… still won’t detect invisible text like &nbsp; or empty nested paragraphs inside each other.

Additionally, this feel like something that should be controlled via a prop by the consumer. Because sometimes, they my really need the empty paragraphs for some legit reason.

So we need JS to handle this, as done in this PR.

fix https://github.com/Lundalogik/limepkg-email/issues/1744

Summary by CodeRabbit

  • New Features
    • Added a public option to preserve or remove empty paragraphs in rendered Markdown, with preservation enabled by default; examples demonstrate both behaviors.
  • Chores
    • Improved component attribute reflection for image lazy-loading.
  • Style
    • Added styling for the new Markdown example layout.
  • Tests
    • Added unit tests covering empty-paragraph preservation and removal scenarios.

Review:

  • Commits are atomic
  • Commits have the correct type for the changes made
  • Commits with breaking changes are marked as such

Browsers tested:

(Check any that applies, it's ok to leave boxes unchecked if testing something didn't seem relevant.)

Windows:

  • Chrome
  • Edge
  • Firefox

Linux:

  • Chrome
  • Firefox

macOS:

  • Chrome
  • Firefox
  • Safari

Mobile:

  • Chrome on Android
  • iOS

@coderabbitai
Copy link

coderabbitai bot commented Nov 7, 2025

Important

Review skipped

Auto incremental reviews are disabled on this repository.

Please check the settings in the CodeRabbit UI or the .coderabbit.yaml file in this repository. To trigger a single review, invoke the @coderabbitai review command.

You can disable this status message by setting the reviews.review_status to false in the CodeRabbit configuration file.

📝 Walkthrough

Walkthrough

Adds a new removeEmptyParagraphs option and Unified plugin to strip empty

nodes from rendered Markdown, exposes it as a reflected @Prop on the Markdown component with a watcher, and wires the plugin into the markdown processing pipeline; also adds an example and styles demonstrating the option.

Changes

Cohort / File(s) Change Summary
Markdown component
src/components/markdown/markdown.tsx
Added @Prop({ reflect: true }) public removeEmptyParagraphs: boolean = true; changed lazyLoadImages to @Prop({ reflect: true }); pass removeEmptyParagraphs into markdownToHTML options; added @Watch('removeEmptyParagraphs') watcher that calls textChanged().
Markdown parser / plugin integration
src/components/markdown/markdown-parser.ts, src/components/markdown/remove-empty-paragraphs-plugin.ts
Added removeEmptyParagraphs?: boolean to MarkdownToHTMLOptions; imported and wired a new remove-empty-paragraphs plugin into the processing pipeline (after sanitization, before lazy-load processing).
Plugin tests
src/components/markdown/remove-empty-paragraphs-plugin.spec.ts
New unit tests covering enabled/disabled behavior, whitespace/NBSP handling, preservation of non-empty paragraphs (images, text), and edge cases.
Examples & styles
src/components/markdown/examples/markdown-remove-empty-paragraphs.tsx, src/components/markdown/examples/markdown-remove-empty-paragraphs.scss
New example component demonstrating default (removed) vs preserved empty paragraphs and associated SCSS for layout and styling.

Sequence Diagram(s)

sequenceDiagram
    autonumber
    participant Component as limel-markdown (component)
    participant Watcher as Watcher
    participant Parser as markdownToHTML
    participant Sanitize as Sanitize HTML
    participant RemoveEmpty as RemoveEmptyParagraphs Plugin
    participant LazyLoad as LazyLoadImages Plugin
    participant DOM as Rendered HTML

    Component->>Parser: render(value, { removeEmptyParagraphs, lazyLoadImages, ... })
    Note over Parser,Sanitize: Processing pipeline
    Parser->>Sanitize: sanitize(html)
    Sanitize-->>RemoveEmpty: sanitized AST
    RemoveEmpty-->>LazyLoad: transformed AST
    LazyLoad-->>DOM: transformed HTML
    DOM-->>Component: rendered output

    Component->>Watcher: removeEmptyParagraphs attribute changed
    Watcher-->>Component: textChanged() -> re-render -> Parser
Loading

Estimated code review effort

🎯 3 (Moderate) | ⏱️ ~25 minutes

  • Review focus:
    • Correctness of AST traversal and removal logic in remove-empty-paragraphs-plugin.ts (edge cases, NBSP handling).
    • Integration order in markdown-parser.ts to ensure plugin runs after sanitization but before lazy-loading transformations.
    • Watcher behavior and reflected property interactions in markdown.tsx.
    • Tests in remove-empty-paragraphs-plugin.spec.ts for completeness and robustness.

Possibly related PRs

  • PR #3559: Modified markdown rendering pipeline to add lazyLoadImages support and related parser changes; strongly related because this PR wires a new plugin into the same pipeline and adjusts the lazyLoadImages property reflection.

Suggested labels

bug

Suggested reviewers

  • jgroth
  • adrianschmidt

Pre-merge checks and finishing touches

✅ Passed checks (3 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title clearly describes enabling removal or preservation of empty paragraphs, matching the primary change.
Docstring Coverage ✅ Passed Docstring coverage is 100.00% which is sufficient. The required threshold is 80.00%.

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands and usage tips.

@coderabbitai coderabbitai bot changed the title @coderabbitai Enable property reflection for lazyLoadImages Nov 7, 2025
@Kiarokh Kiarokh requested a review from a team as a code owner November 7, 2025 13:55
@Kiarokh Kiarokh changed the title Enable property reflection for lazyLoadImages @coderabbitai Nov 7, 2025
@Kiarokh Kiarokh enabled auto-merge (rebase) November 7, 2025 13:56
@Kiarokh Kiarokh changed the title @coderabbitai Text editor: enable removing or keeping empty paragraphs of an HTML value Nov 7, 2025
@Kiarokh Kiarokh force-pushed the markdown-empty-paragraphs branch from 251fd74 to 2748154 Compare November 7, 2025 13:58
@Kiarokh
Copy link
Contributor Author

Kiarokh commented Nov 7, 2025

@coderabbitai review plz

* whitespace (`<br />` or `&nbsp;`).
*/
@Prop({ reflect: true })
public removeEmptyParagraphs = true;

This comment was marked as off-topic.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Oops… sorry the title was wrong. This is a feature for limel-markdown not limel-text-editor. Some 🧠 💨

⚡ ed the title and the commit now.

However, I do agree to your points. I was actually just writing in Slack to see if this approach is good. We would preferably need to remove the empty paragraphs, when the email is being imported, and not leave it up to this innocent component.

But this PR can help make my point and offer a way to sanitize the value before it's saved. I'll add more on Slack

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Oh, hang on, was this limel-markdown? Why did I think it was limel-text-editor? 🤨

Anyway, forget what I said above. I think this property is fine in limel-markdown 👍

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Ah! So it wasn't my mistake then! 😅

Copy link
Contributor

@adrianschmidt adrianschmidt Nov 7, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

However, I do agree to your points. I was actually just writing in Slack to see if this approach is good. We would preferably need to remove the empty paragraphs, when the email is being imported, and not leave it up to this innocent component.

But this PR can help make my point and offer a way to sanitize the value before it's saved. I'll add more on Slack

Yeah, I agree. My original comments still apply in this case, but it's less of a problem for limel-markdown than it would have been in limel-text-editor, hence why I think it's acceptable to add this prop to limel-markdown 👍

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

sorry, yes it was misleading info by me first.

this is a tricky scenario since emails are imported in CMR, and then might get compiled again from the CRM and be sent out. So we may not want to modify their content too much, as they are rendered differently in the Email Clients, than in CRM, and may appear malformatted when they reach the reader. But we still want to render them decently in the CRM, without re-creating the stupid "no-space-between-paragraphs" visual style of Eail Clients everywhere.

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Some preprocessing can perhaps be done by a limebb- component before passing the value to limel-markdown for rendering. Or the database could have two columns, one where we store the original email, and one where we store a sanitised version for display in the app.

@coderabbitai
Copy link

coderabbitai bot commented Nov 7, 2025

🧩 Analysis chain

@Kiarokh Thanks! I’ve pulled the branch and did a focused pass. Here’s the review summary and concrete fixes:

Key findings

  • limel-markdown API
    • New prop removeEmptyParagraphs: boolean (default true, reflected). Good defaults and documented via TS docstring. Watcher correctly re-renders.
    • lazyLoadImages now reflected. However, markdown.tsx shows two decorators on the same property:
      • Both @prop() and @prop({ reflect: true }) are present on lazyLoadImages. Stencil expects a single @prop per field; stacking can lead to metadata conflicts. Please remove the older @prop() line (see Line 513 in the diff view).
  • Plugin correctness
    • The createRemoveEmptyParagraphsPlugin implementation looks solid: reverse-order pruning, safe parent-child splicing, and a conservative keep-list (images, media, etc.). Nice.
    • Consider broadening whitespace detection to also remove zero-width whitespace characters often present in pasted content: U+200B, U+200C, U+200D, U+FEFF. Right now only NBSP is normalized.
    • Add explicit handling tests for paragraphs whose only child is
      (with/without wrapper span/class), as mentioned in the PR body. The logic will treat
      as empty but there isn’t a test for that case.
  • Pipeline order
    • You register createRemoveEmptyParagraphsPlugin before rehypeStringify and after sanitize/style processing, which is the right spot so the AST is stable and already sanitized.
  • Examples
    • The example SCSS has spaces in unit tokens: 1 rem, 10 rem, 1 fr. These should be 1rem, 10rem, 1fr.

Suggested patches

  1. Remove duplicate decorator on lazyLoadImages
// src/components/markdown/markdown.tsx
/**
 * Enable lazy loading for images
 */
- @Prop()
- @Prop({ reflect: true })
+ @Prop({ reflect: true })
public lazyLoadImages = false;
  1. Extend whitespace detection to cover zero‑width spaces
// src/components/markdown/remove-empty-paragraphs-plugin.ts
const NBSP_REGEX = /\u00A0/g;
+const ZERO_WIDTH_REGEX = /[\u200B\u200C\u200D\uFEFF]/g;

const isWhitespace = (value: string): boolean => {
  if (!value) {
    return true;
  }
- return value.replaceAll(NBSP_REGEX, ' ').trim() === '';
+ // Normalize NBSP and zero-width chars to regular spaces, then trim.
+ const normalized = value.replaceAll(NBSP_REGEX, ' ')
+   .replaceAll(ZERO_WIDTH_REGEX, '');
+ return normalized.trim() === '';
};
  1. Add tests for
    -only paragraphs and zero‑width spaces
// src/components/markdown/remove-empty-paragraphs-plugin.spec.ts
it('removes paragraphs with only a <br> (with and without class)', () => {
  const tree = createRoot([
    createParagraph([createElement('br')]),
    createParagraph([createElement('span', [createElement('br')])]),
    createParagraph([createElement('br', [], { className: 'foo' })]),
  ]);
  runPlugin(tree, true);
  expect(tree.children).toHaveLength(0);
});

it('removes paragraphs that contain only zero-width whitespace', () => {
  const zwsp = '\u200B\u200C\u200D\uFEFF';
  const tree = createRoot([
    createParagraph([createText(zwsp)]),
    createParagraph([createElement('span', [createText(zwsp)])]),
  ]);
  runPlugin(tree, true);
  expect(tree.children).toHaveLength(0);
});
  1. Fix example SCSS units
/* src/components/markdown/examples/markdown-remove-empty-paragraphs.scss */
:host(limel-example-markdown-remove-empty-paragraphs) {
  display: grid;
- gap: 1 rem;
- grid-template-columns: repeat(auto-fit, minmax(10 rem, 1 fr));
+ gap: 1rem;
+ grid-template-columns: repeat(auto-fit, minmax(10rem, 1fr));
}

limel-markdown {
  display: block;
- padding: 0.5 rem 1 rem;
+ padding: 0.5rem 1rem;
}

Docs

  • The TS docstring on the prop is good. If there’s a component README, consider adding a short section with:
    • What counts as an “empty paragraph”
    • Default behavior (removed)
    • How to preserve them with removeEmptyParagraphs={false}
    • A note that images/media inside

      will keep the paragraph

Questions

  • Do we also want to treat
    inside a

    as meaningful? If these appear, we might preserve them. Right now,


    isn’t part of MEANINGFUL_VOID_ELEMENTS, so a


    would be removed. If that’s acceptable, no change needed.

If you apply the above, I’m happy to give a quick re-check.


🏁 Script executed:

#!/bin/bash
set -euo pipefail

echo "== Git info =="
git rev-parse --short HEAD || true
git branch --show-current || true
git remote -v || true

echo ""
echo "== Changed files (against initial commit ancestor if origin/main missing) =="
# Try to diff against origin/main; fallback to last two commits if not available.
if git rev-parse --verify origin/main >/dev/null 2>&1; then
  git diff --name-status origin/main...HEAD
else
  git diff --name-status HEAD~2..HEAD || git show --name-status --oneline | head -n 50
fi

echo ""
echo "== Show markdown.tsx around props =="
nl -ba src/components/markdown/markdown.tsx | sed -n '1,180p' | sed -n '1,140p' || true
echo ""
rg -nP --no-heading -C2 '@Prop\s*\(' src/components/markdown/markdown.tsx || true

echo ""
echo "== Show markdown-parser.ts relevant pipeline =="
nl -ba src/components/markdown/markdown-parser.ts | sed -n '1,180p' || true

echo ""
echo "== Show remove-empty-paragraphs-plugin.ts =="
nl -ba src/components/markdown/remove-empty-paragraphs-plugin.ts | sed -n '1,220p' || true

echo ""
echo "== Show remove-empty-paragraphs-plugin.spec.ts =="
nl -ba src/components/markdown/remove-empty-paragraphs-plugin.spec.ts | sed -n '1,220p' || true

echo ""
echo "== Example SCSS (check unit spacing) =="
nl -ba src/components/markdown/examples/markdown-remove-empty-paragraphs.scss || true

echo ""
echo "== Check for readme documentation for limel-markdown prop =="
fd -a -t f 'readme.md' src/components/markdown | sed -n '1,5p' || true
if fd -a -t f 'readme.md' src/components/markdown >/dev/null 2>&1; then
  nl -ba $(fd -a -t f 'readme.md' src/components/markdown) | sed -n '1,240p'
fi

Length of output: 1668


@Kiarokh Thanks for the details and for re-running. I’ve pulled fuller contexts this time. Here’s a crisp review with concrete next steps:

What I verified

  • markdown.tsx
    • lazyLoadImages uses a single decorator: @prop({ reflect: true }) public lazyLoadImages = false; Confirmed no duplicate decorator anymore.
    • New prop: @prop({ reflect: true }) public removeEmptyParagraphs = true; Good default and reflection.
    • Watches: no special @watch for removeEmptyParagraphs; re-render path is fine since parsing happens on render with current props.
  • markdown-parser.ts
    • createRemoveEmptyParagraphsPlugin is conditionally included based on the removeEmptyParagraphs flag and only in HTML parsing path. Order: after sanitize/whitelist and before stringify. Correct.
  • remove-empty-paragraphs-plugin.ts
    • Considers NBSP as whitespace. Treat-as-empty includes br, and a meaningful elements allowlist covers img/media. Good reverse traversal and safe parent splicing.
    • Zero-width whitespace (U+200B/C/D, U+FEFF) is not normalized yet.
  • remove-empty-paragraphs-plugin.spec.ts
    • Covers empty

      ,

       

      , nested spans with NBSP, and nested empty paragraphs. Missing explicit


      cases and zero‑width space cases.
  • Example SCSS
    • Unit tokens have spaces: 1 rem, 10 rem, 1 fr, 0.5 rem, 1 rem. These should be 1rem, 10rem, 1fr etc.

Recommended changes

  1. Normalize zero‑width whitespace in plugin
  • Why: Outlook/Word often inject U+200B/FEFF. Without normalization, paragraphs with “invisible” content won’t be removed.
  • How:
// src/components/markdown/remove-empty-paragraphs-plugin.ts
const NBSP_REGEX = /\u00A0/g;
+const ZERO_WIDTH_REGEX = /[\u200B\u200C\u200D\uFEFF]/g;

function isWhitespace(value: string): boolean {
  if (!value) {
    return true;
  }
- return value.replaceAll(NBSP_REGEX, ' ').trim() === '';
+ const normalized = value.replaceAll(NBSP_REGEX, ' ')
+   .replaceAll(ZERO_WIDTH_REGEX, '');
+ return normalized.trim() === '';
}
  1. Add tests for
    -only paragraphs and zero‑width whitespace
// src/components/markdown/remove-empty-paragraphs-plugin.spec.ts
it('removes paragraphs with only a <br>', () => {
  const tree = createRoot([
    createParagraph([createElement('br')]),
    createParagraph([createElement('span', [createElement('br')])]),
  ]);
  runPlugin(tree, true);
  expect(tree.children).toHaveLength(0);
});

it('removes paragraphs that contain only zero-width whitespace', () => {
  const zw = '\u200B\u200C\u200D\uFEFF';
  const tree = createRoot([
    createParagraph([createText(zw)]),
    createParagraph([createElement('span', [createText(zw)])]),
  ]);
  runPlugin(tree, true);
  expect(tree.children).toHaveLength(0);
});
  1. Fix SCSS unit spacing in the example
/* src/components/markdown/examples/markdown-remove-empty-paragraphs.scss */
:host(limel-example-markdown-remove-empty-paragraphs) {
  display: grid;
- gap: 1 rem;
- grid-template-columns: repeat(auto-fit, minmax(10 rem, 1 fr));
+ gap: 1rem;
+ grid-template-columns: repeat(auto-fit, minmax(10rem, 1fr));
}

limel-markdown {
  display: block;
- padding: 0.5 rem 1 rem;
+ padding: 0.5rem 1rem;
}

Optional considerations

  • Should
    be treated as meaningful when wrapped in a paragraph? Currently not; probably fine because sanitize/rehype will typically not nest hr in p, but flagging in case CRM content does this.
  • Docs: If there’s a component README later, add a brief section on the new prop (default true, what counts as “empty paragraph,” how to opt out).

If you apply the above tweaks, I’m happy to re-check quickly.


🏁 Script executed:

#!/bin/bash
set -euo pipefail

pn() { awk '{printf "%5d  %s\n", NR, $0 }' "$1"; }

echo "== Branch and changed files =="
git branch --show-current || true
if git rev-parse --verify origin/main >/dev/null 2>&1; then
  git diff --name-status origin/main...HEAD
else
  git diff --name-status HEAD~2..HEAD || true
fi
echo

echo "== src/components/markdown/markdown.tsx =="
pn src/components/markdown/markdown.tsx | sed -n '1,260p' || true
echo
echo "-- @Prop occurrences --"
rg -nP --no-heading -C2 '@Prop\s*\(' src/components/markdown/markdown.tsx || true
echo
echo "-- @Watch occurrences --"
rg -nP --no-heading -C2 '@Watch\s*\(' src/components/markdown/markdown.tsx || true
echo

echo "== src/components/markdown/markdown-parser.ts =="
pn src/components/markdown/markdown-parser.ts | sed -n '1,260p' || true
echo
echo "-- removeEmptyParagraphs mentions --"
rg -nP --no-heading -C2 'removeEmptyParagraphs' src/components/markdown/markdown-parser.ts || true
echo

echo "== Plugin: remove-empty-paragraphs-plugin.ts =="
pn src/components/markdown/remove-empty-paragraphs-plugin.ts | sed -n '1,260p' || true
echo
echo "-- Heuristic tokens --"
rg -nP --no-heading -C2 'NBSP|ZERO|TREAT|MEANINGFUL|br' src/components/markdown/remove-empty-paragraphs-plugin.ts || true
echo

echo "== Tests: remove-empty-paragraphs-plugin.spec.ts =="
pn src/components/markdown/remove-empty-paragraphs-plugin.spec.ts | sed -n '1,320p' || true
echo

echo "== Example TSX =="
pn src/components/markdown/examples/markdown-remove-empty-paragraphs.tsx | sed -n '1,240p' || true
echo

echo "== Example SCSS =="
pn src/components/markdown/examples/markdown-remove-empty-paragraphs.scss || true
echo

echo "== API changes (excerpt) =="
# Show only the Markdown interface/component changes
rg -nP -n --no-heading -C3 'limel-markdown|removeEmptyParagraphs|lazyLoadImages' etc/lime-elements.api.md || true

echo "== README under component (if any) =="
if fd -a -t f -H -E node_modules -E dist -E build -E .git 'readme.md' src/components/markdown >/dev/null 2>&1; then
  READMES=$(fd -a -t f -H -E node_modules -E dist -E build -E .git 'readme.md' src/components/markdown)
  echo "$READMES"
  for f in $READMES; do
    echo "-- $f --"
    pn "$f" | sed -n '1,260p' || true
  done
else
  echo "No README.md found under src/components/markdown"
fi

Length of output: 24019


@Kiarokh Thanks for the rerun—here’s a focused follow-up based on the actual files.

What I verified

  • limel-markdown props and watchers
    • lazyLoadImages is reflected and default false (Line 56). removeEmptyParagraphs is reflected and default true (Line 65). Good defaults.
    • Watchers exist for value (Line 68) and removeEmptyParagraphs (Line 88).
    • Missing watchers for lazyLoadImages and whitelist, so toggling either won’t re-render or update the IntersectionObserver without also changing value.
  • Parser pipeline
    • createRemoveEmptyParagraphsPlugin is applied after sanitize/style and before stringify (Line 56 in markdown-parser.ts). Correct placement.
  • Plugin behavior
    • NBSP is normalized. br is treated as empty. Meaningful voids include img/video/etc. Good.
    • Zero‑width whitespace (U+200B, U+200C, U+200D, U+FEFF) not handled yet.
  • Tests
    • Good coverage for empty text, NBSP, empty spans, images, and text.
    • Missing explicit tests for


      and zero‑width whitespace.
  • Examples
    • SCSS units are already fixed (1rem/10rem/1fr). Looks good.

Recommended changes

  1. Re-render when lazyLoadImages or whitelist change
  • Ensures HTML is rebuilt and the ImageIntersectionObserver is set up/cleaned up appropriately when toggled.
// src/components/markdown/markdown.tsx

@Watch('lazyLoadImages')
public handleLazyLoadImagesChange() {
    return this.textChanged();
}

@Watch('whitelist')
public handleWhitelistChange() {
    return this.textChanged();
}
  1. Normalize zero‑width whitespace in the plugin
  • Pasted content from Outlook/Word often includes these invisible chars.
// src/components/markdown/remove-empty-paragraphs-plugin.ts
const NBSP_REGEX = /\u00A0/g;
const ZERO_WIDTH_REGEX = /[\u200B\u200C\u200D\uFEFF]/g;

const isWhitespace = (value: string): boolean => {
    if (!value) {
        return true;
    }
    const normalized = value.replaceAll(NBSP_REGEX, ' ')
        .replaceAll(ZERO_WIDTH_REGEX, '');
    return normalized.trim() === '';
};
  1. Add tests for
    -only paragraphs and zero‑width whitespace
  • Locks in the behavior stated in the PR description.
// src/components/markdown/remove-empty-paragraphs-plugin.spec.ts
it('removes paragraphs with only a <br> (with or without wrapper span)', () => {
    const tree = createRoot([
        createParagraph([createElement('br')]),
        createParagraph([createElement('span', [createElement('br')])]),
    ]);
    runPlugin(tree, true);
    expect(tree.children).toHaveLength(0);
});

it('removes paragraphs that contain only zero-width whitespace', () => {
    const zw = '\u200B\u200C\u200D\uFEFF';
    const tree = createRoot([
        createParagraph([createText(zw)]),
        createParagraph([createElement('span', [createText(zw)])]),
    ]);
    runPlugin(tree, true);
    expect(tree.children).toHaveLength(0);
});

// Optional: nested <p> inside <p>
it('removes a paragraph that only contains an empty nested paragraph', () => {
    const tree = createRoot([
        createParagraph([createParagraph()]),
    ]);
    runPlugin(tree, true);
    expect(tree.children).toHaveLength(0);
});

Notes

  • Example SCSS is all good now.
  • Current behavior removes


    (hr not in the meaningful list). If that scenario matters for CRM content, we can discuss adding hr to the “meaningful” set.

Want me to open a follow-up test/cleanup PR if you prefer to keep this one lean?

✅ Actions performed

Review triggered.

Note: CodeRabbit is an incremental review system and does not re-review already reviewed commits. This command is applicable only when automatic reviews are paused.

@Kiarokh Kiarokh changed the title Text editor: enable removing or keeping empty paragraphs of an HTML value Markdown: enable removing or keeping empty paragraphs of an HTML value Nov 7, 2025
To enable consumer to keep or remove empty paragraphs
from their HTML `value` input, before rendering the content.
@Kiarokh Kiarokh force-pushed the markdown-empty-paragraphs branch from 2748154 to 6e456f7 Compare November 7, 2025 14:25
@Kiarokh
Copy link
Contributor Author

Kiarokh commented Nov 7, 2025

@coderabbitai what do you think about the fixup!?

@coderabbitai
Copy link

coderabbitai bot commented Nov 7, 2025

Tip

For best results, initiate chat on the files or code changes.

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
set -euo pipefail

echo "== Recent commits in the branch =="
git log --oneline origin/main..HEAD || git log --oneline -15

echo ""
echo "== Full commit messages (looking for fixup) =="
git log --format="%H%n%s%n%b%n---" origin/main..HEAD || git log --format="%H%n%s%n%b%n---" -15

echo ""
echo "== Check for any fixup! or squash! commits =="
git log --oneline --grep="^fixup!" --grep="^squash!" origin/main..HEAD || echo "No fixup or squash commits found in log"

echo ""
echo "== Show files changed in most recent commit =="
git show --stat HEAD

echo ""
echo "== Show full diff of most recent commit (limited) =="
git show HEAD | head -n 500

Length of output: 5626


@Kiarokh Excellent fixup! 👍

The fixup commit looks great—you've addressed the two main technical gaps I flagged:

Zero-width whitespace handling

  • The stripZeroWidthCharacters helper cleanly handles U+200B/200C/200D/FEFF
  • Nice use of the hex codes array and String.fromCodePoint
  • Integration into isWhitespace is correct

Test coverage

  • Test for <br>-only paragraphs (with and without wrapper spans) ✓
  • Test for zero-width whitespace characters ✓
  • Both cases will now be caught by CI

Example enhancement

  • Adding zero-width characters to the demo is a nice touch—makes the feature more visible to consumers

Approach: Using a fixup! commit is perfect git hygiene here. You can squash it during rebase before merge to keep the history clean.

Remaining optional item from my earlier review:

  • Watchers for lazyLoadImages and whitelist props (so toggling them triggers re-render and updates the IntersectionObserver). Not blocking, but would make the component more reactive. Let me know if you'd like code for that or prefer to defer.

The core plugin implementation and tests are now solid. Nice work! 🎉

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.

3 participants