Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

🧮 Add inline options to roles and directives #1822

Open
wants to merge 1 commit into
base: main
Choose a base branch
from
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
5 changes: 5 additions & 0 deletions .changeset/healthy-kangaroos-applaud.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"markdown-it-myst": patch
---

Add inline options
6 changes: 6 additions & 0 deletions .changeset/pink-birds-join.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
---
"myst-directives": patch
"myst-transforms": patch
---

Move QMD admonition recognition to a transform
5 changes: 5 additions & 0 deletions .changeset/small-paws-suffer.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"myst-directives": patch
---

div node does not require a body
5 changes: 5 additions & 0 deletions .changeset/tough-terms-push.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"myst-roles": patch
---

Introduce a span role
50 changes: 50 additions & 0 deletions docs/inline-options.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,50 @@
---
title: Inline Options
# subtitle: Generate figures and other rich content using Jupyter kernels
# short_title: Execute During Build
# description: MyST can execute Markdown files and Jupyter Notebooks, making it possible to build rich websites from computational documents.
# thumbnail: thumbnails/execute-notebooks.png
---

MyST Markdown is introducing inline attributes for both roles and directives, allowing concise specification of CSS classes, IDs, and attributes. This complements existing methods for defining options, making markup more expressive and flexible.

```markdown
:::{tip .dropdown open="true"} Title
Tip Content
:::
```

This can also be used for roles:

`` {span .text-red-500}`Red text` ``

{span .text-red-500}`Red text`

## Syntax Overview

The inline attribute syntax follows this pattern:

