bluesky_text 1.8.0
bluesky_text: ^1.8.0 copied to clipboard
Provides the easiest and most powerful way to analyze the text for Bluesky Social.
Release Note #
1.8.0 #
-
feat:
Entity.toFacet,Entities.toFacetsResultandEntities.toFacetsaccept an optionalhttp.Client? client, forwarded to the built-in handle-resolution call. It is used only when noresolveris supplied and exists so the default (network) mention path can be driven against a mock transport. Existing callers are unaffected — the parameter is optional and additive. -
test: the mention-facet tests no longer reach the live network. Seven tests resolved real handles against
bsky.socialand asserted the returned DID, so they broke on any outage, rate limit, or handle change and could not run offline. They now inject aMockClient(via the newclienthook) that answersresolveHandlelocally, covering the success path, the 4xx "unresolvable handle → no facet" swallow, the 5xx "surface the error" rethrow, and thatservicereaches the request. One meaningless "does not throw" assertion was replaced with a real output check. -
security:
isLinkFacadeno longer passes a display text it cannot parse. It used to returnfalse— "not a facade" — both when the text named no host and when it named one that could not be read, so every shape the parser did not recognize went through unflagged.- Display text is now folded the way IDNA (UTS-46) folds it before comparison: the three full-stop variants (
。U+3002,.U+FF0E,。U+FF61) become., and the fullwidth ASCII block (U+FF01–U+FF5E) becomes plain ASCII.bsky。appandbsky.appare hosts a browser really resolves tobsky.app— the reader sees a domain they trust and the link works — and they were not flagged when pointed at another host. The host of the target is folded the same way, so a link written with a fullwidth host still matches an ASCII display text. This is not homograph detection, which stays out of scope: only the characters IDNA maps onto ASCII are folded, and a Cyrillic look-alike is still a genuinely different host. - The authority now ends at a backslash as well as at
/,?and#. The WHATWG URL Standard makes\a synonym for/in a special scheme, so a browser reads the host ofhttps://bsky.app\@evil.example.comasbsky.app; that went unflagged while itshttps://bsky.app\evil.example.comsibling was flagged. - Wrappers and sentence punctuation are stripped from both ends, and a leading
//is read as a protocol-relative reference."bsky.app",<bsky.app>,(bsky.app),//bsky.appandbsky.app,were all unflagged whilebsky.app/and[bsky.app]were flagged.
- Display text is now folded the way IDNA (UTS-46) folds it before comparison: the three full-stop variants (
-
feat: added
checkLinkFacadeandLinkFacadeVerdict, which reportfacade,honest,notAUrlorundeterminedinstead of the single boolean.undeterminedis the case a boolean cannot express — the display text reads as a URL but yields no host, it is host-shaped under an internationalized TLD this package carries no data for, or the link itself has no host to compare against.isLinkFacadeis unchanged and is nowcheckLinkFacade(...) == LinkFacadeVerdict.facade. -
fix:
PostFacet.fromJsonrefuses malformed JSON instead of leaking a_TypeErrorout of the render path. A facet with noindex, anindexthat is not an object, abyteStart/byteEndthat is not a whole number, or afeaturesthat is not a list now throws aFormatExceptionnaming the field; a feature entry that is not an object is skipped, exactly as an unknown$typealready was, and a whole-numbereddoubleoffset such as2.0is accepted. AddedPostFacet.tryFromJson, which returnsnullwherefromJsonthrows, so one bad facet in a fetched post drops out instead of taking the whole render down. -
perf: extracting entities is no longer quadratic in the length of a run of dotted labels or of a path of balanced parens. A run with no usable TLD was rescanned once per label, with the TLD alternation — thousands of literals wide — as the constant, and a path was matched against a star over an alternation that retried every prefix.
'a.' * 150, a legal 300-character post, took 43 ms and now takes 0.13 ms; 3000 characters took 11 s and now take 1.3 ms;https://a.com/followed by 3000(a)groups took 4.9 s and now takes 0.5 ms. Ordinary text is unaffected in either direction. Extraction is unchanged to the entity: the same facets come out of the existing suite and of a 10,344-entry corpus of real and generated posts, and the skip stands down entirely on any text where it could have changed an answer.
1.6.0 #
- feat: added
isLinkFacade, which reports when a facet's display text reads as a URL or host that does not match the host its link points at — the link-facade phishing shape. Display text that is not URL-looking is never flagged: a bare host is flagged only when this package would itself have linkified that same text. Hosts match on equality or a subdomain relation, ignoringwww., trailing root dots, case, ports, paths and punycode encoding. It does not detect homographs, redirects or deceptive non-URL text, and says so. - feat: added
toDisplayHost, which decodes a punycode (xn--) host into the Unicode it stands for, so a link warning can show what the host actually says. - fix: the facet maps returned by
Entity.toFacet,Entities.toFacets,Entities.toFacetsResultandBlueskyText.toPostDataare now wire-complete. The facet carries"$type": "app.bsky.richtext.facet"and itsindexcarries"$type": "app.bsky.richtext.facet#byteSlice", matching what the lexicon models serialize to — previously only the individual features were typed, and the returned map did not even passRichtextFacet.validate. Callers going throughfeed.post.createnever noticed, since the generated converter fills them in; callers assembling a record map themselves, forcom.atproto.repo.applyWritesor to compute a record CID locally, silently produced a record that differed from the converter's output. The "no facet" results (an unresolvable handle, a raw markdown link) are still an empty map.
1.5.4 #
- chore: bump
xrpcto^1.1.3.
1.5.3 #
- chore: bump dev dependency
blueskyto^2.1.0.
1.5.2 #
- docs: added a runnable inline usage snippet to the README — instantiating
BlueskyText, extracting entities (.entities/.handles/.links), and converting withtoPostData({service, resolver})totext/facets/unresolvedHandles. - chore: bump
xrpcto^1.1.2.
1.5.1 #
- fix: email addresses are no longer partially linkified (the domain of
mail@alice.bsky.socialis no longer turned into a link). - fix: URL paths containing non-ASCII characters are no longer truncated, so links such as
https://ja.wikipedia.org/wiki/日本語resolve to the full URL.
1.5.0 #
- FEAT: Added
BlueskyText.overflow, which returns aTextLengthOverflowdescribing the range of the text that exceeds the post-length limit (more than 300 graphemes or 3000 UTF-8 bytes), ornullwhen it is within both. The boundary is reported in UTF-16, UTF-8 byte and grapheme coordinates so a UI can, for example, split the value withTextLengthOverflow.utf16Startand render the overflowing tail in red via a FlutterTextSpan.- The boundary always lands on a grapheme cluster boundary, so emoji and other multi-code-unit characters are never split.
- When the boundary would fall inside an entity (handle, link, tag, cashtag or markdown link) it is snapped back to that entity's start, so the entity is treated atomically — wholly within the limit or wholly in the overflow.
- Because the range is derived from the value, calling
.format().overflowreports the overflow of the formatted text (markdown expanded, links shortened), which is what is displayed and posted.
- FEAT: Added
BlueskyText.segments, which partitions the value into non-overlapping, gap-freeTextSegments in document order. Each segment carries UTF-16 offsets, the entity it belongs to (if any) and whether it lies in the overflow region, so a FlutterTextEditingControllercan color links, handles and tags together with the over-limit tail (for example in red) in a single pass — without merging the byte-based entity indices and the overflow range by hand. Concatenating everyTextSegment.textreproduces the value, and no segment is ever split across an entity boundary. - FEAT: Added
renderFacets(text, facets)andPostFacetfor displaying a fetched post: it partitions the text intoTextSegments using the server-provided facets (authoritative mentions/links/tags, mentions already carrying their DID) instead of re-detecting entities. Each segment exposes aFacetFeaturewith the resolved DID / URI / tag, so a Flutter client can style received posts with oneTextSpanbuilder shared with the compose path.PostFacet.fromJsonparses theapp.bsky.richtext.facetAPI shape, and the byte→UTF-16Utf16IndexConverteris now public. - FEAT:
Entities.toFacets/ the newEntities.toFacetsResultaccept aHandleResolver, so mention DID resolution can be served from a cache or batched instead of the built-in per-handle network call.toFacetsResultadditionally returns the handles that failed to resolve, so a client can warn the user rather than silently posting a mention-less message. - FEAT: Added
BlueskyText.formatted(the memoized, posting-ready form) andBlueskyText.toPostData(...), which formats and resolves facets in one call — the only correct order, since markdown links become link facets only after formatting — returning(text, facets, unresolvedHandles). - FIX:
split()now budgets each chunk against both post limits (300 graphemes and 3000 UTF-8 bytes). Previously it budgeted graphemes only, so a byte-heavy chunk — e.g. many multi-byte ZWJ emoji — could stay under 300 graphemes yet exceed 3000 bytes and still be rejected by the server. - FIX:
split()on aformat()ted instance now splits the original text instead of the lossy formatted value, soformat().split()behaves exactly likesplit()on the original. Splitting formatted text and re-extracting previously corrupted facets — a shortened link'suribecame its truncated display text and a markdown link's facet vanished — because the chunks dropped the position-bound replacements. Each chunk is a raw, independently-formattable piece; format each one after splitting (e.g. viachunk.toPostData()). - FIX:
split()now breaks on any Unicode whitespace — newlines, tabs and the ideographic (full-width) spaceU+3000— not just the ASCII space. Previously a multi-line or CJK post with no ASCII spaces was treated as one giant word and hard-split mid-word (e.g.word44becamewo|rd44). The author's newlines and spacing are now preserved within each chunk, and no chunk starts or ends with whitespace. A markdown link is also kept atomic, so one straddling a chunk boundary is no longer torn open (which would drop its facet). - PERF:
BlueskyTextnow lazily memoizes every derived value (length,handles,entities,overflow,segments,format()…), so touching several properties of one instance in a Flutterbuildcosts one analysis instead of one per property (~1.6x faster when touching seven). Note: as a resultBlueskyTextis no longerconst—const BlueskyText(...)must becomeBlueskyText(...). - PERF: The length-limit hot paths (polled on every keystroke in a Flutter
editor) avoid the regex-based entity extraction entirely unless it is needed.
isLengthLimitExceededandoverflowfall back to a cheap grapheme scan when within the limit,segmentsresolves the entities only once (instead of extracting them again viaoverflow), and the grapheme scan counts UTF-8 bytes without allocating an intermediate byte list. For over-limit text this cutsisLengthLimitExceeded~18x andsegments~2x; a 300-grapheme post segments in well under 0.1 ms.
1.4.1 #
- FIX: Markdown links whose destination contains surrounding whitespace are
now detected and formatted correctly. Previously the link's end offset was
reconstructed from
label length + URL length + 4, which ignored any whitespace padding inside the parentheses. As a result inputs such as[ test ]( https://example.com )left the padding and the closing)behind in the formatted output (" test )") and, because every following entity is positioned relative to that offset, shifted and corrupted all subsequent facets. The destination is now parsed structurally, so the following are all handled:- leading/trailing whitespace, tabs and newlines inside the parentheses
(
[t]( https://example.com ),[t](\n https://example.com \n)); - an optional angle-bracket wrapper (
[t](<https://example.com>)); - a gap between
]and(([t] (https://example.com)); - balanced parentheses in the URL path remain intact
(
[film](https://en.wikipedia.org/wiki/Primer_(film))).
- leading/trailing whitespace, tabs and newlines inside the parentheses
(
- PERF: Destination parsing anchors the URL match with
matchAsPrefixinstead of a forward-scanningfirstMatchover a freshly allocated substring per link opener. Formatting bracket-heavy input on every keystroke (as a Flutter editor does) is nowO(n)instead ofO(n^2)— a 4 KB string of[a](fragments drops from ~240 ms to ~1 ms — and no per-match substring is allocated. - PERF: Entity extraction now converts UTF-16 code-unit boundaries to UTF-8
byte offsets with a forward-only
Utf8IndexConverterinstead of rescanning the prefix from the start on every boundary. Because each extractor pass emits matches left-to-right, the conversion drops fromO(k * n)toO(n)per pass (~12x faster on the conversion step in isolation, ~8% faster end-to-end on entity-dense text). Output byte ranges are byte-identical to before, including the unpaired-surrogate contract.
1.4.0 #
- FIX: Fixed crashes on IDN (internationalized domain) URLs. Text containing
URLs such as
https://日本語.jporhttps://日本.example.comno longer throws from.links/.entities/.format()or markdown-link extraction. - BREAKING: Aligned several detectors with Bluesky's official implementation:
- The mention regex is now case-insensitive, so
@Alice.Bsky.Socialand@SHINYAKATO.DEVare detected. - The hashtag "emoji" character class was rewritten to drop whitespace,
U+3000, line separators, CJK punctuation, and lone surrogate ranges, so#タグ こんにちはis one tag and#tag3 #tag4are both preserved. - Tag facet values strip a single leading
#, and the tag length limit is now 64 graphemes (excluding#), matching the spec. - Mention preceding-character rules follow the official
(^|\s|\()boundary (the leftover twitter-textRT:alternative is removed). - Full-width
#is now recognized as a hashtag sign (partial;#tag1#tag2splitting is still deferred).
- The mention regex is now case-insensitive, so
- FIX: The chunk splitter now budgets by grapheme count instead of UTF-16
length, so emoji-heavy text is packed correctly.
split()also propagates the activeformat()replacements / link config to each chunk, so shortened display strings are no longer re-extracted into truncated facet URLs. - FIX:
http(s)scheme detection is case-insensitive and no longer double-prefixes (HTTPS://EXAMPLE.COMis handled;httpstatus.iois not a scheme). - FIX: Overlapping facets are resolved by priority (link > mention > tag >
cashtag), so an
@handleor#fragmentinside a URL no longer produces a duplicate facet. - FIX: Enforce the lexicon's 3000 UTF-8 byte limit alongside the 300
grapheme limit; misc fixes to
isEmojiOnly, the shorten threshold, andtoFaceterror propagation. - PERF:
toUtf8Indexis now incremental (no per-call full re-encode). - TEST: Added a WS-6 regression suite (IDN input, upper-case TLD/scheme,
format()→split(), non-BMP splitting, facet overlap) and de-duplicated test names. Where existing tests pinned non-official behavior, they were updated to match the reference implementation.
1.3.0 #
- BREAKING: Aligned cashtag detection with Bluesky's official
CASHTAG_REGEXin@atproto/api. Detection is now stricter and consistent with the reference implementation:- The ticker symbol is limited to 1–5 ASCII characters
(
[A-Za-z][A-Za-z0-9]{0,4}); longer candidates like$GOOGLEare rejected. - A cashtag must be preceded by a leading boundary — the start of the string,
a whitespace character (including
U+3000/U+00A0), or an ASCII(— and followed by a trailing boundary — whitespace, the end of the string, or one of the ASCII punctuation characters. , ; : ! ? ) " 'or’(U+2019). As a result, cashtags glued to Japanese (or other non-delimiting) text such as日本株$AAPLor$AAPLですare intentionally not detected, matching the official Bluesky behavior. Full-width delimiters like($AAPL)and$AAPL。are likewise not treated as boundaries. - The ticker is normalized to upper case and the emitted
tagfacet keeps the leading$(e.g.$aapl→$AAPL), mirroring the official cashtag facet.
- The ticker symbol is limited to 1–5 ASCII characters
(
- REGEX: Removed the
cashtagBoundaryandendCashtagpatterns; thevalidCashtagpattern now embeds the official leading/trailing boundaries directly.cashSignsandvalidCashtagremain exported frompackage:bluesky_text/regex.dart. - TEST: Updated and expanded the cashtag test suite to pin the official-compliant boundaries, ticker length limit, upper-case normalization, and Japanese-adjacency behavior.
1.2.1 #
- fix: do not use
.substringwhen creating the cashtag entities.
1.2.0 #
- FEATURE: Added support for cashtag detection (e.g.
$AAPL,$tsla).- New
BlueskyText.cashtagsgetter returns all cashtag entities along with their byte indices. - New
EntityType.cashtagandEntity.isCashtagfor type-safe handling. - Cashtags are also surfaced from
BlueskyText.entitiesalongside handles, links, and hashtags. - Cashtags are converted to
app.bsky.richtext.facet#tagfeatures when callingtoFacets(), mirroring how Bluesky represents tag-like facets. - Symbols must start with an ASCII letter and may contain ASCII letters or
digits afterwards. Bare dollar amounts like
$1000are not detected as cashtags.
- New
- REGEX: Added
cashSigns,cashtagBoundary,endCashtag, andvalidCashtagpatterns underpackage:bluesky_text/regex.dart. - TEST: Added 30+ test cases covering cashtag extraction, boundary detection, length limits, byte index accuracy, and interoperability with hashtags, handles, and links.
1.1.1 #
- FIX: Downgraded characters dependency from ^1.4.1 to ^1.4.0 for compatibility
1.1.0 #
- FEATURE: Added support for Unicode space characters as hashtag delimiters. (#1933)
- Full-width space (U+3000) and other Unicode space characters are now recognized as valid hashtag boundaries
- Improved compatibility with Bluesky's official client behavior
- Enhanced hashtag recognition for international users, especially Japanese users
- ENHANCEMENT: Comprehensive test coverage expansion
- Added extensive tests for hashtags, handles, and links with real-world scenarios
- Added security tests to prevent Unicode normalization attacks and ReDoS vulnerabilities
- Added performance tests with large numbers of entities
- Added multilingual support tests for various languages
- Added boundary detection tests for edge cases
- IMPROVEMENT: Enhanced hashtag boundary detection with support for:
- Ideographic space (U+3000) - commonly used in Japanese text
- No-break space (U+00A0) - commonly used in HTML
- Regular space (U+0020) - standard ASCII space
- TEST: Added 22+ new comprehensive test cases covering edge cases and real-world usage patterns
1.0.4 #
- DEPENDENCY: Updated
xrpcdependency to^1.0.3for compatibility withat_primitivesconsolidation.
1.0.3 #
- chore: update example.
1.0.2 #
- Fix SDK constraint to '">=3.8.0 <4.0.0"'.
1.0.1 #
- chore: optimized docs.
1.0.0 #
- Bump SDK constraint to '^3.8.0'.
0.7.2 #
- Bump
xrpc.
0.7.1 #
- Exposed
bluesky_text/regex.dart.
0.7.0 #
- Bump
xrpc.
0.6.10 #
- Bump
xrpc.
0.6.9 #
- Bump SDK constraint to '^3.3.0'.
0.6.8 #
- Upgraded
xrpc.
0.6.7 #
- Upgraded
xrpc.
0.6.6 #
- Upgraded
xrpc.
0.6.5 #
- Upgraded
xrpc.
0.6.4 #
- Upgraded
xrpc.
0.6.1 #
- Improved extraction algo for markdown links.
0.5.21 #
- Exposed
.getGraphemeLength.
0.5.20 #
- Upgraded
xrpcpackage.
0.5.19 #
- Exposed
.isEmojiOnlyas a function.
0.5.18 #
- Added
.isEmojiOnlyproperty. It can determine if the text contains only emojis.
0.5.15 #
- Improved markdown extraction algo. You can use as a link if the URL contains markdown symbols, such as
https://wikipedia.com//track/We_Up_(Album_Version_(Edited)).
0.5.14 #
- Hashtag formatted text is not allowed as Markdown.
- Improved the extraction algo for markdown. (#901)
0.5.12 #
0.5.11 #
- Improved handle extraction algorithm. From with this version, the use of spaces as well as URLs is no longer required. (#892)
0.5.10 #
- Fixed to add
https://to markdown URLs when it is not given.
0.5.9 #
- The markdown URL must always contain
.symbol.
0.5.8 #
- Added
enableMarkdownparam onBlueskyText. Defaults totrue.
0.5.7 #
- Fixed a bug regarding byte calculation when detecting markdowns.
0.5.6 #
- Improved entity extraction for unformatted markdown. For example,
[test](https://example.com)extracts entities so thattestcan be highlighted. Facets of this entity cannot be generated with.toFacetsuntil.formatis executed.- Also added
EntityType.markdownLink. If you want to exclude entities in the markdown without being.format, you can filter by this fixed value.
- Also added
0.5.4 #
0.5.3 #
- Supported markdown style links. You can set any links to any text such as
[test](https://foo.com). Be sure to execute.format()to make the link in markdown format recognized as a facet. (#629)
0.5.2 #
- Fixed safer processing when shortening links.
0.5.1 #
- Improved link detection algorithm.
0.5.0 #
- Improved algorithm for detecting links.
- Removed deprecated properties.
.hasHandle.hasNotHandle.hasLink.hasNotLink.hasEntity.hasNotEntity
- Changed property from
int maxGraphemeLengthtobool enableShorteningonLinkConfig.
0.4.4 #
- Improved processing when
.format()is executed. Correct if the original text link does not contain thehttpprotocol.
0.4.3 #
- Added the ability to shorten links detected as facets. You just need to pass
LinkConfigtoBlueskyTextand run.format(). You can get new formattedBlueskyTextthen.
0.4.2 #
- Upgraded
xrpcpackage. Fixed field names for rate limit.
0.4.1 #
- Fixed a bug in which URLs were not detected in specific cases.
0.4.0 #
- Improved algos for
.handles,links, and.entitiesbased on official RichText..handles- The value set to
.valueinEntityno longer contains@ - Domain is now validated based on ICANN TLDs
- The value set to
.links- Links with
http|httpsprefixes omitted are now also detected (such asbsky.app) - Domain is now validated based on ICANN TLDs
- Links with
0.3.2 #
- Fixed a bug that caused a range error if a string shorter than the search symbol (like
https://) existed. (#734)
0.3.0 #
- Drop support for null unsafe Dart, bump SDK constraint to '^3.0.0'. (#599)
- Dart3 modifier applied.
0.2.6 #
- Changed the dependency of the communication process from
atprototoxrpc. (#574) - Added
ignoreInvalidHandleoption totoFacetsinEntitiesandtoFacetinEntity. This flag controls the behavior when the handle is invalid. If true, no exception is thrown if an invalid handle is passed, and the process of creating a facet of the invalid handle is skipped; if false, anInvalidRequestExceptionis thrown if an invalid handle is passed.
0.2.2 #
- Upgraded dependencies.
0.2.1 #
- Added
getCustomEntitiesmethod. You can easily generate original facets, etc. by extracting entities from the text that are not officially provided. (#419)
0.1.1 #
- Fixed
toFacetandtoFacetsto exclude invalid handles from the result when they are called. (#354)
0.1.0 #
- Added some utilities. And changed return object from
handles,linksandentitiestoEntities. (#347)isHandleinEntityisLinkinEntityisLinkinEntityEntitiesobject, and you can easily get facet collection by usingtoFacets
- The following characters have been added as entity terminators. (#352)
\n\n\r- full-width space
0.0.1 #
- First Release!