From 43baf5e7734f69471e62ac69ccdbf5554a92f46b Mon Sep 17 00:00:00 2001 From: Bretton May <36870434+BrettM86@users.noreply.github.com> Date: Tue, 08 Jul 2025 21:24:49 +0000 Subject: [PATCH] Merge pull request #5 from BrettM86/feat/richtext-facet-implementation feat: Reimplement richtext using Bluesky-inspired facet system --- .claude/commands/create-pr.md | 19 +++++++++++++++++++ .claude/commands/fastcommit.md | 8 ++++++++ .claude/commands/update-branch-name.md | 9 +++++++++ .gitignore | 5 ++++- cmd/validate-lexicon/main.go | 2 +- internal/atproto/lexicon/social/coves/actor/profile.json | 10 +++------- internal/atproto/lexicon/social/coves/actor/updateProfile.json | 10 +++------- internal/atproto/lexicon/social/coves/community/profile.json | 10 +++------- internal/atproto/lexicon/social/coves/interaction/comment.json | 10 +++------- internal/atproto/lexicon/social/coves/interaction/createComment.json | 10 +++------- internal/atproto/lexicon/social/coves/post/article.json | 12 ++++-------- internal/atproto/lexicon/social/coves/post/create.json | 10 +++------- internal/atproto/lexicon/social/coves/post/get.json | 10 +++------- internal/atproto/lexicon/social/coves/post/image.json | 10 +++------- internal/atproto/lexicon/social/coves/post/microblog.json | 20 ++++---------------- internal/atproto/lexicon/social/coves/post/text.json | 12 ++++-------- internal/atproto/lexicon/social/coves/post/update.json | 10 +++------- internal/atproto/lexicon/social/coves/post/video.json | 10 +++------- internal/atproto/lexicon/social/coves/richtext/README.md | 209 +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ internal/atproto/lexicon/social/coves/richtext/facet.json | 134 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ internal/atproto/lexicon/social/coves/richtext/facet_test.go | 351 +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ internal/atproto/lexicon/social/coves/richtext/link.json | 22 ---------------------- internal/atproto/lexicon/social/coves/richtext/markup.json | 71 ----------------------------------------------------------------------- internal/atproto/lexicon/social/coves/richtext/mention.json | 30 ------------------------------ tests/lexicon_validation_test.go | 6 ++---- 25 file(s) changed, 779 insertion(s)(+), 231 deletion(s)(-) diff --git a/.claude/commands/create-pr.md b/.claude/commands/create-pr.md new file mode 100644 --- /dev/null +++ b/.claude/commands/create-pr.md @@ -0,0 +1,19 @@ +# 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 \ No newline at end of file diff --git a/.claude/commands/fastcommit.md b/.claude/commands/fastcommit.md new file mode 100644 --- /dev/null +++ b/.claude/commands/fastcommit.md @@ -0,0 +1,8 @@ +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) diff --git a/.claude/commands/update-branch-name.md b/.claude/commands/update-branch-name.md new file mode 100644 --- /dev/null +++ b/.claude/commands/update-branch-name.md @@ -0,0 +1,9 @@ +# 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` \ No newline at end of file diff --git a/.gitignore b/.gitignore --- a/.gitignore +++ b/.gitignore @@ -42,4 +42,7 @@ *.log # Temporary files *.tmp -*.temp \ No newline at end of file +*.temp + +# Build artifacts +/validate-lexicon \ No newline at end of file diff --git a/cmd/validate-lexicon/main.go b/cmd/validate-lexicon/main.go --- a/cmd/validate-lexicon/main.go +++ b/cmd/validate-lexicon/main.go @@ -79,7 +79,7 @@ expectedSchemas := []string{ "social.coves.actor.profile", "social.coves.community.profile", "social.coves.post.text", - "social.coves.richtext.markup", + "social.coves.richtext.facet", } if verbose { diff --git a/internal/atproto/lexicon/social/coves/actor/profile.json b/internal/atproto/lexicon/social/coves/actor/profile.json --- a/internal/atproto/lexicon/social/coves/actor/profile.json +++ b/internal/atproto/lexicon/social/coves/actor/profile.json @@ -27,16 +27,12 @@ "maxGraphemes": 256, "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": { diff --git a/internal/atproto/lexicon/social/coves/actor/updateProfile.json b/internal/atproto/lexicon/social/coves/actor/updateProfile.json --- a/internal/atproto/lexicon/social/coves/actor/updateProfile.json +++ b/internal/atproto/lexicon/social/coves/actor/updateProfile.json @@ -22,16 +22,12 @@ "maxGraphemes": 256, "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": { diff --git a/internal/atproto/lexicon/social/coves/community/profile.json b/internal/atproto/lexicon/social/coves/community/profile.json --- a/internal/atproto/lexicon/social/coves/community/profile.json +++ b/internal/atproto/lexicon/social/coves/community/profile.json @@ -27,16 +27,12 @@ "maxGraphemes": 1000, "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": { diff --git a/internal/atproto/lexicon/social/coves/interaction/comment.json b/internal/atproto/lexicon/social/coves/interaction/comment.json --- a/internal/atproto/lexicon/social/coves/interaction/comment.json +++ b/internal/atproto/lexicon/social/coves/interaction/comment.json @@ -44,16 +44,12 @@ "type": "string", "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" } } } diff --git a/internal/atproto/lexicon/social/coves/interaction/createComment.json b/internal/atproto/lexicon/social/coves/interaction/createComment.json --- a/internal/atproto/lexicon/social/coves/interaction/createComment.json +++ b/internal/atproto/lexicon/social/coves/interaction/createComment.json @@ -22,16 +22,12 @@ "maxGraphemes": 3000, "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" } } } diff --git a/internal/atproto/lexicon/social/coves/post/article.json b/internal/atproto/lexicon/social/coves/post/article.json --- a/internal/atproto/lexicon/social/coves/post/article.json +++ b/internal/atproto/lexicon/social/coves/post/article.json @@ -45,16 +45,12 @@ "type": "string", "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": { diff --git a/internal/atproto/lexicon/social/coves/post/create.json b/internal/atproto/lexicon/social/coves/post/create.json --- a/internal/atproto/lexicon/social/coves/post/create.json +++ b/internal/atproto/lexicon/social/coves/post/create.json @@ -33,16 +33,12 @@ "maxGraphemes": 10000, "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": { diff --git a/internal/atproto/lexicon/social/coves/post/get.json b/internal/atproto/lexicon/social/coves/post/get.json --- a/internal/atproto/lexicon/social/coves/post/get.json +++ b/internal/atproto/lexicon/social/coves/post/get.json @@ -64,15 +64,11 @@ }, "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": { diff --git a/internal/atproto/lexicon/social/coves/post/image.json b/internal/atproto/lexicon/social/coves/post/image.json --- a/internal/atproto/lexicon/social/coves/post/image.json +++ b/internal/atproto/lexicon/social/coves/post/image.json @@ -36,16 +36,12 @@ "type": "string", "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": { diff --git a/internal/atproto/lexicon/social/coves/post/microblog.json b/internal/atproto/lexicon/social/coves/post/microblog.json --- a/internal/atproto/lexicon/social/coves/post/microblog.json +++ b/internal/atproto/lexicon/social/coves/post/microblog.json @@ -20,16 +20,12 @@ "type": "string", "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": { @@ -44,14 +40,6 @@ "description": "Hashtags from the original post", "items": { "type": "string", "maxLength": 100 - } - }, - "mentions": { - "type": "array", - "description": "User mentions in the post", - "items": { - "type": "ref", - "ref": "social.coves.richtext.mention" } }, "embed": { diff --git a/internal/atproto/lexicon/social/coves/post/text.json b/internal/atproto/lexicon/social/coves/post/text.json --- a/internal/atproto/lexicon/social/coves/post/text.json +++ b/internal/atproto/lexicon/social/coves/post/text.json @@ -26,16 +26,12 @@ "type": "string", "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": { diff --git a/internal/atproto/lexicon/social/coves/post/update.json b/internal/atproto/lexicon/social/coves/post/update.json --- a/internal/atproto/lexicon/social/coves/post/update.json +++ b/internal/atproto/lexicon/social/coves/post/update.json @@ -28,16 +28,12 @@ "maxGraphemes": 10000, "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": { diff --git a/internal/atproto/lexicon/social/coves/post/video.json b/internal/atproto/lexicon/social/coves/post/video.json --- a/internal/atproto/lexicon/social/coves/post/video.json +++ b/internal/atproto/lexicon/social/coves/post/video.json @@ -31,16 +31,12 @@ "type": "string", "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": { diff --git a/internal/atproto/lexicon/social/coves/richtext/README.md b/internal/atproto/lexicon/social/coves/richtext/README.md new file mode 100644 --- /dev/null +++ b/internal/atproto/lexicon/social/coves/richtext/README.md @@ -0,0 +1,209 @@ +# 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 \ No newline at end of file diff --git a/internal/atproto/lexicon/social/coves/richtext/facet.json b/internal/atproto/lexicon/social/coves/richtext/facet.json new file mode 100644 --- /dev/null +++ b/internal/atproto/lexicon/social/coves/richtext/facet.json @@ -0,0 +1,134 @@ +{ + "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" + } + } + } + } +} \ No newline at end of file diff --git a/internal/atproto/lexicon/social/coves/richtext/facet_test.go b/internal/atproto/lexicon/social/coves/richtext/facet_test.go new file mode 100644 --- /dev/null +++ b/internal/atproto/lexicon/social/coves/richtext/facet_test.go @@ -0,0 +1,351 @@ +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) + } + }) + } +} \ No newline at end of file diff --git a/internal/atproto/lexicon/social/coves/richtext/link.json b/internal/atproto/lexicon/social/coves/richtext/link.json deleted file mode 100644 --- a/internal/atproto/lexicon/social/coves/richtext/link.json +++ /dev/null @@ -1,22 +0,0 @@ -{ - "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" - } - } - } - } -} \ No newline at end of file diff --git a/internal/atproto/lexicon/social/coves/richtext/markup.json b/internal/atproto/lexicon/social/coves/richtext/markup.json deleted file mode 100644 --- a/internal/atproto/lexicon/social/coves/richtext/markup.json +++ /dev/null @@ -1,71 +0,0 @@ -{ - "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" - } - } - } - } -} \ No newline at end of file diff --git a/internal/atproto/lexicon/social/coves/richtext/mention.json b/internal/atproto/lexicon/social/coves/richtext/mention.json deleted file mode 100644 --- a/internal/atproto/lexicon/social/coves/richtext/mention.json +++ /dev/null @@ -1,30 +0,0 @@ -{ - "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)" - } - } - } - } -} \ No newline at end of file diff --git a/tests/lexicon_validation_test.go b/tests/lexicon_validation_test.go --- a/tests/lexicon_validation_test.go +++ b/tests/lexicon_validation_test.go @@ -28,9 +28,7 @@ "social.coves.post.text", "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", @@ -64,7 +62,7 @@ } // 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", } -- tangled.sh