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

Proposal: markdown-it-htmyst #396

Draft
wants to merge 1 commit 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
46 changes: 46 additions & 0 deletions packages/markdown-it-htmyst/package.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,46 @@
{
"name": "markdown-it-htmyst",
"version": "0.1.3",
"sideEffects": false,
"license": "MIT",
"description": "markdown-it HTML hinter for MyST roles and directives",
"author": "Tavin Cole <[email protected]>",
"homepage": "https://github.com/executablebooks/mystjs/tree/main/packages/markdown-it-htmyst",
"main": "./dist/cjs/index.js",
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

In #417 we made quite a few changes for working as ESM packages only (and switching to vitest). I am happy to help out with upgrading here as well when you need it.

"module": "./dist/esm/index.js",
"types": "./dist/types/index.d.ts",
"files": [
"dist"
],
"exports": {
".": {
"import": "./dist/esm/index.js",
"require": "./dist/cjs/index.js"
}
},
"publishConfig": {
"access": "public"
},
"repository": {
"type": "git",
"url": "git+https://github.com/executablebooks/mystjs.git"
},
"scripts": {
"clean": "rimraf dist",
"build:esm": "tsc --project ./tsconfig.json --module es2015 --outDir dist/esm",
"build:cjs": "tsc --project ./tsconfig.json --module commonjs --outDir dist/cjs",
"declarations": "tsc --project ./tsconfig.json --declaration --emitDeclarationOnly --declarationMap --outDir dist/types",
"bundle": "esbuild dist/cjs/index.js --bundle --format=cjs --outfile=dist/markdown-it-htmyst.js",
"build": "npm-run-all -l clean -p build:cjs build:esm declarations -s bundle",
"lint": "eslint \"src/**/!(*.spec).ts\" -c ./.eslintrc.js",
"lint:format": "npx prettier --check \"src/**/*.ts\"",
"test": "jest",
"test:watch": "jest --watchAll"
},
"bugs": {
"url": "https://github.com/executablebooks/mystjs/issues"
},
"dependencies": {
"markdown-it": "^13.0.1"
}
}
75 changes: 75 additions & 0 deletions packages/markdown-it-htmyst/src/admonition.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,75 @@
import type StateCore from 'markdown-it/lib/rules_core/state_core';
import type Token from 'markdown-it/lib/token';

import {
findTokenPair,
ARG_OPEN,
ARG_CLOSE,
BODY_OPEN,
BODY_CLOSE,
} from './util';

const ADMONITIONS = [
'admonition',
'attention',
'caution',
'danger',
'error',
'hint',
'important',
'note',
'seealso',
'tip',
'warning',
];

/**
* Enrich admonition directives with markup tags.
*
* @return true if new tokens are inserted into the array
*/
export function admonitionDecorator(state: StateCore, tokens: Token[]): boolean {

let kind = tokens[0].info;

if (!ADMONITIONS.includes(kind)) return false;

let ins = false;
let [...arg] = findTokenPair(tokens, [ARG_OPEN, ARG_CLOSE], 1);

if (arg[1] === undefined) {
const title = new state.Token('inline', '', 0);
title.content = kind.replace(/^./, char => char.toUpperCase());
title.children = [];
tokens.splice(1, 0, new state.Token(ARG_OPEN, 'p', 1),
title, new state.Token(ARG_CLOSE, 'p', -1));
arg = [1, 3];
ins = true;
}

let [...body] = findTokenPair(tokens, [BODY_OPEN, BODY_CLOSE], 1);

tokens.at(0).attrSet('class', `admonition ${kind}`);
tokens.at(arg[0]).attrSet('class', 'admonition-title');

[0, -1].map(tokens.at, tokens).forEach(token => {
token.hidden = false;
token.block = true;
token.tag = 'aside';
});

arg.map(tokens.at, tokens).forEach(token => {
token.hidden = false;
token.block = true;
token.tag = 'p';
});

body.map(tokens.at, tokens).forEach(token => {
token.hidden = false;
token.block = true;
token.tag = 'div';
});

return ins;
}

57 changes: 57 additions & 0 deletions packages/markdown-it-htmyst/src/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,57 @@
import type MarkdownIt from 'markdown-it/lib';
import type StateCore from 'markdown-it/lib/rules_core/state_core';
import type Token from 'markdown-it/lib/token';

import { admonitionDecorator } from './admonition';
import { proofDecorator } from './proof';

type Decorator = (state: StateCore, tokens: Token[]) => boolean | void;

const DEFAULT_DECORATORS = [
admonitionDecorator,
proofDecorator,
];

/**
* Factory to implement the following rule:
*
* - Run through the parsed token array.
* - Find all slices from `parsed_directive_open` to `parsed_directive_close`.
* - Pass the slices through the decorators.
*/
function rule(decorators: Decorator[] = DEFAULT_DECORATORS) {

return (state: StateCore) => {
const tokens = state.tokens;
const stack = [];
for (let j = 0; j < tokens.length; ++j) {
if (tokens[j].type === 'parsed_directive_open') {
stack.push(j);
} else if (tokens[j].type === 'parsed_directive_close') {
let i = stack.pop()!;
// in principle we'd like a view of the array from i to j;
// hopefully all modern JS engines like V8 will optimize slice() as COW
const slice = tokens.slice(i, j + 1);
let subst = false;
for (let fn of decorators) {
subst = fn(state, slice) || subst;
}
if (subst) {
tokens.splice(i, j - i + 1, ...slice);
j = i + slice.length - 1;
}
}
}
};
}

