···11+import { describe, expect, it, beforeAll } from 'vitest';
22+import { create, toHTMLString, applyFormat } from '@wordpress/rich-text';
33+import { registerMentionFormat, MENTION_FORMAT } from './mention-format';
44+55+beforeAll( () => {
66+ registerMentionFormat();
77+} );
88+99+describe( 'skypress/mention format', () => {
1010+ it( 'round-trips an anchor carrying href + data-did, keeping the text', () => {
1111+ // Build a value "@alice.bsky.social" and apply the mention format across it.
1212+ const value = create( { text: '@alice.bsky.social' } );
1313+ const formatted = applyFormat(
1414+ value,
1515+ // The bundled `RichTextFormat` type only models `{ type }`; the runtime
1616+ // `attributes` map is real, so cast to satisfy the incomplete type.
1717+ {
1818+ type: MENTION_FORMAT,
1919+ attributes: {
2020+ url: 'https://bsky.app/profile/alice.bsky.social',
2121+ did: 'did:plc:alice',
2222+ },
2323+ } as unknown as Parameters< typeof applyFormat >[ 1 ],
2424+ 0,
2525+ value.text.length
2626+ );
2727+ const html = toHTMLString( { value: formatted } );
2828+ expect( html ).toContain( 'href="https://bsky.app/profile/alice.bsky.social"' );
2929+ expect( html ).toContain( 'data-did="did:plc:alice"' );
3030+ expect( html ).toContain( '@alice.bsky.social' );
3131+ } );
3232+3333+ it( 'is idempotent — calling register twice does not throw', () => {
3434+ expect( () => registerMentionFormat() ).not.toThrow();
3535+ } );
3636+} );
+60
src/lib/editor/mention-format.ts
···11+import { registerFormatType, store as richTextStore } from '@wordpress/rich-text';
22+import { select } from '@wordpress/data';
33+44+export const MENTION_FORMAT = 'skypress/mention';
55+66+/**
77+ * Settings accepted by `registerFormatType` at runtime. The bundled
88+ * `@wordpress/rich-text@7.24.0` `WPFormat` type is not re-exported from the package
99+ * index and omits the documented `attributes` map (HTML-attribute → format-attribute
1010+ * key), so model it locally and pass it through a cast at the registration call below.
1111+ */
1212+type MentionFormatSettings = {
1313+ title: string;
1414+ tagName: string;
1515+ className: string;
1616+ attributes: Record< string, string >;
1717+ edit: () => null;
1818+};
1919+2020+/** Minimal shape of the rich-text store selector we depend on. */
2121+type RichTextSelectors = {
2222+ getFormatType: ( name: string ) => unknown;
2323+};
2424+2525+/**
2626+ * Register the `skypress/mention` rich-text format. It serializes to
2727+ * `<a class="skypress-mention" href="{profile}" data-did="{did}">@{handle}</a>`.
2828+ * The `class` survives the reader's sanitizer (so it can be styled and identified);
2929+ * `data-did` is the publish-time marker (`collectMentions`) and is stripped from public
3030+ * HTML by `sanitize.ts`. Inserted by the `@` autocompleter, not a toolbar button.
3131+ */
3232+export function registerMentionFormat(): void {
3333+ // `@wordpress/rich-text@7.24.0` does not re-export `getFormatType` from its
3434+ // index, so query the public store selector directly to stay idempotent
3535+ // without triggering registerFormatType's "already registered" console.error.
3636+ const selectors = select(
3737+ richTextStore as unknown as Parameters< typeof select >[ 0 ]
3838+ ) as unknown as RichTextSelectors;
3939+ if ( selectors.getFormatType( MENTION_FORMAT ) ) {
4040+ return;
4141+ }
4242+4343+ const settings: MentionFormatSettings = {
4444+ title: 'Mention',
4545+ tagName: 'a',
4646+ className: 'skypress-mention',
4747+ attributes: {
4848+ url: 'href',
4949+ did: 'data-did',
5050+ },
5151+ edit() {
5252+ return null;
5353+ },
5454+ };
5555+5656+ registerFormatType(
5757+ MENTION_FORMAT,
5858+ settings as unknown as Parameters< typeof registerFormatType >[ 1 ]
5959+ );
6060+}