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

Inline Attributes #1822

Draft
wants to merge 2 commits into
base: main
Choose a base branch
from
Draft
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
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
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
58 changes: 58 additions & 0 deletions packages/markdown-it-myst/src/inlineAttributes.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,58 @@
// 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' }]],
])('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"'],
])('throws error: %s', (_, header, expectedMessage) => {
expect(() => inlineOptionsToTokens(header, 0, null as any)).toThrow(expectedMessage);
});
});
118 changes: 118 additions & 0 deletions packages/markdown-it-myst/src/inlineAttributes.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,118 @@
import type StateCore from 'markdown-it/lib/rules_core/state_core.js';
import { nestedPartToTokens } from './nestedParse.js';
import type Token from 'markdown-it/lib/token.js';

export type InlineAttributes = {
name: string;
id?: string;
classes?: string[];
attrs?: Record<string, string>;
};

/**
* Tokenizes the inline-attributes header into:
* - `.className` => { kind: 'class', value: string }
* - `#something` => { kind: 'id', value: string } (relaxed to match digits too)
* - `key="someValue"` => { kind: 'attr', key, value }
* - leftover / bare => { kind: 'bare', value }
*/
export function tokenizeInlineAttributes(header: string) {
// This pattern uses four alternations:
// 1) (\.[A-Za-z0-9_-]+) => matches `.className`
// 2) (#[A-Za-z0-9_:.~-]+) => matches `#id` (relaxed to allow digits)
// 3) ([a-zA-Z0-9_:.-]+)="((?:\\.|[^\\"])*)" => matches key="value" with possible escapes
// 4) ([^\s]+) => matches leftover / bare tokens
const pattern =
/(\.[A-Za-z0-9_-]+)|(#[A-Za-z0-9_:.~-]+)|([a-zA-Z0-9_:.-]+)="((?:\\.|[^\\"])*)"|([^\s]+)/g;

const results: Array<
| { kind: 'class'; value: string }
| { kind: 'id'; value: string }
| { kind: 'attr'; key: string; value: string }
| { kind: 'bare'; value: string }
> = [];

let match;
while ((match = pattern.exec(header)) !== null) {
const [, classGroup, idGroup, attrKey, attrVal, bareGroup] = match;

if (classGroup) {
results.push({ kind: 'class', value: classGroup.slice(1) });
} else if (idGroup) {
results.push({ kind: 'id', value: idGroup.slice(1) });
} else if (attrKey && attrVal !== undefined) {
// unescape any \" within the attribute value
const unescaped = attrVal.replace(/\\"/g, '"');
results.push({ kind: 'attr', key: attrKey, value: unescaped });
} else if (bareGroup) {
results.push({ kind: 'bare', value: bareGroup });
}
}

return results;
}

export function inlineOptionsToTokens(
header: string,
lineNumber: number,
state: StateCore,
): { name?: string; tokens: Token[]; options: [string, string][] } {
// Tokenize
const tokens = tokenizeInlineAttributes(header);

if (tokens.length === 0) {
throw new Error('No inline tokens found');
}

// The first token should be a “bare” token => the name
// If no bare token is included, then the name is undefined
let name = undefined;
if (tokens[0].kind === 'bare') {
name = tokens[0].value;
tokens.shift();
}

if (tokens.filter(({ kind }) => kind === 'id').length > 1) {
throw new Error('Cannot have more than one ID defined');
}
if (tokens.some(({ kind }) => kind === 'bare')) {
throw new Error('No additional bare tokens allowed after the first token');
}

const markdownItTokens = tokens.map((opt) => {
if (opt.kind === 'id' && /^[0-9]/.test(opt.value)) {
throw new Error(`ID cannot start with a number: "${opt.value}"`);
}
if (opt.kind === 'class' || opt.kind === 'id' || opt.kind === 'bare') {
const classTokens = [
new state.Token('myst_option_open', '', 1),
new state.Token('myst_option_close', '', -1),
];
classTokens[0].info = opt.kind;
classTokens[0].content =
opt.kind === 'class' ? `.${opt.value}` : opt.kind === 'id' ? `#${opt.value}` : opt.value;
classTokens[0].meta = { location: 'inline', ...opt };
return classTokens;
}

const optTokens = nestedPartToTokens(
'myst_option',
opt.value,
lineNumber,
state,
'run_roles',
true,
);
if (optTokens.length) {
optTokens[0].info = opt.key;
optTokens[0].content = opt.value;
optTokens[0].meta = { location: 'inline', ...opt };
}
return optTokens;
});
const options = tokens.map((t): [string, string] => [
t.kind === 'attr' ? t.key : t.kind === 'id' ? 'label' : t.kind,
t.value,
]);
return { name, tokens: markdownItTokens.flat(), options };
}
2 changes: 1 addition & 1 deletion packages/markdown-it-myst/src/nestedParse.ts
Original file line number Diff line number Diff line change
Expand Up @@ -63,7 +63,7 @@ export function nestedPartToTokens(
state: StateCore,
pluginRuleName: string,
inline: boolean,
) {
): Token[] {
if (!part) return [];
const openToken = new state.Token(`${partName}_open`, '', 1);
openToken.content = part;
Expand Down
Loading
Loading