Skip to content

fix(teams): quote every line of a block quote - #1103

Open
onmax wants to merge 1 commit into
vercel:mainfrom
onmax:fix/teams-blockquote-multiline
Open

onmax wants to merge 1 commit into
vercel:mainfrom
onmax:fix/teams-blockquote-multiline

Conversation

@onmax

@onmax onmax commented Oct 30, 2026 •

Copy link
Copy Markdown
Contributor

Problem

TeamsFormatConverter adds the > prefix only to the first line of each block inside a block quote, and joins the blocks with a single newline. A quote that holds several paragraphs or a list comes out like this:

> Thanks for asking.
> You can change the default region in your settings.
> 1. Open **Settings**.
2. Pick a region under **Region**.
3. Click **Save**.
> New projects use the new region.

Teams merges the paragraphs into one. List items 2 and 3 have no quote prefix, so Teams folds them into item 1. A bot that quotes a reply with numbered steps shows all the steps as one.

Fix

The isBlockquoteNode branch of nodeToTeams in packages/adapter-teams/src/markdown.ts now adds > to every line of each rendered child, splitting on CR, LF, and CRLF. Empty lines get a bare >, and a lone > line separates blocks. The same input now renders as:

> Thanks for asking.
>
> You can change the default region in your settings.
>
> 1. Open **Settings**.
> 2. Pick a region under **Region**.
> 3. Click **Save**.
>
> New projects use the new region.

Nested quotes get > > on every line, and fenced code blocks inside a quote keep the prefix on each of their lines.

Depends on #1104

This branch includes the commit from #1104, so please review only the last commit here. Without #1104, prefixing every line would move a quote inside a list item out of the list. - > a followed by > b came out as - > a and > b, and the second line read back as a separate quote. #1104 indents every line of a block in a list item for Teams, which keeps the quote in its item. Once #1104 merges, I'll rebase this PR so it contains only the Teams commit.

Other adapters

The Slack (packages/adapter-slack/src/markdown.ts), Google Chat (packages/adapter-gchat/src/markdown.ts), and Discord (packages/adapter-discord/src/markdown.ts) converters each have their own copy of the same first-line-only prefix. No shared helper causes it, so this PR changes only Teams. I haven't checked how those platforms render a quote with several blocks, so I left them alone.

