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

@haruhimemoe/bbcode

v0.4.0

Published

Parse, render, lint and count osu! BBCode the way osu! does. Safe HTML, template fields, flags, imagemaps and gradients. No dependencies.

Readme

@haruhimemoe/bbcode

Parses, renders, lints and counts osu! BBCode the way osu! does. Use it for a live preview of a userpage, forum post or beatmap description, to catch mistakes before posting, or to build BBCode from code.

  • Knows exactly the tags osu! supports, with osu!'s argument rules, pairing and newline handling. Tags osu! would show as text stay text.
  • render returns HTML that needs no further sanitizing: text is escaped and every URL, color and size is checked.
  • parse is lossless: serialize(parse(text)) === text for any input, and every node knows its source range.
  • lint explains what osu! would show wrong, with a fix when it's obvious.
  • Extras: color gradients, country flags, profile links, imagemaps, template fields and a stylesheet.
  • No dependencies. Runs in browsers, Node 22.12+, Bun and Deno.

Install

bun add @haruhimemoe/bbcode
# or: npm install @haruhimemoe/bbcode
# or: deno add npm:@haruhimemoe/bbcode

Quick start

import { count, lint, render } from "@haruhimemoe/bbcode";
import "@haruhimemoe/bbcode/styles.css"; // or link dist/styles.css yourself

const source = "[centre][size=150][b]Hello![/b][/size][/centre]\n[box=About me]\nI map [i]a lot[/i].\n[/box]";

const html = render(source);
// <div class="bb"><div class="bb-align bb-align--centre"><span class="bb-size" style="font-size:150%"><strong>Hello!</strong></span></div><details class="bb-box">...</details></div>

for (const d of lint("[center]hi[/center] [size=300]big[/size]")) {
  console.log(d.code, d.severity, d.message);
}
// unknown-tag warning osu! spells it [centre]; [center] shows as text.
// ...

const { length, remaining, over } = count(source); // against osu!'s 60,000 characters

Build BBCode from code:

import { box, flag, gradient, list, profile } from "@haruhimemoe/bbcode/helpers";

const staff = list([`${flag("jp")} ${profile(2, "peppy")}`, `${flag("us")} ${profile("someone")}`], {
  title: "Staff",
});
const post = box("Tournament staff", staff);

const { bbcode, cost } = gradient("Summer Cup", ["#ff66aa", "#66aaff"]);
// cost: how many characters the colors add to the post

API

Six entry points and a stylesheet:

| Import | What's in it | | --- | --- | | @haruhimemoe/bbcode | parse, serialize, render, lint, applyFix, count, LIMITS, OSU_WIDTHS, OSU_FONT_SIZES, TAGS, findTag, LINT_CODES | | @haruhimemoe/bbcode/helpers | color, normalizeColor, gradient, flag, profile, box, list, escapeBBCode | | @haruhimemoe/bbcode/imagemap | parseImagemap, validateImagemap, serializeImagemap, formatPercent | | @haruhimemoe/bbcode/flags | COUNTRIES, findCountry, searchCountries, flagUrl, normalizeCountryCode | | @haruhimemoe/bbcode/template | fillTemplate, templateFields, FIELD_KINDS | | @haruhimemoe/bbcode/markdown | toMarkdown, fromMarkdown, LOSS_CODES | | @haruhimemoe/bbcode/styles.css | Styles for render output |

Every function is pure and synchronous. Offsets are UTF-16 indices into the string you passed, like String.prototype.slice uses.

parse(text: string): Document

Parses BBCode into a tree. Never throws. A tag osu! wouldn't treat as a tag (unclosed, unknown, wrong case, a bad argument) becomes part of a text node.

interface Document { type: "document"; source: string; children: Node[] }
type Node = TextNode | TagNode;
interface TextNode { type: "text"; value: string; start: number; end: number }
interface TagNode {
  type: "tag";
  name: string;         // canonical, from TAGS: "s" for [strike], "*" for a list item
  tag: string;          // as written: "strike"
  arg: string | null;   // after "=", without the quotes of [quote="name"]
  open: string;         // the opening tag as written, e.g. "[color=#ff66aa]"
  close: string | null; // the closing tag as written; null for a list item ended by the next [*]
  children: Node[];     // raw tags (code, img, url without =, ...) hold one text node
  start: number;
  end: number;
}

A [list]'s children are its title (anything before the first item), then * items.

serialize(doc: Document | readonly Node[]): string

Writes a tree back out. serialize(parse(text)) === text for every input.

render(input: string | Document, options?: RenderOptions): string

