@markup-carve/carve-grammars 0.1.5 → 0.1.6

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -162,17 +162,21 @@ Unsupported handling:
162
162
 
163
163
  - `unsupported: 'throw'` is the default. The loader throws `UnsupportedNodeError`
164
164
  instead of silently dropping content.
165
- - `unsupported: 'preserve'` first builds the richest available document and
166
- verifies that serializing it preserves the parsed AST. Unsupported subtrees
167
- use opaque `carveUnsupported` blocks; if a mapped document is still lossy,
168
- the loader falls back to one whole-document opaque block. `serializeToCarve`
169
- writes its source back byte-for-byte, including edge whitespace.
170
-
171
- All corpus documents are therefore load/save lossless in preservation mode.
172
- Some constructs remain opaque rather than directly editable, including parts
173
- of figures, advanced tables, comments, raw passthrough, and source-layout edge
174
- cases. `tiptap/schema-map.json` is the public rich-mapping authority;
175
- `tests/lib/coverage.js` records why structured conversion falls back.
165
+ - `unsupported: 'preserve'` builds the richest available document and verifies
166
+ its canonical serialization against the parsed AST. When authored columns,
167
+ delimiter choices, blank ownership, or other source layout cannot be held in
168
+ ProseMirror attributes, the document carries both the authored source and its
169
+ canonical projection. `serializeToCarve` performs a three-way merge after an
170
+ edit, preserving untouched authored layout while giving changed content the
171
+ canonical Carve spelling.
172
+
173
+ All 1,538 documents and 440 categories in the pinned corpus are load/save
174
+ lossless in preservation mode, with no whole-document fallback. Abbreviation
175
+ definitions and uses, figures and captions, advanced tables, comments, raw
176
+ passthrough, references, and footnotes all have structured editor mappings.
177
+ `carveToProseMirrorWithReport()` identifies any future construct that still has
178
+ to use a local opaque atom; `tiptap/schema-map.json` is the public mapping
179
+ authority.
176
180
 
177
181
  ## Tab sets and code groups in the editor
178
182
 
