Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 13 additions & 0 deletions .changeset/strict-boats-jog.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
---
"@chat-adapter/discord": minor
"@chat-adapter/github": minor
"@chat-adapter/linear": minor
"@chat-adapter/shared": minor
"@chat-adapter/gchat": minor
"@chat-adapter/slack": minor
"@chat-adapter/teams": minor
"chat": minor
"docs": minor
---

Add CardLink element
24 changes: 23 additions & 1 deletion apps/docs/content/docs/api/cards.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@ type: reference
Card components render natively on each platform — Block Kit on Slack, Adaptive Cards on Teams, Embeds on Discord, and Google Chat Cards.

```typescript
import { Card, Text, Button, Actions, Section, Fields, Field, Divider, Image, LinkButton } from "chat";
import { Card, Text, CardLink, Button, Actions, Section, Fields, Field, Divider, Image, LinkButton } from "chat";
```

All components support both function-call and JSX syntax. Function-call syntax is recommended for better type inference.
Expand Down Expand Up @@ -98,6 +98,27 @@ Button({ id: "delete", label: "Delete", style: "danger", value: "item-123" })
}}
/>

## CardLink

Inline hyperlink rendered as text. Can be placed directly in a card alongside other content, unlike `LinkButton` which must live inside `Actions`.

```typescript
CardLink({ url: "https://example.com", label: "Visit Site" })
```

<TypeTable
type={{
url: {
description: 'URL to link to.',
type: 'string',
},
label: {
description: 'Link label text.',
type: 'string',
},
}}
/>

## LinkButton