/**
* A markdown-it plugin for adding markup to parsed myst directives.
*/
export function htmystPlugin(md: MarkdownIt, ...decorators: Decorator[]) {
md.core.ruler.after('run_directives', 'decorate_directives',
decorators.length ? rule(decorators) : rule());
}

export default htmystPlugin;

65 changes: 65 additions & 0 deletions packages/markdown-it-htmyst/src/proof.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,65 @@
import type StateCore from 'markdown-it/lib/rules_core/state_core';
import type Token from 'markdown-it/lib/token';

import {
findTokenPair,
ARG_OPEN,
ARG_CLOSE,
BODY_OPEN,
BODY_CLOSE,
} from './util';

/**
* Enrich proof directives with markup tags.
*
* @return true if new tokens are inserted into the array
*/
export function proofDecorator(state: StateCore, tokens: Token[]): boolean {

let kind = tokens[0].info;

if (!kind.startsWith('prf:')) return false;

const title = new state.Token('inline', '', 0);
title.content = kind.replace(/^prf:(.)/, (_, char) => char.toUpperCase());
title.children = [];

let [...arg] = findTokenPair(tokens, [ARG_OPEN, ARG_CLOSE], 1);

if (arg[1] === undefined) {
tokens.splice(1, 0, new state.Token(ARG_OPEN, 'p', 1),
title, new state.Token(ARG_CLOSE, 'p', -1));
arg = [1, 3];
} else {
title.content += ': ';
tokens.splice(arg[0] + 1, 0, title);
++arg[1];
}

let [...body] = findTokenPair(tokens, [BODY_OPEN, BODY_CLOSE], 1);

tokens.at(0).attrSet('class', 'admonition');
tokens.at(0).attrJoin('class', kind.replace(':', '-'));
tokens.at(arg[0]).attrSet('class', 'admonition-title');

[0, -1].map(tokens.at, tokens).forEach(token => {
token.hidden = false;
token.block = true;
token.tag = 'aside';
});

arg.map(tokens.at, tokens).forEach(token => {
token.hidden = false;
token.block = true;
token.tag = 'p';
});

body.map(tokens.at, tokens).forEach(token => {
token.hidden = false;
token.block = true;
token.tag = 'div';
});

return true;
}

36 changes: 36 additions & 0 deletions packages/markdown-it-htmyst/src/util.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
import type Token from 'markdown-it/lib/token';

export const ARG_OPEN = 'directive_arg_open';
export const ARG_CLOSE = 'directive_arg_close';
export const BODY_OPEN = 'directive_body_open';
export const BODY_CLOSE = 'directive_body_close';

/**
* Find open/close pairs in a token array.
*/
export function* findTokenPair(
tokens: Token[],
pair: [string, string],
from: number = 0
): Generator<number> {

let pos = from - 1;
let depth = 0;

while (++pos < tokens.length) {
depth += tokens[pos].nesting;
if (depth == 1 && tokens[pos].type == pair[0]) {
yield pos;
break;
}
}

while (++pos < tokens.length) {
depth += tokens[pos].nesting;
if (depth == 0 && tokens[pos].type == pair[1]) {
yield pos;
break;
}
}
}

34 changes: 34 additions & 0 deletions packages/markdown-it-htmyst/tsconfig.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
{
"compilerOptions": {
"target": "es6",
// module is overridden from the build:esm/build:cjs scripts
"module": "es2015",
"jsx": "react-jsx",
"lib": ["es2020"],
"esModuleInterop": true,
"noImplicitAny": true,
"strict": true,
"strictNullChecks": false,
"moduleResolution": "node",
"sourceMap": false,
// outDir is overridden from the build:esm/build:cjs scripts
"outDir": "dist/types",
"baseUrl": "src",
"paths": {
"*": ["node_modules/*"]
},
// Type roots allows it to be included in a workspace
"typeRoots": [
"./types",
"./node_modules/@types",
"../../node_modules/@types",
"../../../node_modules/@types"
],
"resolveJsonModule": true,
// Ignore node_modules, etc.
"skipLibCheck": true,
"forceConsistentCasingInFileNames": true
},
"include": ["src/**/*"],
"exclude": ["tests/**/*"]
}
3 changes: 2 additions & 1 deletion packages/markdown-it-myst/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -30,7 +30,8 @@
"build:esm": "tsc --project ./tsconfig.json --module es2015 --outDir dist/esm",
"build:cjs": "tsc --project ./tsconfig.json --module commonjs --outDir dist/cjs",
"declarations": "tsc --project ./tsconfig.json --declaration --emitDeclarationOnly --declarationMap --outDir dist/types",
"build": "npm-run-all -l clean -p build:cjs build:esm declarations",
"bundle": "esbuild dist/cjs/index.js --bundle --format=cjs --outfile=dist/markdown-it-myst.js",
"build": "npm-run-all -l clean -p build:cjs build:esm declarations -s bundle",
"lint": "eslint \"src/**/!(*.spec).ts\" -c ./.eslintrc.js",
"lint:format": "npx prettier --check \"src/**/*.ts\"",
"test": "jest",
Expand Down