| … | … | @@ -55,10 +55,15 @@ const WIKILINK_RE = /\[\[([^\][|]+)(?:\|([^\][]*))?\]\]/g |
| 55 | 55 | // letter. A '#' inside a word, or one followed only by digits, is literal text. |
| 56 | 56 | const HASHTAG_RE = /(^|\s)(#([A-Za-z0-9_À-]{1,64}))/g |
| 57 | 57 | |
| 58 | // §3.2 — only http/https may carry a link facet. did: is handled separately, and ONLY for | |
| 59 | // mentions (§3.4); anything else emits its label as plain text with no facet. | |
| 58 | // §3.2 — only http/https may carry a link facet. Mention schemes are handled separately (§3.4); | |
| 59 | // anything else emits its label as plain text with no facet. | |
| 60 | 60 | const isHttp = (uri) => /^https?:\/\//i.test(String(uri || '')) |
| 61 | const isDid = (uri) => /^did:/i.test(String(uri || '')) | |
| 61 | ||
| 62 | // §3.4 — a mention names a USER. `favela://<identity-root>` is the canonical form; `did:` is the | |
| 63 | // LEGACY form kept because §8 is forward-only and nothing is rewritten. strip() does NOT resolve | |
| 64 | // either: it is a codec with no registry access, and resolution to a bsky DID belongs at | |
| 65 | // PROJECTION time (identity.md §5). So the facet carries the target exactly as written. | |
| 66 | const isMention = (uri) => /^(favela:\/\/|did:)/i.test(String(uri || '')) | |
| 62 | 67 | |
| 63 | 68 | const linkFeature = (uri) => ({ $type: 'app.bsky.richtext.facet#link', uri }) |
| 64 | 69 | const mentionFeature = (did) => ({ $type: 'app.bsky.richtext.facet#mention', did }) |
| … | … | @@ -153,8 +158,8 @@ function inline (nodes, buf) { |
| 153 | 158 | const inner = new Buf() |
| 154 | 159 | inline(node.children, inner) |
| 155 | 160 | |
| 156 | if (isDid(uri)) { | |
| 157 | // §3.4 — a mention carries a resolved DID in the target. The label is kept as-is. | |
| 161 | if (isMention(uri)) { | |
| 162 | // §3.4 — a mention target names the user, not a network identifier. The label is kept. | |
| 158 | 163 | buf.append(inner) |
| 159 | 164 | buf.mark(start, buf.length, mentionFeature(uri)) |
| 160 | 165 | } else if (isHttp(uri)) { |
| … | … | @@ -109,7 +109,7 @@ artifact is the property worth keeping. |
| 109 | 109 | } |
| 110 | 110 | --- |
| 111 | 111 | |
| 112 | hello [@alice](did:plc:xyz) — see [[the design note]] | |
| 112 | hello [@alice](favela://96b89f87…) — see [[the design note]] | |
| 113 | 113 | |
| 114 | 114 | :::spoiler |
| 115 | 115 | the ending |
| … | … | @@ -153,7 +153,7 @@ CommonMark 0.31.2 + exactly the six extensions pinned in `markdown-strip.md` §2 |
| 153 | 153 | GFM tables, footnotes, `[[wiki-links]]`, block directives, hashtags. Unknown extensions need no |
| 154 | 154 | rule — markdown degrades to literal text by nature. |
| 155 | 155 | |
| 156 | Markdown links are mentions: `[@alice](did:plc:xyz)` is display text plus resolved target, so | |
| 156 | Markdown links are mentions: `[@alice](favela://<identity-root>)` is display text plus resolved target, so | |
| 157 | 157 | facets are **derived from the parse** at projection time, never stored or hand-maintained. |
| 158 | 158 | |
| 159 | 159 | --- |
| … | … | @@ -124,6 +124,7 @@ Every loss is already enumerable from the strip rules. Make it structured output |
| 124 | 124 | | `spoiler-revealed` | strip §4.8 — **see §5** | |
| 125 | 125 | | `truncated` | record-vocabulary §4.3 — 300 graphemes + permalink | |
| 126 | 126 | | `visibility-unenforceable` | record-vocabulary §5.2 — atproto has no addressing | |
| 127 | | `mention-unresolvable` | strip §3.4 — the mentioned user has no designated atproto credential, so the facet is dropped and the text kept | | |
| 127 | 128 | |
| 128 | 129 | A composer can then render *"on bsky this becomes…"* from data rather than from a hand-written |
| 129 | 130 | warning list that drifts. **No protocol offers this, because no protocol knows what the other one |
| … | … | @@ -97,11 +97,29 @@ so trailing punctuation stays outside the facet ("see https://x.com." keeps the |
| 97 | 97 | This regex is normative; changing it changes records. |
| 98 | 98 | |
| 99 | 99 | ### 3.4 Mentions |
| 100 | `[@alice](did:plc:xyz)` → `@alice`, plus a `#mention` facet with `did = did:plc:xyz`. | |
| 101 | ||
| 102 | ⛔ **A mention MUST carry a resolved DID in the link target.** A bare `@alice` with no target is | |
| 103 | **plain text with no facet** — never a guess. This preserves the existing rule (favela-web 0.3.11): | |
| 104 | *a mention resolves to a DID or is omitted.* | |
| 100 | `[@alice](favela://<identity-root>)` → `@alice`, plus a `#mention` facet carrying the target | |
| 101 | **exactly as written**. | |
| 102 | ||
| 103 | ⛔ **Revised 2026-09-05 — a mention names a USER, not a DID.** This previously required | |
| 104 | `[@alice](did:plc:xyz)`, putting a *target's* DID into canonical bytes. That is the same defect | |
| 105 | `identity.md` §5 removed for the *author's* DID, for the same reason: a DID in the bytes makes | |
| 106 | rotating one a rewrite of every record, which `record-vocabulary.md` §8 forbids absolutely. When | |
| 107 | alice rotates, every mention of her everywhere would point at a dead identifier. | |
| 108 | ||
| 109 | - **Canonical form:** `favela://<identity-root>` — the same key that anchors her personal room | |
| 110 | (`interaction.md` §3.4), and the one thing that does not rotate. | |
| 111 | - **Legacy form:** `did:` targets are still accepted and still emit a mention. §8 is forward-only; | |
| 112 | nothing is rewritten. | |
| 113 | - ⛔ **`strip()` does NOT resolve either.** It is a codec with no registry access. Resolving a | |
| 114 | mention to a bsky DID happens at **projection** time (`identity.md` §5), and if the mentioned user | |
| 115 | has no designated atproto credential the facet is **dropped, the text kept** — the same fail-closed | |
| 116 | degradation §3.4 already specifies for a bare `@alice`, reported in `loss[]`. | |
| 117 | - ⛔ **A bare `@alice` with no target is plain text with no facet** — never a guess. Unchanged | |
| 118 | (favela-web 0.3.11: *a mention resolves or is omitted*). | |
| 119 | ||
| 120 | ⚠ **Observation, not fixed here.** §2 says facets are `app.bsky.richtext.facet` objects, so this | |
| 121 | codec's output shape is atproto's. That is the inherited-shape problem one level down, and it is why | |
| 122 | a mention target has to travel in a field named `did`. Worth revisiting; it is not blocking. | |
| 105 | 123 | |
| 106 | 124 | ### 3.5 Images |
| 107 | 125 | `` produces **no text and no facet**. Images are `embed`, not body text. |
| … | … | @@ -185,16 +185,17 @@ spelling is still open (`design.md` §8 #2). |
| 185 | 185 | |
| 186 | 186 | ### 3.4 Markdown links *are* facets |
| 187 | 187 | |
| 188 | `[@alice](did:plc:xyz)` carries display text plus resolved target — exactly what a facet is. A | |
| 188 | `[@alice](favela://<identity-root>)` carries display text plus resolved target — exactly what a facet is. A | |
| 189 | 189 | CommonMark parser yields exact source ranges, so facet offsets are **derived from the parse**, never |
| 190 | 190 | hand-maintained. This preserves the existing rule (favela-web 0.3.11) that a mention resolves to a |
| 191 | 191 | target or is omitted, never guessed. |
| 192 | 192 | |
| 193 | ⛔ **OPEN — a mention embeds a DID in canonical bytes.** `[@alice](did:plc:xyz)` writes a *target's* | |
| 194 | DID into the record, which is the same defect §8 and `identity.md` §5 forbid for the *author's* DID: | |
| 195 | when alice rotates, every mention of her everywhere points at a dead identifier, and §8 forbids | |
| 196 | rewriting them. The mention target should be a `favela://` name resolved to a DID at projection time | |
| 197 | — the treatment the author already gets. Not settled here. | |
| 193 | ✅ **RESOLVED 2026-09-05 — a mention names the user, not the DID.** `[@alice](favela://<identity-root>)` | |
| 194 | keeps a *target's* DID out of canonical bytes exactly as `identity.md` §5 keeps the *author's* out, | |
| 195 | and for the same reason: rotation would otherwise be a rewrite of every record, which §8 forbids. | |
| 196 | The DID is resolved at **projection** time; with no designated atproto credential the facet is | |
| 197 | dropped and the text kept, fail-closed. `did:` targets remain accepted as legacy (§8 is | |
| 198 | forward-only). See `markdown-strip.md` §3.4. | |
| 198 | 199 | |
| 199 | 200 | --- |
| 200 | 201 | |
| … | … | @@ -233,7 +234,7 @@ why the extension set can grow without coordination. |
| 233 | 234 | six extensions** — strikethrough, GFM tables, footnotes, `[[wiki-links]]`, block directives |
| 234 | 235 | (`:::name`), and hashtags. *"An implementation MUST NOT enable further extensions. A new one is a |
| 235 | 236 | spec change, because it changes the bytes of every record that uses it."* Fenced code is CommonMark |
| 236 | core, not an extension. Markdown links are mentions — `[@alice](did:plc:xyz)` carries display text | |
| 237 | core, not an extension. Markdown links are mentions — `[@alice](favela://<identity-root>)` carries display text | |
| 237 | 238 | plus resolved target, so facets are *derived from the parse* when projecting (§3.4), never |
| 238 | 239 | hand-maintained. |
| 239 | 240 |
| … | … | @@ -350,6 +350,34 @@ |
| 350 | 350 | "source": ":::unknownName\nbody\n:::", |
| 351 | 351 | "text": "body", |
| 352 | 352 | "facets": [] |
| 353 | }, | |
| 354 | { | |
| 355 | "name": "mention-favela-target", | |
| 356 | "why": "§3.4 a mention names the USER by identity root; the DID is resolved at PROJECTION time, not stored (identity.md §5)", | |
| 357 | "source": "hi [@alice](favela://96b89f87)", | |
| 358 | "text": "hi @alice", | |
| 359 | "facets": [ | |
| 360 | { | |
| 361 | "byteStart": 3, | |
| 362 | "byteEnd": 9, | |
| 363 | "type": "mention", | |
| 364 | "value": "favela://96b89f87" | |
| 365 | } | |
| 366 | ] | |
| 367 | }, | |
| 368 | { | |
| 369 | "name": "mention-did-target-legacy", | |
| 370 | "why": "§8 forward-only — a legacy record carrying a did: target still resolves; nothing is rewritten", | |
| 371 | "source": "hi [@alice](did:plc:xyz)", | |
| 372 | "text": "hi @alice", | |
| 373 | "facets": [ | |
| 374 | { | |
| 375 | "byteStart": 3, | |
| 376 | "byteEnd": 9, | |
| 377 | "type": "mention", | |
| 378 | "value": "did:plc:xyz" | |
| 379 | } | |
| 380 | ] | |
| 353 | 381 | } |
| 354 | 382 | ] |
| 355 | 383 | } |