Renders BBCode (or a parsed tree) as HTML wrapped in <div class="bb">. Carriage returns in a string are normalized first.

interface RenderOptions {
  proxy?: (url: string, kind: "img" | "audio" | "imagemap") => string;
  className?: string; // extra classes on the root, e.g. "bb--light"
  wrap?: boolean;     // false: no root div. Default true
}

proxy rewrites image, audio and imagemap URLs, for example through your own image proxy (osu! sends images through its own). It's only called with http(s) URLs and must return an http(s) URL or a path starting with /; anything else renders the tag as text.

lint(text: string, options?: { limit?: number }): Diagnostic[]

Everything osu! would show differently than intended, sorted by position. limit is the character limit for over-limit (default 60,000).

interface Diagnostic {
  code: LintCode; // see Lint codes
  severity: "error" | "warning" | "info";
  message: string;
  start: number;
  end: number;
  fix?: { start: number; end: number; text: string }; // present when the fix is obvious
}

LINT_CODES lists every code.

applyFix(text: string, fix: Fix): string

Replaces fix.start..fix.end with fix.text. Apply one fix, then lint again: offsets of other diagnostics move.

count(text: string, limit?: number): Count

Counts Unicode code points, tags included, against limit (default 60,000). Returns { length, limit, remaining, over }.

LIMITS

{ forumPost: 60000, userpage: 60000, beatmapDescription: 60000, sizeMin: 30, sizeMax: 200, sizePresets: [50, 85, 100, 150], nesting: 100 }. The userpage and beatmap descriptions are stored as forum posts, so they share the limit. nesting is ours, not osu!'s: tags nested deeper than 100 (boxes inside box titles count too) stay text, so a post's worth of nested tags can't overflow the stack while rendering or linting.

OSU_WIDTHS and OSU_FONT_SIZES

OSU_WIDTHS is { userpage: 890, forum: 750, beatmap: 430 }: the width in px of the column osu! shows BBCode in on desktop, for a userpage, a forum post and a beatmap description. OSU_FONT_SIZES is { userpage: 14, forum: 14, beatmap: 12 }, the font size in px in each. Render a preview at these sizes and scale it down to fit (CSS zoom), instead of letting it reflow in a narrower box, and text and rows of images wrap where they do on osu!. They were measured from osu!'s site in September 2026; a later release updates them if osu!'s layout changes.

import { OSU_WIDTHS, OSU_FONT_SIZES } from "@haruhimemoe/bbcode";

canvas.style.width = `${OSU_WIDTHS.userpage}px`;
canvas.style.fontSize = `${OSU_FONT_SIZES.userpage}px`;
canvas.style.zoom = String(Math.min(1, pane.clientWidth / OSU_WIDTHS.userpage));

TAGS and findTag(name: string): TagSpec | undefined

The registry of supported tags, for docs, toolbars and autocomplete. findTag takes a name or alias, case-sensitively.

interface TagSpec {
  name: string;               // "centre", "*"
  aliases: readonly string[]; // ["strike"] for "s"
  arg: "none" | "optional" | "required";
  argKind: "color" | "size" | "title" | "name" | "list" | "url" | "email" | "user-id" | null;
  display: "inline" | "block";
  content: "bbcode" | "raw" | "items"; // raw: not parsed as BBCode
  singleLine: boolean;                 // open and close must be on one line
  description: string;
  example: string;
}

@haruhimemoe/bbcode/helpers

| Function | Returns | | --- | --- | | color(text, value) | [color=value]text[/color]. value is #rgb, #rrggbb or a color name; throws TypeError otherwise. | | normalizeColor(value) | #abc → #aabbcc, hex lowercased, names kept. Throws TypeError for anything else. | | gradient(text, stops, { skipSpaces? }) | { bbcode, cost }: one color per character, spread evenly over one or more hex stops. Neighbours with the same color share a tag; newlines are never colored; whitespace is skipped unless skipSpaces: false. cost is the characters the colors add. Emoji and other multi-code-point characters stay whole. | | flag(code, { style? }) | [img]<flag>[/img] for a two-letter country code. style: "legacy" (old PNG, small and a fixed size, default) or "modern" (SVG; it has no size of its own, so it fills the width it's shown in). | | profile(idOrName, name?) | A number is a user id: [profile=2]peppy[/profile] (name defaults to the id; osu! swaps in the current username when the post is saved). A string is a username: [profile]peppy[/profile]. | | box(title, body) | [box=title] with the body on its own lines, or [spoilerbox] when title is null or "". Throws TypeError for a title with a newline or unbalanced brackets. | | list(items, { ordered?, title? }) | [list] (or [list=1]), an optional title line, one [*] line per item, [/list]. | | escapeBBCode(text) | The text with every tag osu! would read broken by a zero-width space after its [, so user input shows as typed. |

