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

@arrisa/message

v1.0.29

Published

Arrisa — messenger compose, formatted text, and entity I/O

Readme

@arrisa/message

Messenger compose for Arrisa — chat-style editor presets (composeField block field, messengerCompose inline/spoiler), markdown shortcuts/paste, host mentions & custom emoji, and FormattedText (plain text + UTF-16 offset entities) I/O.

Built on @arrisa/schema compose/messenger marks. Layering: schema chrome → this package (wire format + compose).

Install

pnpm add @arrisa/message @arrisa/editor @arrisa/schema @arrisa/state @arrisa/doc
# or npm / yarn

Add @arrisa/history if you need undo/redo (not included in composeField / messengerCompose).

Quick start

Block chat field (lists, quotes, fenced code):

import {Arrisa} from "@arrisa/editor"
import {history} from "@arrisa/history"
import {composeField, docToFormattedText, formattedTextToDoc} from "@arrisa/message"

let editor = Arrisa.create({
    parent: document.getElementById("compose")!,
    config: [composeField(), history()],
})

Inline/spoiler messenger field:

import {Arrisa} from "@arrisa/editor"
import {history} from "@arrisa/history"
import {messengerCompose, docToFormattedText, formattedTextToDoc} from "@arrisa/message"

let editor = Arrisa.create({
    parent: document.getElementById("compose")!,
    config: [
        messengerCompose({
            resolveMention: (username) => userIds[username] ?? null,
        }),
        history(),
    ],
})

On send — plain text + entities (UTF-16 offsets):

let payload = docToFormattedText(editor.state.doc, {autoDetect: true})
// { text: string, entities?: MessageEntity[] }

// Hydrate a draft
let doc = formattedTextToDoc(payload, editor.state.schema)

Features / concepts

FormattedText

Wire shape used by many chat backends:

type FormattedText = {
    text: string
    entities?: MessageEntity[]
}
  • Offsets and lengths are UTF-16 code units (JavaScript string indices).
  • Entities include flag marks (bold, italic, …), text_url, pre, blockquote, unordered_list, ordered_list, mention_name, custom_emoji, and auto types (url, email, hashtag, …).

Compose presets

composeField(config?) installs a block chat field (Doc + lists/quote/code, no spoiler):

| Feature | Default | |---------|---------| | composeSchema (block Doc + compose marks) | always | | composeKeymap (default keymap off) | always; submit?: "Enter" \| "Shift-Enter" omits that chord | | Mark exclusivity (none / code-strike / custom) | "none" | | Mention + custom emoji schema elements | hostElements: false | | Markdown input rules (**bold**, `code`, finished fences; no spoiler) | markdown: true | | Markdown plain-text paste | markdownPaste: true | | Floating selection toolbar (Menu.Group.top) | floating: true | | Embedded toolbar (embeddedMenu) | false | | Placeholder | "Message…" | | Sync @username → mention | only if resolveMention provided | | Mention picker query (onMentionQuery) | omitted. Word-start @query + caret box; null when unfocused / no query / no rect. Does not consume Space. Leave resolveMention unset for picker hosts. | | Schedule query (scheduleQuery) | omitted / false (off). true ≡ scheduleQuery({}). A config {now, locale, invokers, joiners, onAccept, onQuery, enabled, actions} is passed through. Paints the caret-tied phrase and offers a caret action list. Arrisa does not send and does not draw an armed banner; onAccept is the host arming hook. Mention query wins. Chip is Arrisa Tooltip unless onQuery is set. | | Link add prompt | linkPrompt → link({prompt}) (default "floating") | | Submit chord (submit) | omitted (Enter splits, Shift-Enter breaks). "Enter" / "Shift-Enter" omits that chord; the other is insertLineBreak. Host binds send with EditorState.prec.high(KeyBinding.of({key, run})). Arrisa does not send. A missing host binding may fall through to beforeinput split. |

Embedded toolbar + custom link UI:

composeField({
    floating: false,
    embedded: {parent: toolbarEl, theme: false, class: "host-compose-tools"},
    linkPrompt: (ctx) => {
        hostOpenLinkField({
            href: ctx.href,
            onSubmit: (url) => ctx.apply(url),
            onCancel: ctx.cancel,
        })
    },
})

Default toggle removes existing links or opens add UI; it does not prefill href (remove-first). cancel dismisses the built-in floating field and focuses the editor; custom prompts still own their DOM.

