Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
33 changes: 25 additions & 8 deletions docs/atproto-lexicon.md
Original file line number Diff line number Diff line change
Expand Up @@ -57,21 +57,29 @@ Finally, the `app.bsky` namespace is owned by Bluesky PBC. Extending it with doc

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.

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`:
For example, OXA's `#strong` facet feature is semantically equivalent to Leaflet's `#bold`, so the converter emits both:

```json
{
"index": { "byteStart": 10, "byteEnd": 20 },
"features": [
{ "$type": "pub.oxa.richtext.facet#link", "uri": "https://example.com" },
{ "$type": "app.bsky.richtext.facet#link", "uri": "https://example.com" }
{ "$type": "pub.oxa.richtext.facet#strong" },
{ "$type": "pub.leaflet.richtext.facet#bold" }
]
}
```

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`.
The current Leaflet mappings are:

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.
| OXA feature | Leaflet feature |
| -------------- | ------------------------------------------ |
| `#strong` | `pub.leaflet.richtext.facet#bold` |
| `#emphasis` | `pub.leaflet.richtext.facet#italic` |
| `#inlineCode` | `pub.leaflet.richtext.facet#code` |

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`.

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.

## Flattening inlines into facets

Expand Down Expand Up @@ -109,11 +117,17 @@ AT Protocol [uses facets instead of a tree](https://www.pfrazee.com/blog/why-fac
"facets": [
{
"index": { "byteStart": 8, "byteEnd": 23 },
"features": [{ "$type": "pub.oxa.richtext.facet#strong" }]
"features": [
{ "$type": "pub.oxa.richtext.facet#strong" },
{ "$type": "pub.leaflet.richtext.facet#bold" }
]
},
{
"index": { "byteStart": 17, "byteEnd": 23 },
"features": [{ "$type": "pub.oxa.richtext.facet#emphasis" }]
"features": [
{ "$type": "pub.oxa.richtext.facet#emphasis" },
{ "$type": "pub.leaflet.richtext.facet#italic" }
]
}
]
}
Expand Down Expand Up @@ -201,7 +215,10 @@ Produces:
"facets": [
{
"index": { "byteStart": 5, "byteEnd": 15 },
"features": [{ "$type": "pub.oxa.richtext.facet#emphasis" }]
"features": [
{ "$type": "pub.oxa.richtext.facet#emphasis" },
{ "$type": "pub.leaflet.richtext.facet#italic" }
]
}
]
}
Expand Down
85 changes: 68 additions & 17 deletions packages/oxa-core/src/convert.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -388,7 +388,10 @@ describe("mapBlock", () => {
facets: [
{
index: { byteStart: 6, byteEnd: 10 },
features: [{ $type: "pub.oxa.richtext.facet#strong" }],
features: [
{ $type: "pub.oxa.richtext.facet#strong" },
{ $type: "pub.leaflet.richtext.facet#bold" },
],
},
],
});
Expand All @@ -411,7 +414,10 @@ describe("mapBlock", () => {
facets: [
{
index: { byteStart: 5, byteEnd: 9 },
features: [{ $type: "pub.oxa.richtext.facet#emphasis" }],
features: [
{ $type: "pub.oxa.richtext.facet#emphasis" },
{ $type: "pub.leaflet.richtext.facet#italic" },
],
},
],
});
Expand Down Expand Up @@ -486,11 +492,17 @@ describe("oxaToAtproto", () => {
facets: [
{
index: { byteStart: 8, byteEnd: 12 },
features: [{ $type: "pub.oxa.richtext.facet#strong" }],
features: [
{ $type: "pub.oxa.richtext.facet#strong" },
{ $type: "pub.leaflet.richtext.facet#bold" },
],
},
{
index: { byteStart: 17, byteEnd: 23 },
features: [{ $type: "pub.oxa.richtext.facet#emphasis" }],
features: [
{ $type: "pub.oxa.richtext.facet#emphasis" },
{ $type: "pub.leaflet.richtext.facet#italic" },
],
},
],
},
Expand Down Expand Up @@ -584,7 +596,10 @@ describe("flattenInlines", () => {
facets: [
{
index: { byteStart: 6, byteEnd: 11 },
features: [{ $type: "pub.oxa.richtext.facet#strong" }],
features: [
{ $type: "pub.oxa.richtext.facet#strong" },
{ $type: "pub.leaflet.richtext.facet#bold" },
],
},
],
});
Expand All @@ -604,11 +619,17 @@ describe("flattenInlines", () => {
facets: [
{
index: { byteStart: 8, byteEnd: 12 },
features: [{ $type: "pub.oxa.richtext.facet#strong" }],
features: [
{ $type: "pub.oxa.richtext.facet#strong" },
{ $type: "pub.leaflet.richtext.facet#bold" },
],
},
{
index: { byteStart: 17, byteEnd: 23 },
features: [{ $type: "pub.oxa.richtext.facet#emphasis" }],
features: [
{ $type: "pub.oxa.richtext.facet#emphasis" },
{ $type: "pub.leaflet.richtext.facet#italic" },
],
},
],
});
Expand All @@ -622,7 +643,10 @@ describe("flattenInlines", () => {
facets: [
{
index: { byteStart: 4, byteEnd: 9 },
features: [{ $type: "pub.oxa.richtext.facet#emphasis" }],
features: [
{ $type: "pub.oxa.richtext.facet#emphasis" },
{ $type: "pub.leaflet.richtext.facet#italic" },
],
},
],
});
Expand All @@ -641,11 +665,17 @@ describe("flattenInlines", () => {
facets: [
{
index: { byteStart: 6, byteEnd: 15 },
features: [{ $type: "pub.oxa.richtext.facet#strong" }],
features: [
{ $type: "pub.oxa.richtext.facet#strong" },
{ $type: "pub.leaflet.richtext.facet#bold" },
],
},
{
index: { byteStart: 20, byteEnd: 32 },
features: [{ $type: "pub.oxa.richtext.facet#emphasis" }],
features: [
{ $type: "pub.oxa.richtext.facet#emphasis" },
{ $type: "pub.leaflet.richtext.facet#italic" },
],
},
],
});
Expand All @@ -669,11 +699,17 @@ describe("flattenInlines", () => {
expect.arrayContaining([
{
index: { byteStart: 0, byteEnd: 20 },
features: [{ $type: "pub.oxa.richtext.facet#strong" }],
features: [
{ $type: "pub.oxa.richtext.facet#strong" },
{ $type: "pub.leaflet.richtext.facet#bold" },
],
},
{
index: { byteStart: 9, byteEnd: 20 },
features: [{ $type: "pub.oxa.richtext.facet#emphasis" }],
features: [
{ $type: "pub.oxa.richtext.facet#emphasis" },
{ $type: "pub.leaflet.richtext.facet#italic" },
],
},
]),
);
Expand All @@ -687,7 +723,10 @@ describe("flattenInlines", () => {
facets: [
{
index: { byteStart: 0, byteEnd: 8 },
features: [{ $type: "pub.oxa.richtext.facet#emphasis" }],
features: [
{ $type: "pub.oxa.richtext.facet#emphasis" },
{ $type: "pub.leaflet.richtext.facet#italic" },
],
},
],
});
Expand Down Expand Up @@ -762,15 +801,24 @@ describe("flattenInlines", () => {
expect.arrayContaining([
{
index: { byteStart: 0, byteEnd: 14 },
features: [{ $type: "pub.oxa.richtext.facet#strong" }],
features: [
{ $type: "pub.oxa.richtext.facet#strong" },
{ $type: "pub.leaflet.richtext.facet#bold" },
],
},
{
index: { byteStart: 5, byteEnd: 14 },
features: [{ $type: "pub.oxa.richtext.facet#emphasis" }],
features: [
{ $type: "pub.oxa.richtext.facet#emphasis" },
{ $type: "pub.leaflet.richtext.facet#italic" },
],
},
{
index: { byteStart: 10, byteEnd: 14 },
features: [{ $type: "pub.oxa.richtext.facet#strong" }],
features: [
{ $type: "pub.oxa.richtext.facet#strong" },
{ $type: "pub.leaflet.richtext.facet#bold" },
],
},
]),
);
Expand All @@ -795,7 +843,10 @@ describe("flattenInlines", () => {
facets: [
{
index: { byteStart: 0, byteEnd: 6 },
features: [{ $type: "pub.oxa.richtext.facet#strong" }],
features: [
{ $type: "pub.oxa.richtext.facet#strong" },
{ $type: "pub.leaflet.richtext.facet#bold" },
],
},
],
});
Expand Down
29 changes: 18 additions & 11 deletions packages/oxa-core/src/convert.ts
Original file line number Diff line number Diff line change
Expand Up @@ -153,27 +153,34 @@ const facetFeatureTypes = {
* Compatible facet features from other AT Protocol namespaces.
*
* When an OXA facet feature has a semantically equivalent type in another
* namespace (e.g. Bluesky's `app.bsky.richtext.facet`), the converter emits
* both features in the same facet. This gives consumers that understand the
* other namespace free interoperability without OXA depending on that
* namespace (e.g. Bluesky's `app.bsky.richtext.facet`), the converter
* emits both features in the same facet. This gives consumers that understand
* the other namespace free interoperability without OXA depending on that
* namespace for its core schema.
*
* Each key is an OXA facet feature `$type`. The value is an array of
* functions that receive the OXA inline node and return a compatible
* feature object (or `null` to skip).
*
* Currently empty — the only OXA facet features (`strong`, `emphasis`) have
* no Bluesky equivalents. When `Link` is added to the OXA schema, an entry
* like the following would provide Bluesky link interop:
*
* "pub.oxa.richtext.facet#link": [
* (node) => ({ $type: "app.bsky.richtext.facet#link", uri: node.uri }),
* ],
* These lexicons should be checked periodically for new features that
* could be mapped here:
* - Leaflet: https://github.com/hyperlink-academy/leaflet/blob/main/lexicons/pub/leaflet/richtext/facet.json
* - Bluesky: https://github.com/bluesky-social/atproto/blob/main/lexicons/app/bsky/richtext/facet.json
*/
export const compatibleFeatures: Record<
string,
Array<(node: Record<string, unknown>) => FacetFeature | null>
> = {};
> = {
"pub.oxa.richtext.facet#strong": [
() => ({ $type: "pub.leaflet.richtext.facet#bold" }),
],
"pub.oxa.richtext.facet#emphasis": [
() => ({ $type: "pub.leaflet.richtext.facet#italic" }),
],
"pub.oxa.richtext.facet#inlineCode": [
() => ({ $type: "pub.leaflet.richtext.facet#code" }),
],
};

const formattingPropertyNames = ["id", "classes", "data"] as const;
const blockPropertyNames = ["id", "classes", "data"] as const;
Expand Down
Loading