From bb122a85f1592dfa22eecdf347db9f502a39f2f4 Mon Sep 17 00:00:00 2001 From: Nokome Bentley Date: Wed, 25 Mar 2026 18:04:53 +1300 Subject: [PATCH] feat(atproto): declare Leaflet `compatibleFeatures` --- docs/atproto-lexicon.md | 33 ++++++++--- packages/oxa-core/src/convert.test.ts | 85 +++++++++++++++++++++------ packages/oxa-core/src/convert.ts | 29 +++++---- 3 files changed, 111 insertions(+), 36 deletions(-) diff --git a/docs/atproto-lexicon.md b/docs/atproto-lexicon.md index 18af741..6d05983 100644 --- a/docs/atproto-lexicon.md +++ b/docs/atproto-lexicon.md @@ -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 @@ -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" } + ] } ] } @@ -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" } + ] } ] } diff --git a/packages/oxa-core/src/convert.test.ts b/packages/oxa-core/src/convert.test.ts index 131a5cd..50d5645 100644 --- a/packages/oxa-core/src/convert.test.ts +++ b/packages/oxa-core/src/convert.test.ts @@ -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" }, + ], }, ], }); @@ -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" }, + ], }, ], }); @@ -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" }, + ], }, ], }, @@ -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" }, + ], }, ], }); @@ -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" }, + ], }, ], }); @@ -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" }, + ], }, ], }); @@ -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" }, + ], }, ], }); @@ -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" }, + ], }, ]), ); @@ -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" }, + ], }, ], }); @@ -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" }, + ], }, ]), ); @@ -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" }, + ], }, ], }); diff --git a/packages/oxa-core/src/convert.ts b/packages/oxa-core/src/convert.ts index e303807..8d838ad 100644 --- a/packages/oxa-core/src/convert.ts +++ b/packages/oxa-core/src/convert.ts @@ -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) => 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;