npm package discovery and stats viewer.

Discover Tips

  • General search

    [free text search, go nuts!]

  • Package details

    pkg:[package-name]

  • User packages

    @[username]

Sponsor

Optimize Toolset

I’ve always been into building performant and accessible sites, but lately I’ve been taking it extremely seriously. So much so that I’ve been building a tool to help me optimize and monitor the sites that I build to make sure that I’m making an attempt to offer the best experience to those who visit them. If you’re into performant, accessible and SEO friendly sites, you might like it too! You can check it out at Optimize Toolset.

About

Hi, 👋, I’m Ryan Hefner  and I built this site for me, and you! The goal of this site was to provide an easy way for me to check the stats on my npm packages, both for prioritizing issues and updates, and to give me a little kick in the pants to keep up on stuff.

As I was building it, I realized that I was actually using the tool to build the tool, and figured I might as well put this out there and hopefully others will find it to be a fast and useful way to search and browse npm packages as I have.

If you’re interested in other things I’m working on, follow me on Twitter or check out the open source projects I’ve been publishing on GitHub.

I am also working on a Twitter bot for this site to tweet the most popular, newest, random packages from npm. Please follow that account now and it will start sending out packages soon–ish.

Open Software & Tools

This site wouldn’t be possible without the immense generosity and tireless efforts from the people who make contributions to the world and share their work via open source initiatives. Thank you 🙏

© 2026 – Pkg Stats / Ryan Hefner

nyx-bot-entities

v0.1.0

Published

1:1 replication of Telegram Bot API's text entity parsing, validation, and encoding rules (Markdown, MarkdownV2, HTML) - ported from the TDLib / telegram-bot-api reference implementation.

Readme

nyx-bot-entities

A from-scratch, faithful port of the Telegram Bot API's text-entity engine: parsing MarkdownV2 / Markdown (legacy) / HTML parse_mode source, validating and repairing a raw entities array, auto-detecting @mentions/#hashtags/$cashtags/bot_commands/URLs/emails, and encoding FormattedText back into MarkdownV2/HTML source.

Every parser and validator here is a direct, line-by-line port of the actual reference implementation - tdlib/td's MessageEntity.cpp, LinkManager.cpp, HttpUrl.cpp and tdlib/telegram-bot-api's Client.cpp - not a reimplementation from the public docs. If a text passes validateFormattedText, sending it is not a guess.

Install

bun add nyx-bot-entities

Zero runtime dependencies, part of the nyx-bot ecosystem but usable standalone with any Telegram Bot API client.

Quick start

import { parseMarkdownV2, validateFormattedText, isSendable } from "nyx-bot-entities";

const formatted = parseMarkdownV2("*bold* _italic_ [a link](https://example.com)");
// { text: "bold italic a link", entities: [...] }

validateFormattedText(formatted); // throws EntityParseError if it wouldn't be accepted
isSendable(formatted); // -> true, non-throwing form

// pass straight through to your Bot API client:
await bot.sendMessage(chatId, formatted.text, { entities: formatted.entities });

API

Parsing

parseMarkdownV2(source, options?)   // MarkdownV2 parse_mode
parseMarkdown(source, options?)     // legacy "Markdown" (v1) parse_mode
parseHTML(source, options?)         // HTML parse_mode
parseFormattedText(text, parseMode, options?) // dispatches by parse_mode name, "" | undefined | "none" = plain text

