A calm place to write long-form, and publish it to the open social web. skypress.blog/
0

Configure Feed

Select the types of activity you want to include in your feed.

Add graphemeLength util for Bluesky post limit

+35
+22
src/lib/publish/grapheme.test.ts
··· 1 + import { describe, expect, it } from 'vitest'; 2 + import { graphemeLength } from './grapheme'; 3 + 4 + describe( 'graphemeLength', () => { 5 + it( 'counts ASCII characters one-for-one', () => { 6 + expect( graphemeLength( 'hello' ) ).toBe( 5 ); 7 + } ); 8 + 9 + it( 'counts an emoji as a single grapheme', () => { 10 + // "๐Ÿ‘" is 2 UTF-16 code units / 4 UTF-8 bytes but one grapheme. 11 + expect( graphemeLength( '๐Ÿ‘' ) ).toBe( 1 ); 12 + } ); 13 + 14 + it( 'counts a multi-codepoint emoji cluster as one grapheme', () => { 15 + // Family emoji = several codepoints joined by ZWJ, still one grapheme. 16 + expect( graphemeLength( '๐Ÿ‘จโ€๐Ÿ‘ฉโ€๐Ÿ‘ง' ) ).toBe( 1 ); 17 + } ); 18 + 19 + it( 'returns 0 for the empty string', () => { 20 + expect( graphemeLength( '' ) ).toBe( 0 ); 21 + } ); 22 + } );
+13
src/lib/publish/grapheme.ts
··· 1 + /** 2 + * Count graphemes (user-perceived characters) the way Bluesky counts toward its 3 + * 300-character post limit โ€” emoji and ZWJ clusters count as one, not as their 4 + * UTF-16 length or byte length. 5 + */ 6 + export function graphemeLength( text: string ): number { 7 + const segmenter = new Intl.Segmenter( undefined, { granularity: 'grapheme' } ); 8 + let count = 0; 9 + for ( const _segment of segmenter.segment( text ) ) { 10 + count++; 11 + } 12 + return count; 13 + }