onMentionQuery fires from an Arrisa.updateListener when the collapsed caret sits in a plain-text mention query. Offsets are UTF-16 dump indices (docToFormattedText space). rect is editor.selectionRect() ({top,left,width,height}) on that turn. Use detectMentionQuery(state) to inspect the dump range without a live editor.

| Caret | Callback | |-------|----------| | Word-start @ (start of textblock or whitespace before @) | query = chars until caret ("" if @ alone) | | Same, more typing (@al) | same from, growing query / to / rect | | Mid-word / email (a@b) | null | | Inside or on a MentionName mark (including caret after a completed @name label) | null | | Left of @, or whitespace / ] in the query | null | | Inside pre / inline code | null | | Unfocused, non-collapsed selection, or no caret box | null |

Does not consume Space or wrap text. Keep resolveMention unset when the host owns a picker (insertMention / insertMentionSpec with the query from/to).

composeField({scheduleQuery}) is opt-in (default off). Typed 24h datetime phrases are painted and offered as a caret chip. v1 matches at 14:00 (closest 24h), tomorrow at 14:00, next week at 14:00, {day} at 14:00 (closest in 7 days), and 27.09.26 at 14:00 (dd.mm.yy). locale selects the default invoker/joiner pack (en / ru). Pass invokers / joiners to replace that pack. Arrisa does not send or queue; onAccept is the host arming hook. If a mention query is open, schedule detect returns null (mention wins). The chip is an Arrisa Tooltip unless onQuery is set (host chip; Arrisa still paints and still owns accept/keymap).

composeField({
    scheduleQuery: {
        now: () => new Date(),
        locale: "en",
        invokers: [
            {kind: "tomorrow", words: ["tomorrow", "tmrw"]},
            {kind: "weekday", words: ["monday"], weekday: 1},
        ],
        joiners: ["at"],
        onAccept: (hit) => hostArmSchedule(hit),
    },
})

messengerCompose(config?) remains the inline/spoiler path:

| Feature | Default | |---------|---------| | messengerSchema (inline doc + marks) | always | | Mark exclusivity (none / code-strike / custom) | "none" | | Mention + custom emoji schema elements | hostElements: true | | Markdown input rules (**bold**, `code`, …) | markdown: true | | Markdown plain-text paste | markdownPaste: true | | Floating selection toolbar (inline group) | floating: true | | Placeholder | "Message…" | | Sync @username → mention | only if resolveMention provided |

Does not include history — add @arrisa/history in the host.

Markdown