Flags default to the old PNGs because they are small and have a fixed size. The modern SVGs have no size of their own, so they fill the width they are shown in, and nobody can tell whether osu!'s image proxy passes SVG until they post.

@haruhimemoe/bbcode/imagemap

interface ImagemapRegion { x: number; y: number; w: number; h: number; href: string; title: string }
interface Imagemap { image: string; regions: ImagemapRegion[] }

Positions and sizes are percentages of the image. href is # (no link), an http(s):// URL or mailto:....

| Function | What it does | | --- | --- | | parseImagemap(text) | Reads a whole [imagemap]...[/imagemap] block or just its content. Returns { ok: true, imagemap, issues: [] } or { ok: false, imagemap: null, issues }, where each issue is { line, start, end, message } for a line osu! would refuse. | | validateImagemap(map) | Checks an object: http(s) image without spaces, at least one region, numbers from 0 to 100, valid links, one-line titles, and no [/imagemap] anywhere. Returns { field, message }[]. | | serializeImagemap(map) | Writes the block. Throws TypeError listing the problems when validateImagemap finds any. | | formatPercent(n) | A number with at most 4 decimals and no exponent. |

osu!'s format: a newline after [imagemap], the image URL on the first line, then one line per region, x y width height link title, and [/imagemap] on its own line. If any line is wrong, osu! shows the whole block as text, and so does render.

@haruhimemoe/bbcode/flags

| Export | What it is | | --- | --- | | COUNTRIES | Every ISO 3166-1 alpha-2 country and territory (249), sorted by code: { code, name, flag: { modern, legacy } }. Frozen. | | findCountry(code) | The country for a code (any case), or undefined. | | searchCountries(query) | Matches by code or name, best first: exact code, then names starting with the query, then names containing it. Case and accents are ignored. | | flagUrl(code, style?) | https://osu.ppy.sh/assets/images/flags/<codepoints>.svg (US is 1f1fa-1f1f8), or with "legacy", https://assets.ppy.sh/old-flags/<CC>.png. Throws TypeError unless code is two ASCII letters. | | normalizeCountryCode(code) | Checks and uppercases a two-letter code. |

@haruhimemoe/bbcode/template

A template is BBCode with {{key}} placeholders plus field declarations:

interface TemplateField {
  key: string;   // letters, digits, _, - and .
  label: string;
  kind: "text" | "multiline" | "number" | "date" | "url" | "user" | "users" | "country" | "color";
  required?: boolean;
  default?: string;
}

fillTemplate(body, fields, values) returns { text, errors }. Each value is checked and written by kind:

| Kind | Value | Written as | | --- | --- | --- | | text | one line | as given | | multiline | text | as given, CRLF → LF | | number | a number or numeric text | the number | | date | YYYY-MM-DD | as given | | url | http, https or ftp URL | as given | | user | id (number or digits) or username | [profile] link | | users | an array, or one per line | [profile] links, one per line | | country | two-letter code | flag [img] | | color | #rgb, #rrggbb or a name | normalized color |

An empty value falls back to default; an empty required field is an error. A field with an error, and a placeholder with no declared field, stay as {{key}} in the text. errors is { key, message }[].

