Skip to content

Commit bb122a8

Browse files
committed
feat(atproto): declare Leaflet compatibleFeatures
1 parent e9a6a1d commit bb122a8

3 files changed

Lines changed: 111 additions & 36 deletions

File tree

docs/atproto-lexicon.md

Lines changed: 25 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -57,21 +57,29 @@ Finally, the `app.bsky` namespace is owned by Bluesky PBC. Extending it with doc
5757

5858
Where an OXA facet feature is semantically equivalent to a feature in another AT Protocol namespace, the converter emits both features in the same facet's `features` array. This gives consumers that understand the other namespace free interoperability without OXA depending on that namespace for its core schema.
5959

60-
For example, when `Link` is added to the OXA schema, a link facet will carry both the OXA feature and Bluesky's `app.bsky.richtext.facet#link`:
60+
For example, OXA's `#strong` facet feature is semantically equivalent to Leaflet's `#bold`, so the converter emits both:
6161

6262
```json
6363
{
6464
"index": { "byteStart": 10, "byteEnd": 20 },
6565
"features": [
66-
{ "$type": "pub.oxa.richtext.facet#link", "uri": "https://example.com" },
67-
{ "$type": "app.bsky.richtext.facet#link", "uri": "https://example.com" }
66+
{ "$type": "pub.oxa.richtext.facet#strong" },
67+
{ "$type": "pub.leaflet.richtext.facet#bold" }
6868
]
6969
}
7070
```
7171

72-
This works because AT Protocol facets support multiple features per byte range, and consumers ignore feature types they don't recognise. A Bluesky client rendering an OXA document record will make links clickable even though it doesn't understand `pub.oxa.richtext.facet#emphasis`.
72+
The current Leaflet mappings are:
7373

74-
The mapping is maintained in the `compatibleFeatures` export from `@oxa/core`. It is a record keyed by OXA facet feature `$type`, where each value is an array of functions that produce a compatible feature object (or `null` to skip). This design is not Bluesky-specific — any AT Protocol namespace can be added to the map.
74+
| OXA feature | Leaflet feature |
75+
| -------------- | ------------------------------------------ |
76+
| `#strong` | `pub.leaflet.richtext.facet#bold` |
77+
| `#emphasis` | `pub.leaflet.richtext.facet#italic` |
78+
| `#inlineCode` | `pub.leaflet.richtext.facet#code` |
79+
80+
This works because AT Protocol facets support multiple features per byte range, and consumers ignore feature types they don't recognise. A Leaflet client rendering an OXA document record will render bold, italic, and code spans even though it doesn't understand `pub.oxa.richtext.facet#superscript`.
81+
82+
The mapping is maintained in the `compatibleFeatures` export from `@oxa/core`. It is a record keyed by OXA facet feature `$type`, where each value is an array of functions that produce a compatible feature object (or `null` to skip). This design is not Leaflet-specific — any AT Protocol namespace can be added to the map.
7583

7684
## Flattening inlines into facets
7785

@@ -109,11 +117,17 @@ AT Protocol [uses facets instead of a tree](https://www.pfrazee.com/blog/why-fac
109117
"facets": [
110118
{
111119
"index": { "byteStart": 8, "byteEnd": 23 },
112-
"features": [{ "$type": "pub.oxa.richtext.facet#strong" }]
120+
"features": [
121+
{ "$type": "pub.oxa.richtext.facet#strong" },
122+
{ "$type": "pub.leaflet.richtext.facet#bold" }
123+
]
113124
},
114125
{
115126
"index": { "byteStart": 17, "byteEnd": 23 },
116-
"features": [{ "$type": "pub.oxa.richtext.facet#emphasis" }]
127+
"features": [
128+
{ "$type": "pub.oxa.richtext.facet#emphasis" },
129+
{ "$type": "pub.leaflet.richtext.facet#italic" }
130+
]
117131
}
118132
]
119133
}
@@ -201,7 +215,10 @@ Produces:
201215
"facets": [
202216
{
203217
"index": { "byteStart": 5, "byteEnd": 15 },
204-
"features": [{ "$type": "pub.oxa.richtext.facet#emphasis" }]
218+
"features": [
219+
{ "$type": "pub.oxa.richtext.facet#emphasis" },
220+
{ "$type": "pub.leaflet.richtext.facet#italic" }
221+
]
205222
}
206223
]
207224
}

packages/oxa-core/src/convert.test.ts