All four return { text: string, entities: MessageEntity[] } and throw EntityParseError (same wording as the Bot API's "Can't parse entities" error) on invalid syntax - an unescaped reserved character, an unclosed entity, a malformed URL inside link syntax, etc.

options:

  • autoDetect (default true) - merge in auto-detected mentions/hashtags/cashtags/bot_commands/URLs/emails, exactly like the real server does after any parse_mode.
  • skipBotCommands (default false) - skip bot_command auto-detection specifically.
  • fixEntities (default true) - apply the same canonicalization pass the server applies before storing (see Entity splitting below). Turn off to see your raw, un-repaired markup.

Validating a raw entities array

validateEntities(text, entities)      // throws EntityParseError on anything that would get the request rejected
isValidEntities(text, entities)       // non-throwing boolean form
fixMessageEntities(text, entities)    // returns entities repaired into the exact shape the server would store
isCanonicalEntities(text, entities)   // advanced: the server's conservative "does this need repair" fast-path check

validateEntities checks bounds (offset/length in range, never splitting a UTF-16 surrogate pair) and per-type payload validity (text_link needs a checkable url, custom_emoji needs a numeric id, text_mention needs a user, etc) - the things that actually get a request rejected with a 400. It deliberately does not reject non-canonical nesting shapes, because the real server doesn't either - it silently repairs them (see below). Use fixMessageEntities to see what will actually be stored.

The one-call check

validateFormattedText(formatted, { limit?, allowEmpty? }) // throws
isSendable(formatted, { limit?, allowEmpty? })             // boolean

Validates text length (MESSAGE_TEXT_LIMIT = 4096 by default; pass CAPTION_LIMIT = 1024 for captions/descriptions/quotes) and entities in one call.

Auto-detection

findEntities(text, { skipBotCommands? }) // -> MessageEntity[]

Encoding back to source

toMarkdownV2(formatted) // -> string, reparses via parseMarkdownV2 to equivalent entities
toHTML(formatted)       // -> string, reparses via parseHTML to equivalent entities

escapeMarkdownV2(text)      // escape for use outside any entity
escapeMarkdownV2Code(text)  // escape for use inside a code/pre entity
escapeMarkdownV2Url(text)   // escape for use inside a link/emoji/date URL's (...)
escapeMarkdown(text)        // legacy Markdown (v1) escaping
escapeHtml(text)            // &, <, >
escapeHtmlAttribute(text)   // + "

Entity splitting: the one surprising thing you need to know

Every FormattedText - whether it came from a parse_mode or a raw entities array - passes through the server's fix_entities() repair pass before being stored. Whenever a splittable entity (bold/italic/underline/strikethrough/spoiler) has any other entity nested inside it, the pair is not in canonical shape, and the splittable entity is split into pieces around its child instead of staying as one entity that contains it:

const r = parseMarkdownV2("*bold [a link](https://example.com) tail*");
// r.entities is 3 entities, not 2:
//   { type: "bold", offset: 0, length: 5 }        "bold "
//   { type: "text_link", offset: 5, length: 6, url: "..." }  "a link" (also still bold)
//   { type: "bold", offset: 11, length: 5 }        " tail"

This is not a bug in this library - it is exactly what the real Bot API server stores, verified against the reference implementation. parseMarkdownV2/parseMarkdown/parseHTML apply this by default; pass { fixEntities: false } if you want the raw, unrepaired shape (e.g. for debugging your markup), but that is not what gets sent.

What "1:1" does and doesn't cover

  • Parsing (MarkdownV2/Markdown/HTML) and entity repair/splitting: byte-for-byte port of the reference grammar and fix_entities(), including every documented and undocumented edge case found while porting (the greedy __ vs _..._ ambiguity rule, the || expandable-blockquote marker, pre/code escaping rules, tg://user/emoji/time link parsing, etc).
  • Bounds/payload validation: ported from clean_input_string_with_entities() and get_message_entities() - what actually gets a 400.
  • Auto-detection (mentions/hashtags/cashtags/bot_commands/URLs/emails): ported from find_entities() and friends, including the full IANA TLD allowlist used for scheme-less URL detection. This doesn't gate whether a message is accepted (only affects what entities come back), so it's held to a slightly lower fidelity bar than parsing/validation.
  • URL validation (internal/link.ts): full RFC 3986 port except IPv6 literal hosts, which are accepted by character class rather than TDLib's full structural IPAddress validation - noted in that file.

Scripts

bun run lint   # tsc --noEmit && biome check --write --unsafe
bun test       # bun:test suite
bun run build  # tsc -p tsconfig.build.json -> dist/

Part of the nyx-bot ecosystem, published privately alongside nyx-bot-client/nyx-bot-utils.