feat: add processAllTokens hook (#3114)

This commit is contained in:
Tony Brix 2023-12-11 23:02:22 -07:00 committed by GitHub
parent f6450bc6de
commit faae24356a
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
5 changed files with 126 additions and 14 deletions

View File

@ -1,5 +1,6 @@
import { _defaults } from './defaults.ts';
import type { MarkedOptions } from './MarkedOptions.ts';
import type { Token, TokensList } from './Tokens.ts';
export class _Hooks {
options: MarkedOptions;
@ -10,7 +11,8 @@ export class _Hooks {
static passThroughHooks = new Set([
'preprocess',
'postprocess'
'postprocess',
'processAllTokens'
]);
/**
@ -26,4 +28,11 @@ export class _Hooks {
postprocess(html: string) {
return html;
}
/**
* Process all tokens before walk tokens
*/
processAllTokens(tokens: Token[] | TokensList) {
return tokens;
}
}

View File

@ -204,23 +204,25 @@ export class Marked {
const hooksFunc = pack.hooks[hooksProp] as UnknownFunction;
const prevHook = hooks[hooksProp] as UnknownFunction;
if (_Hooks.passThroughHooks.has(prop)) {
hooks[hooksProp] = (arg: string | undefined) => {
// @ts-expect-error cannot type hook function dynamically
hooks[hooksProp] = (arg: unknown) => {
if (this.defaults.async) {
return Promise.resolve(hooksFunc.call(hooks, arg)).then(ret => {
return prevHook.call(hooks, ret) as string;
return prevHook.call(hooks, ret);
});
}
const ret = hooksFunc.call(hooks, arg);
return prevHook.call(hooks, ret) as string;
return prevHook.call(hooks, ret);
};
} else {
// @ts-expect-error cannot type hook function dynamically
hooks[hooksProp] = (...args: unknown[]) => {
let ret = hooksFunc.apply(hooks, args);
if (ret === false) {
ret = prevHook.apply(hooks, args);
}
return ret as string;
return ret;
};
}
}
@ -292,6 +294,7 @@ export class Marked {
if (opt.async) {
return Promise.resolve(opt.hooks ? opt.hooks.preprocess(src) : src)
.then(src => lexer(src, opt))
.then(tokens => opt.hooks ? opt.hooks.processAllTokens(tokens) : tokens)
.then(tokens => opt.walkTokens ? Promise.all(this.walkTokens(tokens, opt.walkTokens)).then(() => tokens) : tokens)
.then(tokens => parser(tokens, opt))
.then(html => opt.hooks ? opt.hooks.postprocess(html) : html)
@ -302,7 +305,10 @@ export class Marked {
if (opt.hooks) {
src = opt.hooks.preprocess(src) as string;
}
const tokens = lexer(src, opt);
let tokens = lexer(src, opt);
if (opt.hooks) {
tokens = opt.hooks.processAllTokens(tokens) as Token[] | TokensList;
}
if (opt.walkTokens) {
this.walkTokens(tokens, opt.walkTokens);
}

View File

@ -3,6 +3,7 @@ import type { _Parser } from './Parser.ts';
import type { _Lexer } from './Lexer.ts';
import type { _Renderer } from './Renderer.ts';
import type { _Tokenizer } from './Tokenizer.ts';
import type { _Hooks } from './Hooks.ts';
export interface TokenizerThis {
lexer: _Lexer;
@ -33,6 +34,11 @@ export interface RendererExtension {
export type TokenizerAndRendererExtension = TokenizerExtension | RendererExtension | (TokenizerExtension & RendererExtension);
type HooksApi = Omit<_Hooks, 'constructor' | 'options'>;
type HooksObject = {
[K in keyof HooksApi]?: (...args: Parameters<HooksApi[K]>) => ReturnType<HooksApi[K]> | Promise<ReturnType<HooksApi[K]>>
};
type RendererApi = Omit<_Renderer, 'constructor' | 'options'>;
type RendererObject = {
[K in keyof RendererApi]?: (...args: Parameters<RendererApi[K]>) => ReturnType<RendererApi[K]> | false
@ -69,14 +75,10 @@ export interface MarkedExtension {
/**
* Hooks are methods that hook into some part of marked.
* preprocess is called to process markdown before sending it to marked.
* processAllTokens is called with the TokensList before walkTokens.
* postprocess is called to process html after marked has finished parsing.
*/
hooks?: {
preprocess: (markdown: string) => string | Promise<string>,
postprocess: (html: string) => string | Promise<string>,
// eslint-disable-next-line no-use-before-define
options?: MarkedOptions
} | null;
hooks?: HooksObject | undefined | null;
/**
* Conform to obscure parts of markdown.pl as much as possible. Don't fix any of the original markdown bugs or poor behavior.
@ -109,7 +111,12 @@ export interface MarkedExtension {
walkTokens?: ((token: Token) => void | Promise<void>) | undefined | null;
}
export interface MarkedOptions extends Omit<MarkedExtension, 'renderer' | 'tokenizer' | 'extensions' | 'walkTokens'> {
export interface MarkedOptions extends Omit<MarkedExtension, 'hooks' | 'renderer' | 'tokenizer' | 'extensions' | 'walkTokens'> {
/**
* Hooks are methods that hook into some part of marked.
*/
hooks?: _Hooks | undefined | null;
/**
* Type: object Default: new Renderer()
*

View File

@ -323,3 +323,24 @@ marked.use({
}
}
});
marked.use({
hooks: {
processAllTokens(tokens) {
return tokens;
}
}
});
marked.use({
async: true,
hooks: {
async preprocess(markdown) {
return markdown;
},
async postprocess(html) {
return html;
},
async processAllTokens(tokens) {
return tokens;
}
}
});

View File

@ -3,6 +3,18 @@ import { timeout } from './utils.js';
import { describe, it, beforeEach } from 'node:test';
import assert from 'node:assert';
function createHeadingToken(text) {
return {
type: 'heading',
raw: `# ${text}`,
depth: 1,
text,
tokens: [
{ type: 'text', raw: text, text }
]
};
}
describe('Hooks', () => {
let marked;
beforeEach(() => {
@ -93,6 +105,48 @@ describe('Hooks', () => {
assert.strictEqual(html.trim(), '<p><em>text</em></p>\n<h1>postprocess async</h1>');
});
it('should process tokens before walkTokens', () => {
marked.use({
hooks: {
processAllTokens(tokens) {
tokens.push(createHeadingToken('processAllTokens'));
return tokens;
}
},
walkTokens(token) {
if (token.type === 'heading') {
token.tokens[0].text += ' walked';
}
return token;
}
});
const html = marked.parse('*text*');
assert.strictEqual(html.trim(), '<p><em>text</em></p>\n<h1>processAllTokens walked</h1>');
});
it('should process tokens async before walkTokens', async() => {
marked.use({
async: true,
hooks: {
async processAllTokens(tokens) {
await timeout();
tokens.push(createHeadingToken('processAllTokens async'));
return tokens;
}
},
walkTokens(token) {
if (token.type === 'heading') {
token.tokens[0].text += ' walked';
}
return token;
}
});
const promise = marked.parse('*text*');
assert.ok(promise instanceof Promise);
const html = await promise;
assert.strictEqual(html.trim(), '<p><em>text</em></p>\n<h1>processAllTokens async walked</h1>');
});
it('should process all hooks in reverse', async() => {
marked.use({
hooks: {
@ -101,6 +155,10 @@ describe('Hooks', () => {
},
postprocess(html) {
return html + '<h1>postprocess1</h1>\n';
},
processAllTokens(tokens) {
tokens.push(createHeadingToken('processAllTokens1'));
return tokens;
}
}
});
@ -113,12 +171,23 @@ describe('Hooks', () => {
async postprocess(html) {
await timeout();
return html + '<h1>postprocess2 async</h1>\n';
},
processAllTokens(tokens) {
tokens.push(createHeadingToken('processAllTokens2'));
return tokens;
}
}
});
const promise = marked.parse('*text*');
assert.ok(promise instanceof Promise);
const html = await promise;
assert.strictEqual(html.trim(), '<h1>preprocess1</h1>\n<h1>preprocess2</h1>\n<p><em>text</em></p>\n<h1>postprocess2 async</h1>\n<h1>postprocess1</h1>');
assert.strictEqual(html.trim(), `\
<h1>preprocess1</h1>
<h1>preprocess2</h1>
<p><em>text</em></p>
<h1>processAllTokens2</h1>
<h1>processAllTokens1</h1>
<h1>postprocess2 async</h1>
<h1>postprocess1</h1>`);
});
});