Lines changed: 68 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -388,7 +388,10 @@ describe("mapBlock", () => {
388388
facets: [
389389
{
390390
index: { byteStart: 6, byteEnd: 10 },
391-
features: [{ $type: "pub.oxa.richtext.facet#strong" }],
391+
features: [
392+
{ $type: "pub.oxa.richtext.facet#strong" },
393+
{ $type: "pub.leaflet.richtext.facet#bold" },
394+
],
392395
},
393396
],
394397
});
@@ -411,7 +414,10 @@ describe("mapBlock", () => {
411414
facets: [
412415
{
413416
index: { byteStart: 5, byteEnd: 9 },
414-
features: [{ $type: "pub.oxa.richtext.facet#emphasis" }],
417+
features: [
418+
{ $type: "pub.oxa.richtext.facet#emphasis" },
419+
{ $type: "pub.leaflet.richtext.facet#italic" },
420+
],
415421
},
416422
],
417423
});
@@ -486,11 +492,17 @@ describe("oxaToAtproto", () => {
486492
facets: [
487493
{
488494
index: { byteStart: 8, byteEnd: 12 },
489-
features: [{ $type: "pub.oxa.richtext.facet#strong" }],
495+
features: [
496+
{ $type: "pub.oxa.richtext.facet#strong" },
497+
{ $type: "pub.leaflet.richtext.facet#bold" },
498+
],
490499
},
491500
{
492501
index: { byteStart: 17, byteEnd: 23 },
493-
features: [{ $type: "pub.oxa.richtext.facet#emphasis" }],
502+
features: [
503+
{ $type: "pub.oxa.richtext.facet#emphasis" },
504+
{ $type: "pub.leaflet.richtext.facet#italic" },
505+
],
494506
},
495507
],
496508
},
@@ -584,7 +596,10 @@ describe("flattenInlines", () => {
584596
facets: [
585597
{
586598
index: { byteStart: 6, byteEnd: 11 },
587-
features: [{ $type: "pub.oxa.richtext.facet#strong" }],
599+
features: [
600+
{ $type: "pub.oxa.richtext.facet#strong" },
601+
{ $type: "pub.leaflet.richtext.facet#bold" },
602+
],
588603
},
589604
],
590605
});
@@ -604,11 +619,17 @@ describe("flattenInlines", () => {
604619
facets: [
605620
{
606621
index: { byteStart: 8, byteEnd: 12 },
607-
features: [{ $type: "pub.oxa.richtext.facet#strong" }],
622+
features: [
623+
{ $type: "pub.oxa.richtext.facet#strong" },
624+
{ $type: "pub.leaflet.richtext.facet#bold" },
625+
],
608626
},
609627
{
610628
index: { byteStart: 17, byteEnd: 23 },
611-
features: [{ $type: "pub.oxa.richtext.facet#emphasis" }],
629+
features: [
630+
{ $type: "pub.oxa.richtext.facet#emphasis" },
631+
{ $type: "pub.leaflet.richtext.facet#italic" },
632+
],
612633
},
613634
],
614635
});
@@ -622,7 +643,10 @@ describe("flattenInlines", () => {
622643
facets: [
623644
{
624645
index: { byteStart: 4, byteEnd: 9 },
625-
features: [{ $type: "pub.oxa.richtext.facet#emphasis" }],
646+
features: [
647+
{ $type: "pub.oxa.richtext.facet#emphasis" },
648+
{ $type: "pub.leaflet.richtext.facet#italic" },
649+
],
626650
},
627651
],
628652
});
@@ -641,11 +665,17 @@ describe("flattenInlines", () => {
641665
facets: [
642666
{
643667
index: { byteStart: 6, byteEnd: 15 },
644-
features: [{ $type: "pub.oxa.richtext.facet#strong" }],
668+
features: [
669+
{ $type: "pub.oxa.richtext.facet#strong" },
670+
{ $type: "pub.leaflet.richtext.facet#bold" },
671+
],
645672
},
646673
{
647674
index: { byteStart: 20, byteEnd: 32 },
648-
features: [{ $type: "pub.oxa.richtext.facet#emphasis" }],
675+
features: [
676+
{ $type: "pub.oxa.richtext.facet#emphasis" },
677+
{ $type: "pub.leaflet.richtext.facet#italic" },
678+
],
649679
},
650680
],
651681
});
@@ -669,11 +699,17 @@ describe("flattenInlines", () => {
669699
expect.arrayContaining([
670700
{
671701
index: { byteStart: 0, byteEnd: 20 },
672-
features: [{ $type: "pub.oxa.richtext.facet#strong" }],
702+
features: [
703+
{ $type: "pub.oxa.richtext.facet#strong" },
704+
{ $type: "pub.leaflet.richtext.facet#bold" },
705+
],
673706
},
674707
{
675708
index: { byteStart: 9, byteEnd: 20 },
676-
features: [{ $type: "pub.oxa.richtext.facet#emphasis" }],
709+
features: [
710+
{ $type: "pub.oxa.richtext.facet#emphasis" },
711+
{ $type: "pub.leaflet.richtext.facet#italic" },
712+
],
677713
},
678714
]),
679715
);
@@ -687,7 +723,10 @@ describe("flattenInlines", () => {
687723
facets: [
688724
{
689725
index: { byteStart: 0, byteEnd: 8 },
690-
features: [{ $type: "pub.oxa.richtext.facet#emphasis" }],
726+
features: [
727+
{ $type: "pub.oxa.richtext.facet#emphasis" },
728+
{ $type: "pub.leaflet.richtext.facet#italic" },
729+
],
691730
},
692731
],
693732
});
@@ -762,15 +801,24 @@ describe("flattenInlines", () => {
762801
expect.arrayContaining([
763802
{
764803
index: { byteStart: 0, byteEnd: 14 },
765-
features: [{ $type: "pub.oxa.richtext.facet#strong" }],
804+
features: [
805+
{ $type: "pub.oxa.richtext.facet#strong" },
806+
{ $type: "pub.leaflet.richtext.facet#bold" },
807+
],
766808
},
767809
{
768810
index: { byteStart: 5, byteEnd: 14 },
769-
features: [{ $type: "pub.oxa.richtext.facet#emphasis" }],
811+
features: [
812+
{ $type: "pub.oxa.richtext.facet#emphasis" },
813+
{ $type: "pub.leaflet.richtext.facet#italic" },
814+
],
770815
},
771816
{
772817
index: { byteStart: 10, byteEnd: 14 },
773-
features: [{ $type: "pub.oxa.richtext.facet#strong" }],
818+
features: [
819+
{ $type: "pub.oxa.richtext.facet#strong" },
820+
{ $type: "pub.leaflet.richtext.facet#bold" },
821+
],
774822
},
775823
]),
776824
);
@@ -795,7 +843,10 @@ describe("flattenInlines", () => {
795843
facets: [
796844
{
797845
index: { byteStart: 0, byteEnd: 6 },
798-
features: [{ $type: "pub.oxa.richtext.facet#strong" }],
846+
features: [
847+
{ $type: "pub.oxa.richtext.facet#strong" },
848+
{ $type: "pub.leaflet.richtext.facet#bold" },
849+
],
799850
},
800851
],
801852
});

packages/oxa-core/src/convert.ts

Lines changed: 18 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -153,27 +153,34 @@ const facetFeatureTypes = {
153153
* Compatible facet features from other AT Protocol namespaces.
154154
*
155155
* When an OXA facet feature has a semantically equivalent type in another
156-
* namespace (e.g. Bluesky's `app.bsky.richtext.facet`), the converter emits
157-
* both features in the same facet. This gives consumers that understand the
158-
* other namespace free interoperability without OXA depending on that
156+
* namespace (e.g. Bluesky's `app.bsky.richtext.facet`), the converter
157+
* emits both features in the same facet. This gives consumers that understand
158+
* the other namespace free interoperability without OXA depending on that
159159
* namespace for its core schema.
160160
*
161161
* Each key is an OXA facet feature `$type`. The value is an array of
162162
* functions that receive the OXA inline node and return a compatible
163163
* feature object (or `null` to skip).
164164
*
165-
* Currently empty — the only OXA facet features (`strong`, `emphasis`) have
166-
* no Bluesky equivalents. When `Link` is added to the OXA schema, an entry
167-
* like the following would provide Bluesky link interop:
168-
*
169-
* "pub.oxa.richtext.facet#link": [
170-
* (node) => ({ $type: "app.bsky.richtext.facet#link", uri: node.uri }),
171-
* ],
165+
* These lexicons should be checked periodically for new features that
166+
* could be mapped here:
167+
* - Leaflet: https://github.com/hyperlink-academy/leaflet/blob/main/lexicons/pub/leaflet/richtext/facet.json
168+
* - Bluesky: https://github.com/bluesky-social/atproto/blob/main/lexicons/app/bsky/richtext/facet.json
172169
*/
173170
export const compatibleFeatures: Record<
174171
string,
175172
Array<(node: Record<string, unknown>) => FacetFeature | null>
176-
> = {};
173+
> = {
174+
"pub.oxa.richtext.facet#strong": [
175+
() => ({ $type: "pub.leaflet.richtext.facet#bold" }),
176+
],
177+
"pub.oxa.richtext.facet#emphasis": [
178+
() => ({ $type: "pub.leaflet.richtext.facet#italic" }),
179+
],
180+
"pub.oxa.richtext.facet#inlineCode": [
181+
() => ({ $type: "pub.leaflet.richtext.facet#code" }),
182+
],
183+
};
177184

178185
const formattingPropertyNames = ["id", "classes", "data"] as const;
179186
const blockPropertyNames = ["id", "classes", "data"] as const;

0 commit comments

Comments
 (0)