codekingpro/portable-devtools
114k
1from __future__ import annotations2 3from collections.abc import Callable, Generator, Iterable, Mapping, MutableMapping4from contextlib import contextmanager5from typing import Any, Literal, overload6 7from . import helpers, presets8from .common import normalize_url, utils9from .parser_block import ParserBlock10from .parser_core import ParserCore11from .parser_inline import ParserInline12from .renderer import RendererHTML, RendererProtocol13from .rules_core.state_core import StateCore14from .token import Token15from .utils import EnvType, OptionsDict, OptionsType, PresetType16 17try:18 import linkify_it19except ModuleNotFoundError:20 linkify_it = None21 22 23_PRESETS: dict[str, PresetType] = {24 "default": presets.default.make(),25 "js-default": presets.js_default.make(),26 "zero": presets.zero.make(),27 "commonmark": presets.commonmark.make(),28 "gfm-like": presets.gfm_like.make(),29 "gfm-like2": presets.gfm_like2.make(),30}31 32 33class MarkdownIt:34 def __init__(35 self,36 config: str | PresetType = "commonmark",37 options_update: Mapping[str, Any] | None = None,38 *,39 renderer_cls: Callable[[MarkdownIt], RendererProtocol] = RendererHTML,40 ):41 """Main parser class42 43 :param config: name of configuration to load or a pre-defined dictionary44 :param options_update: dictionary that will be merged into ``config["options"]``45 :param renderer_cls: the class to load as the renderer:46 ``self.renderer = renderer_cls(self)47 """48 # add modules49 self.utils = utils50 self.helpers = helpers51 52 # initialise classes53 self.inline = ParserInline()54 self.block = ParserBlock()55 self.core = ParserCore()56 self.renderer = renderer_cls(self)57 self.linkify = linkify_it.LinkifyIt() if linkify_it else None58 59 # set the configuration60 if options_update and not isinstance(options_update, Mapping):61 # catch signature change where renderer_cls was not used as a key-word62 raise TypeError(63 f"options_update should be a mapping: {options_update}"64 "\n(Perhaps you intended this to be the renderer_cls?)"65 )66 self.configure(config, options_update=options_update)67 68 def __repr__(self) -> str:69 return f"{self.__class__.__module__}.{self.__class__.__name__}()"70 71 @overload72 def __getitem__(self, name: Literal["inline"]) -> ParserInline: ...73 74 @overload75 def __getitem__(self, name: Literal["block"]) -> ParserBlock: ...76 77 @overload78 def __getitem__(self, name: Literal["core"]) -> ParserCore: ...79 80 @overload81 def __getitem__(self, name: Literal["renderer"]) -> RendererProtocol: ...82 83 @overload84 def __getitem__(self, name: str) -> Any: ...85 86 def __getitem__(self, name: str) -> Any:87 return {88 "inline": self.inline,89 "block": self.block,90 "core": self.core,91 "renderer": self.renderer,92 }[name]93 94 def set(self, options: OptionsType) -> None:95 """Set parser options (in the same format as in constructor).96 Probably, you will never need it, but you can change options after constructor call.97 98 __Note:__ To achieve the best possible performance, don't modify a99 `markdown-it` instance options on the fly. If you need multiple configurations100 it's best to create multiple instances and initialize each with separate config.101 """102 self.options = OptionsDict(options)103 104 def configure(105 self, presets: str | PresetType, options_update: Mapping[str, Any] | None = None106 ) -> MarkdownIt:107 """Batch load of all options and component settings.108 This is an internal method, and you probably will not need it.109 But if you will - see available presets and data structure110 [here](https://github.com/markdown-it/markdown-it/tree/master/lib/presets)111 112 We strongly recommend to use presets instead of direct config loads.113 That will give better compatibility with next versions.114 """115 if isinstance(presets, str):116 if presets not in _PRESETS:117 raise KeyError(f"Wrong `markdown-it` preset '{presets}', check name")118 config = _PRESETS[presets]119 else:120 config = presets121 122 if not config:123 raise ValueError("Wrong `markdown-it` config, can't be empty")124 125 options = config.get("options", {}) or {}126 if options_update:127 options = {**options, **options_update} # type: ignore128 129 self.set(options)130 131 if "components" in config:132 for name, component in config["components"].items():133 rules = component.get("rules", None)134 if rules:135 self[name].ruler.enableOnly(rules)136 rules2 = component.get("rules2", None)137 if rules2:138 self[name].ruler2.enableOnly(rules2)139 140 return self141 142 def get_all_rules(self) -> dict[str, list[str]]:143 """Return the names of all active rules."""144 rules = {145 chain: self[chain].ruler.get_all_rules()146 for chain in ["core", "block", "inline"]147 }148 rules["inline2"] = self.inline.ruler2.get_all_rules()149 return rules150 151 def get_active_rules(self) -> dict[str, list[str]]:152 """Return the names of all active rules."""153 rules = {154 chain: self[chain].ruler.get_active_rules()155 for chain in ["core", "block", "inline"]156 }157 rules["inline2"] = self.inline.ruler2.get_active_rules()158 return rules159 160 def enable(161 self, names: str | Iterable[str], ignoreInvalid: bool = False162 ) -> MarkdownIt:163 """Enable list or rules. (chainable)164 165 :param names: rule name or list of rule names to enable.166 :param ignoreInvalid: set `true` to ignore errors when rule not found.167 168 It will automatically find appropriate components,169 containing rules with given names. If rule not found, and `ignoreInvalid`170 not set - throws exception.171 172 Example::173 174 md = MarkdownIt().enable(['sub', 'sup']).disable('smartquotes')175 176 """177 result = []178 179 if isinstance(names, str):180 names = [names]181 182 for chain in ["core", "block", "inline"]:183 result.extend(self[chain].ruler.enable(names, True))184 result.extend(self.inline.ruler2.enable(names, True))185 186 missed = [name for name in names if name not in result]187 if missed and not ignoreInvalid:188 raise ValueError(f"MarkdownIt. Failed to enable unknown rule(s): {missed}")189 190 return self191 192 def disable(193 self, names: str | Iterable[str], ignoreInvalid: bool = False194 ) -> MarkdownIt:195 """The same as [[MarkdownIt.enable]], but turn specified rules off. (chainable)196 197 :param names: rule name or list of rule names to disable.198 :param ignoreInvalid: set `true` to ignore errors when rule not found.199 200 """201 result = []202 203 if isinstance(names, str):204 names = [names]205 206 for chain in ["core", "block", "inline"]:207 result.extend(self[chain].ruler.disable(names, True))208 result.extend(self.inline.ruler2.disable(names, True))209 210 missed = [name for name in names if name not in result]211 if missed and not ignoreInvalid:212 raise ValueError(f"MarkdownIt. Failed to disable unknown rule(s): {missed}")213 return self214 215 @contextmanager216 def reset_rules(self) -> Generator[None, None, None]:217 """A context manager, that will reset the current enabled rules on exit."""218 chain_rules = self.get_active_rules()219 yield220 for chain, rules in chain_rules.items():221 if chain != "inline2":222 self[chain].ruler.enableOnly(rules)223 self.inline.ruler2.enableOnly(chain_rules["inline2"])224 225 def add_render_rule(226 self, name: str, function: Callable[..., Any], fmt: str = "html"227 ) -> None:228 """Add a rule for rendering a particular Token type.229 230 Only applied when ``renderer.__output__ == fmt``231 """232 if self.renderer.__output__ == fmt:233 self.renderer.rules[name] = function.__get__(self.renderer) # type: ignore234 235 def use(236 self, plugin: Callable[..., None], *params: Any, **options: Any237 ) -> MarkdownIt:238 """Load specified plugin with given params into current parser instance. (chainable)239 240 It's just a sugar to call `plugin(md, params)` with curring.241 242 Example::243 244 def func(tokens, idx):245 tokens[idx].content = tokens[idx].content.replace('foo', 'bar')246 md = MarkdownIt().use(plugin, 'foo_replace', 'text', func)247 248 """249 plugin(self, *params, **options)250 return self251 252 def parse(self, src: str, env: EnvType | None = None) -> list[Token]:253 """Parse the source string to a token stream254 255 :param src: source string256 :param env: environment sandbox257 258 Parse input string and return list of block tokens (special token type259 "inline" will contain list of inline tokens).260 261 `env` is used to pass data between "distributed" rules and return additional262 metadata like reference info, needed for the renderer. It also can be used to263 inject data in specific cases. Usually, you will be ok to pass `{}`,264 and then pass updated object to renderer.265 """266 env = {} if env is None else env267 if not isinstance(env, MutableMapping):268 raise TypeError(f"Input data should be a MutableMapping, not {type(env)}")269 if not isinstance(src, str):270 raise TypeError(f"Input data should be a string, not {type(src)}")271 state = StateCore(src, self, env)272 self.core.process(state)273 return state.tokens274 275 def render(self, src: str, env: EnvType | None = None) -> Any:276 """Render markdown string into html. It does all magic for you :).277 278 :param src: source string279 :param env: environment sandbox280 :returns: The output of the loaded renderer281 282 `env` can be used to inject additional metadata (`{}` by default).283 But you will not need it with high probability. See also comment284 in [[MarkdownIt.parse]].285 """286 env = {} if env is None else env287 return self.renderer.render(self.parse(src, env), self.options, env)288 289 def parseInline(self, src: str, env: EnvType | None = None) -> list[Token]:290 """The same as [[MarkdownIt.parse]] but skip all block rules.291 292 :param src: source string293 :param env: environment sandbox294 295 It returns the296 block tokens list with the single `inline` element, containing parsed inline297 tokens in `children` property. Also updates `env` object.298 """299 env = {} if env is None else env300 if not isinstance(env, MutableMapping):301 raise TypeError(f"Input data should be an MutableMapping, not {type(env)}")302 if not isinstance(src, str):303 raise TypeError(f"Input data should be a string, not {type(src)}")304 state = StateCore(src, self, env)305 state.inlineMode = True306 self.core.process(state)307 return state.tokens308 309 def renderInline(self, src: str, env: EnvType | None = None) -> Any:310 """Similar to [[MarkdownIt.render]] but for single paragraph content.311 312 :param src: source string313 :param env: environment sandbox314 315 Similar to [[MarkdownIt.render]] but for single paragraph content. Result316 will NOT be wrapped into `<p>` tags.317 """318 env = {} if env is None else env319 return self.renderer.render(self.parseInline(src, env), self.options, env)320 321 # link methods322 323 def validateLink(self, url: str) -> bool:324 """Validate if the URL link is allowed in output.325 326 This validator can prohibit more than really needed to prevent XSS.327 It's a tradeoff to keep code simple and to be secure by default.328 329 Note: the url should be normalized at this point, and existing entities decoded.330 """331 return normalize_url.validateLink(url)332 333 def normalizeLink(self, url: str) -> str:334 """Normalize destination URLs in links335 336 ::337 338 [label]: destination 'title'339 ^^^^^^^^^^^340 """341 return normalize_url.normalizeLink(url)342 343 def normalizeLinkText(self, link: str) -> str:344 """Normalize autolink content345 346 ::347 348 <destination>349 ~~~~~~~~~~~350 """351 return normalize_url.normalizeLinkText(link)352 