From 9fe5d198d129d436b79dd17746be92626a87accb Mon Sep 17 00:00:00 2001 From: Lilleman auf Larv Date: Wed, 16 Sep 2026 23:09:30 +0200 Subject: [PATCH] 13b - link takes the directive form where CommonMark cannot spell it, unspellable-link leaves the code list --- AGENTS.md | 16 +++- MIGRATION.md | 5 ++ README.md | 10 +-- corpus/commonmark-spec/exceptions.json | 21 ----- corpus/errors/directive-link-commonmark.error | 1 + corpus/errors/directive-link-commonmark.md | 1 + .../directive-link-opening-commonmark.error | 1 + .../directive-link-opening-commonmark.md | 1 + .../link-unspellable-targets.json | 57 +++++++++++++ .../normalization/link-unspellable-targets.md | 5 ++ .../combinations/link-definition-carry.md | 3 - ...ry.json => link-definition-directive.json} | 0 .../combinations/link-definition-directive.md | 3 + .../link-autolink-directive-prefix.json | 29 +++++++ .../link-autolink-directive-prefix.md | 1 + .../inline-nodes/link-collection.json | 28 +++++++ .../inline-nodes/link-collection.md | 1 + .../link-destination-angle-space.json | 31 ++++++++ .../link-destination-angle-space.md | 1 + .../link-destination-angle-start.json | 31 ++++++++ .../link-destination-angle-start.md | 1 + .../link-destination-backslash.json | 31 ++++++++ .../link-destination-backslash.md | 1 + .../link-destination-control.json | 31 ++++++++ .../inline-nodes/link-destination-control.md | 1 + .../inline-nodes/link-destination-entity.json | 31 ++++++++ .../inline-nodes/link-destination-entity.md | 1 + corpus/round-trip/inline-nodes/link-id.json | 28 +++++++ corpus/round-trip/inline-nodes/link-id.md | 1 + .../inline-nodes/link-occurrence-key.json | 28 +++++++ .../inline-nodes/link-occurrence-key.md | 1 + .../inline-nodes/link-title-backslash.json | 32 ++++++++ .../inline-nodes/link-title-backslash.md | 1 + .../inline-nodes/link-title-entity.json | 32 ++++++++ .../inline-nodes/link-title-entity.md | 1 + .../inline-nodes/link-title-newline.json | 32 ++++++++ .../inline-nodes/link-title-newline.md | 1 + spec/flavour.md | 24 ++++-- src/adf-schema.test.ts | 2 +- src/adf/mark-attributes.ts | 2 +- src/markdown/emit/adf-to-markdown.test.ts | 43 ++++++---- src/markdown/emit/inline-line.ts | 79 ++++++++++++------- src/markdown/emit/line-escaping.ts | 5 +- src/markdown/link-syntax.ts | 38 +++------ src/markdown/mark-spellings.ts | 19 ++++- src/markdown/parse/directive-marks.ts | 3 +- src/markdown/parse/inline-content.ts | 50 +++++++++--- src/markdown/parse/markdown-to-adf.test.ts | 24 +++++- src/markdown/parse/markdown-to-adf.ts | 13 +-- src/result.ts | 1 - 50 files changed, 662 insertions(+), 141 deletions(-) create mode 100644 corpus/errors/directive-link-commonmark.error create mode 100644 corpus/errors/directive-link-commonmark.md create mode 100644 corpus/errors/directive-link-opening-commonmark.error create mode 100644 corpus/errors/directive-link-opening-commonmark.md create mode 100644 corpus/normalization/link-unspellable-targets.json create mode 100644 corpus/normalization/link-unspellable-targets.md delete mode 100644 corpus/round-trip/combinations/link-definition-carry.md rename corpus/round-trip/combinations/{link-definition-carry.json => link-definition-directive.json} (100%) create mode 100644 corpus/round-trip/combinations/link-definition-directive.md create mode 100644 corpus/round-trip/inline-nodes/link-autolink-directive-prefix.json create mode 100644 corpus/round-trip/inline-nodes/link-autolink-directive-prefix.md create mode 100644 corpus/round-trip/inline-nodes/link-collection.json create mode 100644 corpus/round-trip/inline-nodes/link-collection.md create mode 100644 corpus/round-trip/inline-nodes/link-destination-angle-space.json create mode 100644 corpus/round-trip/inline-nodes/link-destination-angle-space.md create mode 100644 corpus/round-trip/inline-nodes/link-destination-angle-start.json create mode 100644 corpus/round-trip/inline-nodes/link-destination-angle-start.md create mode 100644 corpus/round-trip/inline-nodes/link-destination-backslash.json create mode 100644 corpus/round-trip/inline-nodes/link-destination-backslash.md create mode 100644 corpus/round-trip/inline-nodes/link-destination-control.json create mode 100644 corpus/round-trip/inline-nodes/link-destination-control.md create mode 100644 corpus/round-trip/inline-nodes/link-destination-entity.json create mode 100644 corpus/round-trip/inline-nodes/link-destination-entity.md create mode 100644 corpus/round-trip/inline-nodes/link-id.json create mode 100644 corpus/round-trip/inline-nodes/link-id.md create mode 100644 corpus/round-trip/inline-nodes/link-occurrence-key.json create mode 100644 corpus/round-trip/inline-nodes/link-occurrence-key.md create mode 100644 corpus/round-trip/inline-nodes/link-title-backslash.json create mode 100644 corpus/round-trip/inline-nodes/link-title-backslash.md create mode 100644 corpus/round-trip/inline-nodes/link-title-entity.json create mode 100644 corpus/round-trip/inline-nodes/link-title-entity.md create mode 100644 corpus/round-trip/inline-nodes/link-title-newline.json create mode 100644 corpus/round-trip/inline-nodes/link-title-newline.md diff --git a/AGENTS.md b/AGENTS.md index 5913a36..e53fa57 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -51,6 +51,11 @@ Round-trip equality is a property tested over a corpus, not a claim made in pros - Plain CommonMark is a subset, with carve-outs (`spec/flavour.md`): literal text shaped like a directive, a pipe table or a `~~` pair is claimed — plus one image gap. - Tables: one header row plus plain inline cells → pipe table; anything richer → directive form. +- Links: `[text](url "title")`, or `` for a bare autolink-shaped text, wherever CommonMark + spells the mark; `!adf:link[text]{attrs}` where it does not — an attribute CommonMark cannot + hold, an `href` or `title` no canonical escape spells, a paragraph opening whose CommonMark + spelling would read as a link reference definition — and a directive link CommonMark could spell + is refused (the maintainer, 2026-09-13). - Identity-bearing nodes carry their ids in attributes; a document is only portable within its site — accepted. - The HTML dialect mirrors this: semantic elements, stable `adf-*` classes, `data-*` for what HTML @@ -117,7 +122,7 @@ reuses a code where one fits; the list is complete at `0.1.0`. A code names the cause; where one cause recurs across node types, across one mark's attributes or across directions, one code covers them all and `path` and `message` say which — `unsupported-nesting-depth` is the 500-level guard whichever -direction hits it, `unspellable-link` the destination and the title alike. Where two codes stay +direction hits it, `unspellable-character` the text node and the code block alike. Where two codes stay apart, the line between them is what they name: `unspellable-character` is a character CommonMark rewrites wherever text holds it, `unspellable-whitespace` the newline no inline directive's content slot spans, in either direction. A claim code names the spelling claimed, never the node that spelling would have built: @@ -125,7 +130,8 @@ a malformed `!adf:table` is a `malformed-directive`, and an alignment colon a `m the flavour's own delimiter row is `-` runs, so the grammar refuses the colon rather than ADF's missing column model doing it. A refusal no spelling recovers from is a gap in the flavour rather than a code: give the flavour the spelling and the code goes, which the freeze is the last moment -for. A cause the carry answers gets no code: a mark no +for — `unspellable-link` went at `0.2.0`, the directive link spelling the `href` and `title` it +refused and the attributes the carry held (the maintainer, 2026-09-13). A cause the carry answers gets no code: a mark no spelling writes rides the carry with its node. A directive whose name reads back to no node is `unknown-directive-name` rather than a claim code — the spelling is well formed, and telling that apart from a typo is what a consumer switches on when a later MINOR gives the name meaning. A @@ -298,8 +304,10 @@ someone spells it or pins it. directions must answer alike — whether a list marker interrupts a paragraph — is one function there too, never a copy per direction, however conservative the copy would be. Where the rule is the emitter's own choice, input consults it rather than restating it: the parser asks - `commonMarkSpelling` which form the emitter picks, so no fixture the emitter writes can be - refused, and a spelling the emitter refuses gives its own error rather than a second name for it. + `commonMarkSpelling` which form the emitter picks, and `openingLinkTakesDirective` whether the + line a paragraph's opening link starts forces the directive link, so no fixture the emitter + writes can be refused, and a spelling the emitter refuses gives its own error rather than a + second name for it. - The attribute vocabulary is ADF's: `adf/` walks it and narrows each value to its kind, and a format spells the narrowed value. A spelling that re-checks the type is the check's second copy. Reading a spelling back is the format's own: the reader sits beside the spelling it inverts, so diff --git a/MIGRATION.md b/MIGRATION.md index 659a752..32a84d3 100644 --- a/MIGRATION.md +++ b/MIGRATION.md @@ -42,14 +42,19 @@ function migrateMarkdown(stored: string) { | `:mention[@Mikael]{id=5b10a2}` | `!adf:mention[@Mikael]{id=5b10a2}` | | the `adf` code fence and `:adf{json="…"}` | the `carry` code fence and `!adf:carry{json="…"}` | | `\:` keeps a directive literal | `\!adf:` keeps a directive literal | +| `:adf{json="…"}` carrying a link for its `collection`, `id` or `occurrenceKey` | `!adf:link[text]{attrs}` | A colon run and `:name[` are plain text now, and `adf` an ordinary code block language; text holding an unescaped `!adf:` and a `carry` fence are claimed instead. ### Error codes +`unspellable-link` leaves `ConvertErrorCode`: a `switch` naming it stops compiling, and the link +it named converts. + | Input | `0.1.0` | `0.2.0` | | --- | --- | --- | +| a link whose `href` or `title` no CommonMark escape spells, on emit | `unspellable-link` | spells `!adf:link[text]{attrs}` | | a leaf node given a body (`media`, `listBreak`) | `unsupported-node-shape` | `malformed-directive` | | a node with a block body written as a leaf (`panel`) | `unsupported-node-shape` | `malformed-directive` | | an empty node the `::taskItem` spelling row names, written as a leaf | parses | `malformed-directive` | diff --git a/README.md b/README.md index 03d88aa..3f9928f 100644 --- a/README.md +++ b/README.md @@ -100,7 +100,6 @@ emit refuses: | --- | --- | --- | | `unspellable-character` | text or a code block holds a carriage return or a null character, which CommonMark rewrites wherever it sits | strip or replace the character; no escape carries it through the round-trip | | `unspellable-line-start` | a paragraph line begins with a code span whose backticks would read back as a code fence | put any text before the code span | -| `unspellable-link` | a link `href` or `title` holds what no canonical escape spells — a backslash, a newline, a control character, an entity reference, an angle bracket beside a space | percent-encode the destination (`%5C` for the backslash, `%26` for the `&` that opens the entity), or drop the title | | `unspellable-whitespace` | an `emoji`, `mention` or `status` holds a newline in the text its inline directive spells in the content slot | replace it with a space — an inline directive never spans lines | | `unsupported-nesting-depth` | blocks, marks, an attribute's JSON or a carried node's JSON nest past 500 levels | keep the ADF and pass the document over, or show it read-only; flatten the input where you are the one who wrote it | | `unsupported-node-shape` | a node carries an attribute, value, argument or body its type does not take — or markdown writes as a directive a node the flavour spells as CommonMark | write the shape the message names; `spec/flavour.md` lists every type's attributes and body | @@ -115,7 +114,7 @@ emit refuses: title-less paragraph; mid-text and titled images are error results. Converting back yields the library's canonical spelling, which round-trips byte-identically — where it converts back at all: a parse succeeding is no promise of that, so keep the source until the way back succeeds. - `[a](/a\b)`, `` and `[a](/x y)` read cleanly and then refuse. + `` ` `` ` `` reads cleanly and then refuses. - Three CommonMark spellings parse without an error and build a document the reference renders differently: `[](/url)` and `[]()` stay literal text against CommonMark's empty link, a list continuing past a marker change stays one list against CommonMark's two, and a shortcut @@ -124,10 +123,9 @@ emit refuses: - Raw HTML in markdown input is an error result, never a silent drop — a tag, a comment and a processing instruction alike. ADF holds no raw-HTML node; the element mapping ships at `0.3.0`. - Not every document converts back: `adfToMarkdown` is partial on valid ADF — a text node holding - a carriage return, a link destination or title no canonical escape spells, a paragraph line - beginning with a code span whose backticks read back as a fence. Show the refusal and keep the - document read-only; saving markdown you could not produce is the loss the round-trip exists to - stop. + a carriage return, or a paragraph line beginning with a code span whose backticks read back as a + fence. Show the refusal and keep the document read-only; saving markdown you could not produce + is the loss the round-trip exists to stop. - The pipe table narrows GFM's twice: every row opens with a pipe, so GFM's bare form is an error result rather than the prose it reads as, and an alignment colon in the delimiter row is an error too — ADF holds no column alignment. The trailing pipe is canonical output, optional in diff --git a/corpus/commonmark-spec/exceptions.json b/corpus/commonmark-spec/exceptions.json index 95b5dfa..7f0b710 100644 --- a/corpus/commonmark-spec/exceptions.json +++ b/corpus/commonmark-spec/exceptions.json @@ -1,18 +1,4 @@ [ - { - "check": "fixpoint", - "divergence": "unspellable-link", - "example": 196, - "kind": "unspellable", - "reason": "The link title holds literal newlines no escape spells." - }, - { - "check": "fixpoint", - "divergence": "unspellable-link", - "example": 202, - "kind": "unspellable", - "reason": "The link destination holds a backslash the flavour cannot escape." - }, { "check": "count", "divergence": "ul 2/1", @@ -349,13 +335,6 @@ "kind": "pending", "reason": "An empty link text stays literal text; CommonMark renders an empty link." }, - { - "check": "fixpoint", - "divergence": "unspellable-link", - "example": 502, - "kind": "unspellable", - "reason": "The link destination holds a backslash the flavour cannot escape." - }, { "check": "count", "divergence": "a 1/5 em 1/4", diff --git a/corpus/errors/directive-link-commonmark.error b/corpus/errors/directive-link-commonmark.error new file mode 100644 index 0000000..a811539 --- /dev/null +++ b/corpus/errors/directive-link-commonmark.error @@ -0,0 +1 @@ +unsupported-node-shape diff --git a/corpus/errors/directive-link-commonmark.md b/corpus/errors/directive-link-commonmark.md new file mode 100644 index 0000000..6921c14 --- /dev/null +++ b/corpus/errors/directive-link-commonmark.md @@ -0,0 +1 @@ +See !adf:link[the docs]{href="https://example.com/docs"} here. diff --git a/corpus/errors/directive-link-opening-commonmark.error b/corpus/errors/directive-link-opening-commonmark.error new file mode 100644 index 0000000..a811539 --- /dev/null +++ b/corpus/errors/directive-link-opening-commonmark.error @@ -0,0 +1 @@ +unsupported-node-shape diff --git a/corpus/errors/directive-link-opening-commonmark.md b/corpus/errors/directive-link-opening-commonmark.md new file mode 100644 index 0000000..86b9dd8 --- /dev/null +++ b/corpus/errors/directive-link-opening-commonmark.md @@ -0,0 +1 @@ +!adf:link[the docs]{href="https://example.com/docs"} diff --git a/corpus/normalization/link-unspellable-targets.json b/corpus/normalization/link-unspellable-targets.json new file mode 100644 index 0000000..4ed84e2 --- /dev/null +++ b/corpus/normalization/link-unspellable-targets.json @@ -0,0 +1,57 @@ +{ + "content": [ + { + "content": [ + { + "marks": [ + { + "attrs": { + "href": "/a\\b" + }, + "type": "link" + } + ], + "text": "a", + "type": "text" + } + ], + "type": "paragraph" + }, + { + "content": [ + { + "marks": [ + { + "attrs": { + "href": "http://x?a=1&b=2" + }, + "type": "link" + } + ], + "text": "http://x?a=1&b=2", + "type": "text" + } + ], + "type": "paragraph" + }, + { + "content": [ + { + "marks": [ + { + "attrs": { + "href": "/x\ny" + }, + "type": "link" + } + ], + "text": "a", + "type": "text" + } + ], + "type": "paragraph" + } + ], + "type": "doc", + "version": 1 +} diff --git a/corpus/normalization/link-unspellable-targets.md b/corpus/normalization/link-unspellable-targets.md new file mode 100644 index 0000000..b4474d1 --- /dev/null +++ b/corpus/normalization/link-unspellable-targets.md @@ -0,0 +1,5 @@ +[a](/a\b) + + + +[a](/x y) diff --git a/corpus/round-trip/combinations/link-definition-carry.md b/corpus/round-trip/combinations/link-definition-carry.md deleted file mode 100644 index 66f8434..0000000 --- a/corpus/round-trip/combinations/link-definition-carry.md +++ /dev/null @@ -1,3 +0,0 @@ -!adf:carry{json="{\"marks\":[{\"attrs\":{\"href\":\"/u\"},\"type\":\"link\"},{\"type\":\"code\"}],\"text\":\"]: a\",\"type\":\"text\"}"} - -!adf:carry{json="{\"attrs\":{\"timestamp\":\"]:a\"},\"marks\":[{\"attrs\":{\"href\":\"/u\"},\"type\":\"link\"}],\"type\":\"date\"}"} diff --git a/corpus/round-trip/combinations/link-definition-carry.json b/corpus/round-trip/combinations/link-definition-directive.json similarity index 100% rename from corpus/round-trip/combinations/link-definition-carry.json rename to corpus/round-trip/combinations/link-definition-directive.json diff --git a/corpus/round-trip/combinations/link-definition-directive.md b/corpus/round-trip/combinations/link-definition-directive.md new file mode 100644 index 0000000..389e4ab --- /dev/null +++ b/corpus/round-trip/combinations/link-definition-directive.md @@ -0,0 +1,3 @@ +!adf:link[`]: a`]{href="/u"} + +!adf:link[!adf:date{timestamp="]:a"}]{href="/u"} diff --git a/corpus/round-trip/inline-nodes/link-autolink-directive-prefix.json b/corpus/round-trip/inline-nodes/link-autolink-directive-prefix.json new file mode 100644 index 0000000..2253052 --- /dev/null +++ b/corpus/round-trip/inline-nodes/link-autolink-directive-prefix.json @@ -0,0 +1,29 @@ +{ + "content": [ + { + "content": [ + { + "marks": [ + { + "attrs": { + "color": "#ae2e24" + }, + "type": "textColor" + }, + { + "attrs": { + "href": "ab:\\!adf:name{" + }, + "type": "link" + } + ], + "text": "ab:\\!adf:name{", + "type": "text" + } + ], + "type": "paragraph" + } + ], + "type": "doc", + "version": 1 +} diff --git a/corpus/round-trip/inline-nodes/link-autolink-directive-prefix.md b/corpus/round-trip/inline-nodes/link-autolink-directive-prefix.md new file mode 100644 index 0000000..3cd712e --- /dev/null +++ b/corpus/round-trip/inline-nodes/link-autolink-directive-prefix.md @@ -0,0 +1 @@ +!adf:textColor[!adf:link[ab:\\\!adf:name{]{href="ab:\\!adf:name{"}]{color="#ae2e24"} diff --git a/corpus/round-trip/inline-nodes/link-collection.json b/corpus/round-trip/inline-nodes/link-collection.json new file mode 100644 index 0000000..89fb9f7 --- /dev/null +++ b/corpus/round-trip/inline-nodes/link-collection.json @@ -0,0 +1,28 @@ +{ + "content": [ + { + "content": [ + { + "marks": [ + { + "attrs": { + "collection": "contentId-98237", + "href": "https://example.com/docs" + }, + "type": "link" + } + ], + "text": "the docs", + "type": "text" + }, + { + "text": " explain it.", + "type": "text" + } + ], + "type": "paragraph" + } + ], + "type": "doc", + "version": 1 +} diff --git a/corpus/round-trip/inline-nodes/link-collection.md b/corpus/round-trip/inline-nodes/link-collection.md new file mode 100644 index 0000000..157a00b --- /dev/null +++ b/corpus/round-trip/inline-nodes/link-collection.md @@ -0,0 +1 @@ +!adf:link[the docs]{collection=contentId-98237 href="https://example.com/docs"} explain it. diff --git a/corpus/round-trip/inline-nodes/link-destination-angle-space.json b/corpus/round-trip/inline-nodes/link-destination-angle-space.json new file mode 100644 index 0000000..5a75084 --- /dev/null +++ b/corpus/round-trip/inline-nodes/link-destination-angle-space.json @@ -0,0 +1,31 @@ +{ + "content": [ + { + "content": [ + { + "text": "See ", + "type": "text" + }, + { + "marks": [ + { + "attrs": { + "href": "https://example.com/a b>c" + }, + "type": "link" + } + ], + "text": "the docs", + "type": "text" + }, + { + "text": ".", + "type": "text" + } + ], + "type": "paragraph" + } + ], + "type": "doc", + "version": 1 +} diff --git a/corpus/round-trip/inline-nodes/link-destination-angle-space.md b/corpus/round-trip/inline-nodes/link-destination-angle-space.md new file mode 100644 index 0000000..f7fa8dd --- /dev/null +++ b/corpus/round-trip/inline-nodes/link-destination-angle-space.md @@ -0,0 +1 @@ +See !adf:link[the docs]{href="https://example.com/a b>c"}. diff --git a/corpus/round-trip/inline-nodes/link-destination-angle-start.json b/corpus/round-trip/inline-nodes/link-destination-angle-start.json new file mode 100644 index 0000000..92f05ae --- /dev/null +++ b/corpus/round-trip/inline-nodes/link-destination-angle-start.json @@ -0,0 +1,31 @@ +{ + "content": [ + { + "content": [ + { + "text": "See ", + "type": "text" + }, + { + "marks": [ + { + "attrs": { + "href": "` for a bare autolink-shaped text, else `[text](url "title")` — and is +the inline directive `!adf:link[text]{attrs}` only where CommonMark does not: an attribute besides +`href` and `title`, an `href` or `title` no canonical escape spells (a control character, a +backslash, an entity reference, an angle bracket beside a space or opening a bare destination, a +newline in the title), or a link opening a paragraph whose markdown spelling would read as a link +reference definition. A directive link CommonMark could spell is a named error. - `border` — Attributes: `color` (string, `#rrggbb` or `#rrggbbaa`), `size` (number, 1–3). - `code`, `em`, `strike`, `strong` — Attributes: none. -- `link` — Attributes: `href` (string), `title` (string). +- `link` — Attributes: `collection` (string), `href` (string), `id` (string), `occurrenceKey` + (string), `title` (string). - `subsup` — Attributes: `type` (`sub` `sup`). - `textColor` — Attributes: `color` (string, `#rrggbb`). - `underline` — Attributes: none. @@ -490,13 +497,14 @@ and the mark lacks, an order putting a code span outside another mark, `code` ov text node or over text holding a newline, or a spelling CommonMark's flanking rules cannot open or close where the run sits (`un**-real**istic`), or one CommonMark's matching pairs elsewhere — the intra-word `*` runs together with a neighbouring `**`, and the multiple-of-3 rule can leave the -merged run's pairing to another delimiter — rides the inline carry whole. A value the spelling -holds but CommonMark cannot write — a link destination or title — is a named error instead. An -opaque carry inside a mark spelling is a named error in input: the carry restores its node -exactly, marks included (AGENTS.md §3). +merged run's pairing to another delimiter — rides the inline carry whole. An opaque carry inside a +mark spelling is a named error in input: the carry restores its node exactly, marks included +(AGENTS.md §3). ``` !adf:textColor[**Overdue**]{color="#ae2e24"}, H!adf:subsup[2]{type=sub}O, !adf:underline[signed]. +!adf:link[the release]{collection=contentId-98237 href="https://example.com/release notes"} + !adf:border[!adf:mediaInline{collection=contentId-98237 id=01a032c3-7a90-70c9-88f6-c60f710eda07}]{color="#091e42" size=2} ``` diff --git a/src/adf-schema.test.ts b/src/adf-schema.test.ts index 4ab504d..06ad808 100644 --- a/src/adf-schema.test.ts +++ b/src/adf-schema.test.ts @@ -18,7 +18,7 @@ type Spelled = [string, Map] const carried = ['alignment', 'annotation', 'backgroundColor', 'blockCard', 'bodiedRule', 'breakout', 'dataConsumer', 'embedCard', 'fontSize', 'fragment', 'indentation', 'inlineExtension', 'placeholder'] const definitionReference = '#/definitions/' -const gaps = ['link.collection', 'link.id', 'link.occurrenceKey'] +const gaps: string[] = [] const grammarOwn = ['doc', 'text'] const readKeywords = ['$ref', 'additionalProperties', 'allOf', 'anyOf', 'enum', 'items', 'maxItems', 'maximum', 'minItems', 'minLength', 'minimum', 'pattern', 'properties', 'required', 'type'] const root = join(dirname(fileURLToPath(import.meta.url)), '..', 'spec', 'adf-schema') diff --git a/src/adf/mark-attributes.ts b/src/adf/mark-attributes.ts index 7ae22eb..3b4f619 100644 --- a/src/adf/mark-attributes.ts +++ b/src/adf/mark-attributes.ts @@ -4,7 +4,7 @@ export const markAttributes = { border: { color: 'string', size: 'number' }, code: {}, em: {}, - link: { href: 'string', title: 'string' }, + link: { collection: 'string', href: 'string', id: 'string', occurrenceKey: 'string', title: 'string' }, strike: {}, strong: {}, subsup: { type: 'string' }, diff --git a/src/markdown/emit/adf-to-markdown.test.ts b/src/markdown/emit/adf-to-markdown.test.ts index e847e7f..f2eea24 100644 --- a/src/markdown/emit/adf-to-markdown.test.ts +++ b/src/markdown/emit/adf-to-markdown.test.ts @@ -82,15 +82,25 @@ test('spells a code block language no info string holds as an attribute', () => assert.equal(language('a\tb'), '!adf:codeBlock {language="a\\tb"}\n```\n```\n!adf:/codeBlock\n') }) -test('refuses a link destination CommonMark cannot spell', () => { - const link = (href: string): AdfDocument => document(paragraph({ marks: [{ attrs: { href }, type: 'link' }], text: 't', type: 'text' })) - assert.equal(code(adfToMarkdown(link('https://example.com/a b>c'))), 'unspellable-link') - assert.equal(code(adfToMarkdown(link(' { + const link = (href: string): string => markdown(adfToMarkdown(document(paragraph({ marks: [{ attrs: { href }, type: 'link' }], text: 't', type: 'text' })))) + assert.equal(link('https://example.com/a b>c'), '!adf:link[t]{href="https://example.com/a b>c"}\n') + assert.equal(link(' { + const link = (attrs: Record): string => + markdown(adfToMarkdown(document(paragraph({ marks: [{ attrs, type: 'link' }], text: 't', type: 'text' })))) + assert.equal(link({ collection: 'c', href: 'https://example.com/' }), '!adf:link[t]{collection=c href="https://example.com/"}\n') + assert.equal(link({ href: 'https://example.com/', id: 'i' }), '!adf:link[t]{href="https://example.com/" id=i}\n') + assert.equal(link({ href: 'https://example.com/', occurrenceKey: 'k' }), '!adf:link[t]{href="https://example.com/" occurrenceKey=k}\n') + assert.equal(link({ href: 'https://example.com/', id: 'i', title: 'a\nb' }), '!adf:link[t]{href="https://example.com/" id=i title="a\\nb"}\n') }) test('escapes the parenthesis a link destination leaves unbalanced, and no other', () => { @@ -102,19 +112,20 @@ test('escapes the parenthesis a link destination leaves unbalanced, and no other assert.equal(link('https://example.com/a (b'), '[t]()\n') }) -test('escapes the quote a link title holds, and refuses the rest', () => { - const titled = (title: string): AdfDocument => - document(paragraph({ marks: [{ attrs: { href: 'https://example.com/', title }, type: 'link' }], text: 't', type: 'text' })) - assert.equal(markdown(adfToMarkdown(titled('He said "hi"'))), '[t](https://example.com/ "He said \\"hi\\"")\n') - assert.equal(code(adfToMarkdown(titled('a\nb'))), 'unspellable-link') - assert.equal(code(adfToMarkdown(titled('a\\b'))), 'unspellable-link') +test('escapes the quote a link title holds, and spells the rest as the directive link', () => { + const titled = (title: string): string => + markdown(adfToMarkdown(document(paragraph({ marks: [{ attrs: { href: 'https://example.com/', title }, type: 'link' }], text: 't', type: 'text' })))) + assert.equal(titled('He said "hi"'), '[t](https://example.com/ "He said \\"hi\\"")\n') + assert.equal(titled('a\nb'), '!adf:link[t]{href="https://example.com/" title="a\\nb"}\n') + assert.equal(titled('a\\b'), '!adf:link[t]{href="https://example.com/" title="a\\\\b"}\n') + assert.equal(titled('a & b'), '!adf:link[t]{href="https://example.com/" title="a \\u0026amp; b"}\n') }) test('carries a link mark the link spelling cannot write', () => { const carried = (mark: AdfMark): string => markdown(adfToMarkdown(document(paragraph({ marks: [mark], text: 't', type: 'text' })))) assert.equal( - carried({ attrs: { href: 'x', id: 'y' }, type: 'link' }), - '!adf:carry{json="{\\"marks\\":[{\\"attrs\\":{\\"href\\":\\"x\\",\\"id\\":\\"y\\"},\\"type\\":\\"link\\"}],\\"text\\":\\"t\\",\\"type\\":\\"text\\"}"}\n', + carried({ attrs: { href: 'x', rel: 'y' }, type: 'link' }), + '!adf:carry{json="{\\"marks\\":[{\\"attrs\\":{\\"href\\":\\"x\\",\\"rel\\":\\"y\\"},\\"type\\":\\"link\\"}],\\"text\\":\\"t\\",\\"type\\":\\"text\\"}"}\n', ) assert.equal(carried({ attrs: { href: 4 }, type: 'link' }), '!adf:carry{json="{\\"marks\\":[{\\"attrs\\":{\\"href\\":4},\\"type\\":\\"link\\"}],\\"text\\":\\"t\\",\\"type\\":\\"text\\"}"}\n') assert.equal(carried({ type: 'link' }), '!adf:carry{json="{\\"marks\\":[{\\"type\\":\\"link\\"}],\\"text\\":\\"t\\",\\"type\\":\\"text\\"}"}\n') diff --git a/src/markdown/emit/inline-line.ts b/src/markdown/emit/inline-line.ts index 6bac40a..72fd21c 100644 --- a/src/markdown/emit/inline-line.ts +++ b/src/markdown/emit/inline-line.ts @@ -2,21 +2,20 @@ import type { AdfMark, AdfNode } from '../../adf/document.ts' import type { InlineDirective } from '../../adf/inline-directives.ts' import { assembleInlineLine, isSyntax, type InlineEscaping, type InlineSegment, type LineContainer, type NodeRange } from './line-escaping.ts' import { carriedInline } from '../opaque-carry.ts' -import { claimsLine, holdsNullCharacter, isAutolink } from '../commonmark-grammar.ts' -import { escapeUnbalanced, spellDestination, spellLinkTarget } from '../link-syntax.ts' +import { claimsLine, holdsNullCharacter } from '../commonmark-grammar.ts' +import { commonMarkLink, markSpelling, spellMarkAttributes, type CommonMarkLink } from '../mark-spellings.ts' +import { escapeUnbalanced, spellDestination } from '../link-syntax.ts' import { failure, faulted, success, type ConvertErrorPath, type Result } from '../../result.ts' -import { holdsDirectivePrefix, slotLineEndingFault, spellInlineDirectiveOpener, spellInlineLeafDirective } from '../directive-syntax.ts' -import { holdsEntityReference } from '../entity-references.ts' import { inlineDirective } from '../../adf/inline-directives.ts' import { largestNesting } from '../../nesting.ts' import { longestBacktickRun } from '../backtick-runs.ts' -import { markSpelling, spellMarkAttributes } from '../mark-spellings.ts' import { nodeAttrs, nodeContent, nodeMarks } from '../../adf/document.ts' import { sameMark } from '../../adf/editor-normal.ts' +import { slotLineEndingFault, spellInlineDirectiveOpener, spellInlineLeafDirective } from '../directive-syntax.ts' import { spellInlineNodeAttributes } from './inline-directive-spelling.ts' import { spellTextDirective } from '../text-directive.ts' -type EmittedLine = { line: string; segments: InlineSegment[] } +type EmittedLine = { line: string; openingLinkAsDirective: boolean; segments: InlineSegment[] } type Emission = { carry: NodeRange; segments?: undefined } | { carry?: undefined; segments: InlineSegment[] } @@ -24,13 +23,17 @@ type InlineContext = { atBlockEnd: boolean bracketed: boolean carried: ReadonlySet + openingLinkAsDirective: boolean path: ConvertErrorPath spansLines: boolean } type InlineRun = { index: number; kind: 'marked'; mark: AdfMark; nodes: AdfNode[] } | { index: number; kind: 'plain'; node: AdfNode } -type LineAttempt = { carry: NodeRange; line?: undefined } | { carry?: undefined; line: string } +type LineAttempt = + | { carry: NodeRange; line?: undefined; openingLinkAsDirective?: undefined } + | { carry?: undefined; line?: undefined; openingLinkAsDirective: true } + | { carry?: undefined; line: string; openingLinkAsDirective?: undefined } export function emitInlineLine(nodes: readonly AdfNode[], container: LineContainer, path: ConvertErrorPath): Result { const emitted = emitLine(nodes, container, path) @@ -38,6 +41,13 @@ export function emitInlineLine(nodes: readonly AdfNode[], container: LineContain return success(emitted.value.line) } +// The parser asks which form a paragraph's opening link takes rather than restating the line rule (AGENTS.md §11). +export function openingLinkTakesDirective(nodes: readonly AdfNode[], path: ConvertErrorPath): Result { + const emitted = emitLine(nodes, 'paragraph', path) + if (!emitted.ok) return emitted + return success(emitted.value.openingLinkAsDirective) +} + export function tryPipeCell(nodes: readonly AdfNode[], path: ConvertErrorPath): string | undefined { const emitted = emitLine(nodes, 'table-cell', path) if (!emitted.ok) return undefined @@ -47,18 +57,19 @@ export function tryPipeCell(nodes: readonly AdfNode[], path: ConvertErrorPath): export function tryImageLine(alt: string | undefined, href: string, path: ConvertErrorPath): string | undefined { if (alt !== undefined && (/^[ \t]|[ \t]$|[\n\r]/.test(alt) || holdsNullCharacter(alt))) return undefined - const destination = spellDestination(href, path) - if (!destination.ok) return undefined + const destination = spellDestination(href) + if (destination === undefined) return undefined const description: InlineSegment[] = alt === undefined ? [] : [{ escaping: 'bracketed', text: alt }] - const attempt = attemptLine([syntax('!['), ...description, syntax(`](${destination.value})`)], 'paragraph', path) + const attempt = attemptLine([syntax('!['), ...description, syntax(`](${destination})`)], 'paragraph', path) return attempt.ok ? attempt.value.line : undefined } -// A carried node joins no run, so every pass carries at least one more node. +// A carried node joins no run and the opening link turns directive once, so every pass carries at least one more node. function emitLine(nodes: readonly AdfNode[], container: LineContainer, path: ConvertErrorPath): Result { const carried = new Set() + let openingLinkAsDirective = false for (;;) { - const emission = lineSegments(nodes, container, path, carried) + const emission = lineSegments(nodes, container, path, carried, openingLinkAsDirective) if (!emission.ok) return emission if (emission.value.carry !== undefined) { carryRange(carried, emission.value.carry) @@ -66,8 +77,9 @@ function emitLine(nodes: readonly AdfNode[], container: LineContainer, path: Con } const attempt = attemptLine(emission.value.segments, container, path) if (!attempt.ok) return attempt - if (attempt.value.carry === undefined) return success({ line: attempt.value.line, segments: emission.value.segments }) - carryRange(carried, attempt.value.carry) + if (attempt.value.line !== undefined) return success({ line: attempt.value.line, openingLinkAsDirective, segments: emission.value.segments }) + if (attempt.value.carry !== undefined) carryRange(carried, attempt.value.carry) + else openingLinkAsDirective = true } } @@ -75,8 +87,15 @@ function carryRange(carried: Set, range: NodeRange): void { for (let index = range.first; index <= range.last; index += 1) carried.add(index) } -function lineSegments(nodes: readonly AdfNode[], container: LineContainer, path: ConvertErrorPath, carried: ReadonlySet): Result { - const emission = emitRun(nodes, 0, 0, { atBlockEnd: true, bracketed: false, carried, path, spansLines: container === 'paragraph' }) +function lineSegments( + nodes: readonly AdfNode[], + container: LineContainer, + path: ConvertErrorPath, + carried: ReadonlySet, + openingLinkAsDirective: boolean, +): Result { + const context: InlineContext = { atBlockEnd: true, bracketed: false, carried, openingLinkAsDirective, path, spansLines: container === 'paragraph' } + const emission = emitRun(nodes, 0, 0, context) if (!emission.ok) return emission if (emission.value.carry !== undefined) return emission return success({ segments: carryStrippedWhitespace(emission.value.segments) }) @@ -84,6 +103,7 @@ function lineSegments(nodes: readonly AdfNode[], container: LineContainer, path: function attemptLine(segments: readonly InlineSegment[], container: LineContainer, path: ConvertErrorPath): Result { const assembled = assembleInlineLine(segments, container) + if (assembled.openingLinkAsDirective) return success({ openingLinkAsDirective: true }) if (assembled.unspellableRun !== undefined) return success({ carry: assembled.unspellableRun }) for (const [index, single] of assembled.line.split('\n').entries()) { if (container === 'paragraph' && claimsLine(single, index === 0 ? 'first' : 'later')) { @@ -237,7 +257,13 @@ function emitMarkedRun(nodes: readonly AdfNode[], mark: AdfMark, depth: number, if (attributes === undefined) return success({ carry: range }) if (spelling.kind === 'code') return emitCodeSpan(nodes, depth, range, path) if (spelling.kind === 'emphasis') return emitEmphasis(nodes, spelling.spelling, depth, range, context) - if (spelling.kind === 'link') return emitLink(nodes, mark, depth, range, context, path) + if (spelling.kind === 'link') { + const href = nodeAttrs(mark)['href'] + if (typeof href !== 'string') return success({ carry: range }) + const opening = depth === 0 && index === 0 && context.openingLinkAsDirective + const commonMark = opening ? undefined : commonMarkLink(nodeAttrs(mark), href, isBareLink(nodes, href, depth), context.bracketed) + if (commonMark !== undefined) return emitLink(nodes, href, commonMark, depth, range, context) + } const inner = emitRun(nodes, depth + 1, index, { ...context, bracketed: true, spansLines: false }) if (!inner.ok) return inner if (inner.value.carry !== undefined) return inner @@ -278,20 +304,17 @@ function needsPadding(text: string): boolean { return text.startsWith(' ') && text.endsWith(' ') && /[^ ]/.test(text) } -function emitLink(nodes: readonly AdfNode[], mark: AdfMark, depth: number, range: NodeRange, context: InlineContext, path: ConvertErrorPath): Result { - const href = nodeAttrs(mark)['href'] - const title = nodeAttrs(mark)['title'] - if (typeof href !== 'string') return success({ carry: range }) +function isBareLink(nodes: readonly AdfNode[], href: string, depth: number): boolean { const node = nodes[0] - const bare = nodes.length === 1 && node !== undefined && node.type === 'text' && node.text === href && nodeMarks(node).length === depth + 1 - const autolinkHolds = !context.bracketed || (!href.includes('`') && !holdsDirectivePrefix(href) && escapeUnbalanced(href, '[', ']') === href) - if (bare && autolinkHolds && title === undefined && isAutolink(href) && !holdsEntityReference(href)) return success({ segments: [syntax(`<${href}>`)] }) - const target = spellLinkTarget(href, typeof title === 'string' ? title : undefined, path) - if (!target.ok) return target + return nodes.length === 1 && node !== undefined && node.type === 'text' && node.text === href && nodeMarks(node).length === depth + 1 +} + +function emitLink(nodes: readonly AdfNode[], href: string, commonMark: CommonMarkLink, depth: number, range: NodeRange, context: InlineContext): Result { + if (commonMark.form === 'autolink') return success({ segments: [syntax(`<${href}>`)] }) const inner = emitRun(nodes, depth + 1, range.first, { ...context, bracketed: true }) if (!inner.ok) return inner if (inner.value.carry !== undefined) return inner - const spelledTarget: InlineSegment = context.bracketed ? { escaping: 'bracketed-link-target', text: escapeUnbalanced(target.value, '[', ']') } : syntax(target.value) - return success({ segments: [{ escaping: 'none', nodes: range, text: '[' }, ...inner.value.segments, syntax(']('), spelledTarget, syntax(')')] }) + const target: InlineSegment = context.bracketed ? { escaping: 'bracketed-link-target', text: escapeUnbalanced(commonMark.target, '[', ']') } : syntax(commonMark.target) + return success({ segments: [{ escaping: 'none', nodes: range, text: '[' }, ...inner.value.segments, syntax(']('), target, syntax(')')] }) } diff --git a/src/markdown/emit/line-escaping.ts b/src/markdown/emit/line-escaping.ts index 7066730..84b9955 100644 --- a/src/markdown/emit/line-escaping.ts +++ b/src/markdown/emit/line-escaping.ts @@ -17,7 +17,7 @@ export type InlineSegment = | { emphasis?: undefined; escaping: 'none'; nodes: NodeRange; text: string } | { emphasis?: undefined; escaping: InlineEscaping; nodes?: undefined; text: string } -export type AssembledLine = { line: string; unspellableRun: NodeRange | undefined } +export type AssembledLine = { line: string; openingLinkAsDirective?: true; unspellableRun: NodeRange | undefined } type ScanLine = { position: LinePosition; start: number; text: string } @@ -77,8 +77,7 @@ function escape(segments: readonly InlineSegment[], container: LineContainer): A output += scan.charAt(index) } if (container === 'paragraph' && opensLinkDefinition(output)) { - const opener = segments[0]?.nodes - if (opener !== undefined) return { line: output, unspellableRun: opener } + if (segments[0]?.nodes !== undefined) return { line: output, openingLinkAsDirective: true, unspellableRun: undefined } return { line: `\\${output}`, unspellableRun: unspellableRun(segments, output, placements) } } return { line: output, unspellableRun: unspellableRun(segments, output, placements) } diff --git a/src/markdown/link-syntax.ts b/src/markdown/link-syntax.ts index 38aff4d..0210f9d 100644 --- a/src/markdown/link-syntax.ts +++ b/src/markdown/link-syntax.ts @@ -1,5 +1,4 @@ import { backslashEscape, decodeTextEscapes, holdsControlCharacter } from './commonmark-grammar.ts' -import { failure, success, type ConvertErrorPath, type Result } from '../result.ts' import { holdsEntityReference } from './entity-references.ts' export type LinkDefinition = { destination: string; title?: string } @@ -90,28 +89,17 @@ export function skipLinkWhitespace(text: string, offset: number): number { return offset + rest.length - rest.replace(/^[ \t]*\n?[ \t]*/, '').length } -export function spellDestination(href: string, path: ConvertErrorPath): Result { - if (holdsControlCharacter(href)) return failure('unspellable-link', 'a link destination holds a control character', path) - if (href.includes('\\')) return failure('unspellable-link', 'no canonical escape spells a backslash in a link destination', path) - if (holdsEntityReference(href)) { - return failure('unspellable-link', 'a link destination holds an entity reference that decodes on the way back', path) - } - if (href.includes(' ')) { - if (/[<>]/.test(href)) { - return failure('unspellable-link', 'no canonical escape spells an angle bracket beside a space in a link destination', path) - } - return success(`<${href}>`) - } - if (href.startsWith('<')) return failure('unspellable-link', 'a bare link destination cannot begin with an angle bracket', path) - return success(escapeUnbalanced(href, '(', ')')) +export function spellDestination(href: string): string | undefined { + if (holdsControlCharacter(href) || href.includes('\\') || holdsEntityReference(href)) return undefined + if (href.includes(' ')) return /[<>]/.test(href) ? undefined : `<${href}>` + return href.startsWith('<') ? undefined : escapeUnbalanced(href, '(', ')') } -export function spellLinkTarget(href: string, title: string | undefined, path: ConvertErrorPath): Result { - const destination = spellDestination(href, path) - if (!destination.ok || title === undefined) return destination - const spelledTitle = spellTitle(title, path) - if (!spelledTitle.ok) return spelledTitle - return success(`${destination.value === '' ? '<>' : destination.value}${spelledTitle.value}`) +export function spellLinkTarget(href: string, title: string | undefined): string | undefined { + const destination = spellDestination(href) + if (destination === undefined || title === undefined) return destination + const spelledTitle = spellTitle(title) + return spelledTitle === undefined ? undefined : `${destination === '' ? '<>' : destination}${spelledTitle}` } export function escapeUnbalanced(spelling: string, opener: string, closer: string): string { @@ -128,10 +116,6 @@ export function escapeUnbalanced(spelling: string, opener: string, closer: strin return spelled } -function spellTitle(title: string, path: ConvertErrorPath): Result { - if (/[\n\r\\]/.test(title)) { - return failure('unspellable-link', 'no canonical escape spells a backslash or newline in a link title', path) - } - if (holdsEntityReference(title)) return failure('unspellable-link', 'a link title holds an entity reference that decodes on the way back', path) - return success(` "${title.replaceAll('"', '\\"')}"`) +function spellTitle(title: string): string | undefined { + return /[\n\r\\]/.test(title) || holdsEntityReference(title) ? undefined : ` "${title.replaceAll('"', '\\"')}"` } diff --git a/src/markdown/mark-spellings.ts b/src/markdown/mark-spellings.ts index 7b4ce42..1ef53df 100644 --- a/src/markdown/mark-spellings.ts +++ b/src/markdown/mark-spellings.ts @@ -1,15 +1,20 @@ -import type { AdfMark } from '../adf/document.ts' +import type { AdfAttributes, AdfMark } from '../adf/document.ts' import type { AttributeVocabulary } from '../adf/attribute-vocabulary.ts' import type { MarkType } from '../adf/mark-attributes.ts' +import { escapeUnbalanced, spellLinkTarget } from './link-syntax.ts' +import { holdsDirectivePrefix, spellAttributes, spellVocabulary } from './directive-syntax.ts' +import { holdsEntityReference } from './entity-references.ts' +import { isAutolink } from './commonmark-grammar.ts' import { isMarkType, markAttributes } from '../adf/mark-attributes.ts' import { nodeAttrs } from '../adf/document.ts' -import { spellAttributes, spellVocabulary } from './directive-syntax.ts' import { vocabularyPairs } from '../adf/attribute-vocabulary.ts' type Spelling = { kind: 'code' | 'directive' | 'link'; spelling?: undefined } | { kind: 'emphasis'; spelling: string } export type MarkSpelling = Spelling & { attributes: AttributeVocabulary } +export type CommonMarkLink = { form: 'autolink'; target?: undefined } | { form: 'inline'; target: string } + const markSpellings: Readonly> = { border: { kind: 'directive' }, code: { kind: 'code' }, @@ -30,6 +35,16 @@ export function markSpelling(type: string): MarkSpelling | undefined { return { attributes, kind: spelling.kind } } +// spec/flavour.md, Marks. `bare`: one text node reading exactly `href`, the link innermost. +export function commonMarkLink(attrs: AdfAttributes, href: string, bare: boolean, bracketed: boolean): CommonMarkLink | undefined { + if (Object.keys(attrs).some((key) => key !== 'href' && key !== 'title')) return undefined + const title = attrs['title'] + const autolinkHolds = !bracketed || (!href.includes('`') && !holdsDirectivePrefix(href) && escapeUnbalanced(href, '[', ']') === href) + if (bare && autolinkHolds && title === undefined && isAutolink(href) && !holdsEntityReference(href)) return { form: 'autolink' } + const target = spellLinkTarget(href, typeof title === 'string' ? title : undefined) + return target === undefined ? undefined : { form: 'inline', target } +} + export function spellMarkAttributes(mark: AdfMark, vocabulary: AttributeVocabulary): string | undefined { const pairs = vocabularyPairs(nodeAttrs(mark), vocabulary, []) return pairs === undefined ? undefined : spellAttributes(spellVocabulary(pairs)) diff --git a/src/markdown/parse/directive-marks.ts b/src/markdown/parse/directive-marks.ts index b6dc14f..c9f5fc7 100644 --- a/src/markdown/parse/directive-marks.ts +++ b/src/markdown/parse/directive-marks.ts @@ -28,10 +28,9 @@ function markdownForm(spelling: MarkSpelling): string | undefined { case 'code': return '`x`' case 'directive': + case 'link': return undefined case 'emphasis': return `${spelling.spelling}x${spelling.spelling}` - case 'link': - return '[x](url)' } } diff --git a/src/markdown/parse/inline-content.ts b/src/markdown/parse/inline-content.ts index 15eec46..fbf0b8e 100644 --- a/src/markdown/parse/inline-content.ts +++ b/src/markdown/parse/inline-content.ts @@ -1,15 +1,18 @@ import type { AdfMark, AdfNode } from '../../adf/document.ts' import type { DirectiveSpan } from '../directive-syntax.ts' import type { EmphasisPairing } from '../emphasis-matching.ts' +import type { LineContainer } from '../emit/line-escaping.ts' import type { LinkDefinition } from '../link-syntax.ts' import { backslashEscape, decodeTextEscapes, inlineHtmlConstruct, readBracketedAutolink, readEmailAutolink, trimTrailingSpace } from '../commonmark-grammar.ts' import { backtickRun, closingBacktickRun } from '../backtick-runs.ts' +import { commonMarkLink } from '../mark-spellings.ts' import { delimiterFlags, matchEmphasis, runLength } from '../emphasis-matching.ts' import { failure, faulted, success, type ConvertErrorPath, type Result } from '../../result.ts' import { inlineDirective } from '../../adf/inline-directives.ts' import { mergeAdjacentText } from '../../adf/editor-normal.ts' import { nodeAttrs, nodeMarks } from '../../adf/document.ts' import { normalizeLabel, readInlineTarget, readLabel } from '../link-syntax.ts' +import { openingLinkTakesDirective } from '../emit/inline-line.ts' import { readCarriedInline } from '../opaque-carry.ts' import { readDirectiveMark } from './directive-marks.ts' import { readInlineDirective } from '../directive-syntax.ts' @@ -33,19 +36,29 @@ type Piece = type Run = { canClose: boolean; canOpen: boolean; character: string; index: number; length: number } -type Scan = { definitions: LinkDefinitions; path: ConvertErrorPath; pending: string; pieces: Piece[]; source: string } +// `container` is the emitter's line container, `undefined` inside a directive's content slot; `openingSpellableLink` defers a directive link at offset 0 to `assemble`. +type Scan = { + container: LineContainer | undefined + definitions: LinkDefinitions + openingSpellableLink: boolean + path: ConvertErrorPath + pending: string + pieces: Piece[] + source: string +} type SlotContent = { carry: boolean; nodes: AdfNode[] } const carriedInMark = 'no mark spelling wraps an opaque carry: the carried node restores exactly, marks included' const imageAlone = 'an image fits only as a paragraph of its own: this one sits inside other content' +const spellableLink = 'link takes the directive form only where CommonMark cannot spell it: this one it can' -export function parseInlineContent(source: string, definitions: LinkDefinitions, path: ConvertErrorPath): Result { - return parseInline(source, definitions, path, true) +export function parseInlineContent(source: string, definitions: LinkDefinitions, path: ConvertErrorPath, container: LineContainer): Result { + return parseInline(source, definitions, path, container) } -function parseInline(source: string, definitions: LinkDefinitions, path: ConvertErrorPath, strip: boolean): Result { - const scan: Scan = { definitions, path, pending: '', pieces: [], source } +function parseInline(source: string, definitions: LinkDefinitions, path: ConvertErrorPath, container: LineContainer | undefined): Result { + const scan: Scan = { container, definitions, openingSpellableLink: false, path, pending: '', pieces: [], source } let index = 0 while (index < source.length) { switch (source.charAt(index)) { @@ -93,7 +106,7 @@ function parseInline(source: string, definitions: LinkDefinitions, path: Convert index += 1 } } - flush(scan, strip) + flush(scan, container !== undefined) return assemble(scan) } @@ -158,14 +171,14 @@ function readDirective(scan: Scan, index: number): Result | undefined { const directive = readInlineDirective(scan.source, index) if (directive === undefined) return undefined if (directive.fault !== undefined) return faulted(directive.fault, scan.path) - const piece = directivePiece(scan, directive.value) + const piece = directivePiece(scan, directive.value, index) if (!piece.ok) return piece flush(scan, false) scan.pieces.push(piece.value) return success(index + directive.value.length) } -function directivePiece(scan: Scan, span: DirectiveSpan): Result { +function directivePiece(scan: Scan, span: DirectiveSpan, index: number): Result { const carried = readCarriedInline(span) if (carried !== undefined) { if (carried.fault !== undefined) return faulted(carried.fault, scan.path) @@ -183,6 +196,8 @@ function directivePiece(scan: Scan, span: DirectiveSpan): Result { return failure('unsupported-node-shape', `the ${span.name} mark wraps the [content] it marks: this one wraps none`, scan.path) } if (slot.value.carry) return failure('unsupported-node-shape', carriedInMark, scan.path) + const refused = mark.value.type === 'link' ? refuseSpellableLink(scan, mark.value, slot.value.nodes, index) : undefined + if (refused !== undefined) return refused return success({ kind: 'nodes', nodes: applyMark(slot.value.nodes, mark.value) }) } const node = readInlineDirectiveNode(span.name, span.attributes, slot.value?.nodes, scan.path) @@ -190,9 +205,21 @@ function directivePiece(scan: Scan, span: DirectiveSpan): Result { return success({ kind: 'nodes', nodes: [node.value] }) } +// spec/flavour.md, Marks. A link opening a paragraph may still need the directive form for the line it opens, which `assemble` asks the emitter. +function refuseSpellableLink(scan: Scan, mark: AdfMark, nodes: readonly AdfNode[], index: number): Result | undefined { + const href = nodeAttrs(mark)['href'] + if (typeof href !== 'string') return undefined + const only = nodes[0] + const bare = nodes.length === 1 && only !== undefined && only.type === 'text' && only.text === href && nodeMarks(only).length === 0 + if (commonMarkLink(nodeAttrs(mark), href, bare, scan.container === undefined) === undefined) return undefined + if (index !== 0 || scan.container !== 'paragraph') return failure('unsupported-node-shape', spellableLink, scan.path) + scan.openingSpellableLink = true + return undefined +} + function slotContent(scan: Scan, content: string | undefined): Result { if (content === undefined) return success(undefined) - const parsed = parseInline(content, scan.definitions, scan.path, false) + const parsed = parseInline(content, scan.definitions, scan.path, undefined) if (!parsed.ok) return parsed if (parsed.value.image !== undefined) return failure('unmappable-image', imageAlone, scan.path) return success(parsed.value) @@ -214,6 +241,11 @@ function assemble(scan: Scan): Result { if (holdsImage(scan.pieces)) return failure('unmappable-image', imageAlone, scan.path) const nodes = resolveNodes(scan.pieces, scan.path) if (!nodes.ok) return nodes + if (scan.openingSpellableLink) { + const takesDirective = openingLinkTakesDirective(nodes.value, scan.path) + if (!takesDirective.ok) return takesDirective + if (!takesDirective.value) return failure('unsupported-node-shape', spellableLink, scan.path) + } return success({ carry: holdsCarry(scan.pieces), nodes: nodes.value }) } diff --git a/src/markdown/parse/markdown-to-adf.test.ts b/src/markdown/parse/markdown-to-adf.test.ts index 75908e1..ccb716d 100644 --- a/src/markdown/parse/markdown-to-adf.test.ts +++ b/src/markdown/parse/markdown-to-adf.test.ts @@ -287,8 +287,8 @@ test('names the directive form a node CommonMark spells refuses', () => { // The spelling the emitter refuses gives the emitter's own error, never a second name for it. test('gives back the refusal the CommonMark spelling itself raises', () => { - const destination = '!adf:blockquote\n[t](https://example.com/a\\b)\n!adf:/blockquote\n' - assert.equal(content(markdownToAdf(destination)), 'unspellable-link: no canonical escape spells a backslash in a link destination') + const lineStart = '!adf:blockquote\n` `` `\n!adf:/blockquote\n' + assert.equal(content(markdownToAdf(lineStart)), 'unspellable-line-start: block parsing would claim the emitted line "``` `` ```"') }) test('names the directive name no node reads back to', () => { @@ -966,7 +966,25 @@ test('names the mark markdown spells, never a directive', () => { assert.equal(content(markdownToAdf('!adf:strong[a]\n')), 'unsupported-node-shape: strong is spelled **x**, never as a directive') assert.equal(content(markdownToAdf('!adf:strike[a]\n')), 'unsupported-node-shape: strike is spelled ~~x~~, never as a directive') assert.equal(content(markdownToAdf('!adf:code[a]\n')), 'unsupported-node-shape: code is spelled `x`, never as a directive') - assert.equal(content(markdownToAdf('!adf:link[a]{href="/u"}\n')), 'unsupported-node-shape: link is spelled [x](url), never as a directive') +}) + +// spec/flavour.md, Marks: the directive link is refused wherever the emitter would pick the markdown spelling. +test('refuses the directive link CommonMark could spell, and reads the one it could not', () => { + const refused = 'unsupported-node-shape: link takes the directive form only where CommonMark cannot spell it: this one it can' + assert.equal(content(markdownToAdf('!adf:link[a]{href="/u"}\n')), refused) + assert.equal(content(markdownToAdf('See !adf:link[a]{href="/u"}.\n')), refused) + assert.equal(content(markdownToAdf('!adf:link[https://example.com/]{href="https://example.com/"}\n')), refused) + assert.equal(content(markdownToAdf('# !adf:link[`]: a`]{href="/u"}\n')), refused) + assert.equal(content(markdownToAdf('| !adf:link[`]: a`]{href="/u"} |\n| --- |\n')), refused) + assert.equal(content(markdownToAdf('!adf:underline[!adf:link[a]{href="/u"}]\n')), refused) + assert.deepEqual(path(markdownToAdf('Part.\n\nSee !adf:link[a]{href="/u"}.\n')), ['content', 1]) + const titled: AdfNode = { marks: [{ attrs: { title: 't' }, type: 'link' }], text: 'a', type: 'text' } + assert.deepEqual(content(markdownToAdf('!adf:link[a]{title=t}\n')), [{ content: [titled], type: 'paragraph' }]) + const opening: AdfNode = { marks: [{ attrs: { href: '/u' }, type: 'link' }, { type: 'code' }], text: ']: a', type: 'text' } + assert.deepEqual(content(markdownToAdf('!adf:link[`]: a`]{href="/u"}\n')), [{ content: [opening], type: 'paragraph' }]) + assert.deepEqual(content(markdownToAdf('!adf:heading {level=1 localId=h}\n!adf:link[`]: a`]{href="/u"}\n!adf:/heading\n')), [ + { attrs: { level: 1, localId: 'h' }, content: [opening], type: 'heading' }, + ]) }) test('names the directive mark left without the content it wraps', () => { diff --git a/src/markdown/parse/markdown-to-adf.ts b/src/markdown/parse/markdown-to-adf.ts index ace6616..2c2425b 100644 --- a/src/markdown/parse/markdown-to-adf.ts +++ b/src/markdown/parse/markdown-to-adf.ts @@ -2,6 +2,7 @@ import type { AdfDocument, AdfNode } from '../../adf/document.ts' import type { Block, DirectiveBlock } from './blocks.ts' import type { BlockDirectiveNode } from './directive-nodes.ts' import type { ConvertFault } from '../../result.ts' +import type { LineContainer } from '../emit/line-escaping.ts' import type { LinkDefinitions } from './inline-content.ts' import { carryName, readCarriedBlock } from '../opaque-carry.ts' import { commonMarkSpelling } from '../emit/adf-to-markdown.ts' @@ -67,7 +68,7 @@ function blockNode(block: Block, definitions: LinkDefinitions, path: ConvertErro case 'fault': return faulted(block.fault, path) case 'heading': - return contentNode({ attrs: { level: block.level }, type: 'heading' }, block.text, definitions, path) + return contentNode({ attrs: { level: block.level }, type: 'heading' }, block.text, definitions, path, 'heading') case 'html': return failure('unmappable-html', `no raw HTML converts at this version: ${block.construct}`, path) case 'orderedList': @@ -119,7 +120,7 @@ function tableNode(rows: readonly string[][], definitions: LinkDefinitions, path const type = rowIndex === 0 ? 'tableHeader' : 'tableCell' const row: AdfNode[] = [] for (const [cellIndex, cell] of cells.entries()) { - const paragraph = contentNode({ type: 'paragraph' }, cell, definitions, [...path, 'content', rowIndex, 'content', cellIndex, 'content', 0]) + const paragraph = contentNode({ type: 'paragraph' }, cell, definitions, [...path, 'content', rowIndex, 'content', cellIndex, 'content', 0], 'table-cell') if (!paragraph.ok) return paragraph row.push({ content: [paragraph.value], type }) } @@ -133,7 +134,7 @@ function inlineBodyNode(node: AdfNode, blocks: readonly Block[], definitions: Li const only = blocks.length === 1 ? blocks[0] : undefined if (only?.kind === 'fault') return positioned(faulted(only.fault, path), only.position) if (only?.kind !== 'paragraph') return failure('unsupported-node-shape', `${node.type} takes one paragraph as its body: this body is not one`, path) - return positioned(contentNode(node, only.text, definitions, path), only.position) + return positioned(contentNode(node, only.text, definitions, path, 'paragraph'), only.position) } function containerNode(node: AdfNode, blocks: readonly Block[], definitions: LinkDefinitions, path: ConvertErrorPath, depth: number): Result { @@ -168,14 +169,14 @@ function codeBlockNode(language: string, text: string, path: ConvertErrorPath, d // spec/flavour.md, The CommonMark image: only a plain paragraph gives an image the block it needs. function paragraphNode(text: string, definitions: LinkDefinitions, path: ConvertErrorPath): Result { - const content = parseInlineContent(text, definitions, path) + const content = parseInlineContent(text, definitions, path, 'paragraph') if (!content.ok) return content const image = content.value.image return success(image === undefined ? withContent({ type: 'paragraph' }, content.value.nodes) : image) } -function contentNode(node: AdfNode, text: string, definitions: LinkDefinitions, path: ConvertErrorPath): Result { - const content = parseInlineContent(text, definitions, path) +function contentNode(node: AdfNode, text: string, definitions: LinkDefinitions, path: ConvertErrorPath, container: LineContainer): Result { + const content = parseInlineContent(text, definitions, path, container) if (!content.ok) return content if (content.value.image !== undefined) return failure('unmappable-image', `no ADF node carries an image inside a ${node.type}`, path) return success(withContent(node, content.value.nodes)) diff --git a/src/result.ts b/src/result.ts index 60d212b..7ca127b 100644 --- a/src/result.ts +++ b/src/result.ts @@ -7,7 +7,6 @@ export type ConvertErrorCode = | 'unmappable-image' | 'unspellable-character' | 'unspellable-line-start' - | 'unspellable-link' | 'unspellable-whitespace' | 'unsupported-document-version' | 'unsupported-nesting-depth'