diff --git a/AGENTS.md b/AGENTS.md index a1dd948..f3017b0 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -23,8 +23,8 @@ backticks read back as a fence — so a parse succeeding does not imply a spella `corpus/commonmark-spec/exceptions.json` names those. "Equals" is structural equality over editor-normal ADF — adjacent text nodes with identical marks -merged, JSON number semantics, an empty attrs object, marks array or content array the absent -key — the only domain markdown can restore. +and no attributes merged, JSON number semantics, an empty attrs object, marks array or content +array the absent key — the only domain markdown can restore. Round-trip equality is a property tested over a corpus, not a claim made in prose. diff --git a/corpus/round-trip/combinations/carry-text-attributes.json b/corpus/round-trip/combinations/carry-text-attributes.json new file mode 100644 index 0000000..fbc01a1 --- /dev/null +++ b/corpus/round-trip/combinations/carry-text-attributes.json @@ -0,0 +1,76 @@ +{ + "content": [ + { + "content": [ + { + "attrs": { + "localId": "01a0a067-68cf-78af-abd6-c660ec0d189b" + }, + "text": "Owner", + "type": "text" + }, + { + "text": " signs off.", + "type": "text" + } + ], + "type": "paragraph" + }, + { + "content": [ + { + "text": "Signed by ", + "type": "text" + }, + { + "attrs": { + "localId": "01a0a067-68d2-787e-afc5-2459776aa029" + }, + "text": "the owner", + "type": "text" + } + ], + "type": "paragraph" + }, + { + "content": [ + { + "attrs": { + "localId": "01a0a067-68d5-7372-9962-29a039654056" + }, + "text": "One ", + "type": "text" + }, + { + "attrs": { + "localId": "01a0a067-68d5-7372-9962-29a039654056" + }, + "text": "anchor", + "type": "text" + } + ], + "type": "paragraph" + }, + { + "content": [ + { + "attrs": { + "localId": "01a0a067-68cf-78af-abd6-c660ec0d189b" + }, + "text": "Two ", + "type": "text" + }, + { + "attrs": { + "localId": "01a0a067-68d9-73fa-9b9a-caa42061993c" + }, + "text": "anchors", + "type": "text" + } + ], + "type": "paragraph" + } + ], + "type": "doc", + "version": 1 +} diff --git a/corpus/round-trip/combinations/carry-text-attributes.md b/corpus/round-trip/combinations/carry-text-attributes.md new file mode 100644 index 0000000..83c35d2 --- /dev/null +++ b/corpus/round-trip/combinations/carry-text-attributes.md @@ -0,0 +1,7 @@ +:adf{json="{\"attrs\":{\"localId\":\"01a0a067-68cf-78af-abd6-c660ec0d189b\"},\"text\":\"Owner\",\"type\":\"text\"}"} signs off. + +Signed by :adf{json="{\"attrs\":{\"localId\":\"01a0a067-68d2-787e-afc5-2459776aa029\"},\"text\":\"the owner\",\"type\":\"text\"}"} + +:adf{json="{\"attrs\":{\"localId\":\"01a0a067-68d5-7372-9962-29a039654056\"},\"text\":\"One \",\"type\":\"text\"}"}:adf{json="{\"attrs\":{\"localId\":\"01a0a067-68d5-7372-9962-29a039654056\"},\"text\":\"anchor\",\"type\":\"text\"}"} + +:adf{json="{\"attrs\":{\"localId\":\"01a0a067-68cf-78af-abd6-c660ec0d189b\"},\"text\":\"Two \",\"type\":\"text\"}"}:adf{json="{\"attrs\":{\"localId\":\"01a0a067-68d9-73fa-9b9a-caa42061993c\"},\"text\":\"anchors\",\"type\":\"text\"}"} diff --git a/spec/flavour.md b/spec/flavour.md index dd653f8..853f94b 100644 --- a/spec/flavour.md +++ b/spec/flavour.md @@ -404,10 +404,10 @@ Right. Attributes and the carry fallback read as in the block sections, the carry in its inline form. Of the nodes below, `emoji`, `mention` and `status` spell their `text` attribute in the content slot as plain text: `[]` is the empty string, absent content is the absent attribute, non-empty content -parsing to anything but one unmarked text node — adjacent identical-mark text nodes merged first — -is a named error, and so is a `text` key in `{attrs}`. An enclosing mark spelling does not reach -into the slot. The rest take no content, `:text` included; content on a node that takes none is a -named error. +parsing to anything but one unmarked text node — adjacent text nodes with identical marks and no +attributes merged first — is a named error, and so is a `text` key in `{attrs}`. An enclosing mark +spelling does not reach into the slot. The rest take no content, `:text` included; content on a +node that takes none is a named error. - `date` — Attributes: `localId` (string), `timestamp` (string, epoch milliseconds). - `emoji` — Attributes: `id` (string), `localId` (string), `shortName` (string, `:name:`), `text` @@ -434,10 +434,10 @@ CommonMark strips or refuses one — a block's inline content edges, either side an em, strong or strike spelling's inner edges, a pipe cell's edges — is spelled `:text{text="…"}`, the reserved key carrying the node's text, escaped by the attribute grammar and never literal: pipe cells trim and pad. The emitter wraps the whitespace run alone and leaves -the rest plain text; `markdownToAdf` merges adjacent text nodes carrying identical marks -(AGENTS.md §2). Input reads that spelling alone: the value is one run of spaces and tabs, or one -run of newlines, and anything else — a mixed run, or text CommonMark carries plainly — is a named -error. +the rest plain text; `markdownToAdf` merges adjacent text nodes carrying identical marks and no +attributes (AGENTS.md §2). Input reads that spelling alone: the value is one run of spaces and +tabs, or one run of newlines, and anything else — a mixed run, or text CommonMark carries plainly — +is a named error. ``` :text{text=" "}Two leading spaces held, and one text node split:text{text="\n"}over two lines. diff --git a/src/adf/editor-normal.ts b/src/adf/editor-normal.ts index 7f65586..cd5b738 100644 --- a/src/adf/editor-normal.ts +++ b/src/adf/editor-normal.ts @@ -5,12 +5,12 @@ export function sameMark(candidate: AdfMark, mark: AdfMark): boolean { return markKey(candidate) === markKey(mark) } -// AGENTS.md §2: adjacent text nodes carrying identical marks are one node. +// AGENTS.md §2: adjacent text nodes carrying identical marks and no attributes are one node. export function mergeAdjacentText(nodes: readonly AdfNode[]): AdfNode[] { const merged: AdfNode[] = [] for (const node of nodes) { const previous = merged[merged.length - 1] - if (previous !== undefined && previous.type === 'text' && node.type === 'text' && sameMarks(previous, node)) { + if (previous !== undefined && mergesText(previous) && mergesText(node) && sameMarks(previous, node)) { merged[merged.length - 1] = { ...previous, text: `${previous.text ?? ''}${node.text ?? ''}` } continue } @@ -19,6 +19,10 @@ export function mergeAdjacentText(nodes: readonly AdfNode[]): AdfNode[] { return merged } +function mergesText(node: AdfNode): boolean { + return node.type === 'text' && Object.keys(node.attrs ?? {}).length === 0 +} + function sameMarks(previous: AdfNode, node: AdfNode): boolean { return marksKey(previous.marks ?? []) === marksKey(node.marks ?? []) }