Checklist

  • All commits are signed and verified
  • All commits are signed off for the DCO (git commit -s)
  • pnpm validate passes (ran only the Teams adapter's tests, typecheck, and lint above)
  • Changeset added
  • Documentation updated (or N/A)

Model: Claude Opus 5.5, harness: Claude Code

@onmax
onmax requested a review from a team October 30, 2026 14:18
@vercel

vercel Bot commented Oct 30, 2026

Copy link
Copy Markdown
Contributor

@onmax is attempting to deploy a commit to the Vercel Team on Vercel.

A member of the Team first needs to authorize it.

@onmax
onmax force-pushed the fix/teams-blockquote-multiline branch from 655f845 to c0e804c Compare October 30, 2026 14:24

@roboeve roboeve Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Thanks for this, the fix is right for top-level quotes and the new tests are genuine: I reverted markdown.ts to main and all three fail, then pass again with the change (full Teams suite: 495/495).

I also ran ~15 edge cases through fromAst and re-parsed the output with the repo's own parseMarkdown (CommonMark, as a proxy for the Teams renderer; I could not check a live Teams client). Paragraphs, lists, nested lists, nested quotes, fenced code with blank lines, tables, and thematic breaks inside a quote all now round-trip to the same structure. One case regresses, so requesting changes:

Regression: a multi-line quote inside a list item now escapes the list

renderList (in packages/chat/src/markdown.ts) only prefixes the first line of a child with the bullet and never indents the rest. Before this PR the second line of a quote had no >, so CommonMark treated it as a lazy continuation and it accidentally stayed in place. Now it starts with > at column 0, which opens a new top-level block quote:

Input main output PR output PR output re-parsed
- > a / > b - > a / b - > a / > b list with quote a, then a separate top-level quote b

A quote with two paragraphs inside a list item was already broken on main and stays broken.

A fix that keeps other adapters' output unchanged (Slack would show the extra indent inside code blocks, so I made it opt-in):

--- a/packages/chat/src/markdown.ts
+++ b/packages/chat/src/markdown.ts
@@ export abstract class BaseFormatConverter implements FormatConverter {
   abstract fromAst(ast: Root): string;
   abstract toAst(platformText: string): Root;
 
+  /**
+   * Whether `renderList` indents the continuation lines of multi-line item
+   * content (block quotes, code blocks) to the item's content column.
+   * Enable for CommonMark targets, where an unindented line escapes the list.
+   */
+  protected indentListContinuationLines = false;
+
   protected renderList(
@@
-        const text = nodeConverter(child);
+        let text = nodeConverter(child);
         if (!text.trim()) {
           continue;
         }
+        if (this.indentListContinuationLines) {
+          const contentIndent = `${indent}${" ".repeat(prefix.length + 1)}`;
+          text = text.replaceAll("\n", `\n${contentIndent}`);
+        }
         if (isFirstContent) {
--- a/packages/adapter-teams/src/markdown.ts
+++ b/packages/adapter-teams/src/markdown.ts
 export class TeamsFormatConverter extends BaseFormatConverter {
+  protected override indentListContinuationLines = true;
+

With this plus the test suggestions below, I get: Teams 496/496, chat markdown tests 137/137, Teams typecheck and Biome clean. Because it touches chat, the changeset needs a chat entry too (suggestion inline).

Not blocking, just noting: I agree the Slack, Google Chat, and Discord converters have the same first-line-only prefix; Slack in particular needs > on every line, so a follow-up issue seems worthwhile.

.map((child) =>
this.nodeToTeams(child)
.split("\n")
.map((line) => (line ? `> ${line}` : ">"))

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Prefixing every line is right, but it changes what a quote looks like when renderList embeds it: continuation lines now start with > at column 0 and escape the list item (e.g. - > a\n > b renders as - > a\n> b, which re-parses as a list plus a separate top-level quote). See the review summary for an opt-in renderList fix and the regression test suggested below.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Fixed by #1104, which this branch is now stacked on: Teams opts into indentListContinuationLines, so every line of a quote inside a list item is indented to the item's content column.

Comment on lines +85 to +86
);
});

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

The exact-string check is good. Adding a structural check makes the test assert what Teams actually needs (the three steps stay one list inside the quote) rather than only the text shape, so it also catches lazy-continuation mistakes that look fine as strings. toAst on plain markdown is just parseMarkdown, so no new import is needed.

Suggested change
);
});
);
const reparsed = converter.toAst(result);
expect(reparsed.children).toHaveLength(1);
expect(reparsed.children[0]).toMatchObject({
type: "blockquote",
children: [
{ type: "paragraph" },
{ type: "paragraph" },
{ type: "list", ordered: true, children: [{}, {}, {}] },
{ type: "paragraph" },
],
});
});

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Applied in 118e62b.

"> ```",
].join("\n")
);
});

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Regression test for the quote-inside-a-list case. It fails on this branch as-is and passes with the indentListContinuationLines change from the review summary.

Suggested change
});
});
it("should keep a multi-line blockquote inside its list item", () => {
const ast = converter.toAst("- > first line\n > second line");
const result = converter.fromAst(ast);
expect(result).toBe("- > first line\n > second line");
const reparsed = converter.toAst(result);
expect(reparsed.children).toHaveLength(1);
expect(reparsed.children[0]).toMatchObject({
type: "list",
children: [{ type: "listItem", children: [{ type: "blockquote" }] }],
});
});

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Applied in 118e62b, with a second quoted paragraph so it also covers the > separator line. It fails without #1104's opt-in.

@@ -0,0 +1,5 @@
---
"@chat-adapter/teams": patch

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Needed if the renderList change lands, since it adds a protected member to BaseFormatConverter.

Suggested change
"@chat-adapter/teams": patch
"chat": patch
"@chat-adapter/teams": patch

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

The chat entry is in #1104's changeset, which carries the renderList change, so this PR's changeset stays Teams-only.

@onmax
onmax force-pushed the fix/teams-blockquote-multiline branch from c0e804c to c318189 Compare October 30, 2026 14:42
@onmax
onmax force-pushed the fix/teams-blockquote-multiline branch from c318189 to 0c9f8f4 Compare October 30, 2026 14:55
@onmax

onmax commented Oct 30, 2026

Copy link
Copy Markdown
Contributor Author

Thanks, the quote-inside-a-list regression was real. Here's what changed:

Teams suite: 497 passed. Once #1104 merges, I'll rebase this PR so it contains only the Teams commit.

@onmax
onmax force-pushed the fix/teams-blockquote-multiline branch from 0c9f8f4 to 118e62b Compare October 30, 2026 15:17

@roboeve roboeve Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Thanks, all of my requested changes are addressed. This approval covers the Teams commit (118e62b), reviewed on top of #1104's a679006 as stacked here. Merge order is #1104 first, then the rebase you described.

