Skip to content
Open
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
4 changes: 4 additions & 0 deletions docs/dropdowns-cards-and-tabs.md
Original file line number Diff line number Diff line change
Expand Up @@ -92,6 +92,10 @@ A button is an element with text content that triggers an action to navigate to
{button}`MyST-MD GitHub <https://github.com/jupyter-book/mystmd>`
```

```{myst}
{button}`A button without a link!`
```

:::{myst:role} button
:::

Expand Down
63 changes: 55 additions & 8 deletions packages/myst-ext-button/src/index.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,10 @@
import type { RoleSpec, RoleData, GenericNode } from 'myst-common';
import type { Link } from 'myst-spec-ext';
import { fileWarn, RuleId } from 'myst-common';

const REF_PATTERN = /^(.+?)<([^<>]+)>$/;
// Matches "text<link>" capturing body text (group 1) and an optional "<link>" suffix (group 2).
// Group 2 keeps the angle brackets; it can also be exactly "<>" to signal an empty target.
const TEXT_LINK_PATTERN = /^([^<>]*)(<[^<>]*>)?$/;

export const buttonRole: RoleSpec = {
name: 'button',
Expand All @@ -11,18 +14,62 @@ export const buttonRole: RoleSpec = {
doc: 'The body of the button.',
required: true,
},
run(data: RoleData): GenericNode[] {
run(data, vfile): GenericNode[] {
const body = data.body as string;
const match = REF_PATTERN.exec(body);
const [, modified, rawLabel] = match ?? [];
const url = rawLabel ?? body;
/**
* Behavior:
* - `{button}`text`` => button with text, no link target (rendered as span.button).
* - `{button}`<text>`` => button links to `text`, shows `text`.
* - `{button}`text<label>`` => button links to `label`, shows `text`.
* - `{button}`text<>`` => button with text, no link target (rendered as span.button).
*/
const match = TEXT_LINK_PATTERN.exec(body);
if (!match) {
fileWarn(vfile, `Invalid {button} role with body: "${body}"`, {
source: 'role:button',
node: data.node,
ruleId: RuleId.roleBodyCorrect,
});

// Fallback if we don't match: degrade to a plain-text button.
return [
{
type: 'span',
class: 'button',
children: [{ type: 'text', value: '❌ could not parse button syntax!' }],
},
];
}

const [, rawBodyText, rawLink] = match;
const bodyText = rawBodyText?.trim() ?? '';
// If no link, return nothing. Otherwise strip brackets.
const linkTarget =
rawLink && rawLink !== '<>'
? rawLink.slice(1, -1) // strip angle brackets
: undefined;

// Prefer body text, otherwise fall back to the link text.
const displayText = bodyText || linkTarget || '';

// No link target -> render a <span> button container.
if (!linkTarget) {
return [
{
type: 'span',
children: displayText ? [{ type: 'text', value: displayText }] : [],
class: 'button', // TODO: allow users to extend this
},
];
}

// Link target present -> render a link-styled button.
const node: Link = {
type: 'link',
url,
children: [],
url: linkTarget,
children: displayText ? [{ type: 'text', value: displayText }] : [],
class: 'button', // TODO: allow users to extend this
};
if (modified) node.children = [{ type: 'text', value: modified.trim() }];
return [node];
},
};
54 changes: 50 additions & 4 deletions packages/myst-ext-button/tests/button.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@ import { buttonRole } from '../src';
import { VFile } from 'vfile';

describe('Button component', () => {
it('should process button role correctly', () => {
it('should process text<link> syntax correctly', () => {
const result = buttonRole.run(
{ name: 'button', body: 'Click me<http://example.com>' },
new VFile(),
Expand All @@ -19,15 +19,61 @@ describe('Button component', () => {
]);
});

it('should process button role without label correctly', () => {
const result = buttonRole.run({ name: 'button', body: 'http://example.com' }, new VFile());
it('should process autolink-style bodies', () => {
const result = buttonRole.run({ name: 'button', body: '<http://example.com>' }, new VFile());
expect(result).toEqual([
{
type: 'link',
class: 'button',
url: 'http://example.com',
children: [],
children: [{ type: 'text', value: 'http://example.com' }],
},
]);
});

it('should treat bare text (even if it looks like a URL) as a non-link button', () => {
const result = buttonRole.run({ name: 'button', body: 'http://example.com' }, new VFile());
expect(result).toEqual([
{
type: 'span',
class: 'button',
children: [{ type: 'text', value: 'http://example.com' }],
},
]);
});

it('should display body text with no link when no URL is provided', () => {
const result = buttonRole.run({ name: 'button', body: 'Click me' }, new VFile());
expect(result).toEqual([
{
type: 'span',
class: 'button',
children: [{ type: 'text', value: 'Click me' }],
},
]);
});

it('should treat an empty link target as a non-link button', () => {
const result = buttonRole.run({ name: 'button', body: 'Click <>' }, new VFile());
expect(result).toEqual([
{
type: 'span',
class: 'button',
children: [{ type: 'text', value: 'Click' }],
},
]);
});

it('should treat an invalid role body as an error, and recover', () => {
const file = new VFile();
const result = buttonRole.run({ name: 'button', body: 'Click<' }, file);
expect(result).toEqual([
{
type: 'span',
class: 'button',
children: [{ type: 'text', value: '❌ could not parse button syntax!' }],
},
]);
expect(file.messages.length > 0);
});
});
Loading