Typing (input rules) and paste share messenger-style delimiters:

  • **bold**, __italic__, ~~strike~~, ||spoiler||. Typing `code` applies the inline code mark. After conversion, further typing is unmarked
  • A finished fence becomes a code block when the schema can contain one: one line ```code```, one paragraph with line breaks, or a closing ``` paragraph after an opening ``` / ```lang paragraph. Otherwise the backticks stay text
  • ``` followed by a space at the start of a block still creates an empty code block. ```lang then space sets the language.
  • Paste also understands [label](url), fenced code, and > quotes via parseMarkdownText
  • composeField omits the spoiler input rule; paste/import skip spoiler entities when Spoiler is not in the schema

Host atoms

  • MentionName — mark with host user id; non-inclusive so typing after does not extend it. Export mentionText ("label" default, "userId", or a function) serializes the dump slice independently of the visible label; overlapping marks cover that serialized length. Import mentionLabel(userId, raw) replaces the dump slice with a display label. composeField({onMentionQuery}) emits a dump-range query + caret box for a host picker and does not consume Space — leave resolveMention unset on that path. insertMention / insertMentionSpec take optional from/to dump offsets (UTF-16, same space as docToFormattedText). Both are required together; one without the other or an out-of-range pair returns false and leaves the document unchanged. Omit both to insert at the selection. When dump offsets come from a custom mentionText serializer, pass the same mentionText on insert (default mapping is "label"). The caret is left after the mention (no trailing space required).
  • CustomEmoji — leaf {documentId, alt}; text contribution is alt for entity offsets

Main public API

Compose

import {
    composeField,
    type ComposeFieldConfig,
    messengerCompose,
    type MessengerComposeConfig,
    messengerMarks,
    messengerSchema,
    markExclusivity,
} from "@arrisa/message"
// Schema / command pieces are also available from @arrisa/schema and @arrisa/command.
composeField({
    exclusivity: "none",
    floating: true,              // or FloatingMenuConfig | false
    embedded: false,             // or EmbeddedMenuConfig
    placeholder: "Message…",     // or false
    markdown: true,
    markdownPaste: true,
    hostElements: false,
    resolveMention: (u) => ids[u] ?? null, // omit when using onMentionQuery
    onMentionQuery: (q) => hostShowMentionPicker(q),
    scheduleQuery: {             // default off; true ≡ scheduleQuery({})
        now: () => new Date(),
        locale: "en",
        onAccept: (hit) => hostArmSchedule(hit),
    },
    linkPrompt: "floating",      // or false | (req) => { req.apply(url) }
    submit: "Enter",             // omit Enter; host binds send. or "Shift-Enter"
})
messengerCompose({
    exclusivity: "code-strike",
    floating: true,              // or FloatingMenuConfig | false
    placeholder: "Message…",     // or false
    markdown: true,
    markdownPaste: true,
    hostElements: true,
    resolveMention: (u) => ids[u] ?? null,
})

messengerMarks / messengerSchema / markExclusivity are re-exported for hosts that only need pieces. Prefer composeField for a block chat field; messengerCompose for the inline/spoiler path.

Entities and I/O

import {
    type FormattedText,
    type MessageEntity,
    type FlagEntity,
    type TextUrlEntity,
    type PreEntity,
    type BlockquoteEntity,
    type UnorderedListEntity,
    type OrderedListEntity,
    type MentionNameEntity,
    type CustomEmojiEntity,
    type AutoEntity,
    entityInBounds,
    unorderedListEntity,
    orderedListEntity,
    docToFormattedText,
    type ToFormattedOptions,
    formattedTextToDoc,
    materializeRuns,
    type FromFormattedOptions,
} from "@arrisa/message"

let ft = docToFormattedText(doc, {
    blockSeparator: "\n",
    autoDetect: true, // skips type "mention" when MentionName is in the schema
    mentionText: (userId) => `@[${userId}]`,
    blockquoteCanCollapse: true,
})

let doc2 = formattedTextToDoc(ft, schema, {
    blockSeparator: "\n",
    mentionLabel: (userId, raw) => labels[userId] ?? raw,
})
  • Inline schemas — single stream; \n becomes line breaks when present.
  • Block schemas — paragraphs; pre → code block; unordered_list / ordered_list wrap as BulletList / OrderedList of ListItems (one item per paragraph or code block); blockquote wraps ranges. Cut-set covers pre + lists + blockquote; lists wrap before quotes so a quote covering a whole list becomes Blockquote > List. Quote-only and partial quotes are supported. Ordered startIndex is omitted when 1. One-level lists are required.
  • Out-of-range entities are dropped on import (entityInBounds).
  • Custom emoji — marks that only partially overlap a custom_emoji atom are dropped around the atom (fully-contained entities only).

Auto entities

import {detectAutoEntities, mergeAutoEntities, type AutoDetectOptions} from "@arrisa/message"

// Used by docToFormattedText({autoDetect: true})
let auto = detectAutoEntities(text, {types: ["url", "email", "hashtag"]})

Detects url, email, phone, hashtag, cashtag, bot_command, mention without overlapping skip ranges or each other.

docToFormattedText({autoDetect: true}) omits type "mention" when the document schema registers MentionName. Explicit autoDetect: {types: [...]} is never rewritten.

mergeAutoEntities / autoDetect skip only exclusive ranges (pre, inline code, custom_emoji, text_url, mention_name, existing autos) — not style marks. A bold URL gets both bold and url.

Mark ↔ entity map

import {
    markToEntityPartial,
    entityToMark,
    DEFAULT_FLAG_MARKS,
    markKey,
    isInlineEntityMark,
} from "@arrisa/message"

Markdown

import {
    markdownInputRules,
    markdownBold,
    markdownItalic,
    markdownStrike,
    markdownSpoiler,
    markdownCode,
    markdownCodeBlock,
    parseMarkdownText,
    markdownPaste,
    markdownClipboardParser,
    looksLikeMarkdown,
} from "@arrisa/message"

Schema elements and insert

import {
    CustomEmoji,
    MentionName,
    customEmoji,
    mentionName,
    messengerHostElements,
    type CustomEmojiParam,
    insertCustomEmoji,
    insertMention,
    insertCustomEmojiSpec,
    insertMentionSpec,
    mentionResolve,
    mentionResolveRule,
    detectMentionQuery,
    type MentionQuery,
} from "@arrisa/message"

// Programmatic insert (pure specs available for tests)
insertMentionSpec(state, {userId: "u1", label: "@alice"})
// Replace a dump-text range (UTF-16 offsets, same space as docToFormattedText)
insertMentionSpec(state, {userId: "u1", label: "@alice", from: 3, to: 6})
insertMentionSpec(state, {
    userId: "u1",
    label: "@alice",
    from: 8,
    to: 11,
    mentionText: (id) => `@[${id}]`,
})
insertCustomEmojiSpec(state, {documentId: "sticker-1", alt: "👍"})

// Typing: @alice␣ → mention when resolve returns an id
mentionResolve((name) => name == "alice" ? "user-alice" : null)

// Picker path (no Space): dump range without a live editor
detectMentionQuery(state) // {from, to, query} | null

Schedule query

Opt-in typed datetime offer. Not an AutoEntity and not a schema mark. Arrisa does not send and does not draw an armed / queued mark under the field — hosts render that from onAccept. Phrase paint and the action list apply only while the caret sits on the complete phrase; typing past it clears both, deleting the extra text restores them. Mention query wins. Chip is Arrisa Tooltip unless onQuery is set.

import {
    composeField,
    scheduleQuery,
    detectScheduleQuery,
    scheduleQueryListener,
    acceptScheduleQuery,
    type ScheduleQuery,
    type ScheduleQueryConfig,
    type ScheduleInvoker,
    type ScheduleAction,
} from "@arrisa/message"

composeField({
    scheduleQuery: {
        now: () => new Date(),
        locale: "en",
        onAccept: (hit) => hostArmSchedule(hit),
    },
})

// Standalone (composeField({scheduleQuery: true}) ≡ scheduleQuery({}))
scheduleQuery({
    now: () => new Date(),
    locale: "ru",
    invokers: [{kind: "tomorrow", words: ["завтра"]}],
    joiners: ["в", "во"],
    onAccept: (hit) => hostArmSchedule(hit),
})

detectScheduleQuery(state) // {from, to, phrase, scheduledTime, label} | null

from / to are UTF-16 dump offsets. scheduledTime is unix seconds. Clock is 24h H:mm / HH:mm (minutes two digits). invokers is {kind, words} with kind "today" / "tomorrow" / "next-week" / "weekday" (weekday 0–6, Sunday=0). Omitting invokers / joiners uses the locale pack. The caret list is one focusable action by default (Schedule {phrase}). Pass actions(hit, state) to replace it; each {id, label, run?} is a button. ArrowUp / ArrowDown focus the list (Up starts at the last item, Down at the first). Enter runs the focused action. An action with run is called after the phrase is deleted; otherwise onAccept fires. detectScheduleQuery is the pure detector (no rect). scheduleQueryListener emits ScheduleQuery (with rect) for custom chip hosts. acceptScheduleQuery deletes the caret-tied phrase (collapsing a doubled space) and emits scheduleAccepted; the plugin updateListener calls onAccept or run.

Code token roles

Re-exported from @arrisa/schema:

import {highlightCode, type CodeRole, type CodeSpan} from "@arrisa/message"

let spans = highlightCode(source, "ts")

highlightCode(text, language?) returns CodeSpan[]. from and to are UTF-16 indexes into text (from inclusive, to exclusive).

Spans are a view. They are not part of PreEntity or the dump.

Blocks over 32_000 UTF-16 units or 400 lines return [].

Roles: kw-flow, kw-decl, modifier, type, function, property, string, interpolation, escape, number, constant, comment, directive, tag, attribute, regex.

Doc comments set emphasis: "bold". Other spans omit emphasis.

Language ids are case-folded. Aliases: typescript → ts, javascript → js, python → py, kotlin → kt, yml → yaml. A missing, empty, or unknown language does not add keywords.

Layering / related packages

@arrisa/schema (composeSchema / messengerSchema)
        ↓
@arrisa/message (composeField / messengerCompose + FormattedText + host elements)
        ↓
host chat app

| Package | Relationship | |---------|----------------| | @arrisa/schema | Marks, composeSchema, messengerSchema, exclusivity config base | | @arrisa/types | Strong, Link, Code, … mapped to entity flags | | @arrisa/editor | Input rules, clipboard parser, floating menu, placeholder | | @arrisa/command | markExclusivity, menu templates | | @arrisa/history | Undo — add separately |

Security

  • Paste/HTML: markdown paste builds a document from plain text; general HTML paste still needs Arrisa.htmlSanitize if the host allows rich HTML.
  • Links: entity text_url / Link marks should use safe hrefs (@arrisa/types helpers) when rendering clickable UI.
  • Mentions / emoji: host-controlled ids; treat remote FormattedText as untrusted input (validate bounds, id formats).
  • See SECURITY.md.

License

MIT © inshinrei