Button that opens a URL. No `onAction` handler needed.
Expand Down Expand Up @@ -210,6 +231,7 @@ The `children` array in `Card` and `Section` accepts these element types:
| Type | Created by |
|------|-----------|
| `TextElement` | `Text()` |
| `LinkElement` | `CardLink()` |
| `ImageElement` | `Image()` |
| `DividerElement` | `Divider()` |
| `ActionsElement` | `Actions()` |
Expand Down
21 changes: 20 additions & 1 deletion apps/docs/content/docs/cards.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -109,6 +109,24 @@ The `id` maps to your `onAction` handler. Optional `value` passes extra data:
<Button id="report" value="bug">Report Bug</Button>
```

### CardLink

Inline hyperlink rendered as text. Unlike `LinkButton` (which must be inside `Actions`), `CardLink` can be placed directly in a card alongside other content.

```tsx title="lib/bot.tsx"
<CardLink url="https://example.com/order/1234" label="View order details" />
```

Or with children as the label:

```tsx title="lib/bot.tsx"
<CardLink url="https://example.com/docs">Read the docs</CardLink>
```

<Callout type="info">
`CardLink` renders as a platform-native link: `<url|label>` on Slack, `[label](url)` on Teams/Discord/GitHub/Linear, and `<a href>` on Google Chat.
</Callout>

### LinkButton

Opens an external URL. No `onAction` handler needed.
Expand Down Expand Up @@ -181,7 +199,7 @@ A visual separator between sections.

```tsx title="lib/bot.tsx" lineNumbers
import {
Card, CardText, Button, LinkButton, Actions,
Card, CardText, CardLink, Button, LinkButton, Actions,
Section, Fields, Field, Divider, Image,
Select, SelectOption, RadioSelect,
} from "chat";
Expand All @@ -194,6 +212,7 @@ await thread.post(
<Field label="Role" value="Engineer" />
<Field label="Team" value="Platform" />
</Fields>
<CardLink url="https://example.com/profile/123">View full profile</CardLink>
<Divider />
<Section>
<CardText>Select an action below to manage this profile.</CardText>
Expand Down
4 changes: 4 additions & 0 deletions examples/nextjs-chat/src/lib/bot.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import {
Actions,
Button,
Card,
CardLink,
Chat,
Divider,
emoji,
Expand Down Expand Up @@ -93,6 +94,9 @@ bot.onNewMention(async (thread, message) => {
<Text>
{`${emoji.sparkles} **Mention me with "AI"** to enable AI assistant mode`}
</Text>
<CardLink url="https://chat-sdk.dev/docs/cards">
View documentation
</CardLink>
<Divider />
<Fields>
<Field label="DM Support" value={thread.isDM ? "Yes" : "No"} />
Expand Down
16 changes: 16 additions & 0 deletions packages/adapter-discord/src/cards.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import {
Actions,
Button,
Card,
CardLink,
CardText,
Divider,
Field,
Expand Down Expand Up @@ -334,3 +335,18 @@ describe("cardToFallbackText", () => {
expect(text).toContain("**C**: 3");
});
});

describe("cardToDiscordPayload with CardLink", () => {
it("appends markdown link to embed description", () => {
const card = Card({
children: [CardLink({ url: "https://example.com", label: "Click here" })],
});

const payload = cardToDiscordPayload(card);

expect(payload.embeds).toHaveLength(1);
expect(payload.embeds[0].description).toBe(
"[Click here](https://example.com)"
);
});
});
3 changes: 3 additions & 0 deletions packages/adapter-discord/src/cards.ts
Original file line number Diff line number Diff line change
Expand Up @@ -115,6 +115,9 @@ function processChild(
case "fields":
convertFieldsElement(child, fields);
break;
case "link":
textParts.push(`[${convertEmoji(child.label)}](${child.url})`);
break;
default:
break;
}
Expand Down
19 changes: 19 additions & 0 deletions packages/adapter-gchat/src/cards.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import {
Actions,
Button,
Card,
CardLink,
CardText,
Divider,
Field,
Expand Down Expand Up @@ -448,3 +449,21 @@ describe("markdown bold to Google Chat conversion", () => {
expect(widgets[0].textParagraph.text).toBe("Plain text");
});
});

describe("cardToGoogleCard with CardLink", () => {
it("converts CardLink to a textParagraph widget with HTML link", () => {
const card = Card({
children: [CardLink({ url: "https://example.com", label: "Click here" })],
});

const googleCard = cardToGoogleCard(card);

expect(googleCard.card.sections).toHaveLength(1);
expect(googleCard.card.sections[0].widgets).toHaveLength(1);
expect(googleCard.card.sections[0].widgets[0]).toEqual({
textParagraph: {
text: '<a href="https://example.com">Click here</a>',
},
});
});
});
8 changes: 8 additions & 0 deletions packages/adapter-gchat/src/cards.ts
Original file line number Diff line number Diff line change
Expand Up @@ -194,6 +194,14 @@ function convertChildToWidgets(
return convertSectionToWidgets(child, endpointUrl);
case "fields":
return convertFieldsToWidgets(child);
case "link":
return [
{
textParagraph: {
text: `<a href="${child.url}">${convertEmoji(child.label)}</a>`,
},
},
];
default:
return [];
}
Expand Down
13 changes: 13 additions & 0 deletions packages/adapter-github/src/cards.test.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import type { CardElement } from "chat";
import { Card, CardLink } from "chat";
import { describe, expect, it } from "vitest";
import { cardToGitHubMarkdown, cardToPlainText } from "./cards";

Expand Down Expand Up @@ -194,3 +195,15 @@ describe("cardToPlainText", () => {
expect(result).toContain("Key: Value");
});
});

describe("cardToGitHubMarkdown with CardLink", () => {
it("renders CardLink as markdown link", () => {
const card = Card({
children: [CardLink({ url: "https://example.com", label: "Click here" })],
});

const markdown = cardToGitHubMarkdown(card);

expect(markdown).toBe("[Click here](https://example.com)");
});
});
3 changes: 3 additions & 0 deletions packages/adapter-github/src/cards.ts
Original file line number Diff line number Diff line change
Expand Up @@ -115,6 +115,9 @@ function renderChild(child: CardChild): string[] {
}
return [`![](${child.url})`];

case "link":
return [`[${escapeMarkdown(child.label)}](${child.url})`];

case "divider":
return ["---"];

Expand Down
13 changes: 13 additions & 0 deletions packages/adapter-linear/src/cards.test.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import type { CardElement } from "chat";
import { Card, CardLink } from "chat";
import { describe, expect, it } from "vitest";
import { cardToLinearMarkdown, cardToPlainText } from "./cards";

Expand Down Expand Up @@ -194,3 +195,15 @@ describe("cardToPlainText", () => {
expect(result).toContain("Key: Value");
});
});