package/package.json CHANGED
@@ -1,12 +1,12 @@
1
1
  {
2
2
  "name": "@markup-carve/carve-grammars",
3
- "version": "0.1.5",
3
+ "version": "0.1.6",
4
4
  "description": "Grammars for the Carve markup language: Tiptap editor kit + serializer, plus Prism, highlight.js and TextMate syntax-highlighting grammars",
5
5
  "type": "module",
6
6
  "main": "tiptap/index.js",
7
7
  "scripts": {
8
8
  "test": "npm run test:types && node tests/carve-editor-test.js && node tests/block-battery-test.js && node tests/smart-typography-test.js && node tests/highlight-opener-test.js && node tests/fenced-quote-opener-test.js && node tests/coverage-test.js && node tests/construct-ledger-test.js && node tests/opaque-payload-test.js && node tests/latest-syntax-test.js && node tests/schema-map-test.js && node tests/scans-are-bounded-test.js && node tests/braced-scan-equivalence-test.js && node tests/line-ambiguity-test.js && node tests/snapshot-test.js && node tests/roundtrip-test.js && node tests/node-producers-test.js && node tests/wire-fixtures-test.js && node tests/loss-report-test.js && node tests/authored-spelling-test.js && node tests/optional-corpus-test.js && node tests/mounted-roundtrip-test.js && node tests/serializer-test.js && node tests/tabs-roundtrip-test.js && node tests/panel-bar-test.js && node tests/parse-test.js && node tests/language-attribute-test.js && node tests/blockquote-caption-test.js && node tests/composite-figure-test.js && node tests/composite-figure-tiptap-test.js && node tests/grammar-test.js && node tests/shiki-test.js && node tests/alias-parity-test.js && node tests/engine-sweep-test.js && node tests/abbreviation-term-test.js && node tests/unclosed-delimiter-test.js && node tests/textmate-sweep-test.js && node tests/kroki-test.js && node tests/client-diagram-test.js && node tests/packaging-test.js && node tests/no-git-dependencies-test.js && node tests/engine-drift-test.js && node tests/surface-drift-test.js",
9
- "test:types": "tsc -p tsconfig.types.json",
9
+ "test:types": "tsc -p tsconfig.types.json && node tests/source-merge-test.js",
10
10
  "test:citations": "node tests/spec-citations-test.js",
11
11
  "test:coverage": "node tests/coverage-test.js",
12
12
  "test:snapshot": "node tests/snapshot-test.js",
@@ -83,7 +83,8 @@
83
83
  "./package.json": "./package.json"
84
84
  },
85
85
  "dependencies": {
86
- "@markup-carve/carve": "^0.1.4"
86
+ "@markup-carve/carve": "^0.1.5",
87
+ "node-diff3": "^3.2.1"
87
88
  },
88
89
  "peerDependencies": {
89
90
  "@shikijs/themes": "^2 || ^3",
@@ -32,6 +32,7 @@ import { CarveMath } from './extensions/carve-math.js';
32
32
  import { CarveFootnoteDefinition } from './extensions/carve-footnote-definition.js';
33
33
  import { CarveEmbed } from './extensions/carve-embed.js';
34
34
  import { CarveAbbreviation } from './extensions/carve-abbreviation.js';
35
+ import { CarveAbbreviationDefinition } from './extensions/carve-abbreviation-definition.js';
35
36
  import { CarveDefinitionList, CarveDefinitionTerm, CarveDefinitionDescription } from './extensions/carve-definition-list.js';
36
37
  import { CarveUnsupported } from './extensions/carve-unsupported.js';
37
38
  import { CarveUnsupportedInline } from './extensions/carve-unsupported-inline.js';
@@ -678,6 +679,9 @@ export const CarveKit = Extension.create({
678
679
  if (this.options.carveAbbreviation !== false) {
679
680
  extensions.push(CarveAbbreviation.configure(this.options.carveAbbreviation ?? {}));
680
681
  }
682
+ if (this.options.carveAbbreviationDefinition !== false) {
683
+ extensions.push(CarveAbbreviationDefinition.configure(this.options.carveAbbreviationDefinition ?? {}));
684
+ }
681
685
 
682
686
  // Definition list nodes (maps to : term with definition)
683
687
  if (this.options.definitionList !== false) {
@@ -129,20 +129,22 @@ function opaqueDocument(source, ctx = null) {
129
129
 
130
130
  function sourceEnvelope(doc, sourceLayout, ctx = null) {
131
131
  // The rich projection is kept, but writing it back would not reproduce the
132
- // document, so the source rides along and the serializer replays it while
133
- // the document is untouched. The FIRST EDIT invalidates the fingerprint and
134
- // the projection becomes what is written - which is why this is reported.
132
+ // document, so the source and canonical projection ride along as the two
133
+ // merge bases. Edits are applied to the authored branch, preserving layout
134
+ // outside the changed region.
135
135
  record(ctx, 'preserved', 'document',
136
- 'the rich projection is not write-identical; the source envelope carries the document until it is edited');
136
+ 'the rich projection is not write-identical; edits are merged into its authored source envelope');
137
137
 
138
138
  const clean = { ...doc };
139
139
  delete clean.attrs;
140
+ const projectedSource = serializeToCarve(clean);
140
141
  return {
141
142
  ...clean,
142
143
  attrs: {
143
144
  carveSource: sourceLayout.source,
144
145
  carveFingerprint: pmFingerprint(clean),
145
146
  carveSourceLayout: JSON.stringify(sourceLayout),
147
+ carveProjectedSource: projectedSource,
146
148
  },
147
149
  };
148
150
  }
@@ -545,7 +547,10 @@ function convertBlock(node, ctx) {
545
547
  // does not faithfully represent. Throwing routes the category to SKIP.
546
548
  case 'abbreviation-def':
547
549
  case 'abbreviation_def':
548
- return unsupported('abbreviation-def', node, ctx);
550
+ return {
551
+ type: 'carveAbbreviationDefinition',
552
+ attrs: { abbr: node.abbr || '', expansion: node.expansion || '' },
553
+ };
549
554
  case 'raw-block':
550
555
  case 'raw_block':
551
556
  return {
@@ -708,7 +713,23 @@ function convertDefinitionList(node, ctx) {
708
713
  // The list's OWN attribute run. Nothing read it, so `{loose}` above a
709
714
  // definition list was gone before the projection was even mounted - the
710
715
  // node arrived with no attrs at all (markup-carve/carve-grammars#344).
711
- const attrs = convertAttrs(node.attrs);
716
+ // Looseness is CONTENT here - a loose description renders its body in a
717
+ // `<p>`, a tight one bare - and PART 9 section 17 L7 leaves `{loose}` as
718
+ // its only spelling, since no blank line can carry it.
719
+ //
720
+ // The engine used to leave it in the attribute run and now reads it into
721
+ // the node's own `loose` flag, so reading `attrs` alone dropped it. Folding
722
+ // it back into the run rather than inventing a second slot means the
723
+ // existing value-less-attribute path writes it, and a mount keeps it in
724
+ // `carveKeyValues`, which is already declared.
725
+ const runAttrs = node.loose === true
726
+ ? {
727
+ ...(node.attrs || {}),
728
+ keyValues: { loose: '', ...(node.attrs?.keyValues || {}) },
729
+ order: ['loose', ...((node.attrs?.order || []).filter((k) => k !== 'loose'))],
730
+ }
731
+ : node.attrs;
732
+ const attrs = convertAttrs(runAttrs);
712
733
  for (const item of node.items || []) {
713
734
  for (const term of item.terms || []) {
714
735
  content.push({ type: 'definitionTerm', content: convertInline(term, ctx) });
@@ -908,6 +929,24 @@ function convertInlineNode(node, marks, ctx) {
908
929
  ? [{ type: 'text', text: node.value, ...(marks.length ? { marks } : {}) }]
909
930
  : [];
910
931
 
932
+ // Smart punctuation is a TYPOGRAPHIC reading of characters the author
933
+ // typed, so the characters are what the editor carries: `node.value`
934
+ // is the authored spelling (`--`, `...`, `"`) and `node.glyph` the
935
+ // rendered one. Writing the glyph would bake the engine's reading into
936
+ // the source and change the document; writing the value re-parses to
937
+ // the same node, so the round trip is byte-identical.
938
+ //
939
+ // Left unmapped, the converter THREW on ordinary prose - an em dash, an
940
+ // ellipsis or a typographic quote was enough - so any such document
941
+ // took the whole-document fallback instead of being editable.
942
+ case 'smart_punctuation':
943
+ record(ctx, 'degraded', 'smart_punctuation',
944
+ 'the typographic reading is re-derived on parse; the authored characters survive as text');
945
+
946
+ return node.value
947
+ ? [{ type: 'text', text: node.value, ...(marks.length ? { marks } : {}) }]
948
+ : [];
949
+
911
950
  case 'soft-break':
912
951
  case 'soft_break':
913
952
  // A NEWLINE, not a space. A soft break is a line break the author
@@ -1120,6 +1159,24 @@ function convertInlineNode(node, marks, ctx) {
1120
1159
  attrs: { content: node.content || '', delimited: Boolean(node.delimited) },
1121
1160
  }];
1122
1161
 
1162
+ case 'abbreviation':
1163
+ // A definition-resolved abbreviation is still editable text. The
1164
+ // mark carries its expansion for `<abbr title>`, while `resolved`
1165
+ // tells the writer the author used the bare term rather than an
1166
+ // explicit semantic span.
1167
+ if (marks.some((mark) => mark.type === 'carveAbbreviation')) {
1168
+ return [{ type: 'text', text: node.abbr || '', ...(marks.length ? { marks } : {}) }];
1169
+ }
1170
+ return [{ type: 'text', text: node.abbr || '', marks: [...marks, {
1171
+ type: 'carveAbbreviation',
1172
+ attrs: { title: node.expansion || '', resolved: true },
1173
+ }] }];
1174
+
1175
+ case 'caption_number':
1176
+ // `#` is the authored placeholder. Its rendered number is a
1177
+ // resolution artifact and therefore does not belong on the wire.
1178
+ return [{ type: 'text', text: '#', ...(marks.length ? { marks } : {}) }];
1179
+
1123
1180
  default: {
1124
1181
  const markType = INLINE_MARKS[node.type];
1125
1182
  if (markType) {
@@ -1164,8 +1221,15 @@ function convertSpan(node, marks, ctx) {
1164
1221
  // Only a lone abbr attribute round-trips through carveAbbreviation; any
1165
1222
  // companion id/class/other key would be dropped.
1166
1223
  const extraKeys = Object.keys(a.keyValues).filter((k) => k !== 'abbr');
1167
- if (extraKeys.length || a.id || (a.classes && a.classes.length)) throw new UnsupportedNodeError('span-abbr-plus-attrs', node);
1168
- return descend(node, [...marks, { type: 'carveAbbreviation', attrs: { title: a.keyValues.abbr } }], ctx);
1224
+ const hasCompanions = extraKeys.length || a.id || (a.classes && a.classes.length);
1225
+ const abbrMark = {
1226
+ type: 'carveAbbreviation',
1227
+ attrs: {
1228
+ title: a.keyValues.abbr,
1229
+ ...(hasCompanions ? { resolved: false, ...(convertAttrs(a) || {}) } : {}),
1230
+ },
1231
+ };
1232
+ return descend(node, [...marks, abbrMark], ctx);
1169
1233
  }
1170
1234
  const attrs = convertAttrs(a) || {};
1171
1235
  return descend(node, [...marks, { type: 'carveSpan', attrs }], ctx);
@@ -0,0 +1,22 @@
1
+ import { Node, mergeAttributes } from '@tiptap/core';
2
+
3
+ /** An authored document-level abbreviation definition: `*[HTML]: expansion`. */
4
+ export const CarveAbbreviationDefinition = Node.create({
5
+ name: 'carveAbbreviationDefinition',
6
+ group: 'block',
7
+ atom: true,
8
+ addAttributes() {
9
+ return {
10
+ abbr: { default: '' },
11
+ expansion: { default: '' },
12
+ };
13
+ },
14
+ parseHTML() { return [{ tag: 'div[data-carve-abbreviation-definition]' }]; },
15
+ renderHTML({ HTMLAttributes, node }) {
16
+ return ['div', mergeAttributes(HTMLAttributes, {
17
+ 'data-carve-abbreviation-definition': 'true',
18
+ }), `${node.attrs.abbr}: ${node.attrs.expansion}`];
19
+ },
20
+ });
21
+
22
+ export default CarveAbbreviationDefinition;
@@ -1,4 +1,5 @@
1
1
  import { Mark, mergeAttributes } from '@tiptap/core';
2
+ import { attributeSlots } from './carve-attribute-slots.js';
2
3
 
3
4
  /**
4
5
  * Carve Abbreviation extension for Tiptap
@@ -32,6 +33,8 @@ export const CarveAbbreviation = Mark.create({
32
33
  return { title: attributes.title };
33
34
  },
34
35
  },
36
+ resolved: { default: false, rendered: false },
37
+ ...attributeSlots(['title', 'data-carve-abbreviation']),
35
38
  };
36
39
  },
37
40
 
@@ -1,6 +1,6 @@
1
1
  import { Extension } from '@tiptap/core';
2
2
 
3
- /** Lossless source envelope for a structured document that has not been edited. */
3
+ /** Merge base for preserving authored source layout around structured edits. */
4
4
  export const CarveSourcePreservation = Extension.create({
5
5
  name: 'carveSourcePreservation',
6
6
  addGlobalAttributes() {
@@ -8,9 +8,10 @@ export const CarveSourcePreservation = Extension.create({
8
8
  {
9
9
  types: ['doc'],
10
10
  attributes: {
11
- carveSource: { default: null, rendered: false },
12
- carveFingerprint: { default: null, rendered: false },
13
- carveSourceLayout: { default: null, rendered: false },
11
+ carveSource: { default: null, rendered: false },
12
+ carveFingerprint: { default: null, rendered: false },
13
+ carveSourceLayout: { default: null, rendered: false },
14
+ carveProjectedSource: { default: null, rendered: false },
14
15
  },
15
16
  },
16
17
  {
@@ -13,6 +13,7 @@ export { CarveFootnoteDefinition } from './carve-footnote-definition.js';
13
13
  export { CarveMath } from './carve-math.js';
14
14
  export { CarveEmbed } from './carve-embed.js';
15
15
  export { CarveAbbreviation } from './carve-abbreviation.js';
16
+ export { CarveAbbreviationDefinition } from './carve-abbreviation-definition.js';
16
17
  export { CarveDefinitionList, CarveDefinitionTerm, CarveDefinitionDescription } from './carve-definition-list.js';
17
18
  export { CarveUnsupported } from './carve-unsupported.js';
18
19
  export { CarveUnsupportedInline } from './carve-unsupported-inline.js';
package/tiptap/index.d.ts CHANGED
@@ -25,6 +25,8 @@ export const CarveCriticComment: Mark;
25
25
  export const CarveDiv: Node;
26
26
  export const CarveMath: Node;
27
27
  export const CarveFootnoteDefinition: Node;
28
+ export const CarveAbbreviation: Mark;
29
+ export const CarveAbbreviationDefinition: Node;
28
30
  export const CarveMention: Node;
29
31
  export const CarveTag: Node;
30
32
  export const CarveUnsupported: Node;
package/tiptap/index.js CHANGED
@@ -47,6 +47,8 @@ export { CarveCriticComment } from './extensions/carve-critic-comment.js';
47
47
  export { CarveDiv } from './extensions/carve-div.js';
48
48
  export { CarveMath } from './extensions/carve-math.js';
49
49
  export { CarveFootnoteDefinition } from './extensions/carve-footnote-definition.js';
50
+ export { CarveAbbreviation } from './extensions/carve-abbreviation.js';
51
+ export { CarveAbbreviationDefinition } from './extensions/carve-abbreviation-definition.js';
50
52
  export { CarveKeymap } from './extensions/carve-keymap.js';
51
53
  export { CarveMention, CarveTag } from './extensions/carve-mention.js';
52
54
  export { CarveInlineExtension } from './extensions/carve-inline-extension.js';
@@ -500,10 +500,17 @@
500
500
  "id": "authored id",
501
501
  "key": "the citation key as written, without the `@`; the same string `citation.key` carries at the use site"
502
502
  }
503
+ },
504
+ "abbreviation_def": {
505
+ "kind": "node",
506
+ "pm": "carveAbbreviationDefinition",
507
+ "attrs": {
508
+ "abbr": "the defined term",
509
+ "expansion": "the authored expansion"
510
+ }
503
511
  }
504
512
  },
505
513
  "unmapped": {
506
- "abbreviation_def": "abbreviation definitions ride on the doc node's attrs",
507
514
  "caption_number": "numbered captions are a resolution artifact, not editor content",
508
515
  "citation": "a citation is one item in citation_group and rides in carveCitation's items attribute rather than becoming its own ProseMirror node",
509
516
  "raw_text": "raw text is the payload of a raw block, not a node an editor holds",
@@ -23,6 +23,7 @@
23
23
  * space (e.g. literal `**` immediately followed by bold text) - the run
24
24
  * merges into a longer literal delimiter run on reparse.
25
25
  */
26
+ import { diff3Merge } from 'node-diff3';
26
27
 
27
28
  /**
28
29
  * Serialize a Tiptap/ProseMirror JSON document to Carve markup
@@ -167,6 +168,17 @@ export function serializeToCarve(doc) {
167
168
  const clean = { ...doc };
168
169
  delete clean.attrs;
169
170
  if (pmFingerprint(clean) === preservedFingerprint) return preservedSource;
171
+ const projectedSource = doc?.attrs?.carveProjectedSource;
172
+ if (typeof projectedSource === 'string') {
173
+ // The authored source and the editable projection are two branches
174
+ // from the same canonical baseline. Merge the editor's changes
175
+ // into the authored branch so untouched columns, blank ownership,
176
+ // delimiter choices and marker placement survive. Where both sides
177
+ // changed the same characters, the editor wins: the user changed
178
+ // that construct and canonical Carve is safer than stale source.
179
+ const currentProjection = serializeToCarve(clean);
180
+ return mergeAuthoredSource(preservedSource, projectedSource, currentProjection);
181
+ }
170
182
  }
171
183
  // A whole-document fallback is already exact Carve source. Sending it
172
184
  // through the normal block joiner and edge trimmer would corrupt precisely
@@ -241,12 +253,26 @@ export function serializeToCarve(doc) {
241
253
  // is present: a list whose items hold one paragraph each is
242
254
  // loose or tight purely by the blank lines between them, which
243
255
  // no amount of looking at the items can recover.
244
- const isLoose = node.attrs?.carveTight === false || (node.content || []).some((item) => {
256
+ const multiBlockItem = (node.content || []).some((item) => {
245
257
  const blocks = (item.content || []).filter(
246
258
  (b) => !['bulletList', 'orderedList', 'taskList'].includes(b.type),
247
259
  );
248
260
  return blocks.length > 1;
249
261
  });
262
+ const isLoose = node.attrs?.carveTight === false || multiBlockItem;
263
+ // Blank lines between items are how looseness is normally
264
+ // spelled, and they need two items to sit between. A loose list
265
+ // of ONE item whose item holds one block has nowhere to put
266
+ // them, so it reads back tight and the round trip changes the
267
+ // document - which is why the attribute is written instead.
268
+ //
269
+ // This only became reachable when the engine consumed `{loose}`
270
+ // into the list's own `tight`. While it stayed an ordinary
271
+ // attribute, serializeAttributes wrote it and nothing was lost.
272
+ if (node.attrs?.carveTight === false && !multiBlockItem
273
+ && (node.content || []).length < 2) {
274
+ output += indent + '{loose}\n';
275
+ }
250
276
  let num = node.attrs?.start || 1;
251
277
  // `carveOlType` carries the style from the Carve AST; `type` is what Tiptap's own
252
278
  // OrderedList records when the editor is seeded from rendered
@@ -558,6 +584,10 @@ export function serializeToCarve(doc) {
558
584
  break;
559
585
  }
560
586
 
587
+ case 'carveAbbreviationDefinition':
588
+ output += `*[${node.attrs?.abbr || ''}]: ${node.attrs?.expansion || ''}\n`;
589
+ break;
590
+
561
591
  case 'carveCitationDefinition': {
562
592
  // `[@key]: {metadata} entry`. The metadata block LEADS the
563
593
  // entry text here, unlike the link reference definition above
@@ -664,6 +694,10 @@ export function serializeToCarve(doc) {
664
694
  }
665
695
  }
666
696
 
697
+ // The column a description's continuation blocks sit at, set by the width
698
+ // of the canonical `: ` separator this serializer writes.
699
+ const DEFINITION_CONTENT_INDENT = ' ';
700
+
667
701
  function serializeDefinitionList(dl) {
668
702
  const children = dl.content || [];
669
703
  // The list's own attribute run, on its own line above the first term.
@@ -684,16 +718,45 @@ export function serializeToCarve(doc) {
684
718
  output += ':: ' + serializeInline(child.content) + '\n';
685
719
  afterDescription = false;
686
720
  } else if (child.type === 'definitionDescription') {
687
- (child.content || []).forEach(block => {
688
- if (block.type === 'paragraph') {
689
- output += ': ' + serializeInline(block.content) + '\n';
690
- } else {
691
- // For other block types, serialize with indentation.
692
- const blockText = serializeNodeToString(block);
693
- blockText.split('\n').filter(l => l).forEach(line => {
694
- output += ': ' + line + '\n';
721
+ // ONE description, however many blocks it holds. Every block
722
+ // used to get its own `: ` marker, which spells a NEW
723
+ // description each time - a two-paragraph definition came back
724
+ // as two definitions of the same term. A continuation belongs
725
+ // at the description's content column, which the canonical
726
+ // `: ` separator puts at 2 (PART 9 section 17: the separator's
727
+ // width sets the column).
728
+ const blocks = child.content || [];
729
+ // A bare `:` is not an empty description: it is prose, and
730
+ // omitting the line removes the description altogether. Carve
731
+ // gives empty bodies an explicit canonical spelling so their
732
+ // boundary survives a rich-editor round trip.
733
+ if (blocks.length === 0) {
734
+ output += ': {empty}\n';
735
+ // Unlike a body-bearing pair, the canonical empty form is
736
+ // glued to the next term; a blank would end the list.
737
+ afterDescription = false;
738
+ return;
739
+ }
740
+ blocks.forEach((block, i) => {
741
+ const text = block.type === 'paragraph'
742
+ ? serializeInline(block.content)
743
+ : serializeNodeToString(block).replace(/\n+$/, '');
744
+ const lines = text.split('\n');
745
+ if (i === 0) {
746
+ // The first line rides the marker; the rest are already
747
+ // below it and only need the column.
748
+ output += ': ' + lines[0] + '\n';
749
+ lines.slice(1).forEach(line => {
750
+ output += (line ? DEFINITION_CONTENT_INDENT + line : '') + '\n';
695
751
  });
752
+
753
+ return;
696
754
  }
755
+ // A blank line, or the block would join the one above it.
756
+ output += '\n';
757
+ lines.forEach(line => {
758
+ output += (line ? DEFINITION_CONTENT_INDENT + line : '') + '\n';
759
+ });
697
760
  });
698
761
  afterDescription = true;
699
762
  }
@@ -904,7 +967,16 @@ export function serializeToCarve(doc) {
904
967
  //
905
968
  // Widening this set is a measurement, not a judgement: add a case that
906
969
  // loses its paragraph without the escape, then add the character.
907
- const CONTINUATION_BLOCK_OPENER = /\n([>#])/g;
970
+ // Every shape that OPENS a block at column 0, so a soft-break line holding
971
+ // one stops being text when it is written back there.
972
+ //
973
+ // Was `>` and `#` only, which left seven others leaking. `1. outer` with a
974
+ // lazy ` 1. inner` under it came back as `1. outer` / `1. inner` - two
975
+ // items where the source had one, measured against the engine rather than
976
+ // reasoned about. A lookahead rather than a capture, because the fix is to
977
+ // insert a space and never to rewrite the opener.
978
+ const CONTINUATION_BLOCK_OPENER =
979
+ /\n(?=[>#|]|[-*][ \t]|-{3,}|:{2,}|(?:[0-9]{1,9}|[A-Za-z])[.)][ \t])/g;
908
980
 
909
981
  function escapeContinuationOpeners(text) {
910
982
  // A single SPACE, not a backslash. Both keep the line as text - the
@@ -918,7 +990,7 @@ export function serializeToCarve(doc) {
918
990
  // One space is below every item's content column (the shallowest is 2,
919
991
  // for `- `), so the line stays a lazy continuation rather than becoming
920
992
  // the block it would be at that column.
921
- return text.replace(CONTINUATION_BLOCK_OPENER, (match, opener) => '\n ' + opener);
993
+ return text.replace(CONTINUATION_BLOCK_OPENER, '\n ');
922
994
  }
923
995
 
924
996
  function serializeParagraphText(content) {
@@ -941,7 +1013,7 @@ export function serializeToCarve(doc) {
941
1013
  // each such atom on its own (no marks, so this recursion terminates) and
942
1014
  // hand the result to the text path as a verbatim run that still carries
943
1015
  // the marks.
944
- const content = (rawContent || []).map((node) => (
1016
+ const normalized = (rawContent || []).map((node) => (
945
1017
  node && node.type !== 'text' && (node.marks || []).length
946
1018
  ? {
947
1019
  type: 'text',
@@ -951,6 +1023,48 @@ export function serializeToCarve(doc) {
951
1023
  }
952
1024
  : node
953
1025
  ));
1026
+ // ProseMirror splits one marked range whenever a nested mark begins or
1027
+ // ends. Serializing each resulting text node independently repeats the
1028
+ // outer delimiter (`*a *` + `*/b/*` + `* c*`) instead of keeping it open
1029
+ // across the inner span. Collapse runs that share their outermost mark
1030
+ // into one verbatim text node, then recurse over their contents after
1031
+ // removing that mark. Recursion handles arbitrary nesting depth while
1032
+ // leaving the escaping and bare/autolink choices in the existing text
1033
+ // path below.
1034
+ const sameOuterMark = (left, right) => Boolean(left && right
1035
+ && left.type === right.type
1036
+ && pmFingerprint(left.attrs || {}) === pmFingerprint(right.attrs || {}));
1037
+ const groupableDelimitedMarks = new Set([
1038
+ 'bold', 'italic', 'underline', 'strike', 'highlight',
1039
+ 'superscript', 'subscript', 'carveInsert', 'carveDelete',
1040
+ ]);
1041
+ const content = [];
1042
+ for (let index = 0; index < normalized.length;) {
1043
+ const node = normalized[index];
1044
+ const candidateOuter = node?.type === 'text' ? node.marks?.[0] : null;
1045
+ const outer = groupableDelimitedMarks.has(candidateOuter?.type) ? candidateOuter : null;
1046
+ let end = index + 1;
1047
+ while (outer && end < normalized.length) {
1048
+ const candidate = normalized[end];
1049
+ if (candidate?.type !== 'text' || !sameOuterMark(outer, candidate.marks?.[0])) break;
1050
+ end++;
1051
+ }
1052
+ if (outer && end - index > 1) {
1053
+ const inner = normalized.slice(index, end).map((part) => ({
1054
+ ...part,
1055
+ marks: (part.marks || []).slice(1),
1056
+ }));
1057
+ content.push({
1058
+ type: 'text',
1059
+ text: serializeInline(inner),
1060
+ marks: [outer],
1061
+ carveVerbatim: true,
1062
+ });
1063
+ } else {
1064
+ content.push(node);
1065
+ }
1066
+ index = end;
1067
+ }
954
1068
  if (!content) return '';
955
1069
  let result = '';
956
1070
  let resumeDelimitedBold = false;
@@ -1296,7 +1410,12 @@ export function serializeToCarve(doc) {
1296
1410
  // syntax: with that extension enabled the `abbr` attribute is
1297
1411
  // promoted to a real `<abbr title="…">`; without it, it stays a
1298
1412
  // `<span abbr="…">`. (Title escaped like a link title.)
1299
- if (abbr) t = '[' + t + ']{abbr="' + escapeTitle(abbr.attrs?.title || '') + '"}';
1413
+ if (abbr && !abbr.attrs?.resolved) {
1414
+ const attrs = { ...abbr.attrs };
1415
+ attrs.carveKeyValues = { ...(attrs.carveKeyValues || {}), abbr: attrs.title || '' };
1416
+ attrs.carveAttrOrder ||= ['abbr'];
1417
+ t = '[' + t + ']' + serializeAttributes(attrs, ['title', 'resolved'], true);
1418
+ }
1300
1419
 
1301
1420
  result += t;
1302
1421
  } else if (node.type === 'hardBreak') {
@@ -1365,6 +1484,33 @@ export function serializeToCarve(doc) {
1365
1484
  return result;
1366
1485
  }
1367
1486
 
1487
+ function mergeAuthoredSource(authored, baseline, edited) {
1488
+ // Appending and prepending blocks are the most common editor operations.
1489
+ // Handle them without diff alignment: repeated punctuation can otherwise
1490
+ // make a character diff align an untouched delimiter with the new text and
1491
+ // needlessly replace the author's spelling in the original document.
1492
+ const appended = edited.startsWith(baseline) ? edited.slice(baseline.length) : null;
1493
+ const baselineClose = baseline.match(/(?:^|\n)([ \t]*(?:`{3,}|~{3,}|:{3,}))$/)?.[1];
1494
+ const authoredHasClose = !baselineClose || authored.trimEnd().endsWith(baselineClose);
1495
+ if (appended?.startsWith('\n\n') && authoredHasClose) return authored.trimEnd() + appended;
1496
+ if (edited.endsWith(baseline)) return edited.slice(0, -baseline.length) + authored;
1497
+
1498
+ const characters = Math.max(authored.length, baseline.length, edited.length) <= 20_000;
1499
+ const tokens = characters
1500
+ ? (source) => [...source]
1501
+ : (source) => source.match(/[^\n]*\n|[^\n]+$/g) || [];
1502
+ const regions = diff3Merge(tokens(authored), tokens(baseline), tokens(edited), {
1503
+ excludeFalseConflicts: true,
1504
+ });
1505
+ let merged = regions.flatMap((region) => region.ok || region.conflict?.b || []).join('');
1506
+ // Canonical projections omit structural edge whitespace. It is outside the
1507
+ // editable tree, so a content edit must not silently remove the author's
1508
+ // terminal line ending.
1509
+ const ending = authored.match(/\r\n$|[\r\n]$/)?.[0];
1510
+ if (ending && !/[\r\n]$/.test(merged)) merged += ending;
1511
+ return merged;
1512
+ }
1513
+
1368
1514
  /**
1369
1515
  * Escape the "structural" Carve constructs in a text run - the ones whose
1370
1516
  * delimiters are unambiguous regardless of flanking. Used for both plain and
@@ -1384,7 +1530,16 @@ function escapeStructural(text, trailingSafe = false) {
1384
1530
  .replace(/`/g, '\\`')
1385
1531
  .replace(/\[(?=\^)/g, '\\[')
1386
1532
  .replace(/\[(?=[^\]\n]*\][([{:])/g, '\\[')
1387
- .replace(/\{(?=[+\-~#=%])/g, '\\{')
1533
+ // An EMPTY doubled pair is text since carve-js 0.1.5, so escaping the
1534
+ // brace there does not protect a construct - it creates a difference.
1535
+ // `{--}` reaches smart typography and renders an en dash; `\{--}` is
1536
+ // the literal characters, so the escape changed the document. Skip it
1537
+ // for the five markers whose empty pair is text, the same way the
1538
+ // `:name:` rule below only escapes where a symbol would form.
1539
+ //
1540
+ // `%` is NOT among them: `{%%}` is an empty COMMENT and renders to
1541
+ // nothing, so its brace still has a construct to protect.
1542
+ .replace(/\{(?!([+\-~#=])\1\})(?=[+\-~#=%])/g, '\\{')
1388
1543
  .replace(/(^|[^\w.])@(?=[A-Za-z0-9_])/g, '$1\\@')
1389
1544
  .replace(/(^|[^\w])#(?=[A-Za-z0-9_])/g, '$1\\#')
1390
1545
  // A `:name:` symbol only opens at a word boundary, and its name starts
@@ -505,7 +505,7 @@
505
505
  },
506
506
  {
507
507
  "name": "table-with-spans",
508
- "carve": "| a || b |\n|=h |= i |\n",
508
+ "carve": "| a || b |\n|= h |= i |\n",
509
509
  "pm": {
510
510
  "type": "doc",
511
511
  "content": [