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}30 31 32class MarkdownIt:33 def __init__(34 self,35 config: str | PresetType = "commonmark",36 options_update: Mapping[str, Any] | None = None,37 *,38 renderer_cls: Callable[[MarkdownIt], RendererProtocol] = RendererHTML,39 ):40 """Main parser class41 42 :param config: name of configuration to load or a pre-defined dictionary43 :param options_update: dictionary that will be merged into ``config["options"]``44 :param renderer_cls: the class to load as the renderer:45 ``self.renderer = renderer_cls(self)46 """47 # add modules48 self.utils = utils49 self.helpers = helpers50 51 # initialise classes52 self.inline = ParserInline()53 self.block = ParserBlock()54 self.core = ParserCore()55 self.renderer = renderer_cls(self)56 self.linkify = linkify_it.LinkifyIt() if linkify_it else None57 58 # set the configuration59 if options_update and not isinstance(options_update, Mapping):60 # catch signature change where renderer_cls was not used as a key-word61 raise TypeError(62 f"options_update should be a mapping: {options_update}"63 "\n(Perhaps you intended this to be the renderer_cls?)"64 )65 self.configure(config, options_update=options_update)66 67 def __repr__(self) -> str:68 return f"{self.__class__.__module__}.{self.__class__.__name__}()"69 70 @overload71 def __getitem__(self, name: Literal["inline"]) -> ParserInline:72 ...73 74 @overload75 def __getitem__(self, name: Literal["block"]) -> ParserBlock:76 ...77 78 @overload79 def __getitem__(self, name: Literal["core"]) -> ParserCore:80 ...81 82 @overload83 def __getitem__(self, name: Literal["renderer"]) -> RendererProtocol:84 ...85 86 @overload87 def __getitem__(self, name: str) -> Any:88 ...89 90 def __getitem__(self, name: str) -> Any:91 return {92 "inline": self.inline,93 "block": self.block,94 "core": self.core,95 "renderer": self.renderer,96 }[name]97 98 def set(self, options: OptionsType) -> None:99 """Set parser options (in the same format as in constructor).100 Probably, you will never need it, but you can change options after constructor call.101 102 __Note:__ To achieve the best possible performance, don't modify a103 `markdown-it` instance options on the fly. If you need multiple configurations104 it's best to create multiple instances and initialize each with separate config.105 """106 self.options = OptionsDict(options)107 108 def configure(109 self, presets: str | PresetType, options_update: Mapping[str, Any] | None = None110 ) -> MarkdownIt:111 """Batch load of all options and component settings.112 This is an internal method, and you probably will not need it.113 But if you will - see available presets and data structure114 [here](https://github.com/markdown-it/markdown-it/tree/master/lib/presets)115 116 We strongly recommend to use presets instead of direct config loads.117 That will give better compatibility with next versions.118 """119 if isinstance(presets, str):120 if presets not in _PRESETS:121 raise KeyError(f"Wrong `markdown-it` preset '{presets}', check name")122 config = _PRESETS[presets]123 else:124 config = presets125 126 if not config:127 raise ValueError("Wrong `markdown-it` config, can't be empty")128 129 options = config.get("options", {}) or {}130 if options_update:131 options = {**options, **options_update} # type: ignore132 133 self.set(options) # type: ignore134 135 if "components" in config:136 for name, component in config["components"].items():137 rules = component.get("rules", None)138 if rules:139 self[name].ruler.enableOnly(rules)140 rules2 = component.get("rules2", None)141 if rules2:142 self[name].ruler2.enableOnly(rules2)143 144 return self145 146 def get_all_rules(self) -> dict[str, list[str]]:147 """Return the names of all active rules."""148 rules = {149 chain: self[chain].ruler.get_all_rules()150 for chain in ["core", "block", "inline"]151 }152 rules["inline2"] = self.inline.ruler2.get_all_rules()153 return rules154 155 def get_active_rules(self) -> dict[str, list[str]]:156 """Return the names of all active rules."""157 rules = {158 chain: self[chain].ruler.get_active_rules()159 for chain in ["core", "block", "inline"]160 }161 rules["inline2"] = self.inline.ruler2.get_active_rules()162 return rules163 164 def enable(165 self, names: str | Iterable[str], ignoreInvalid: bool = False166 ) -> MarkdownIt:167 """Enable list or rules. (chainable)168 169 :param names: rule name or list of rule names to enable.170 :param ignoreInvalid: set `true` to ignore errors when rule not found.171 172 It will automatically find appropriate components,173 containing rules with given names. If rule not found, and `ignoreInvalid`174 not set - throws exception.175 176 Example::177 178 md = MarkdownIt().enable(['sub', 'sup']).disable('smartquotes')179 180 """181 result = []182 183 if isinstance(names, str):184 names = [names]185 186 for chain in ["core", "block", "inline"]:187 result.extend(self[chain].ruler.enable(names, True))188 result.extend(self.inline.ruler2.enable(names, True))189 190 missed = [name for name in names if name not in result]191 if missed and not ignoreInvalid:192 raise ValueError(f"MarkdownIt. Failed to enable unknown rule(s): {missed}")193 194 return self195 196 def disable(197 self, names: str | Iterable[str], ignoreInvalid: bool = False198 ) -> MarkdownIt:199 """The same as [[MarkdownIt.enable]], but turn specified rules off. (chainable)200 201 :param names: rule name or list of rule names to disable.202 :param ignoreInvalid: set `true` to ignore errors when rule not found.203 204 """205 result = []206 207 if isinstance(names, str):208 names = [names]209 210 for chain in ["core", "block", "inline"]:211 result.extend(self[chain].ruler.disable(names, True))212 result.extend(self.inline.ruler2.disable(names, True))213 214 missed = [name for name in names if name not in result]215 if missed and not ignoreInvalid:216 raise ValueError(f"MarkdownIt. Failed to disable unknown rule(s): {missed}")217 return self218 219 @contextmanager220 def reset_rules(self) -> Generator[None, None, None]:221 """A context manager, that will reset the current enabled rules on exit."""222 chain_rules = self.get_active_rules()223 yield224 for chain, rules in chain_rules.items():225 if chain != "inline2":226 self[chain].ruler.enableOnly(rules)227 self.inline.ruler2.enableOnly(chain_rules["inline2"])228 229 def add_render_rule(230 self, name: str, function: Callable[..., Any], fmt: str = "html"231 ) -> None:232 """Add a rule for rendering a particular Token type.233 234 Only applied when ``renderer.__output__ == fmt``235 """236 if self.renderer.__output__ == fmt:237 self.renderer.rules[name] = function.__get__(self.renderer) # type: ignore238 239 def use(240 self, plugin: Callable[..., None], *params: Any, **options: Any241 ) -> MarkdownIt:242 """Load specified plugin with given params into current parser instance. (chainable)243 244 It's just a sugar to call `plugin(md, params)` with curring.245 246 Example::247 248 def func(tokens, idx):249 tokens[idx].content = tokens[idx].content.replace('foo', 'bar')250 md = MarkdownIt().use(plugin, 'foo_replace', 'text', func)251 252 """253 plugin(self, *params, **options)254 return self255 256 def parse(self, src: str, env: EnvType | None = None) -> list[Token]:257 """Parse the source string to a token stream258 259 :param src: source string260 :param env: environment sandbox261 262 Parse input string and return list of block tokens (special token type263 "inline" will contain list of inline tokens).264 265 `env` is used to pass data between "distributed" rules and return additional266 metadata like reference info, needed for the renderer. It also can be used to267 inject data in specific cases. Usually, you will be ok to pass `{}`,268 and then pass updated object to renderer.269 """270 env = {} if env is None else env271 if not isinstance(env, MutableMapping):272 raise TypeError(f"Input data should be a MutableMapping, not {type(env)}")273 if not isinstance(src, str):274 raise TypeError(f"Input data should be a string, not {type(src)}")275 state = StateCore(src, self, env)276 self.core.process(state)277 return state.tokens278 279 def render(self, src: str, env: EnvType | None = None) -> Any:280 """Render markdown string into html. It does all magic for you :).281 282 :param src: source string283 :param env: environment sandbox284 :returns: The output of the loaded renderer285 286 `env` can be used to inject additional metadata (`{}` by default).287 But you will not need it with high probability. See also comment288 in [[MarkdownIt.parse]].289 """290 env = {} if env is None else env291 return self.renderer.render(self.parse(src, env), self.options, env)292 293 def parseInline(self, src: str, env: EnvType | None = None) -> list[Token]:294 """The same as [[MarkdownIt.parse]] but skip all block rules.295 296 :param src: source string297 :param env: environment sandbox298 299 It returns the300 block tokens list with the single `inline` element, containing parsed inline301 tokens in `children` property. Also updates `env` object.302 """303 env = {} if env is None else env304 if not isinstance(env, MutableMapping):305 raise TypeError(f"Input data should be an MutableMapping, not {type(env)}")306 if not isinstance(src, str):307 raise TypeError(f"Input data should be a string, not {type(src)}")308 state = StateCore(src, self, env)309 state.inlineMode = True310 self.core.process(state)311 return state.tokens312 313 def renderInline(self, src: str, env: EnvType | None = None) -> Any:314 """Similar to [[MarkdownIt.render]] but for single paragraph content.315 316 :param src: source string317 :param env: environment sandbox318 319 Similar to [[MarkdownIt.render]] but for single paragraph content. Result320 will NOT be wrapped into `<p>` tags.321 """322 env = {} if env is None else env323 return self.renderer.render(self.parseInline(src, env), self.options, env)324 325 # link methods326 327 def validateLink(self, url: str) -> bool:328 """Validate if the URL link is allowed in output.329 330 This validator can prohibit more than really needed to prevent XSS.331 It's a tradeoff to keep code simple and to be secure by default.332 333 Note: the url should be normalized at this point, and existing entities decoded.334 """335 return normalize_url.validateLink(url)336 337 def normalizeLink(self, url: str) -> str:338 """Normalize destination URLs in links339 340 ::341 342 [label]: destination 'title'343 ^^^^^^^^^^^344 """345 return normalize_url.normalizeLink(url)346 347 def normalizeLinkText(self, link: str) -> str:348 """Normalize autolink content349 350 ::351 352 <destination>353 ~~~~~~~~~~~354 """355 return normalize_url.normalizeLinkText(link)356 