A community based topic aggregation platform built on atproto

Merge pull request #5 from BrettM86/feat/richtext-facet-implementation

feat: Reimplement richtext using Bluesky-inspired facet system

+19
.claude/commands/create-pr.md
···
+
# Create Pull Request Command
+
+
Create a new branch, commit changes, and submit a pull request.
+
+
## Behavior
+
- Creates a new branch based on current changes
+
- Formats modified files using Biome
+
- Analyzes changes and automatically splits into logical commits when appropriate
+
- Each commit focuses on a single logical change or feature
+
- Creates descriptive commit messages for each logical unit
+
- Pushes branch to remote
+
- Creates pull request with proper summary and test plan
+
+
## Guidelines for Automatic Commit Splitting
+
- Split commits by feature, component, or concern
+
- Keep related file changes together in the same commit
+
- Separate refactoring from feature additions
+
- Ensure each commit can be understood independently
+
- Multiple unrelated changes should be split into separate commits
+8
.claude/commands/fastcommit.md
···
+
Create new fast commit task
+
+
This task uses the same logic as the commit task (.claude/commands/commit.md) but automatically selects the first suggested commit message without asking for confirmation.
+
+
Generate 3 commit message suggestions following the same format as the commit task
+
Automatically use the first suggestion without asking the user
+
Immediately run git commit -m with the first message
+
All other behaviors remain the same as the commit task (format, package names, staged files only)
+9
.claude/commands/update-branch-name.md
···
+
# Update Branch Name
+
+
Follow these steps to update the current branch name:
+
+
1. Check differences between current branch and main branch HEAD using `git diff main...HEAD`
+
2. Analyze the changed files to understand what work is being done
+
3. Determine an appropriate descriptive branch name based on the changes
+
4. Update the current branch name using `git branch -m [new-branch-name]`
+
5. Verify the branch name was updated with `git branch`
+4 -1
.gitignore
···
# Temporary files
*.tmp
-
*.temp
+
*.temp
+
+
# Build artifacts
+
/validate-lexicon
+1 -1
cmd/validate-lexicon/main.go
···
"social.coves.actor.profile",
"social.coves.community.profile",
"social.coves.post.text",
-
"social.coves.richtext.markup",
+
"social.coves.richtext.facet",
}
if verbose {
+3 -7
internal/atproto/lexicon/social/coves/actor/profile.json
···
"maxLength": 2560,
"description": "User bio with rich text support"
},
-
"bioMarkup": {
+
"bioFacets": {
"type": "array",
"description": "Rich text annotations for bio",
"items": {
-
"type": "union",
-
"refs": [
-
"social.coves.richtext.markup",
-
"social.coves.richtext.mention",
-
"social.coves.richtext.link"
-
]
+
"type": "ref",
+
"ref": "social.coves.richtext.facet"
}
},
"avatar": {
+3 -7
internal/atproto/lexicon/social/coves/actor/updateProfile.json
···
"maxLength": 2560,
"description": "User bio with rich text support"
},
-
"bioMarkup": {
+
"bioFacets": {
"type": "array",
"description": "Rich text annotations for bio",
"items": {
-
"type": "union",
-
"refs": [
-
"social.coves.richtext.markup",
-
"social.coves.richtext.mention",
-
"social.coves.richtext.link"
-
]
+
"type": "ref",
+
"ref": "social.coves.richtext.facet"
}
},
"avatar": {
+3 -7
internal/atproto/lexicon/social/coves/community/profile.json
···
"maxLength": 10000,
"description": "Community description with rich text support"
},
-
"descriptionMarkup": {
+
"descriptionFacets": {
"type": "array",
"description": "Rich text annotations for description",
"items": {
-
"type": "union",
-
"refs": [
-
"social.coves.richtext.markup",
-
"social.coves.richtext.mention",
-
"social.coves.richtext.link"
-
]
+
"type": "ref",
+
"ref": "social.coves.richtext.facet"
}
},
"avatar": {
+3 -7
internal/atproto/lexicon/social/coves/interaction/comment.json
···
"maxLength": 10000,
"description": "Comment text"
},
-
"markup": {
+
"facets": {
"type": "array",
"description": "Rich text annotations",
"items": {
-
"type": "union",
-
"refs": [
-
"social.coves.richtext.markup",
-
"social.coves.richtext.mention",
-
"social.coves.richtext.link"
-
]
+
"type": "ref",
+
"ref": "social.coves.richtext.facet"
}
}
}
+3 -7
internal/atproto/lexicon/social/coves/interaction/createComment.json
···
"maxLength": 30000,
"description": "Comment text"
},
-
"textMarkup": {
+
"textFacets": {
"type": "array",
"description": "Rich text annotations",
"items": {
-
"type": "union",
-
"refs": [
-
"social.coves.richtext.markup",
-
"social.coves.richtext.mention",
-
"social.coves.richtext.link"
-
]
+
"type": "ref",
+
"ref": "social.coves.richtext.facet"
}
}
}
+4 -8
internal/atproto/lexicon/social/coves/post/article.json
···
"maxLength": 50000,
"description": "Optional text content to accompany link"
},
-
"contentMarkup": {
+
"facets": {
"type": "array",
-
"description": "Rich text annotations for content",
+
"description": "Rich text annotations",
"items": {
-
"type": "union",
-
"refs": [
-
"social.coves.richtext.markup",
-
"social.coves.richtext.mention",
-
"social.coves.richtext.link"
-
]
+
"type": "ref",
+
"ref": "social.coves.richtext.facet"
}
},
"tags": {
+3 -7
internal/atproto/lexicon/social/coves/post/create.json
···
"maxLength": 40000,
"description": "Post body text"
},
-
"textMarkup": {
+
"textFacets": {
"type": "array",
"description": "Rich text annotations for body",
"items": {
-
"type": "union",
-
"refs": [
-
"social.coves.richtext.markup",
-
"social.coves.richtext.mention",
-
"social.coves.richtext.link"
-
]
+
"type": "ref",
+
"ref": "social.coves.richtext.facet"
}
},
"images": {
+3 -7
internal/atproto/lexicon/social/coves/post/get.json
···
"text": {
"type": "string"
},
-
"textMarkup": {
+
"textFacets": {
"type": "array",
"items": {
-
"type": "union",
-
"refs": [
-
"social.coves.richtext.markup",
-
"social.coves.richtext.mention",
-
"social.coves.richtext.link"
-
]
+
"type": "ref",
+
"ref": "social.coves.richtext.facet"
}
},
"images": {
+3 -7
internal/atproto/lexicon/social/coves/post/image.json
···
"maxLength": 10000,
"description": "Optional caption or description"
},
-
"captionMarkup": {
+
"captionFacets": {
"type": "array",
"description": "Rich text annotations for caption",
"items": {
-
"type": "union",
-
"refs": [
-
"social.coves.richtext.markup",
-
"social.coves.richtext.mention",
-
"social.coves.richtext.link"
-
]
+
"type": "ref",
+
"ref": "social.coves.richtext.facet"
}
},
"tags": {
+4 -16
internal/atproto/lexicon/social/coves/post/microblog.json
···
"maxLength": 5000,
"description": "Microblog post content (plain text or markdown)"
},
-
"contentMarkup": {
+
"facets": {
"type": "array",
-
"description": "Rich text annotations for content",
+
"description": "Rich text annotations",
"items": {
-
"type": "union",
-
"refs": [
-
"social.coves.richtext.markup",
-
"social.coves.richtext.mention",
-
"social.coves.richtext.link"
-
]
+
"type": "ref",
+
"ref": "social.coves.richtext.facet"
}
},
"title": {
···
"items": {
"type": "string",
"maxLength": 100
-
}
-
},
-
"mentions": {
-
"type": "array",
-
"description": "User mentions in the post",
-
"items": {
-
"type": "ref",
-
"ref": "social.coves.richtext.mention"
}
},
"embed": {
+4 -8
internal/atproto/lexicon/social/coves/post/text.json
···
"maxLength": 50000,
"description": "Markdown-formatted post content"
},
-
"contentMarkup": {
+
"facets": {
"type": "array",
-
"description": "Rich text annotations for content",
+
"description": "Rich text annotations",
"items": {
-
"type": "union",
-
"refs": [
-
"social.coves.richtext.markup",
-
"social.coves.richtext.mention",
-
"social.coves.richtext.link"
-
]
+
"type": "ref",
+
"ref": "social.coves.richtext.facet"
}
},
"tags": {
+3 -7
internal/atproto/lexicon/social/coves/post/update.json
···
"maxLength": 100000,
"description": "Updated body text"
},
-
"textMarkup": {
+
"textFacets": {
"type": "array",
"description": "Updated rich text annotations",
"items": {
-
"type": "union",
-
"refs": [
-
"social.coves.richtext.markup",
-
"social.coves.richtext.mention",
-
"social.coves.richtext.link"
-
]
+
"type": "ref",
+
"ref": "social.coves.richtext.facet"
}
},
"tags": {
+3 -7
internal/atproto/lexicon/social/coves/post/video.json
···
"maxLength": 10000,
"description": "Optional caption or description"
},
-
"captionMarkup": {
+
"captionFacets": {
"type": "array",
"description": "Rich text annotations for caption",
"items": {
-
"type": "union",
-
"refs": [
-
"social.coves.richtext.markup",
-
"social.coves.richtext.mention",
-
"social.coves.richtext.link"
-
]
+
"type": "ref",
+
"ref": "social.coves.richtext.facet"
}
},
"tags": {
+209
internal/atproto/lexicon/social/coves/richtext/README.md
···
+
# Rich Text Facets Documentation
+
+
## Overview
+
+
Rich text facets provide a way to annotate ranges of text with formatting, mentions, links, and other features in the Coves platform. This implementation follows the AT Protocol standards while extending them with additional formatting options.
+
+
## UTF-8 Byte Counting
+
+
**IMPORTANT**: All byte indices in facets use UTF-8 byte positions, not character positions or UTF-16 code units.
+
+
### Why UTF-8 Bytes?
+
+
The AT Protocol uses UTF-8 byte counting to ensure consistent text indexing across all platforms and programming languages. This is crucial because:
+
+
1. **Character counting varies** - What counts as one "character" differs between Unicode grapheme clusters, code points, and visual characters
+
2. **UTF-16 inconsistencies** - JavaScript uses UTF-16 internally, but other languages don't
+
3. **Network efficiency** - AT Protocol data is transmitted as UTF-8
+
+
### Calculating Byte Positions
+
+
```go
+
text := "Hello 👋 @alice!"
+
// Finding byte position of "@alice"
+
prefix := "Hello 👋 "
+
byteStart := len([]byte(prefix)) // 9 bytes (not 8 characters!)
+
byteEnd := byteStart + len([]byte("@alice")) // 9 + 6 = 15
+
```
+
+
### Common Pitfalls
+
+
1. **Emoji can be multiple bytes**:
+
- "👋" = 4 bytes
+
- "👨‍👩‍👧‍👧" = 25 bytes (family emoji with zero-width joiners)
+
+
2. **Non-ASCII text**:
+
- "café" = 5 bytes (é is 2 bytes)
+
- "Привет" = 12 bytes (each Cyrillic letter is 2 bytes)
+
+
## Facet Structure
+
+
Each facet consists of:
+
- **index**: Byte range in the text
+
- **features**: Array of features applied to this range
+
+
```json
+
{
+
"index": {
+
"byteStart": 5,
+
"byteEnd": 11
+
},
+
"features": [
+
{
+
"$type": "social.coves.richtext.facet#mention",
+
"did": "did:plc:example123",
+
"handle": "alice.bsky.social"
+
}
+
]
+
}
+
```
+
+
## Supported Feature Types
+
+
### 1. Mention (`social.coves.richtext.facet#mention`)
+
For @mentions of users or !mentions of communities.
+
+
```json
+
{
+
"$type": "social.coves.richtext.facet#mention",
+
"did": "did:plc:example123",
+
"handle": "alice.bsky.social" // Optional, for display
+
}
+
```
+
+
### 2. Link (`social.coves.richtext.facet#link`)
+
For hyperlinks in text.
+
+
```json
+
{
+
"$type": "social.coves.richtext.facet#link",
+
"uri": "https://example.com"
+
}
+
```
+
+
### 3. Bold (`social.coves.richtext.facet#bold`)
+
For **bold** text formatting.
+
+
```json
+
{
+
"$type": "social.coves.richtext.facet#bold"
+
}
+
```
+
+
### 4. Italic (`social.coves.richtext.facet#italic`)
+
For *italic* text formatting.
+
+
```json
+
{
+
"$type": "social.coves.richtext.facet#italic"
+
}
+
```
+
+
### 5. Strikethrough (`social.coves.richtext.facet#strikethrough`)
+
For ~~strikethrough~~ text formatting.
+
+
```json
+
{
+
"$type": "social.coves.richtext.facet#strikethrough"
+
}
+
```
+
+
### 6. Spoiler (`social.coves.richtext.facet#spoiler`)
+
For hidden/spoiler text that requires user interaction to reveal.
+
+
```json
+
{
+
"$type": "social.coves.richtext.facet#spoiler",
+
"reason": "Movie spoiler" // Optional
+
}
+
```
+
+
## Examples
+
+
### Complete Post with Facets
+
+
```json
+
{
+
"text": "Check out **this** amazing post by @alice about ~secret stuff~!",
+
"facets": [
+
{
+
"index": {"byteStart": 10, "byteEnd": 18},
+
"features": [{"$type": "social.coves.richtext.facet#bold"}]
+
},
+
{
+
"index": {"byteStart": 36, "byteEnd": 42},
+
"features": [{
+
"$type": "social.coves.richtext.facet#mention",
+
"did": "did:plc:alice123",
+
"handle": "alice.coves.social"
+
}]
+
},
+
{
+
"index": {"byteStart": 49, "byteEnd": 62},
+
"features": [{
+
"$type": "social.coves.richtext.facet#spoiler",
+
"reason": "Plot details"
+
}]
+
}
+
]
+
}
+
```
+
+
### Multiple Features on Same Range
+
+
Text can have multiple formatting features:
+
+
```json
+
{
+
"text": "This is ***really*** important!",
+
"facets": [
+
{
+
"index": {"byteStart": 8, "byteEnd": 20},
+
"features": [
+
{"$type": "social.coves.richtext.facet#bold"},
+
{"$type": "social.coves.richtext.facet#italic"}
+
]
+
}
+
]
+
}
+
```
+
+
## Best Practices
+
+
1. **Validate byte ranges**: Ensure byteEnd > byteStart and both are within text bounds
+
2. **Sort facets**: Order facets by byteStart for easier processing
+
3. **Handle overlaps**: Multiple facets can overlap - render them in a sensible order
+
4. **Validate features**: Each feature must have a valid `$type` field
+
5. **UTF-8 safety**: Always calculate bytes using UTF-8 encoding, not string length
+
+
## Integration with Bluesky
+
+
When federating content from Bluesky:
+
- Bluesky uses `app.bsky.richtext.facet` with similar structure
+
- Convert their facet types to Coves equivalents
+
- Preserve byte indices (they also use UTF-8)
+
+
## Client Implementation Notes
+
+
For web clients:
+
```javascript
+
// Converting JavaScript string index to UTF-8 bytes
+
const textEncoder = new TextEncoder();
+
const bytes = textEncoder.encode(text.substring(0, charIndex));
+
const byteIndex = bytes.length;
+
```
+
+
For Go implementations:
+
```go
+
// Already UTF-8 native
+
byteIndex := len(text[:runeIndex])
+
```
+
+
## Validation
+
+
Always validate:
+
1. Byte indices are non-negative integers
+
2. ByteEnd > byteStart
+
3. Byte ranges don't exceed text length
+
4. Each feature has required fields
+
5. `$type` values are recognized
+134
internal/atproto/lexicon/social/coves/richtext/facet.json
···
+
{
+
"lexicon": 1,
+
"id": "social.coves.richtext.facet",
+
"defs": {
+
"main": {
+
"type": "object",
+
"description": "Annotation of a sub-string within rich text",
+
"required": ["index", "features"],
+
"properties": {
+
"index": {
+
"type": "ref",
+
"ref": "#byteSlice"
+
},
+
"features": {
+
"type": "array",
+
"description": "Features applied to this text range",
+
"items": {
+
"type": "union",
+
"refs": [
+
"#mention",
+
"#link",
+
"#bold",
+
"#italic",
+
"#strikethrough",
+
"#spoiler"
+
]
+
}
+
}
+
}
+
},
+
"byteSlice": {
+
"type": "object",
+
"description": "Specifies the sub-string range via byte indices",
+
"required": ["byteStart", "byteEnd"],
+
"properties": {
+
"byteStart": {
+
"type": "integer",
+
"minimum": 0,
+
"description": "Inclusive start position in UTF-8 bytes"
+
},
+
"byteEnd": {
+
"type": "integer",
+
"minimum": 0,
+
"description": "Exclusive end position in UTF-8 bytes"
+
}
+
}
+
},
+
"mention": {
+
"type": "object",
+
"description": "Facet feature for user or community mentions",
+
"required": ["$type", "did"],
+
"properties": {
+
"$type": {
+
"type": "string",
+
"const": "social.coves.richtext.facet#mention"
+
},
+
"did": {
+
"type": "string",
+
"format": "did",
+
"description": "DID of the mentioned user (@) or community (!)"
+
},
+
"handle": {
+
"type": "string",
+
"description": "Handle at time of mention (may change)"
+
}
+
}
+
},
+
"link": {
+
"type": "object",
+
"description": "Facet feature for hyperlinks",
+
"required": ["$type", "uri"],
+
"properties": {
+
"$type": {
+
"type": "string",
+
"const": "social.coves.richtext.facet#link"
+
},
+
"uri": {
+
"type": "string",
+
"format": "uri",
+
"description": "Target URI of the link"
+
}
+
}
+
},
+
"bold": {
+
"type": "object",
+
"description": "Bold text formatting",
+
"required": ["$type"],
+
"properties": {
+
"$type": {
+
"type": "string",
+
"const": "social.coves.richtext.facet#bold"
+
}
+
}
+
},
+
"italic": {
+
"type": "object",
+
"description": "Italic text formatting",
+
"required": ["$type"],
+
"properties": {
+
"$type": {
+
"type": "string",
+
"const": "social.coves.richtext.facet#italic"
+
}
+
}
+
},
+
"strikethrough": {
+
"type": "object",
+
"description": "Strikethrough text formatting",
+
"required": ["$type"],
+
"properties": {
+
"$type": {
+
"type": "string",
+
"const": "social.coves.richtext.facet#strikethrough"
+
}
+
}
+
},
+
"spoiler": {
+
"type": "object",
+
"description": "Hidden/spoiler text that requires user interaction to reveal",
+
"required": ["$type"],
+
"properties": {
+
"$type": {
+
"type": "string",
+
"const": "social.coves.richtext.facet#spoiler"
+
},
+
"reason": {
+
"type": "string",
+
"maxLength": 128,
+
"description": "Optional explanation of what's hidden"
+
}
+
}
+
}
+
}
+
}
+351
internal/atproto/lexicon/social/coves/richtext/facet_test.go
···
+
package richtext
+
+
import (
+
"encoding/json"
+
"testing"
+
)
+
+
// TestFacetStructure tests the basic structure of facets
+
func TestFacetStructure(t *testing.T) {
+
tests := []struct {
+
name string
+
facet string
+
wantErr bool
+
}{
+
{
+
name: "valid mention facet",
+
facet: `{
+
"index": {
+
"byteStart": 5,
+
"byteEnd": 18
+
},
+
"features": [{
+
"$type": "social.coves.richtext.facet#mention",
+
"did": "did:plc:example123",
+
"handle": "alice.bsky.social"
+
}]
+
}`,
+
wantErr: false,
+
},
+
{
+
name: "valid link facet",
+
facet: `{
+
"index": {
+
"byteStart": 10,
+
"byteEnd": 35
+
},
+
"features": [{
+
"$type": "social.coves.richtext.facet#link",
+
"uri": "https://example.com"
+
}]
+
}`,
+
wantErr: false,
+
},
+
{
+
name: "valid formatting facet",
+
facet: `{
+
"index": {
+
"byteStart": 0,
+
"byteEnd": 5
+
},
+
"features": [{
+
"$type": "social.coves.richtext.facet#bold"
+
}]
+
}`,
+
wantErr: false,
+
},
+
{
+
name: "multiple features on same range",
+
facet: `{
+
"index": {
+
"byteStart": 0,
+
"byteEnd": 10
+
},
+
"features": [
+
{"$type": "social.coves.richtext.facet#bold"},
+
{"$type": "social.coves.richtext.facet#italic"}
+
]
+
}`,
+
wantErr: false,
+
},
+
}
+
+
for _, tt := range tests {
+
t.Run(tt.name, func(t *testing.T) {
+
var facet map[string]interface{}
+
err := json.Unmarshal([]byte(tt.facet), &facet)
+
if err != nil {
+
if !tt.wantErr {
+
t.Errorf("json.Unmarshal() unexpected error = %v", err)
+
}
+
return
+
}
+
+
// Basic validation
+
if _, hasIndex := facet["index"]; !hasIndex && !tt.wantErr {
+
t.Error("facet missing required 'index' field")
+
}
+
if _, hasFeatures := facet["features"]; !hasFeatures && !tt.wantErr {
+
t.Error("facet missing required 'features' field")
+
}
+
})
+
}
+
}
+
+
// TestUTF8ByteCounting tests proper UTF-8 byte counting for facets
+
func TestUTF8ByteCounting(t *testing.T) {
+
tests := []struct {
+
name string
+
text string
+
substring string
+
wantStart int
+
wantEnd int
+
}{
+
{
+
name: "ASCII text",
+
text: "Hello @alice!",
+
substring: "@alice",
+
wantStart: 6,
+
wantEnd: 12,
+
},
+
{
+
name: "Emoji in text",
+
text: "Hi 👋 @alice!",
+
substring: "@alice",
+
wantStart: 8, // "Hi " (3) + "👋" (4) + " " (1) = 8
+
wantEnd: 14, // 8 + 6 = 14
+
},
+
{
+
name: "Complex emoji (family)",
+
text: "Family: 👨‍👩‍👧‍👧 @alice",
+
substring: "@alice",
+
wantStart: 34, // "Family: " (8) + complex emoji (25) + " " (1) = 34
+
wantEnd: 40, // 34 + 6 = 40
+
},
+
{
+
name: "Multibyte characters",
+
text: "Привет @alice!",
+
substring: "@alice",
+
wantStart: 13, // Cyrillic "Привет " = 12 bytes + 1 space = 13
+
wantEnd: 19, // 13 + 6 = 19
+
},
+
{
+
name: "Mixed content",
+
text: "Test 测试 @alice done",
+
substring: "@alice",
+
wantStart: 12, // "Test " (5) + "测试" (6) + " " (1) = 12
+
wantEnd: 18, // 12 + 6 = 18
+
},
+
}
+
+
for _, tt := range tests {
+
t.Run(tt.name, func(t *testing.T) {
+
// Find byte positions using strings.Index (which works on bytes)
+
idx := -1
+
for i := 0; i < len(tt.text); i++ {
+
if i+len(tt.substring) <= len(tt.text) && tt.text[i:i+len(tt.substring)] == tt.substring {
+
idx = i
+
break
+
}
+
}
+
+
if idx == -1 {
+
t.Fatalf("substring %q not found in text %q", tt.substring, tt.text)
+
}
+
+
// Calculate byte positions
+
startByte := len([]byte(tt.text[:idx]))
+
endByte := startByte + len([]byte(tt.substring))
+
+
if startByte != tt.wantStart {
+
t.Errorf("ByteStart = %d, want %d", startByte, tt.wantStart)
+
}
+
if endByte != tt.wantEnd {
+
t.Errorf("ByteEnd = %d, want %d", endByte, tt.wantEnd)
+
}
+
})
+
}
+
}
+
+
// TestOverlappingFacets tests validation of overlapping facet ranges
+
func TestOverlappingFacets(t *testing.T) {
+
tests := []struct {
+
name string
+
facets []map[string]interface{}
+
expectError bool
+
description string
+
}{
+
{
+
name: "non-overlapping facets",
+
facets: []map[string]interface{}{
+
{
+
"index": map[string]int{
+
"byteStart": 0,
+
"byteEnd": 5,
+
},
+
},
+
{
+
"index": map[string]int{
+
"byteStart": 10,
+
"byteEnd": 15,
+
},
+
},
+
},
+
expectError: false,
+
description: "Facets with non-overlapping ranges should be valid",
+
},
+
{
+
name: "exact same range",
+
facets: []map[string]interface{}{
+
{
+
"index": map[string]int{
+
"byteStart": 5,
+
"byteEnd": 10,
+
},
+
},
+
{
+
"index": map[string]int{
+
"byteStart": 5,
+
"byteEnd": 10,
+
},
+
},
+
},
+
expectError: false,
+
description: "Multiple facets on the same range are allowed (e.g., bold + italic)",
+
},
+
{
+
name: "nested ranges",
+
facets: []map[string]interface{}{
+
{
+
"index": map[string]int{
+
"byteStart": 0,
+
"byteEnd": 20,
+
},
+
},
+
{
+
"index": map[string]int{
+
"byteStart": 5,
+
"byteEnd": 15,
+
},
+
},
+
},
+
expectError: false,
+
description: "Nested facet ranges are allowed",
+
},
+
{
+
name: "partial overlap",
+
facets: []map[string]interface{}{
+
{
+
"index": map[string]int{
+
"byteStart": 0,
+
"byteEnd": 10,
+
},
+
},
+
{
+
"index": map[string]int{
+
"byteStart": 5,
+
"byteEnd": 15,
+
},
+
},
+
},
+
expectError: false,
+
description: "Partially overlapping facets are allowed",
+
},
+
}
+
+
for _, tt := range tests {
+
t.Run(tt.name, func(t *testing.T) {
+
// For now, we're not implementing overlap validation
+
// as it's allowed in AT Protocol
+
// This test documents the expected behavior
+
if tt.expectError {
+
t.Skip("Overlap validation not implemented - all overlaps are currently allowed")
+
}
+
})
+
}
+
}
+
+
// TestFacetFeatureTypes tests all supported facet feature types
+
func TestFacetFeatureTypes(t *testing.T) {
+
featureTypes := []struct {
+
name string
+
typeName string
+
feature map[string]interface{}
+
}{
+
{
+
name: "mention",
+
typeName: "social.coves.richtext.facet#mention",
+
feature: map[string]interface{}{
+
"$type": "social.coves.richtext.facet#mention",
+
"did": "did:plc:example123",
+
"handle": "alice.bsky.social",
+
},
+
},
+
{
+
name: "link",
+
typeName: "social.coves.richtext.facet#link",
+
feature: map[string]interface{}{
+
"$type": "social.coves.richtext.facet#link",
+
"uri": "https://example.com",
+
},
+
},
+
{
+
name: "bold",
+
typeName: "social.coves.richtext.facet#bold",
+
feature: map[string]interface{}{
+
"$type": "social.coves.richtext.facet#bold",
+
},
+
},
+
{
+
name: "italic",
+
typeName: "social.coves.richtext.facet#italic",
+
feature: map[string]interface{}{
+
"$type": "social.coves.richtext.facet#italic",
+
},
+
},
+
{
+
name: "strikethrough",
+
typeName: "social.coves.richtext.facet#strikethrough",
+
feature: map[string]interface{}{
+
"$type": "social.coves.richtext.facet#strikethrough",
+
},
+
},
+
{
+
name: "spoiler",
+
typeName: "social.coves.richtext.facet#spoiler",
+
feature: map[string]interface{}{
+
"$type": "social.coves.richtext.facet#spoiler",
+
"reason": "Plot spoiler",
+
},
+
},
+
}
+
+
for _, ft := range featureTypes {
+
t.Run(ft.name, func(t *testing.T) {
+
// Verify the $type field is present and correct
+
if typeVal, ok := ft.feature["$type"].(string); !ok || typeVal != ft.typeName {
+
t.Errorf("Feature type mismatch: got %v, want %s", ft.feature["$type"], ft.typeName)
+
}
+
+
// Create a complete facet with this feature
+
facet := map[string]interface{}{
+
"index": map[string]interface{}{
+
"byteStart": 0,
+
"byteEnd": 10,
+
},
+
"features": []interface{}{ft.feature},
+
}
+
+
// Verify it can be marshaled/unmarshaled
+
data, err := json.Marshal(facet)
+
if err != nil {
+
t.Errorf("Failed to marshal facet: %v", err)
+
}
+
+
var decoded map[string]interface{}
+
if err := json.Unmarshal(data, &decoded); err != nil {
+
t.Errorf("Failed to unmarshal facet: %v", err)
+
}
+
})
+
}
+
}
-22
internal/atproto/lexicon/social/coves/richtext/link.json
···
-
{
-
"lexicon": 1,
-
"id": "social.coves.richtext.link",
-
"defs": {
-
"main": {
-
"type": "object",
-
"description": "Link within text content",
-
"required": ["index", "uri"],
-
"properties": {
-
"index": {
-
"type": "ref",
-
"ref": "social.coves.richtext.markup#byteSlice"
-
},
-
"uri": {
-
"type": "string",
-
"format": "uri",
-
"description": "Target URI of the link"
-
}
-
}
-
}
-
}
-
}
-71
internal/atproto/lexicon/social/coves/richtext/markup.json
···
-
{
-
"lexicon": 1,
-
"id": "social.coves.richtext.markup",
-
"defs": {
-
"main": {
-
"type": "object",
-
"description": "Text markup and formatting annotations",
-
"required": ["index", "features"],
-
"properties": {
-
"index": {
-
"type": "ref",
-
"ref": "#byteSlice"
-
},
-
"features": {
-
"type": "array",
-
"description": "Formatting features applied to this text range",
-
"items": {
-
"type": "union",
-
"refs": ["#bold", "#italic", "#code", "#strikethrough", "#spoiler"]
-
}
-
}
-
}
-
},
-
"byteSlice": {
-
"type": "object",
-
"description": "Byte position and length of text slice",
-
"required": ["byteStart", "byteLength"],
-
"properties": {
-
"byteStart": {
-
"type": "integer",
-
"minimum": 0
-
},
-
"byteLength": {
-
"type": "integer",
-
"minimum": 0
-
}
-
}
-
},
-
"bold": {
-
"type": "object",
-
"description": "Bold text formatting",
-
"properties": {}
-
},
-
"italic": {
-
"type": "object",
-
"description": "Italic text formatting",
-
"properties": {}
-
},
-
"code": {
-
"type": "object",
-
"description": "Code/monospace text formatting",
-
"properties": {}
-
},
-
"strikethrough": {
-
"type": "object",
-
"description": "Strikethrough text formatting",
-
"properties": {}
-
},
-
"spoiler": {
-
"type": "object",
-
"description": "Spoiler text that is hidden until clicked",
-
"properties": {
-
"reason": {
-
"type": "string",
-
"maxLength": 128,
-
"description": "Optional explanation of what's hidden"
-
}
-
}
-
}
-
}
-
}
-30
internal/atproto/lexicon/social/coves/richtext/mention.json
···
-
{
-
"lexicon": 1,
-
"id": "social.coves.richtext.mention",
-
"defs": {
-
"main": {
-
"type": "object",
-
"description": "Mention of a user or community in text",
-
"required": ["index", "did"],
-
"properties": {
-
"index": {
-
"type": "ref",
-
"ref": "social.coves.richtext.markup#byteSlice"
-
},
-
"did": {
-
"type": "string",
-
"format": "did",
-
"description": "DID of the mentioned user or community"
-
},
-
"handle": {
-
"type": "string",
-
"description": "Handle at time of mention (may change)"
-
},
-
"federatedFrom": {
-
"type": "string",
-
"description": "Platform the mentioned entity is from (bluesky, lemmy, coves)"
-
}
-
}
-
}
-
}
-
}
+2 -4
tests/lexicon_validation_test.go
···
"social.coves.post.image",
"social.coves.post.video",
"social.coves.post.article",
-
"social.coves.richtext.markup",
-
"social.coves.richtext.mention",
-
"social.coves.richtext.link",
+
"social.coves.richtext.facet",
"social.coves.embed.image",
"social.coves.embed.video",
"social.coves.embed.external",
···
// Test specific cross-references that should work
crossRefs := map[string]string{
-
"social.coves.richtext.markup#byteSlice": "byteSlice definition in markup schema",
+
"social.coves.richtext.facet#byteSlice": "byteSlice definition in facet schema",
"social.coves.actor.profile#geoLocation": "geoLocation definition in actor profile",
"social.coves.community.rules#rule": "rule definition in community rules",
}