````text
{role #id .class key="value" key=value}`content`

```{directive #id .class key="value" key=value}
content
```
````

Name (e.g. `tip` or `cite:p`)
: The directive or role name must come first. There must only be a single "bare" token.

ID (`#id`)
: Defines the label/identifier of the node

Class (`.class`)
: Adds CSS class(es).

Quoted Attributes (`key="value"`)
: Supports attributes containing spaces or special characters.

Unquoted Attributes (`key=value` or `key=123`)
: Allows simpler attribute values.

For directives, these can be mixed with other ways to define options on directives, classes are combined in a single string other repeated directive options will raise a duplicate option warning.
1 change: 1 addition & 0 deletions docs/myst.yml
Original file line number Diff line number Diff line change
Expand Up @@ -101,6 +101,7 @@ project:
- file: glossaries-and-terms.md
- file: writing-in-latex.md
- file: table-of-contents.md
- file: inline-options.md
- title: Executable Content
children:
- file: notebooks-with-markdown.md
Expand Down
49 changes: 36 additions & 13 deletions packages/markdown-it-myst/src/directives.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import type MarkdownIt from 'markdown-it/lib';
import type StateCore from 'markdown-it/lib/rules_core/state_core.js';
import { nestedPartToTokens } from './nestedParse.js';
import { stateError, stateWarn } from './utils.js';
import { inlineOptionsToTokens } from './inlineAttributes.js';

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

Expand All @@ -26,7 +27,7 @@ function computeBlockTightness(
function replaceFences(state: StateCore): boolean {
for (const token of state.tokens) {
if (token.type === 'fence' || token.type === 'colon_fence') {
const match = token.info.match(/^\s*\{\s*([^}\s]+)\s*\}\s*(.*)$/);
const match = token.info.match(/^\s*\{\s*([^}]+)\s*\}\s*(.*)$/);
if (match) {
token.type = 'directive';
token.info = match[1].trim();
Expand All @@ -45,38 +46,49 @@ function runDirectives(state: StateCore): boolean {
try {
const { info, map } = token;
const arg = token.meta.arg?.trim() || undefined;
const {
name = 'div',
tokens: inlineOptTokens,
options: inlineOptions,
} = inlineOptionsToTokens(info, map?.[0] ?? 0, state);
const content = parseDirectiveContent(
token.content.trim() ? token.content.split(/\r?\n/) : [],
info,
name,
state,
);
const { body, options } = content;
const { body, options, optionsLocation } = content;
let { bodyOffset } = content;
while (body.length && !body[0].trim()) {
body.shift();
bodyOffset++;
}
const bodyString = body.join('\n').trimEnd();
const directiveOpen = new state.Token('parsed_directive_open', '', 1);
directiveOpen.info = info;
directiveOpen.info = name;
directiveOpen.hidden = true;
directiveOpen.content = bodyString;
directiveOpen.map = map;
directiveOpen.meta = {
arg,
options: getDirectiveOptions(options),
options: getDirectiveOptions([...inlineOptions, ...(options ?? [])]),
// Tightness is computed for all directives (are they separated by a newline before/after)
tight: computeBlockTightness(state.src, token.map),
};
const startLineNumber = map ? map[0] : 0;
const argTokens = directiveArgToTokens(arg, startLineNumber, state);
const optsTokens = directiveOptionsToTokens(options || [], startLineNumber + 1, state);
const optsTokens = directiveOptionsToTokens(
options || [],
startLineNumber + 1,
state,
optionsLocation,
);
const bodyTokens = directiveBodyToTokens(bodyString, startLineNumber + bodyOffset, state);
const directiveClose = new state.Token('parsed_directive_close', '', -1);
directiveClose.info = info;
directiveClose.hidden = true;
const newTokens = [
directiveOpen,
...inlineOptTokens,
...argTokens,
...optsTokens,
...bodyTokens,
Expand Down Expand Up @@ -110,6 +122,7 @@ function parseDirectiveContent(
body: string[];
bodyOffset: number;
options?: [string, string | true][];
optionsLocation?: 'yaml' | 'colon';
} {
let bodyOffset = 1;
let yamlBlock: string[] | null = null;
Expand All @@ -136,7 +149,12 @@ function parseDirectiveContent(
try {
const options = yaml.load(yamlBlock.join('\n')) as Record<string, any>;
if (options && typeof options === 'object') {
return { body: newContent, options: Object.entries(options), bodyOffset };
return {
body: newContent,
options: Object.entries(options),
bodyOffset,
optionsLocation: 'yaml',
};
}
} catch (err) {
stateWarn(
Expand All @@ -162,7 +180,7 @@ function parseDirectiveContent(
bodyOffset++;
}
}
return { body: newContent, options, bodyOffset };
return { body: newContent, options, bodyOffset, optionsLocation: 'colon' };
}
return { body: content, bodyOffset: 1 };
}
Expand All @@ -172,9 +190,13 @@ function directiveArgToTokens(arg: string, lineNumber: number, state: StateCore)
}

function getDirectiveOptions(options?: [string, string | true][]) {
if (!options) return undefined;
if (!options || options.length === 0) return undefined;
const simplified: Record<string, string | true> = {};
options.forEach(([key, val]) => {
if (key === 'class' && simplified.class) {
simplified.class += ` ${val}`;
return;
}
if (simplified[key] !== undefined) {
return;
}
Expand All @@ -187,28 +209,29 @@ function directiveOptionsToTokens(
options: [string, string | true][],
lineNumber: number,
state: StateCore,
optionsLocation?: 'yaml' | 'colon',
) {
const tokens = options.map(([key, value], index) => {
// lineNumber mapping assumes each option is only one line;
// not necessarily true for yaml options.
const optTokens =
typeof value === 'string'
? nestedPartToTokens(
'directive_option',
'myst_option',
value,
lineNumber + index,
state,
'run_directives',
true,
)
: [
new state.Token('directive_option_open', '', 1),
new state.Token('directive_option_close', '', -1),
new state.Token('myst_option_open', '', 1),
new state.Token('myst_option_close', '', -1),
];
if (optTokens.length) {
optTokens[0].info = key;
optTokens[0].content = typeof value === 'string' ? value : '';
optTokens[0].meta = { value };
optTokens[0].meta = { location: optionsLocation, value };
}
return optTokens;
});
Expand Down
98 changes: 98 additions & 0 deletions packages/markdown-it-myst/src/inlineAttributes.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,98 @@
// parseRoleHeader.spec.ts
import { describe, expect, test } from 'vitest';
import { inlineOptionsToTokens, tokenizeInlineAttributes } from './inlineAttributes';

describe('parseRoleHeader', () => {
// Good (valid) test cases
test.each([
['simple', [{ kind: 'bare', value: 'simple' }]],
[
'someRole .cls1 .cls2',
[
{ kind: 'bare', value: 'someRole' },
{ kind: 'class', value: 'cls1' },
{ kind: 'class', value: 'cls2' },
],
],
[
'myRole #foo',
[
{ kind: 'bare', value: 'myRole' },
{ kind: 'id', value: 'foo' },
],
],
[
'myRole .red #xyz attr="value"',
[
{ kind: 'bare', value: 'myRole' },
{ kind: 'class', value: 'red' },
{ kind: 'id', value: 'xyz' },
{ kind: 'attr', key: 'attr', value: 'value' },
],
],
[
'roleName data="some \\"escaped\\" text"',
[
{ kind: 'bare', value: 'roleName' },
{ kind: 'attr', key: 'data', value: 'some "escaped" text' },
],
],
['.className', [{ kind: 'class', value: 'className' }]],
[
'myRole open=true',
[
{ kind: 'bare', value: 'myRole' },
{ kind: 'attr', key: 'open', value: 'true' },
],
],
[
'myRole open=""',
[
{ kind: 'bare', value: 'myRole' },
{ kind: 'attr', key: 'open', value: '' },
],
],
[
'myRole #foo.',
[
{ kind: 'bare', value: 'myRole' },
{ kind: 'unknown', value: '#foo.' },
],
],
[
'myRole foo="{testing .blah}`content`"',
[
{ kind: 'bare', value: 'myRole' },
{ kind: 'attr', key: 'foo', value: '{testing .blah}`content`' },
],
],
['cite:p', [{ kind: 'bare', value: 'cite:p' }]],
['CITE:P', [{ kind: 'bare', value: 'CITE:P' }]],
['1', [{ kind: 'bare', value: '1' }]],
[' abc ', [{ kind: 'bare', value: 'abc' }]],
[
'half-quote blah="asdf',
[
{ kind: 'bare', value: 'half-quote' },
{ kind: 'unknown', value: 'blah="asdf' },
],
],
])('parses valid header: %s', (header, expected) => {
const result = tokenizeInlineAttributes(header);
expect(result).toEqual(expected);
});

// Error test cases
test.each([
[
'Extra bare token after name',
'myRole anotherWord',
'No additional bare tokens allowed after the first token',
],
['Multiple IDs', 'myRole #first #second', 'Cannot have more than one ID defined'],
['ID starts with a digit', 'myRole #1bad', 'ID cannot start with a number: "1bad"'],
['Unknown token', 'myRole #bad.', 'Unknown token "#bad."'],
])('throws error: %s', (_, header, expectedMessage) => {
expect(() => inlineOptionsToTokens(header, 0, null as any)).toThrow(expectedMessage);
});
});
Loading
Loading