What I checked locally on 118e62b:

  • Suites: Teams 499/499 after rebuilding chat from this branch. Typecheck and Biome are clean, and CI is green apart from the fork deploy authorization.
  • Mutation check on the Teams change: each mutation fails at least one test.
    • Splitting on \n only fails the CR test. I confirmed parseMarkdown keeps \r in code and text values, so that test really exercises LINE_BREAK_REGEX.
    • Joining blocks with \n instead of \n>\n fails 5 tests.
    • Writing > instead of a bare > on empty lines fails the CR test.
    • Turning Teams' indentListContinuationLines off fails the 3 list-item tests.
  • Round trip: 17 edge cases went through fromAst, then parseMarkdown again, and came back with the same structure.
    • They include soft breaks, quotes inside list items and ordered items, lists and code inside a quote inside a list, code inside a list inside a quote, nested quotes inside a list item, and tables inside a quote and inside a list item.
    • They also include two lists inside a quote, a horizontal rule inside a quote, a loose item inside a quote, CR and CRLF input, and a quote under a nested ordered list. Every output also renders the same when converted a second time.
    • The one case that doesn't round-trip is an empty > inside a list item, which gets dropped. That matches the existing behavior of skipping empty item content, so I'm not blocking on it.

As before, this uses the repo's CommonMark parser to stand in for the Teams renderer. I didn't check a live Teams client.

bensabic added a commit that referenced this pull request Oct 31, 2026
## Problem

`BaseFormatConverter.renderList` in `packages/chat/src/markdown.ts`
loses list structure in three cases. Discord, Google Chat, Slack, Teams,
Twitch, and X all use it to render lists. Teams output is shown below.

A fenced code block, or any other block that spans several lines, keeps
the indentation only on its first line, so the rest of the block falls
out of the item:

````md
- item
  ```
x
```
- next
````

Read back as Markdown, that gives an empty code block, a paragraph with `x`, and a second code block that swallows `- next`.

A list nested under an ordered item gets two spaces, but `1. ` needs three. The child becomes a sibling of its parent:

```md
1. parent
  1. child
2. next
```

Two paragraphs in one item are joined with a single newline, so they merge into one paragraph:

```md
- first
  second
```

## Fix

Nested lists now start at the item's content column, which is the marker's width plus one space. That gives two spaces under `-` or `•`, three under `1. `, and four under `10. `, so nesting under an unordered list doesn't change. Two blocks in one item get a blank line between them. A nested list still follows its parent's text directly, unless it comes after HTML or is an ordered list that doesn't start at 1. Neither of those can follow a paragraph directly in CommonMark, so they keep the blank line. These changes apply to every adapter.

Indenting the rest of a multi-line block, such as a code block, is opt-in through a new protected `indentListContinuationLines` property on `BaseFormatConverter`. It defaults to `false` and applies at every nesting level. Lines split on CR, LF, and CRLF. On targets like Slack the extra spaces would show inside code blocks, and an indented `>` may no longer read as a quote. The Teams converter turns it on, because Teams messages go out with `textFormat: "markdown"`, where an unindented line leaves the item. Discord, Google Chat, Slack, Twitch, and X keep the old behavior for these lines. I haven't checked whether Discord should opt in too.

With Teams, the three inputs now come out unchanged:

````md
- item

  ```
  x
  ```
- next
````

```md
1. parent
   1. child
2. next
```

```md
- first

  second
```

#1103 builds on this. It quotes every line of a block quote in Teams, which only stays inside a list item once these lines are indented.

## Checklist

- [x] All commits are signed and verified
- [x] All commits are signed off for the DCO (`git commit -s`)
- [ ] `pnpm validate` passes
- [x] Changeset added
- [x] Documentation updated (or N/A)

Model: Claude Opus 5.5, harness: Claude Code

---------

Signed-off-by: onmax <maximogarciamtnez@gmail.com>
Signed-off-by: Ben Sabic <27636870+bensabic@users.noreply.github.com>
Co-authored-by: Ben Sabic <27636870+bensabic@users.noreply.github.com>
Co-authored-by: roboeve[bot] <311457182+roboeve[bot]@users.noreply.github.com>
The Teams converter added the `> ` prefix only to the first line of each block in a quote and joined blocks with a single newline. Teams then merged the quote's paragraphs and folded list items after the first into the first item. Prefix every line, split on CR, LF, and CRLF, and separate blocks with a lone `>` line.

Signed-off-by: onmax <maximogarciamtnez@gmail.com>
@onmax
onmax force-pushed the fix/teams-blockquote-multiline branch from 118e62b to 4912904 Compare November 3, 2026 18:40

This branch has not been deployed

No deployments
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.

1 participant