Define canonical link destinations and export parity fixtures
2 files changed,
+366
−9
implementation/link-destinations.json+235 −0implementation/link-roundtrip.md+131 −9
CI failed — run details
diff --git a/implementation/link-destinations.json b/implementation/link-destinations.json| new file mode 100644 |
| index 0000000..6f2c5dd |
| --- /dev/null |
| +++ b/implementation/link-destinations.json |
| @@ -0,0 +1,235 @@ |
| +{ |
| + "status": "UNAPPROVED design fixtures; no product implementation", |
| + "cases": [ |
| + { |
| + "id": "http-basic", |
| + "input": "http://example.test", |
| + "canonical": "http://example.test/" |
| + }, |
| + { |
| + "id": "scheme-host-port", |
| + "input": "HTTPS://EXAMPLE.TEST:443/a/../b", |
| + "canonical": "https://example.test/b" |
| + }, |
| + { |
| + "id": "ipv6", |
| + "input": "https://[2001:db8::1]:8443/a", |
| + "canonical": "https://[2001:db8::1]:8443/a" |
| + }, |
| + { |
| + "id": "userinfo", |
| + "input": "https://u:p@example.test/x", |
| + "canonical": "https://u:p@example.test/x" |
| + }, |
| + { |
| + "id": "encoded-separators", |
| + "input": "https://example.test/a%2fb%25%5C%09", |
| + "canonical": "https://example.test/a%2fb%25%5C%09" |
| + }, |
| + { |
| + "id": "parentheses", |
| + "input": "https://example.test/a(b)c", |
| + "canonical": "https://example.test/a(b)c" |
| + }, |
| + { |
| + "id": "path-brackets", |
| + "input": "https://example.test/a[b]", |
| + "canonical": "https://example.test/a%5Bb%5D" |
| + }, |
| + { |
| + "id": "entity-query", |
| + "input": "https://example.test/?x=1©=2&y=a+b", |
| + "canonical": "https://example.test/?x=1©=2&y=a+b" |
| + }, |
| + { |
| + "id": "angle-quotes", |
| + "input": "https://example.test/<x>\"", |
| + "canonical": "https://example.test/%3Cx%3E%22" |
| + }, |
| + { |
| + "id": "unicode", |
| + "input": "https://b\u00fccher.example/\u96ea?q=caf\u00e9#\u7ae0", |
| + "canonical": "https://xn--bcher-kva.example/%E9%9B%AA?q=caf%C3%A9#%E7%AB%A0" |
| + }, |
| + { |
| + "id": "nbsp-path", |
| + "input": "https://example.test/a\u00a0b", |
| + "canonical": "https://example.test/a%C2%A0b" |
| + }, |
| + { |
| + "id": "line-separator-path", |
| + "input": "https://example.test/a\u2028b", |
| + "canonical": "https://example.test/a%E2%80%A8b" |
| + }, |
| + { |
| + "id": "percent-space", |
| + "input": "https://example.test/a%20b", |
| + "canonical": "https://example.test/a%20b" |
| + }, |
| + { |
| + "id": "mailto", |
| + "input": "MAILTO:User+tag@example.test", |
| + "canonical": "mailto:User+tag@example.test" |
| + }, |
| + { |
| + "id": "mailto-body", |
| + "input": "mailto:a@example.test?subject=Hello%20there&body=one%0D%0Atwo", |
| + "canonical": "mailto:a@example.test?subject=Hello%20there&body=one%0D%0Atwo" |
| + }, |
| + { |
| + "id": "mailto-unicode", |
| + "input": "mailto:a@example.test?subject=caf\u00e9", |
| + "canonical": "mailto:a@example.test?subject=caf%C3%A9" |
| + }, |
| + { |
| + "id": "mailto-query-only", |
| + "input": "mailto:?body=x%26y&subject=a+b&subject=c", |
| + "canonical": "mailto:?body=x%26y&subject=a+b&subject=c" |
| + }, |
| + { |
| + "id": "mailto-empty", |
| + "input": "mailto:", |
| + "canonical": "mailto:" |
| + }, |
| + { |
| + "id": "suffix-tab", |
| + "input": "https://example.test/a\tb", |
| + "canonical": null |
| + }, |
| + { |
| + "id": "suffix-lf", |
| + "input": "https://example.test/a\nb", |
| + "canonical": null |
| + }, |
| + { |
| + "id": "suffix-cr", |
| + "input": "https://example.test/a\rb", |
| + "canonical": null |
| + }, |
| + { |
| + "id": "suffix-null", |
| + "input": "https://example.test/a\u0000b", |
| + "canonical": null |
| + }, |
| + { |
| + "id": "suffix-del", |
| + "input": "https://example.test/a\u007fb", |
| + "canonical": null |
| + }, |
| + { |
| + "id": "raw-space", |
| + "input": "https://example.test/a b", |
| + "canonical": null |
| + }, |
| + { |
| + "id": "leading-space", |
| + "input": " https://example.test/", |
| + "canonical": null |
| + }, |
| + { |
| + "id": "leading-nbsp", |
| + "input": "\u00a0https://example.test/", |
| + "canonical": null |
| + }, |
| + { |
| + "id": "scheme-tab", |
| + "input": "ht\ttps://example.test", |
| + "canonical": null |
| + }, |
| + { |
| + "id": "scheme-relative", |
| + "input": "https:relative", |
| + "canonical": null |
| + }, |
| + { |
| + "id": "one-slash", |
| + "input": "https:/example.test", |
| + "canonical": null |
| + }, |
| + { |
| + "id": "three-slashes", |
| + "input": "https:///example.test", |
| + "canonical": null |
| + }, |
| + { |
| + "id": "empty-authority", |
| + "input": "https://?x", |
| + "canonical": null |
| + }, |
| + { |
| + "id": "protocol-relative", |
| + "input": "//example.test/x", |
| + "canonical": null |
| + }, |
| + { |
| + "id": "relative", |
| + "input": "../x", |
| + "canonical": null |
| + }, |
| + { |
| + "id": "backslash", |
| + "input": "https://example.test/a\\b", |
| + "canonical": null |
| + }, |
| + { |
| + "id": "authority-backslash", |
| + "input": "https:\\example.test", |
| + "canonical": null |
| + }, |
| + { |
| + "id": "bad-percent", |
| + "input": "https://example.test/%zz", |
| + "canonical": null |
| + }, |
| + { |
| + "id": "trailing-percent", |
| + "input": "https://example.test/%", |
| + "canonical": null |
| + }, |
| + { |
| + "id": "bad-port", |
| + "input": "https://example.test:70000/", |
| + "canonical": null |
| + }, |
| + { |
| + "id": "bad-host", |
| + "input": "https://[broken]/", |
| + "canonical": null |
| + }, |
| + { |
| + "id": "javascript", |
| + "input": "javascript:alert(1)", |
| + "canonical": null |
| + }, |
| + { |
| + "id": "data", |
| + "input": "data:text/html,hi", |
| + "canonical": null |
| + }, |
| + { |
| + "id": "blob", |
| + "input": "blob:https://example.test/id", |
| + "canonical": null |
| + }, |
| + { |
| + "id": "mailto-raw-body-newline", |
| + "input": "mailto:a@example.test?body=a\nb", |
| + "canonical": null |
| + }, |
| + { |
| + "id": "mailto-authority", |
| + "input": "mailto://a@example.test", |
| + "canonical": null |
| + }, |
| + { |
| + "id": "mailto-fragment", |
| + "input": "mailto:a@example.test#x", |
| + "canonical": null |
| + }, |
| + { |
| + "id": "unpaired-surrogate", |
| + "input": "https://example.test/\ud800", |
| + "canonical": null |
| + } |
| + ] |
| +} |
diff --git a/implementation/link-roundtrip.md b/implementation/link-roundtrip.md| index 41d087b..d3fae20 100644 |
| --- a/implementation/link-roundtrip.md |
| +++ b/implementation/link-roundtrip.md |
| @@ -1,5 +1,9 @@ |
| # Link round-trip execution slice |
| +Revision 2 — UNAPPROVED design correction; no product implementation authorized |
| +by this draft. Supersedes only the URL paragraph in frozen293ccd0 after the |
| +independent Codex review recorded in Docs44. Original Fable job stays frozen. |
| + |
| Agent implementation specification, based on origin/main 87662f5 and the existing |
| Plan 7 D1/D3. The unmerged revision-3 plan at c1ac560 is supporting analysis, |
| not a merged ruling. This slice implements pasted links end to end in the read |
| @@ -14,15 +18,11 @@ slice and its existing disabled commands must not be represented as complete. |
| consumed by both HTML and JSON. Only link may carry a value; unknown mark |
| names retain current escaped name-only behavior. Reject malformed/non-string |
| href to unlinked text, preserving other marks and all text. |
| -- Adopt Plan 7 positive ASCII scheme grammar and case-insensitive http, https, |
| - mailto allowlist. No trimming/relative URL inference. Reject leading controls, |
| - embedded scheme controls, protocol-relative, data, blob and javascript forms. |
| - Preserve accepted href content and escape it appropriately at each output |
| - syntax boundary. HTML uses href plus rel=noreferrer, no target or title. |
| -- Exports consume only normalized core link attributes. Markdown destination |
| - encoding must preserve URL meaning while preventing delimiter/control injection; |
| - a dangerous URL must never become a mark again downstream. Preserve formatting |
| - and text escaping, including adjacent differently marked runs. |
| +- Apply the accepted-shape and normalization contract below once in the shared |
| + core, before both HTML and JSON. Exports consume that normalized href only. |
| + Rejection drops only the link mark; text and other marks survive unchanged. |
| +- HTML uses the canonical href plus rel=noreferrer, with no target or title. |
| + Markdown and PDF serialize the same canonical destination as specified below. |
| - PDF emits actual URI annotations for every wrapped visible linked text segment, |
| aligned to its page and drawn geometry, preserving baseline and line layout. |
| URI PDF string/object encoding must resist parentheses, backslashes, line breaks |
| @@ -49,3 +49,125 @@ canary evidence are required. Shared hosts receive only merged verified main. |
| Review question: identify any unsafe ambiguity or architectural mismatch before |
| implementation, especially Markdown URL encoding and PDF annotation geometry. |
| + |
| +## Accepted shape and one normalization |
| + |
| +This is a proposed bounded amendment to Plan7 D1, not an already-attributed |
| +product ruling: the positive scheme check remains necessary but is no longer |
| +sufficient. It previously admitted suffix controls and base-dependent HTTP |
| +forms. Those become unlinked text, not repaired URLs. Accepted spelling may |
| +change through the one canonicalization below; the earlier promise to preserve |
| +accepted input bytes is replaced by preserving the canonical destination across |
| +outputs. No insertion UI, target-fetch policy, or other scheme is added. |
| + |
| +The shared core applies these steps in order to the original string: |
| + |
| +1. Require valid Unicode scalar values and the existing anchored ASCII scheme |
| + grammar `^[A-Za-z][A-Za-z0-9+.-]*:`; allow only http, https and mailto after |
| + ASCII case folding. Never trim, prepend a scheme, or supply a base. |
| +2. Reject any raw U+0000–U+0020, U+007F, or backslash anywhere in the string, |
| + including after the colon. Reject every `%` not followed by two ASCII hex |
| + digits. Do not percent-decode the string before these tests. Valid `%20`, |
| + `%09`, `%0D%0A`, `%25`, `%2F` and `%5C` remain encoded data, not raw syntax. |
| + Non-ASCII characters after the scheme are allowed and serialized below; |
| + this includes NBSP/U+2028 in a path, not before the scheme. Do not Unicode |
| + normalize, case-fold paths, or convert plus to a space. |
| +3. HTTP(S) must have literal `://` immediately after the scheme and a nonempty |
| + authority substring before the first `/`, `?` or `#`. Thus `https:relative`, |
| + `https:/host`, `https:///host` and `https://?x` are refused before parsing. |
| + Parse with the WHATWG URL algorithm without a base, require success and a |
| + nonempty host with the same allowed scheme. Its host/IPv4/IPv6/port parsing |
| + is authoritative; no separate hand-written host grammar. Existing userinfo, |
| + ports, fragments and query ordering are retained under that algorithm. |
| +4. Mailto must be non-hierarchical: the suffix cannot begin `//`, and cannot |
| + contain a raw `#` (mailto has no fragment component). The suffix consists |
| + of opaque recipient text, optionally followed by `?` and opaque header text; |
| + empty recipient text and `mailto:` remain allowed. No mailbox deliverability |
| + check or header allowlist is introduced. Parse without a base and require an |
| + opaque path and scheme mailto. Preserve recipient case, header order, |
| + duplicate headers, plus signs, existing percent escapes and encoded body |
| + line breaks. This is URI fidelity, not a guarantee of what a mail client |
| + will send or display. Do not use form-urlencoded/URLSearchParams decoding. |
| +5. Serialize the parsed URL. On that serialization only, percent-encode UTF-8 |
| + bytes outside ASCII unreserved characters plus `:/?#@!$&'()*+,;=%`; |
| + preserve `[` and `]` only as the parsed HTTP IPv6 host delimiters, encoding |
| + them elsewhere. Work from parsed components, not a substring search; |
| + uppercase hex for newly encoded bytes, preserve existing escape spelling. |
| + This makes one ASCII URI, including an ASCII IDNA host for HTTP(S), with |
| + no raw whitespace, controls, backslash, angle brackets or double quote. |
| + Parse/serialize this ASCII result again without a base and require the same |
| + result (fixed point), scheme and HTTP host/mailto opaque-path invariants. |
| + Failure means unlinked text. The resulting ASCII string is the canonical |
| + href carried by normalized HTML/JSON marks; it is never written back into |
| + the stored CRDT by reading or exporting. |
| + |
| +Use a maintained WHATWG implementation in the Rust core, not a partial URL |
| +parser or a Go/browser-specific second normalizer. The exact released crate |
| +and locked dependency set must be selected and tested in implementation review: |
| +wasm32-unknown-unknown builds, no host imports, no networking/entropy dependency, |
| +existing deterministic wasm gate. This draft does not approve a dependency pin. |
| +Canonicalization must pass the versioned fixtures and independent browser URL |
| +oracle; a library mismatch is a failed gate, not a reason to change fixtures. |
| + |
| +## Output encoding and parity definition |
| + |
| +Let C be the normalized ASCII href. Every output must preserve C, not re-run |
| +scheme policy or recover an href dropped by the core. |
| + |
| +- HTML: escape C once as an attribute value. The parsed DOM attribute must be |
| + exactly C and its resolved `a.href` must equal C with either of two unrelated |
| + document bases. Escaping ampersands/quotes is HTML syntax only. |
| +- Markdown: emit an angle-delimited destination `[label](<C>)`. In that source |
| + destination replace each literal `&` with `&` once, so a URI containing |
| + `©` is not decoded into a different query. C contains no raw angle |
| + brackets, line breaks or backslashes; parentheses are harmless inside the |
| + angle-delimited destination. Never percent-encode existing `%` again. |
| + Exception: for an HTTP(S) IPv6 host, emit a raw inline HTML anchor with |
| + the same escaped href/rel contract as the HTML reader, surrounding the |
| + normally rendered inline Markdown label. CommonMark raw inline HTML leaves |
| + inline emphasis/code processing active; a link label cannot create nested |
| + anchors. This fallback is required by actual reference-parser evidence: |
| + both commonmark-js0.31.2 and markdown-it14.3.0 percent-encode IPv6 host brackets |
| + in ordinary link destinations, producing an invalid browser destination. |
| + Do not reject otherwise-valid IPv6 links or percent-encode their authority. |
| + Raw HTML is generated from canonical C and escaped text, never copied from |
| + a mark value. This export requires a CommonMark reader with raw HTML enabled; |
| + readers configured to strip HTML may retain the label without a link. That |
| + limitation is explicit; no Markdown export can promise all sanitizer modes. |
| + An independent CommonMark parser must produce one link whose DOM attribute |
| + and resolved destination equal C, including labels with code/emphasis and |
| + leading/trailing whitespace. Preserve text/mark rendering and distinguish |
| + adjacent links or adjacent differently marked runs; no nested links. |
| +- PDF: store C as the URI action's ASCII byte string. Escape only PDF string |
| + delimiters/backslashes (or use a hex string), never URL-decode or re-encode C. |
| + Parse the actual PDF objects with an independent reader: decoded `/URI` bytes |
| + must equal ASCII(C), not a substring somewhere in the output. There is no |
| + document-base dependency. Use the existing draw pass for annotation rectangles |
| + after line/page/alignment resolution, never a second measuring pass. |
| + |
| +Parity is exact equality of normalized C, decoded HTML destination, independently |
| +parsed Markdown destination, and decoded PDF URI bytes, plus base independence |
| +for the first two. It is not equality of display labels, browser address-bar |
| +presentation, network redirects, remote content, or mail-client behavior. Never |
| +fetch any fixture target. Rejected cases must produce no anchor, no normalized |
| +link JSON attribute and no PDF URI action while retaining text and other marks. |
| + |
| +`implementation/link-destinations.json` is the normative synthetic corpus. The |
| +fixture label/mark matrix also includes linked code, whitespace-only/edge-space |
| +runs, adjacent strong/plain runs, parentheses, brackets, ampersands and Unicode |
| +labels. PDF geometry adds mixed linked/unlinked runs, split long words, wrapped |
| +lines, center/right alignment, list/quote indentation and page boundaries. |
| +HTML and JSON rejection tests must execute independently; the inherited RED |
| +HTML assertion must not prevent observing the JSON rejection path. |
| + |
| +Implementation acceptance runs this corpus through real y-prosemirror→Yjs→core |
| +HTML/JSON→Go Markdown/PDF, then independent syntax readers and offline browser |
| +URL resolution. A local design-oracle check can validate fixture expectations; |
| +it is explicitly not proof that those currently-missing product paths work. |
| + |
| +Primary references consulted 2026-09-08: WHATWG URL parsing/serialization |
| +https://url.spec.whatwg.org/ (living standard dated 2026-08-18), CommonMark0.31.2 |
| +link destinations/entities https://spec.commonmark.org/0.31.2/, and RFC6068 |
| +mailto percent encoding/query syntax https://www.rfc-editor.org/rfc/rfc6068. |
| +The raw-control, explicit-authority and backslash restrictions above are this |
| +proposed application contract, not claims that the standards reject them all. |