describe("cardToLinearMarkdown with CardLink", () => {
it("renders CardLink as markdown link", () => {
const card = Card({
children: [CardLink({ url: "https://example.com", label: "Click here" })],
});

const markdown = cardToLinearMarkdown(card);

expect(markdown).toBe("[Click here](https://example.com)");
});
});
3 changes: 3 additions & 0 deletions packages/adapter-linear/src/cards.ts
Original file line number Diff line number Diff line change
Expand Up @@ -118,6 +118,9 @@ function renderChild(child: CardChild): string[] {
}
return [`![](${child.url})`];

case "link":
return [`[${escapeMarkdown(child.label)}](${child.url})`];

case "divider":
return ["---"];

Expand Down
2 changes: 2 additions & 0 deletions packages/adapter-shared/src/card-utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -135,6 +135,8 @@ function childToFallbackText(
switch (child.type) {
case "text":
return convertText(child.content);
case "link":
return `${convertText(child.label)} (${child.url})`;
case "fields":
return child.children
.map((f) => `${convertText(f.label)}: ${convertText(f.value)}`)
Expand Down
42 changes: 42 additions & 0 deletions packages/adapter-slack/src/cards.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import {
Actions,
Button,
Card,
CardLink,
CardText,
Divider,
Field,
Expand Down Expand Up @@ -700,3 +701,44 @@ describe("markdown bold to Slack mrkdwn conversion", () => {
expect(blocks[0].text.text).toBe("*Start* and *end*");
});
});

describe("cardToBlockKit with CardLink", () => {
it("converts CardLink to a mrkdwn section block with Slack link syntax", () => {
const card = Card({
children: [CardLink({ url: "https://example.com", label: "Click here" })],
});

const blocks = cardToBlockKit(card);

expect(blocks).toHaveLength(1);
expect(blocks[0]).toEqual({
type: "section",
text: {
type: "mrkdwn",
text: "<https://example.com|Click here>",
},
});
});

it("converts CardLink alongside other children", () => {
const card = Card({
title: "Test",
children: [
CardText("Hello"),
CardLink({ url: "https://example.com", label: "Link" }),
],
});

const blocks = cardToBlockKit(card);

// header + text section + link section
expect(blocks).toHaveLength(3);
expect(blocks[2]).toEqual({
type: "section",
text: {
type: "mrkdwn",
text: "<https://example.com|Link>",
},
});
});
});
13 changes: 13 additions & 0 deletions packages/adapter-slack/src/cards.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ import type {
FieldsElement,
ImageElement,
LinkButtonElement,
LinkElement,
RadioSelectElement,
SectionElement,
SelectElement,
Expand Down Expand Up @@ -146,6 +147,8 @@ function convertChildToBlocks(child: CardChild): SlackBlock[] {
return convertSectionToBlocks(child);
case "fields":
return [convertFieldsToBlock(child)];
case "link":
return [convertLinkToBlock(child)];
default:
return [];
}
Expand Down Expand Up @@ -181,6 +184,16 @@ export function convertTextToBlock(element: TextElement): SlackBlock {
};
}

function convertLinkToBlock(element: LinkElement): SlackBlock {
return {
type: "section",
text: {
type: "mrkdwn",
text: `<${element.url}|${convertEmoji(element.label)}>`,
},
};
}

function convertImageToBlock(element: ImageElement): SlackBlock {
return {
type: "image",
Expand Down
18 changes: 18 additions & 0 deletions packages/adapter-teams/src/cards.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import {
Actions,
Button,
Card,
CardLink,
CardText,
Divider,
Field,
Expand Down Expand Up @@ -298,3 +299,20 @@ describe("cardToFallbackText", () => {
expect(text).toBe("**Simple Card**");
});
});

describe("cardToAdaptiveCard with CardLink", () => {
it("converts CardLink to a TextBlock with markdown link", () => {
const card = Card({
children: [CardLink({ url: "https://example.com", label: "Click here" })],
});

const adaptive = cardToAdaptiveCard(card);

expect(adaptive.body).toHaveLength(1);
expect(adaptive.body[0]).toEqual({
type: "TextBlock",
text: "[Click here](https://example.com)",
wrap: true,
});
});
});
Loading