@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 +15 -11
- package/package.json +4 -3
- package/tiptap/carve-kit.js +4 -0
- package/tiptap/carve-to-pm.js +72 -8
- package/tiptap/extensions/carve-abbreviation-definition.js +22 -0
- package/tiptap/extensions/carve-abbreviation.js +3 -0
- package/tiptap/extensions/carve-source-preservation.js +5 -4
- package/tiptap/extensions/index.js +1 -0
- package/tiptap/index.d.ts +2 -0
- package/tiptap/index.js +2 -0
- package/tiptap/schema-map.json +8 -1
- package/tiptap/serializer.js +169 -14
- package/tiptap/wire-fixtures.json +1 -1
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'`
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
the
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
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.
|
|
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.
|
|
86
|
+
"@markup-carve/carve": "^0.1.5",
|
|
87
|
+
"node-diff3": "^3.2.1"
|
|
87
88
|
},
|
|
88
89
|
"peerDependencies": {
|
|
89
90
|
"@shikijs/themes": "^2 || ^3",
|
package/tiptap/carve-kit.js
CHANGED
|
@@ -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) {
|
package/tiptap/carve-to-pm.js
CHANGED
|
@@ -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
|
|
133
|
-
//
|
|
134
|
-
// the
|
|
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;
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
1168
|
-
|
|
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
|
-
/**
|
|
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
|
-
|
|
12
|
-
|
|
13
|
-
|
|
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';
|
package/tiptap/schema-map.json
CHANGED
|
@@ -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",
|
package/tiptap/serializer.js
CHANGED
|
@@ -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
|
|
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
|
-
|
|
688
|
-
|
|
689
|
-
|
|
690
|
-
|
|
691
|
-
|
|
692
|
-
|
|
693
|
-
|
|
694
|
-
|
|
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
|
-
|
|
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,
|
|
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
|
|
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
|
|
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
|
-
.
|
|
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
|