Skip to content
Contents

@haruhimemoe/bbcode

Parses, renders, lints and counts osu! BBCode the way osu! does: safe HTML, template fields, flags, imagemaps and osu!'s own widths.

Version 0.2.2, MIT

bun add @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

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

Quick start

#
ts
import { count, lint, render } from "@haruhimemoe/bbcode";import "@haruhimemoe/bbcode/styles.css"; // or link dist/styles.css yourselfconst 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:

ts
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

#

Five entry points and a stylesheet:

ImportWhat's in it
@haruhimemoe/bbcodeparse, serialize, render, lint, applyFix, count, LIMITS, OSU_WIDTHS, OSU_FONT_SIZES, TAGS, findTag, LINT_CODES
@haruhimemoe/bbcode/helperscolor, normalizeColor, gradient, flag, profile, box, list, escapeBBCode
@haruhimemoe/bbcode/imagemapparseImagemap, validateImagemap, serializeImagemap, formatPercent
@haruhimemoe/bbcode/flagsCOUNTRIES, findCountry, searchCountries, flagUrl, normalizeCountryCode
@haruhimemoe/bbcode/templatefillTemplate, templateFields, FIELD_KINDS
@haruhimemoe/bbcode/styles.cssStyles 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.

ts
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.

ts
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).

ts
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.

ts
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.

ts
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

#
FunctionReturns
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

#
ts
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:....

FunctionWhat 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

#
ExportWhat it is
COUNTRIESEvery 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:

ts
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:

KindValueWritten as
textone lineas given
multilinetextas given, CRLF → LF
numbera number or numeric textthe number
dateYYYY-MM-DDas given
urlhttp, https or ftp URLas given
userid (number or digits) or username[profile] link
usersan array, or one per line[profile] links, one per line
countrytwo-letter codeflag [img]
color#rgb, #rrggbb or a namenormalized 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 }[].

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

@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!.

TagFormRenders 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 numberspan.bb-size, clamped to 30..200 %
centre left right[centre]x[/centre]div.bb-align. [center] is not a tag
headingone lineh2.bb-heading
cone lineinline code.bb-c
codeblock, content shown as writtenpre.bb-code
noticeblockdiv.bb-notice
box[box=Title], the title can hold inline tagsclosed 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 linea.bb-link; http, https and ftp only
email[email]a@b.c[/email], [email=a@b.c]text[/email]mailto a.bb-link; the text isn't parsed
img[img]url[/img], no [ in the URLimg.bb-img; http and https only
audio[audio]url[/audio]audio.bb-audio
youtubea video id or a youtube.com, youtu.be or shorts linkiframe.bb-youtube
profile[profile]name[/profile], [profile=id]name[/profile]a.bb-profile to the osu! user
imagemapsee imagemapdiv.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

#
CodeSeverityWhenFix
unclosed-tagerrorAn opening tag with no close (or closed after the tag around it, or on another line for one-line tags)
unclosed-tagwarningA [box] or [spoilerbox] with no close: it runs to the end of what holds it
stray-closewarningA closing tag with no opening tagremoves it
unknown-tagwarning[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-sizeerrorNot a whole number, or outside 30..200 (osu! clamps)the clamped value
bad-sizewarningIn range but not 50, 85, 100 or 150
bad-colorerrorNot #rrggbb or letters#abc → #aabbcc, ff66aa → #ff66aa
bad-colorwarningA name browsers don't know
bad-urlerrorA link, image, audio or email osu! or render refuses, or a bad YouTube idadds https:// to www. links
self-nestedwarningAn inline tag inside itself
img-bracketerrorAn image URL containing [
imagemap-lineerrorA line osu! would refuse (one diagnostic per line)
list-no-itemswarningA list without [*]
over-limiterrorOver the character limit; the range starts at the first character over
too-deepwarningTags 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.

#