A section, {{#key}}...{{/key}}, keeps what's inside when key fills to something and drops it when the field is empty, so an optional link doesn't leave [url=] behind:

{{#song_link}}[url={{song_link}}]Listen to the song[/url]{{/song_link}}

A section that fills whole lines (its markers at the start of a line and before a line break) takes its line breaks with it, kept or dropped, so no blank lines are left. Sections of different keys nest. {{#a|b}}...{{/a|b}} keeps its content when either field fills, for a box that holds two optional lines. A section whose key isn't declared, or whose field has an error, stays as written, and so does an opening marker with no closing one.

templateFields(body, fields?) returns { keys, undeclared, unused }: placeholders and section keys in the body, those without a declared field, and declared fields the body never uses. FIELD_KINDS lists the kinds.

@haruhimemoe/bbcode/markdown

toMarkdown and fromMarkdown convert between BBCode and markdown, best effort. Both return what they couldn't carry over instead of silently dropping it.

interface Conversion { output: string; lost: Loss[] }
interface Loss { code: LossCode; message: string; start: number; end: number } // UTF-16 offsets into the input

| Function | Signature | | --- | --- | | toMarkdown | (input: string \| Document, options?: { boxStyle?: "bold-title" \| "quote" }) => Conversion | | fromMarkdown | (markdown: string, options?: { headingStyle?: "heading" \| "bold"; tableStyle?: "code" \| "text" }) => Conversion |

import { fromMarkdown, toMarkdown } from "@haruhimemoe/bbcode/markdown";

toMarkdown("[b]hi[/b] [size=150]big[/size]");
// { output: "**hi** big", lost: [{ code: "no-equivalent", message: "[size] has no markdown form, kept the text.", start: 10, end: 30 }] }

fromMarkdown("| a | b |\n| - | - |\n| 1 | 2 |");
// { output: "[code]\na | b\n--+--\n1 | 2\n[/code]", lost: [{ code: "table", message: "osu! has no tables, kept as aligned text.", start: 0, end: 29 }] }

Behaviors worth knowing:

  • Tables become an aligned [code] block (tableStyle: "text" for plain rows instead).
  • [color], [size], [centre]/[left]/[right], [u] and [spoiler] keep their text in markdown and report a no-equivalent loss; markdown has no equivalent tag.
  • A markdown code span whose content looks like BBCode gets a loss, because osu! parses [c]'s content as BBCode, so the tag would actually render instead of staying literal.
  • Link and image URLs are limited to the schemes osu! accepts (http, https, ftp for links; http, https for images). Anything else keeps the link text or alt text and reports a loss.
  • [, ] and spaces in a URL are percent-encoded, since osu! reads them literally and [/] can cut a [url=...] argument short.
  • Content nested deeper than LIMITS.nesting (100) is kept as text, with a loss, instead of recursing further.
  • Emphasis follows a simplified CommonMark: no "rule of 3" for picking */_ delimiter runs apart.
  • Typed BBCode written inside markdown passes through as-is; fromMarkdown then lints its own output, and any problem osu! would have with that BBCode comes back as a no-equivalent loss instead of silently shipping broken markup.
  • LOSS_CODES: no-equivalent, table, html, heading-level, footnote, reference-link-unresolved.
  • Not full CommonMark: it targets GitHub/Discord-style markdown (GFM strikethrough, autolinks, tables), not every CommonMark edge case.
  • No dependencies, like the rest of the package.

@haruhimemoe/bbcode/styles.css

Styles for everything render outputs. Dark by default; add bb--light to the root (render(text, { className: "bb--light" })) for a light version. Colors are CSS custom properties on .bb (--bb-text, --bb-link, --bb-surface...), so you can retheme without overriding rules. Box bodies are indented 20px, 10px under their title, as on osu!.

Supported tags

Tag names are lowercase and case-sensitive, like on osu!.

| Tag | Form | Renders as | | --- | --- | --- | | b i u | [b]x[/b] | <strong>, <em>, <u> | | s, strike | [s]x[/s], [strike]x[/strike] | <del> | | spoiler | [spoiler]x[/spoiler] | blacked-out span.bb-spoiler, shown on hover | | color | [color=#rrggbb], [color=name] (letters only) | span.bb-color | | size | [size=N], a whole number | span.bb-size, clamped to 30..200 % | | centre left right | [centre]x[/centre] | div.bb-align. [center] is not a tag | | heading | one line | h2.bb-heading | | c | one line | inline code.bb-c | | code | block, content shown as written | pre.bb-code | | notice | block | div.bb-notice | | box | [box=Title], the title can hold inline tags | closed details.bb-box | | spoilerbox | [spoilerbox] | closed details.bb-box titled "SPOILER" | | quote | [quote], [quote="name"] | blockquote.bb-quote, "name wrote:" | | list | [list] bullets, [list=anything] numbered, items [*] | ul/ol.bb-list; text before the first [*] is div.bb-list__title | | url | [url]https://..[/url], [url=https://..]text[/url], one line | a.bb-link; http, https and ftp only | | email | [email][email protected][/email], [[email protected]]text[/email] | mailto a.bb-link; the text isn't parsed | | img | [img]url[/img], no [ in the URL | img.bb-img; http and https only | | audio | [audio]url[/audio] | audio.bb-audio | | youtube | a video id or a youtube.com, youtu.be or shorts link | iframe.bb-youtube | | profile | [profile]name[/profile], [profile=id]name[/profile] | a.bb-profile to the osu! user | | imagemap | see imagemap | div.bb-imagemap with positioned links |

Also like osu!:

  • Newlines become <br>. Block tags eat newlines next to them so a block on its own line leaves no gap: boxes, notices and code eat all newlines inside their edges and one after; quotes eat whitespace inside and two newlines after; lists eat whitespace before items and two newlines after; alignment eats one newline after the opening and one after the closing tag; headings and imagemaps eat one after.
  • Bare http://, https://, ftp:// and www. URLs and email addresses become links.
  • A tag inside itself ([b]a[b]b[/b]c[/b]) breaks: the first close ends the outer tag and the inner one shows as text. Boxes, quotes and lists nest fine.
  • [s] closes only with [/s] and [strike] only with [/strike].

Lint codes

| Code | Severity | When | Fix | | --- | --- | --- | --- | | unclosed-tag | error | An opening tag with no close (or closed after the tag around it, or on another line for one-line tags) | closes it: before the tag that crossed it (or swaps the two closes); a one-line tag with no close after it, at the end of its line; one open at the end of the text, at the end of its line (inline tags) or of the text (block tags). None when a close exists on a later line. Several can insert at one spot: apply the later-opened tag's first. | | unclosed-tag | warning | A [box] or [spoilerbox] with no close: it runs to the end of what holds it | | | stray-close | warning | A closing tag with no opening tag | removes it | | unknown-tag | warning | [center], wrong case ([B]), tags from other forums ([font], [table]...), an argument a tag doesn't take, [quote=name] without quotes, [*] outside a list | [centre], lowercase, drop the argument, add quotes | | bad-size | error | Not a whole number, or outside 30..200 (osu! clamps) | the clamped value | | bad-size | warning | In range but not 50, 85, 100 or 150 | | | bad-color | error | Not #rrggbb or letters | #abc → #aabbcc, ff66aa → #ff66aa | | bad-color | warning | A name browsers don't know | | | bad-url | error | A link, image, audio or email osu! or render refuses, or a bad YouTube id | adds https:// to www. links | | self-nested | warning | An inline tag inside itself | | | img-bracket | error | An image URL containing [ | | | imagemap-line | error | A line osu! would refuse (one diagnostic per line) | | | list-no-items | warning | A list without [*] | | | over-limit | error | Over the character limit; the range starts at the first character over | | | too-deep | warning | Tags nested more than LIMITS.nesting (100) deep; reported once, at the first tag kept as text | |

Safety

render's output is meant to go into a page as is:

  • Every text node and attribute value is HTML-escaped.
  • Links allow http, https and ftp; images, audio and imagemaps http and https; emails mailto:. A URL with whitespace or control characters is refused. A refused tag renders as its source text, escaped.
  • Colors must be #rrggbb or letters only; sizes are whole numbers clamped to 30..200; YouTube ids are limited to A-Z a-z 0-9 _ -; imagemap numbers must be plain decimals.
  • These checks run again when you render a hand-built tree, not only on parsed input.
  • A proxy result must be an http(s) URL or a / path.

Images, audio and YouTube embeds load from their own hosts in the viewer's browser. If your page has a Content Security Policy, allow those hosts (for example img-src https:, media-src https:, frame-src https://www.youtube.com), or send media through proxy.

Differences from osu!

  • Malformed nesting. osu! converts each tag on its own, so crossed tags ([b][i]x[/b][/i]) can still render there. A tree can't hold that, so here the inner tag shows as text and lint reports it.
  • Unclosed quotes and lists. osu! pairs the first openings with the closes there are, so with one opening too many it's the last one that shows as text there; here it's the one left without a close. Unclosed boxes match osu!: they run to the end of what holds them.
  • Strict values. URLs with spaces, imagemap numbers like 1.2.3, and YouTube ids with other characters are refused here, where osu! outputs something broken.
  • Markup. Boxes are native <details> elements and classes use a bb- prefix. The output is meant to look like osu!, not to copy its HTML.
  • Not rendered: smilies, image sizes (osu! measures images on its servers), and profile names (osu! replaces them with the current username when the post is saved).
  • Counting. count counts code points of the BBCode as typed.

Compatibility

  • ESM only. Node 22.12+ can also require() it.
  • Node 22.12 or later (engines). CI runs the built package on Node 22.12 and 24.
  • Browsers, Bun and Deno (through the npm: specifier). No DOM or Node APIs are used.
  • Bundlers: import @haruhimemoe/bbcode/styles.css where your bundler handles CSS; the file is at dist/styles.css otherwise. sideEffects covers only the CSS, so the JavaScript tree-shakes.
  • TypeScript: types ship in the package. CI typechecks a consumer with strict, exactOptionalPropertyTypes and skipLibCheck: false.

License

MIT. See LICENSE.

Written from osu!'s observed behavior. No code, styles or text from osu-web or the osu! wiki. Not affiliated with osu! or ppy Pty Ltd.

Links