| … | … | @@ -1,7 +1,7 @@ |
| 1 | 1 | // strip(source) -> { text, facets } — spec/markdown-strip.md |
| 2 | 2 | // |
| 3 | 3 | // The strip's output is what bsky sees and what a CID commits to, so every rule here cites the |
| 4 | // spec section it implements and nothing is left to "whatever the parser does" (§1). The 36 | |
| 4 | // spec section it implements and nothing is left to "whatever the parser does" (§1). The conformance | |
| 5 | 5 | // vectors are the oracle; this file exists to reproduce them byte-for-byte. |
| 6 | 6 | // |
| 7 | 7 | // Structure, and why: |
| … | … | @@ -32,7 +32,10 @@ import { directive } from 'micromark-extension-directive' |
| 32 | 32 | import { directiveFromMarkdown } from 'mdast-util-directive' |
| 33 | 33 | |
| 34 | 34 | const MICROMARK = { |
| 35 | extensions: [gfmStrikethrough(), gfmTable(), gfmFootnote(), directive()], | |
| 35 | // ⛔ singleTilde:false — micromark defaults it TRUE, which would consume `~x~` as strikethrough. | |
| 36 | // §2.1 pins the extension as `~~x~~`, and the dialect is CLOSED: syntax outside it is text, not | |
| 37 | // markup. Caught by the `single-tilde-is-literal` vector, which exists because of that rule. | |
| 38 | extensions: [gfmStrikethrough({ singleTilde: false }), gfmTable(), gfmFootnote(), directive()], | |
| 36 | 39 | mdastExtensions: [ |
| 37 | 40 | gfmStrikethroughFromMarkdown(), |
| 38 | 41 | gfmTableFromMarkdown(), |
| … | … | @@ -47,6 +47,24 @@ strip(source: string) -> { text: string, facets: Facet[] } |
| 47 | 47 | An implementation MUST NOT enable further extensions. A new one is a spec change, because it |
| 48 | 48 | changes the bytes of every record that uses it. |
| 49 | 49 | |
| 50 | ⛔ **The dialect is CLOSED, so syntax outside it is TEXT, not markup.** Decided 2026-08-11 | |
| 51 | (hyper-social #43) as *"unknown syntax → literal text"*, written 2026-09-05. `==highlight==`, | |
| 52 | `$$x$$`, `{: .cls}`, a task-list checkbox and a definition list are all **literal text**, because | |
| 53 | they are not in the table above. This is not leniency — it is what "the dialect is exactly these six" | |
| 54 | means, and it is the property that lets a future extension be added without reinterpreting a single | |
| 55 | existing record. | |
| 56 | ||
| 57 | ⚠ **It also constrains how the parser is CONFIGURED, not just which extensions are enabled.** A | |
| 58 | parser option that widens a pinned syntax silently breaks the rule: micromark's `gfmStrikethrough` | |
| 59 | defaults `singleTilde: true`, which consumes `~x~` even though §2.1 pins strikethrough as `~~x~~`. | |
| 60 | That defect shipped in `lib/strip.js` and was caught only by writing the vector for this rule | |
| 61 | (`single-tilde-is-literal`). **Every parser option that affects accepted syntax is part of the | |
| 62 | dialect and must be pinned explicitly.** | |
| 63 | ||
| 64 | **One exception, and only one:** an unknown *directive name* still yields its contents (§4.8), because | |
| 65 | `:::name` itself IS in the dialect — only the name is unrecognised. `unknown-directive-keeps-contents` | |
| 66 | pins it. | |
| 67 | ||
| 50 | 68 | --- |
| 51 | 69 | |
| 52 | 70 | ## 3. Inline rules |
| … | … | @@ -329,6 +329,27 @@ |
| 329 | 329 | "value": "https://two.example" |
| 330 | 330 | } |
| 331 | 331 | ] |
| 332 | }, | |
| 333 | { | |
| 334 | "name": "unknown-syntax-is-literal", | |
| 335 | "why": "§2.1 the dialect is closed, so syntax outside it is TEXT, never markup (hyper-social #43)", | |
| 336 | "source": "==highlight== and $$x$$ and {: .cls}", | |
| 337 | "text": "==highlight== and $$x$$ and {: .cls}", | |
| 338 | "facets": [] | |
| 339 | }, | |
| 340 | { | |
| 341 | "name": "single-tilde-is-literal", | |
| 342 | "why": "§2.1 strikethrough is pinned as ~~x~~; a SINGLE tilde is outside the dialect and stays text", | |
| 343 | "source": "~sub~ not strikethrough", | |
| 344 | "text": "~sub~ not strikethrough", | |
| 345 | "facets": [] | |
| 346 | }, | |
| 347 | { | |
| 348 | "name": "unknown-directive-keeps-contents", | |
| 349 | "why": "§4.8 the ONE exception — an unknown directive NAME still yields its contents, not literal text", | |
| 350 | "source": ":::unknownName\nbody\n:::", | |
| 351 | "text": "body", | |
| 352 | "facets": [] | |
| 332 | 353 | } |
| 333 | 354 | ] |
| 334 | } | |
| 355 | } |
| … | … | @@ -32,8 +32,8 @@ function expandFacet (f) { |
| 32 | 32 | |
| 33 | 33 | const utf8 = (s) => Buffer.from(s, 'utf8') |
| 34 | 34 | |
| 35 | test('strip: all 36 vectors reproduce byte-for-byte', (t) => { | |
| 36 | t.is(VECTORS.length, 36, 'the vector set is the one the spec pins') | |
| 35 | test('strip: every vector reproduces byte-for-byte', (t) => { | |
| 36 | t.ok(VECTORS.length >= 36, 'the pinned vector set may grow, never shrink') | |
| 37 | 37 | |
| 38 | 38 | for (const v of VECTORS) { |
| 39 | 39 | const got = strip(v.source) |