/**
* [micromark](https://github.com/micromark/micromark) extension to
* support `kbd` element syntax with configurable delimiters,
* escaping, `var` sequences, and arbitrary nesting (e.g.
* Ctrl + key).
*
* **Warning**: Do not set delimiters to characters that already being
* processed specially. This will result in undefined behaviour.
*
* ## Syntax
*
* ### Keyboard sequences
*
* Recognizes any sequence of two or more unescaped occurrences of
* {@link IOptions.delimiter | delimiter} (defaults to `|`) as a
* keyboard sequence.
*
* * All whitespace is preserved except immediately after an opening
* sequence or immediately before a closing sequence.
* * Nesting is possible using a longer sequence on the outside and a
* shorter sequence on the inside. For example, `||| ||Ctrl|| + ||x||
* |||` will yield Ctrl + x.
* * The opening sequence will be considered to end at the first
* whitespace character or non-delimiter, including escape characters.
* For example, these will all produce `|`:
* * `||\|||`
* * `|| | ||`
* * `|| | ||`
* * `++|++` (with a delimiter of `+`)
* * `++ | ++` (with a delimiter of `+`)
*
* ### Variable sequence
*
* Recognizes sequences of two occurrences of {@link
* IOptions.variableDelimiter | delimiter} (defaults to `/`) *within*
* keyboard sequences as mark variable sections.
*
* * Must always use two variable delimiters. Further occurrences will be
* interpreted as the closing sequence.
* * Cannot be nested.
* * All whitespace is preserved except immediately after an opening
* sequence or immediately before a closing sequence.
*
* @module
*/
import type {
Code,
CompileContext,
Effects,
Event,
Extension,
HtmlExtension,
State,
TokenTypeMap,
Tokenizer,
} from "micromark-util-types";
import { codes } from "micromark-util-symbol/codes";
import { types } from "micromark-util-symbol/types";
import { markdownLineEndingOrSpace } from "micromark-util-character";
/** Options that can be passed to the extension. */
export interface IOptions {
/** Character code or character to use as a delimiter for keyboard sequences. Defaults to `|`. */
delimiter?: string | number;
/** Character code or character to use as a delimiter for variable sequences within keyboard sequences. Defaults to `/`. */
variableDelimiter?: string | number;
}
const MINIMUM_MARKER_LENGTH = 2;
const VARIABLE_MARKER_LENGTH = 2;
declare module "micromark-util-types" {
interface TokenTypeMap {
keyboardSequence: "keyboardSequence";
keyboardSequenceEscape: "keyboardSequenceEscape";
keyboardSequenceMarker: "keyboardSequenceMarker";
keyboardSequenceVariableMarker: "keyboardSequenceVariableMarker";
keyboardSequenceVariable: "keyboardSequenceVariableMarker";
}
}
const KEYBOARD_TYPE = "keyboardSequence";
const KEYBOARD_TEXT_TYPE = types.codeTextData; // TODO check whether this is okay
const KEYBOARD_TEXT_ESCAPE_TYPE = "keyboardSequenceEscape";
const KEYBOARD_MARKER_TYPE = "keyboardSequenceMarker";
const KEYBOARD_VARIABLE_MARKER_TYPE = "keyboardSequenceVariableMarker";
const KEYBOARD_VARIABLE_TYPE = "keyboardSequenceVariable"; // TODO check whether this is okay
const SPACE_TYPE = "space";
const DEFAULT_DELIMITER = codes.verticalBar;
const DEFAULT_VARIABLE_DELIMITER = codes.slash;
/**
* Extension for micromark to compile keyboard sequences as ``
* elements and variable sequences as `` elements. Can be passed
* in `htmlExtensions.` */
export const html: HtmlExtension = Object.freeze({
enter: {
[KEYBOARD_TYPE]: function (this: CompileContext): void {
this.tag("");
},
[KEYBOARD_VARIABLE_TYPE]: function (this: CompileContext): void {
this.tag("");
},
},
exit: {
[KEYBOARD_TYPE]: function (this: CompileContext): void {
this.tag("");
},
[KEYBOARD_VARIABLE_TYPE]: function (this: CompileContext): void {
this.tag("");
},
},
});
/**
* Returns an extension for micromark to parse keyboard sequences
* optionally containing variable sequences. Can be passed in
* `extensions`.
*
* @param options
* @returns Extension to parse keyboard sequences.
*/
// adapted from
export const syntax = (options: IOptions = {}): Extension => {
const delimiter = normalizeDelimiter(options.delimiter, DEFAULT_DELIMITER);
const variableDelimiter = normalizeDelimiter(
options.variableDelimiter,
DEFAULT_VARIABLE_DELIMITER,
);
const makeTokenizer: (insideText: boolean) => Tokenizer = (insideText) =>
function (effects, ok, nok): State {
let size = 0;
const onlyLiteral = makeConsumeOne(effects, data);
const literal = makeLiteral(
effects,
onlyLiteral,
KEYBOARD_TEXT_TYPE,
KEYBOARD_TEXT_ESCAPE_TYPE,
);
return start;
function start(): void | State {
if (insideText) {
effects.exit(KEYBOARD_TEXT_TYPE);
}
effects.enter(KEYBOARD_TYPE);
effects.enter(KEYBOARD_MARKER_TYPE);
return opening;
}
function opening(code: Code): void | State {
if (code !== delimiter && size < MINIMUM_MARKER_LENGTH) {
return nok(code);
}
if (code === delimiter) {
effects.consume(code);
size++;
return opening;
}
effects.exit(KEYBOARD_MARKER_TYPE);
return openingGap;
}
function openingGap(code: Code): void | State {
if (isEof(code)) {
return nok;
}
if (tryWhitespace(code, effects)) {
return openingGap;
}
return startData;
}
function startData(): void | State {
effects.enter(KEYBOARD_TEXT_TYPE);
return data;
}
function data(code: Code): void | State {
const closingTokenizer = makeClosingTokenizer(
delimiter,
size,
true,
insideText,
);
const varTokenizer = makeVariableTokenizer(variableDelimiter);
if (isEof(code)) {
effects.exit(KEYBOARD_TEXT_TYPE);
effects.enter(KEYBOARD_MARKER_TYPE);
effects.exit(KEYBOARD_MARKER_TYPE);
return nok(code);
}
if (markdownLineEndingOrSpace(code) || code === delimiter) {
return effects.attempt(
{
tokenize: closingTokenizer,
partial: true,
},
ok,
code === delimiter
? () =>
effects.attempt(
{
tokenize: makeTokenizer(true),
partial: true,
},
data,
nok,
)
: literal,
);
}
if (code === variableDelimiter) {
return effects.attempt(
{
tokenize: varTokenizer,
partial: true,
},
data,
nok,
);
}
return literal;
}
};
const tokenizer = {
tokenize: makeTokenizer(false),
resolveAll: (events: Event[]) => events,
};
return {
text: { [delimiter]: tokenizer },
insideSpan: { null: [tokenizer] },
attentionMarkers: { null: [delimiter] },
};
};
/**
* Returns a {@link Tokenizer} that consumes the end of a keyboard
* sequence with the given delimiters repeated `size` times,
* optionally preceded by whitespace.
*
* @param delimiter The character code of the delimiter.
* @param size Number of delimiters to expect.
* @param exitText Whether to exit {@link KEYBOARD_TEXT_TYPE} before starting.
* @param enterText Whether to enter {@link KEYBOARD_TEXT_TYPE} after ending.
*/
function makeClosingTokenizer(
delimiter: number,
size: number,
exitText: boolean,
enterText: boolean,
): Tokenizer {
return function (effects, ok, nok) {
let current = 0;
function start(): void | State {
if (exitText) {
effects.exit(KEYBOARD_TEXT_TYPE);
}
effects.enter(SPACE_TYPE);
return gap;
}
function gap(code: Code): State {
if (tryWhitespace(code, effects)) {
return gap;
}
effects.exit(SPACE_TYPE);
effects.enter(KEYBOARD_MARKER_TYPE);
return closing;
}
function closing(code: Code): void | State {
if (code === delimiter) {
effects.consume(code);
current++;
if (current === size) {
effects.exit(KEYBOARD_MARKER_TYPE)._close = true;
effects.exit(KEYBOARD_TYPE)._close = true;
if (enterText) {
effects.enter(KEYBOARD_TEXT_TYPE);
}
return ok(code);
} else {
return closing;
}
}
return nok(code);
}
return start;
};
}
/**
* Returns a {@link Tokenizer} that consumes a variable sequence marked by `delimiter`.
* @param delimiter The character code of the delimiter.
*/
function makeVariableTokenizer(delimiter: number): Tokenizer {
return function (effects, ok, nok) {
const onlyLiteral = makeConsumeOne(effects, data);
const literal = makeLiteral(
effects,
onlyLiteral,
KEYBOARD_VARIABLE_TYPE,
KEYBOARD_TEXT_ESCAPE_TYPE,
);
let size = 0;
return start;
function start(): void | State {
effects.exit(KEYBOARD_TEXT_TYPE);
effects.enter(KEYBOARD_VARIABLE_TYPE);
effects.enter(KEYBOARD_VARIABLE_MARKER_TYPE);
return opening;
}
function opening(code: Code): void | State {
if (delimiter !== code) {
return nok(code);
}
size++;
effects.consume(delimiter);
if (size === VARIABLE_MARKER_LENGTH) {
effects.exit(KEYBOARD_VARIABLE_MARKER_TYPE);
return gap;
}
return opening;
}
function gap(code: Code): void | State {
if (tryWhitespace(code, effects)) {
return gap;
}
effects.enter(KEYBOARD_TEXT_TYPE);
return data;
}
function data(code: Code): void | State {
if (markdownLineEndingOrSpace(code) || code === delimiter) {
return effects.attempt(
{
tokenize: makeVariableClosingTokenizer(delimiter),
partial: true,
},
ok,
literal,
);
}
return literal;
}
};
}
/**
* Returns a {@link Tokenizer} that consumes the end of a variable
* sequence, optionally preceded by whitespace.
*
* @param delimiter The character code of the delimiter.
*/
function makeVariableClosingTokenizer(delimiter: number): Tokenizer {
return function (effects, ok, nok) {
let size = 0;
return start;
function start(): void | State {
effects.exit(KEYBOARD_TEXT_TYPE);
return gap;
}
function gap(code: Code): void | State {
if (tryWhitespace(code, effects)) {
return gap;
}
effects.enter(KEYBOARD_VARIABLE_MARKER_TYPE);
return marker;
}
function marker(code: Code): void | State {
if (code === delimiter) {
effects.consume(code);
size++;
if (size === VARIABLE_MARKER_LENGTH) {
effects.exit(KEYBOARD_VARIABLE_MARKER_TYPE);
effects.exit(KEYBOARD_VARIABLE_TYPE);
effects.enter(KEYBOARD_TEXT_TYPE);
return ok(code);
}
return marker;
}
return nok(code);
}
};
}
/**
* Converts the optional `delimiter` into a character code. Returns
* `defaultValue` if no delimiter is provided.
*
* @param delimiter The optional character code or character.
* @param defaultValue The character code to return if no delimiter is provided.
*/
export function normalizeDelimiter(
delimiter: string | number | undefined,
defaultValue: number,
): number {
return typeof delimiter === "string"
? delimiter.charCodeAt(0)
: delimiter || defaultValue;
}
/**
* Detects whether the given code marks EOF.
*/
function isEof(code: Code): boolean {
return code === codes.eof;
}
function tryWhitespace(code: Code, effects: Effects): boolean {
if (code === codes.space) {
effects.enter(SPACE_TYPE);
effects.consume(code);
effects.exit(SPACE_TYPE);
return true;
}
if (markdownLineEndingOrSpace(code)) {
effects.enter(types.lineEnding);
effects.consume(code);
effects.exit(types.lineEnding);
return true;
}
return false;
}
/**
* Returns a {@link State} that consumes a single character.
* @param effects
* @param next The state to enter after the character is consumed.
*/
function makeConsumeOne(effects: Effects, next: State): (code: Code) => State {
return (code: Code) => {
effects.consume(code);
return next;
};
}
/**
* Returns a {@link State} that optionally consumes a single backslash
* as `escapeType`.
*
* @param effects
* @param next The next state to enter.
* @param outerType The type to exit before and enter after consuming an escape.
* @param escapeType The type to consume an escape as.
* @returns
*/
function makeLiteral(
effects: Effects,
next: State,
outerType: keyof TokenTypeMap,
escapeType: keyof TokenTypeMap,
): (code: Code) => State {
return (code: Code) => {
if (code === codes.backslash) {
effects.exit(outerType);
effects.enter(escapeType);
effects.consume(code);
effects.exit(escapeType);
effects.enter(outerType);
return next;
}
return next;
};
}