Skip to content

Commit c6213ed

Browse files
authored
🧮 Add inline options to roles and directives (#1822)
1 parent 91bed6d commit c6213ed

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

49 files changed

+1005
-281
lines changed
Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
"markdown-it-myst": patch
3+
---
4+
5+
Add inline options

.changeset/pink-birds-join.md

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
---
2+
"myst-directives": patch
3+
"myst-transforms": patch
4+
---
5+
6+
Move QMD admonition recognition to a transform

.changeset/small-paws-suffer.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
"myst-directives": patch
3+
---
4+
5+
div node does not require a body

.changeset/tough-terms-push.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
"myst-roles": patch
3+
---
4+
5+
Introduce a span role

docs/inline-options.md

Lines changed: 54 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,54 @@
1+
---
2+
title: Inline Options
3+
subtitle: Concise specification of CSS classes, IDs, and attributes
4+
description: MyST Markdown has support for inline attributes for both roles and directives, allowing concise specification of CSS classes, IDs, and attributes. This complements other methods for defining options, making markup more expressive and flexible.
5+
# thumbnail: thumbnails/inline-options.png
6+
---
7+
8+
:::{warning} Inline Options are in Beta
9+
The support for inline attributes is in beta and may have some bugs or limitations.
10+
Please give feedback on [GitHub](https://github.com/orgs/jupyter-book/discussions).
11+
:::
12+
13+
MyST Markdown has support for inline attributes for both roles and directives, allowing concise specification of CSS classes, IDs, and attributes. This complements other methods for defining options, making markup more expressive and flexible.
14+
15+
```markdown
16+
:::{tip .dropdown open="true"} Title
17+
Tip Content
18+
:::
19+
```
20+
21+
This can also be used for roles:
22+
23+
`` {span .text-red-500}`Red text` ``
24+
25+
{span .text-red-500}`Red text`
26+
27+
## Syntax Overview
28+
29+
The inline attribute syntax follows this pattern:
30+
31+
````text
32+
{role #id .class key="value" key=value}`content`
33+
34+
```{directive #id .class key="value" key=value}
35+
content
36+
```
37+
````
38+
39+
Name (e.g. `tip` or `cite:p`)
40+
: The directive or role name must come first. There must only be a single "bare" token.
41+
42+
ID (`#id`)
43+
: Defines the label/identifier of the node
44+
45+
Class (`.class`)
46+
: Adds CSS class(es).
47+
48+
Quoted Attributes (`key="value"`)
49+
: Supports attributes containing spaces or special characters.
50+
51+
Unquoted Attributes (`key=value` or `key=123`)
52+
: Allows simpler attribute values when there are no spaces.
53+
54+
For directives, these can be mixed with other ways to define options on directives, classes are combined in a single space-separated string; other repeated directive options will raise a duplicate option warning.

docs/myst.yml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -101,6 +101,7 @@ project:
101101
- file: glossaries-and-terms.md
102102
- file: writing-in-latex.md
103103
- file: table-of-contents.md
104+
- file: inline-options.md
104105
- title: Executable Content
105106
children:
106107
- file: notebooks-with-markdown.md

docs/syntax-overview.md

Lines changed: 15 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -63,7 +63,7 @@ My directive content.
6363

6464
2\) **directive options** - a collection of flags or key/value pairs that come just underneath `{directivename}`.
6565

66-
There are two ways to write directive options, as `:key: value` or as a YAML block.
66+
There are three ways to write directive options: as `:key: value`, as a YAML block, or directly inline.
6767

6868
``````{tab-set}
6969
`````{tab-item} Key value pairs
@@ -89,6 +89,14 @@ key1: metadata1
8989
key2: metadata2
9090
---
9191
92+
My directive content.
93+
```
94+
````
95+
`````
96+
`````{tab-item} Inline Options
97+
Options can be included inline. See [](./inline-options.md) for more information.
98+
````markdown
99+
```{directivename .class-name #label key="value"}
92100
My directive content.
93101
```
94102
````
@@ -117,6 +125,12 @@ Roles are defined inline, with an identifier and input. There are a number of ro
117125
Here is an {abc}`unknown role`.
118126
```
119127

128+
Options for roles can be included inline. See [](./inline-options.md) for more information.
129+
130+
```markdown
131+
Here is my {span #label .class-name key="value"}`custom span`.
132+
```
133+
120134
(nesting-content)=
121135

122136
## Nesting content blocks in Markdown

packages/markdown-it-myst/src/directives.ts

Lines changed: 36 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ import type MarkdownIt from 'markdown-it/lib';
55
import type StateCore from 'markdown-it/lib/rules_core/state_core.js';
66
import { nestedPartToTokens } from './nestedParse.js';
77
import { stateError, stateWarn } from './utils.js';
8+
import { inlineOptionsToTokens } from './inlineAttributes.js';
89

910
const COLON_OPTION_REGEX = /^:(?<option>[^:\s]+?):(\s*(?<value>.*)){0,1}\s*$/;
1011

@@ -26,7 +27,7 @@ function computeBlockTightness(
2627
function replaceFences(state: StateCore): boolean {
2728
for (const token of state.tokens) {
2829
if (token.type === 'fence' || token.type === 'colon_fence') {
29-
const match = token.info.match(/^\s*\{\s*([^}\s]+)\s*\}\s*(.*)$/);
30+
const match = token.info.match(/^\s*\{\s*([^}]+)\s*\}\s*(.*)$/);
3031
if (match) {
3132
token.type = 'directive';
3233
token.info = match[1].trim();
@@ -45,38 +46,49 @@ function runDirectives(state: StateCore): boolean {
4546
try {
4647
const { info, map } = token;
4748
const arg = token.meta.arg?.trim() || undefined;
49+
const {
50+
name = 'div',
51+
tokens: inlineOptTokens,
52+
options: inlineOptions,
53+
} = inlineOptionsToTokens(info, map?.[0] ?? 0, state);
4854
const content = parseDirectiveContent(
4955
token.content.trim() ? token.content.split(/\r?\n/) : [],
50-
info,
56+
name,
5157
state,
5258
);
53-
const { body, options } = content;
59+
const { body, options, optionsLocation } = content;
5460
let { bodyOffset } = content;
5561
while (body.length && !body[0].trim()) {
5662
body.shift();
5763
bodyOffset++;
5864
}
5965
const bodyString = body.join('\n').trimEnd();
6066
const directiveOpen = new state.Token('parsed_directive_open', '', 1);
61-
directiveOpen.info = info;
67+
directiveOpen.info = name;
6268
directiveOpen.hidden = true;
6369
directiveOpen.content = bodyString;
6470
directiveOpen.map = map;
6571
directiveOpen.meta = {
6672
arg,
67-
options: getDirectiveOptions(options),
73+
options: getDirectiveOptions([...inlineOptions, ...(options ?? [])]),
6874
// Tightness is computed for all directives (are they separated by a newline before/after)
6975
tight: computeBlockTightness(state.src, token.map),
7076
};
7177
const startLineNumber = map ? map[0] : 0;
7278
const argTokens = directiveArgToTokens(arg, startLineNumber, state);
73-
const optsTokens = directiveOptionsToTokens(options || [], startLineNumber + 1, state);
79+
const optsTokens = directiveOptionsToTokens(
80+
options || [],
81+
startLineNumber + 1,
82+
state,
83+
optionsLocation,
84+
);
7485
const bodyTokens = directiveBodyToTokens(bodyString, startLineNumber + bodyOffset, state);
7586
const directiveClose = new state.Token('parsed_directive_close', '', -1);
7687
directiveClose.info = info;
7788
directiveClose.hidden = true;
7889
const newTokens = [
7990
directiveOpen,
91+
...inlineOptTokens,
8092
...argTokens,
8193
...optsTokens,
8294
...bodyTokens,
@@ -110,6 +122,7 @@ function parseDirectiveContent(
110122
body: string[];
111123
bodyOffset: number;
112124
options?: [string, string | true][];
125+
optionsLocation?: 'yaml' | 'colon';
113126
} {
114127
let bodyOffset = 1;
115128
let yamlBlock: string[] | null = null;
@@ -136,7 +149,12 @@ function parseDirectiveContent(
136149
try {
137150
const options = yaml.load(yamlBlock.join('\n')) as Record<string, any>;
138151
if (options && typeof options === 'object') {
139-
return { body: newContent, options: Object.entries(options), bodyOffset };
152+
return {
153+
body: newContent,
154+
options: Object.entries(options),
155+
bodyOffset,
156+
optionsLocation: 'yaml',
157+
};
140158
}
141159
} catch (err) {
142160
stateWarn(
@@ -162,7 +180,7 @@ function parseDirectiveContent(
162180
bodyOffset++;
163181
}
164182
}
165-
return { body: newContent, options, bodyOffset };
183+
return { body: newContent, options, bodyOffset, optionsLocation: 'colon' };
166184
}
167185
return { body: content, bodyOffset: 1 };
168186
}
@@ -172,9 +190,13 @@ function directiveArgToTokens(arg: string, lineNumber: number, state: StateCore)
172190
}
173191

174192
function getDirectiveOptions(options?: [string, string | true][]) {
175-
if (!options) return undefined;
193+
if (!options || options.length === 0) return undefined;
176194
const simplified: Record<string, string | true> = {};
177195
options.forEach(([key, val]) => {
196+
if (key === 'class' && simplified.class) {
197+
simplified.class += ` ${val}`;
198+
return;
199+
}
178200
if (simplified[key] !== undefined) {
179201
return;
180202
}
@@ -187,28 +209,29 @@ function directiveOptionsToTokens(
187209
options: [string, string | true][],
188210
lineNumber: number,
189211
state: StateCore,
212+
optionsLocation?: 'yaml' | 'colon',
190213
) {
191214
const tokens = options.map(([key, value], index) => {
192215
// lineNumber mapping assumes each option is only one line;
193216
// not necessarily true for yaml options.
194217
const optTokens =
195218
typeof value === 'string'
196219
? nestedPartToTokens(
197-
'directive_option',
220+
'myst_option',
198221
value,
199222
lineNumber + index,
200223
state,
201224
'run_directives',
202225
true,
203226
)
204227
: [
205-
new state.Token('directive_option_open', '', 1),
206-
new state.Token('directive_option_close', '', -1),
228+
new state.Token('myst_option_open', '', 1),
229+
new state.Token('myst_option_close', '', -1),
207230
];
208231
if (optTokens.length) {
209232
optTokens[0].info = key;
210233
optTokens[0].content = typeof value === 'string' ? value : '';
211-
optTokens[0].meta = { value };
234+
optTokens[0].meta = { location: optionsLocation, value };
212235
}
213236
return optTokens;
214237
});
Lines changed: 99 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,99 @@
1+
// parseRoleHeader.spec.ts
2+
import { describe, expect, test } from 'vitest';
3+
import { inlineOptionsToTokens, tokenizeInlineAttributes } from './inlineAttributes';
4+
5+
describe('parseRoleHeader', () => {
6+
// Good (valid) test cases
7+
test.each([
8+
['simple', [{ kind: 'bare', value: 'simple' }]],
9+
[
10+
'someRole .cls1 .cls2',
11+
[
12+
{ kind: 'bare', value: 'someRole' },
13+
{ kind: 'class', value: 'cls1' },
14+
{ kind: 'class', value: 'cls2' },
15+
],
16+
],
17+
[
18+
'myRole #foo',
19+
[
20+
{ kind: 'bare', value: 'myRole' },
21+
{ kind: 'id', value: 'foo' },
22+
],
23+
],
24+
[
25+
'myRole .red #xyz attr="value"',
26+
[
27+
{ kind: 'bare', value: 'myRole' },
28+
{ kind: 'class', value: 'red' },
29+
{ kind: 'id', value: 'xyz' },
30+
{ kind: 'attr', key: 'attr', value: 'value' },
31+
],
32+
],
33+
[
34+
'roleName data="some \\"escaped\\" text"',
35+
[
36+
{ kind: 'bare', value: 'roleName' },
37+
{ kind: 'attr', key: 'data', value: 'some "escaped" text' },
38+
],
39+
],
40+
['.className', [{ kind: 'class', value: 'className' }]],
41+
[
42+
'myRole open=true',
43+
[
44+
{ kind: 'bare', value: 'myRole' },
45+
{ kind: 'attr', key: 'open', value: 'true' },
46+
],
47+
],
48+
[
49+
'myRole open=""',
50+
[
51+
{ kind: 'bare', value: 'myRole' },
52+
{ kind: 'attr', key: 'open', value: '' },
53+
],
54+
],
55+
[
56+
'myRole #foo.',
57+
[
58+
{ kind: 'bare', value: 'myRole' },
59+
{ kind: 'unknown', value: '#foo.' },
60+
],
61+
],
62+
[
63+
'myRole foo="{testing .blah}`content`"',
64+
[
65+
{ kind: 'bare', value: 'myRole' },
66+
{ kind: 'attr', key: 'foo', value: '{testing .blah}`content`' },
67+
],
68+
],
69+
['cite:p', [{ kind: 'bare', value: 'cite:p' }]],
70+
['CITE:P', [{ kind: 'bare', value: 'CITE:P' }]],
71+
['1', [{ kind: 'bare', value: '1' }]],
72+
[' abc ', [{ kind: 'bare', value: 'abc' }]],
73+
[
74+
'half-quote blah="asdf',
75+
[
76+
{ kind: 'bare', value: 'half-quote' },
77+
{ kind: 'unknown', value: 'blah="asdf' },
78+
],
79+
],
80+
])('parses valid header: %s', (header, expected) => {
81+
const result = tokenizeInlineAttributes(header);
82+
expect(result).toEqual(expected);
83+
});
84+
85+
// Error test cases
86+
test.each([
87+
[
88+
'Extra bare token after name',
89+
'myRole anotherWord',
90+
'No additional bare tokens allowed after the first token',
91+
],
92+
['Multiple IDs', 'myRole #first #second', 'Cannot have more than one ID defined'],
93+
['ID starts with a digit', 'myRole #1bad', 'ID cannot start with a number: "1bad"'],
94+
['Unknown token', 'myRole #bad.', 'Unknown token "#bad."'],
95+
['Unknown token', 'myRole .class.no.space', 'Classes must be separated by spaces'],
96+
])('throws error: %s', (_, header, expectedMessage) => {
97+
expect(() => inlineOptionsToTokens(header, 0, null as any)).toThrow(expectedMessage);
98+
});
99+
});

0 commit comments

Comments
 (0)