@portabletext/markdown 2.1.0 → 2.2.0
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 +145 -3
- package/dist/index.d.ts +415 -186
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +908 -34
- package/dist/index.js.map +1 -1
- package/package.json +2 -1
package/README.md
CHANGED
|
@@ -56,6 +56,18 @@ const markdown = portableTextToMarkdown([
|
|
|
56
56
|
# Hello **world**
|
|
57
57
|
```
|
|
58
58
|
|
|
59
|
+
**Edit through Markdown without losing keys**
|
|
60
|
+
|
|
61
|
+
```ts
|
|
62
|
+
import {applyMarkdownEdit, portableTextToMarkdown} from '@portabletext/markdown'
|
|
63
|
+
|
|
64
|
+
const markdown = portableTextToMarkdown(stored)
|
|
65
|
+
const editedMarkdown = markdown.replace('tomorow', 'tomorrow')
|
|
66
|
+
const edited = applyMarkdownEdit(stored, editedMarkdown)
|
|
67
|
+
// same content as parsing editedMarkdown, with the stored `_key`s
|
|
68
|
+
// kept the way the same edit in an editor would have kept them
|
|
69
|
+
```
|
|
70
|
+
|
|
59
71
|
## Supported features
|
|
60
72
|
|
|
61
73
|
| Feature | Markdown → Portable Text | Portable Text → Markdown |
|
|
@@ -87,7 +99,7 @@ Converting Markdown to Portable Text and back isn't a lossless mirror:
|
|
|
87
99
|
2. The normalized Markdown is a fixpoint for plain text and the [Supported features](#supported-features) table: parsing it and serializing again reproduces it byte-for-byte.
|
|
88
100
|
3. MD→PT survival is schema-driven: a construct whose type the schema doesn't declare keeps its content and drops the structure that named it.
|
|
89
101
|
4. PT structures with no Markdown form degrade predictably on PT→MD (extra table header rows flatten into the body, deep or level-skipping lists collapse to relative nesting, unknown marks pass their text through unformatted). Unknown object types round-trip instead: block-level as a ` ```json:object ` fence, inline as a `json:object`-tagged code span, both carrying the value as JSON. A fence or span whose body isn't a JSON object with a `_type` is ordinary code.
|
|
90
|
-
5. Identity does not round-trip for text blocks: keys are regenerated on every parse, and adjacent spans with identical marks merge into one. Unknown objects keep their `_key`.
|
|
102
|
+
5. Identity does not round-trip for text blocks: keys are regenerated on every parse, and adjacent spans with identical marks merge into one. Unknown objects keep their `_key`. [`applyMarkdownEdit`](#applymarkdownedit) restores stored keys after an edit.
|
|
91
103
|
6. A hard break and a `\n` in a span's text are exclusive counterparts in both directions: a `\n` always renders as hard-break syntax on the way out, and hard-break syntax always becomes `\n` on the way in, never the space a soft wrap joins with.
|
|
92
104
|
|
|
93
105
|
The named exceptions to the fixpoint claim: an explicit-scheme URL or email keeps its text but gains a `link` mark on reparse, and a fuzzy `www.` form does too unless it carries markdown-significant punctuation; a hard break inside a heading splits into a second block on reparse, since an ATX heading is single-line; leading or trailing whitespace that CommonMark's own block parsing trims isn't part of the fixpoint; a `code` object with the reserved language `json:object` loses that language on serialization; and span text ending in `json:object` directly before a code-marked span holding a typed JSON object binds into an inline object on reparse.
|
|
@@ -537,7 +549,7 @@ The conversion is driven by **Renderers**: functions that render Portable Text e
|
|
|
537
549
|
|
|
538
550
|
Unknown types render as JSON code blocks by default; unknown styles, list items, and marks pass through their children.
|
|
539
551
|
|
|
540
|
-
The default type renderers are collision-safe: because the serializer dispatches on the `_type` name alone, `code`, `html`, `image`, `callout`, and `table` fall back to the `unknownType` renderer (a JSON code block) when a value doesn't match the shape their renderer expects (say, your own differently-shaped `code` type); `horizontal-rule` has no shape to check and always renders `---`. Register your own `types.<name>` renderer to override how any of them serialize, or to handle a same-named type of a different shape.
|
|
552
|
+
The default type renderers are collision-safe: because the serializer dispatches on the `_type` name alone, `code`, `html`, `image`, `callout`, and `table` fall back to the `unknownType` renderer (a JSON code block) when a value doesn't match the shape their renderer expects (say, your own differently-shaped `code` type); `horizontal-rule` has no shape to check and always renders `---`. `image` also falls back when `src` is a string a Markdown parser would refuse (a `javascript:`/`vbscript:`/`file:` URI, or a `data:` URI outside `png`/`gif`/`jpeg`/`webp`), so the value survives as a `json:object` fence instead of reparsing as literal text. Register your own `types.<name>` renderer to override how any of them serialize, or to handle a same-named type of a different shape.
|
|
541
553
|
|
|
542
554
|
> **Note:** The `underline` renderer is included for Portable Text that uses it, but there's no standard Markdown syntax for underline, so it renders as HTML.
|
|
543
555
|
|
|
@@ -668,7 +680,41 @@ portableTextToMarkdown(blocks, {
|
|
|
668
680
|
|
|
669
681
|
By default, unknown types render as `json:object` fences or tagged code spans that round-trip (see [Round-trip behavior](#round-trip-behavior)), and unknown marks/styles pass through their children unchanged.
|
|
670
682
|
|
|
671
|
-
|
|
683
|
+
#### Gating default renderers on a schema
|
|
684
|
+
|
|
685
|
+
Going to convert the markdown back with `markdownToPortableText`? Pass the same `schema` to both, and nothing the schema can't rebuild becomes markdown that gets destroyed on the way back: undeclared types travel as `json:object` fences that reparse to the same value.
|
|
686
|
+
|
|
687
|
+
```ts
|
|
688
|
+
import {compileSchema, defineSchema} from '@portabletext/schema'
|
|
689
|
+
|
|
690
|
+
const schema = compileSchema(
|
|
691
|
+
defineSchema({
|
|
692
|
+
blockObjects: [
|
|
693
|
+
{
|
|
694
|
+
name: 'code',
|
|
695
|
+
fields: [
|
|
696
|
+
{name: 'code', type: 'string'},
|
|
697
|
+
{name: 'language', type: 'string'},
|
|
698
|
+
],
|
|
699
|
+
},
|
|
700
|
+
],
|
|
701
|
+
}),
|
|
702
|
+
)
|
|
703
|
+
|
|
704
|
+
portableTextToMarkdown(blocks, {schema})
|
|
705
|
+
```
|
|
706
|
+
|
|
707
|
+
A default renderer (`callout`, `code`, `horizontal-rule`, `html`, `image`, `table`) runs only when the schema declares that type at the position the node appears in: `blockObjects` for a block, `inlineObjects` for an inline object. An `image` declared in only one of the two still falls back to `unknownType` at the other position.
|
|
708
|
+
|
|
709
|
+
The gate reads type names, never field values: declaring a type doesn't validate anything, and a value's fields play no part in which renderer runs. Fields matter on the parse side instead: `markdownToPortableText` filters a construct down to its declared fields, so declare each type with the fields its values carry, or the markdown forms this gate lets through come back rebuilt without them.
|
|
710
|
+
|
|
711
|
+
An undeclared type falls back to `unknownType`, whose default output is the same `json:object` fence or tagged code span described above, so it round-trips at block and inline positions. Inside a table cell the `json:object` form is the inline code span (a GFM cell is one line, and a fence would be squashed), so an undeclared object in a cell survives too; declared types whose markdown form spans multiple lines (a code block in a cell) still flatten on reparse. Renderers you register in `types` bypass the gate entirely, whether or not the schema declares them.
|
|
712
|
+
|
|
713
|
+
Without a `schema`, every default renderer stays active.
|
|
714
|
+
|
|
715
|
+
#### Hard breaks
|
|
716
|
+
|
|
717
|
+
Customize how a hard break (a `\n` inside a span's text) renders:
|
|
672
718
|
|
|
673
719
|
```ts
|
|
674
720
|
portableTextToMarkdown(blocks, {
|
|
@@ -697,6 +743,102 @@ portableTextToMarkdown(blocks, {
|
|
|
697
743
|
})
|
|
698
744
|
```
|
|
699
745
|
|
|
746
|
+
### `applyMarkdownEdit`
|
|
747
|
+
|
|
748
|
+
Parsing markdown mints fresh `_key`s for text blocks (see [Round-trip behavior](#round-trip-behavior)), so converting a document to markdown, editing one word, and converting back returns what looks like a full rewrite: comment anchors detach, history churns, and granular patching is impossible. `applyMarkdownEdit` converts edited markdown back to Portable Text and restores stored `_key`s the way the same edit in an editor would have kept them:
|
|
749
|
+
|
|
750
|
+
```ts
|
|
751
|
+
import {applyMarkdownEdit, portableTextToMarkdown} from '@portabletext/markdown'
|
|
752
|
+
|
|
753
|
+
const stored = [
|
|
754
|
+
{
|
|
755
|
+
_type: 'block',
|
|
756
|
+
_key: 'b1',
|
|
757
|
+
style: 'normal',
|
|
758
|
+
children: [{_type: 'span', _key: 's1', text: 'Ships tomorow.', marks: []}],
|
|
759
|
+
markDefs: [],
|
|
760
|
+
},
|
|
761
|
+
]
|
|
762
|
+
|
|
763
|
+
const markdown = portableTextToMarkdown(stored)
|
|
764
|
+
// markdown === 'Ships tomorow.'; an agent (or anything else) fixes the typo
|
|
765
|
+
const edited = applyMarkdownEdit(stored, 'Ships tomorrow.')
|
|
766
|
+
```
|
|
767
|
+
|
|
768
|
+
`edited`:
|
|
769
|
+
|
|
770
|
+
```json
|
|
771
|
+
[
|
|
772
|
+
{
|
|
773
|
+
"_type": "block",
|
|
774
|
+
"_key": "b1",
|
|
775
|
+
"style": "normal",
|
|
776
|
+
"children": [
|
|
777
|
+
{"_type": "span", "_key": "s1", "text": "Ships tomorrow.", "marks": []}
|
|
778
|
+
],
|
|
779
|
+
"markDefs": []
|
|
780
|
+
}
|
|
781
|
+
]
|
|
782
|
+
```
|
|
783
|
+
|
|
784
|
+
Keys follow the edit the way they would in an editor, and when the evidence is unclear, a block gets a fresh key rather than a wrong one. The result is a value, not patches; output keys are always unique among siblings; the inputs are never mutated.
|
|
785
|
+
|
|
786
|
+
#### What keeps its key
|
|
787
|
+
|
|
788
|
+
- Unchanged and moved blocks. Repeated content pairs in order.
|
|
789
|
+
- A block rewritten in place, like typing over it. Style changes count as rewrites, and an edited table cell keeps the whole table's keys.
|
|
790
|
+
- A split keeps the key on the first non-empty fragment, like pressing enter; a merge keeps the first block's key, like pressing backspace. A soft-wrap join is a merge.
|
|
791
|
+
- A typo fix lands as a text change on the same span, and editing a link's URL keeps its annotation key. Two identical annotations in one block (the same link twice, say) pair in order, like any repeated content.
|
|
792
|
+
- A `json:object` payload keeps the `_key` it carries, unless the payload matches stored content, which keeps the stored key: editing markdown cannot re-key existing content.
|
|
793
|
+
|
|
794
|
+
#### What gets restored
|
|
795
|
+
|
|
796
|
+
Markdown cannot carry everything a block stores, so an adopted block gets back what the edit could not have touched:
|
|
797
|
+
|
|
798
|
+
- A field the dialect cannot express, like a text block's `alignment`, including a whole custom object-array field the dialect drops. A field markdown does express, like `language` on a code block, follows the edit.
|
|
799
|
+
- A custom style, list kind, or decorator markdown has no syntax for.
|
|
800
|
+
- An empty or whitespace-only paragraph, which has no markdown form at all (blank lines are the block separator): it is restored next to its surviving neighbor, and deleted along with that neighbor if the neighbor goes. This covers top-level blocks; an empty paragraph nested inside a table cell or callout content is not restored. An empty heading or list item has a visible markdown form (`## `, `- `) and round-trips like any other block.
|
|
801
|
+
- A block the edit did not touch comes back exactly as stored, span structure and unmappable marks included: an adjacent pair of spans that only differ by a decorator markdown has no syntax for keeps its split rather than merging into the one span a plain parse would produce.
|
|
802
|
+
|
|
803
|
+
#### When keys reset
|
|
804
|
+
|
|
805
|
+
- Ambiguity: when an insertion or deletion makes a match unclear, a block keeps its key only on clear evidence; everything else gets a new key, and short blocks near the change are the usual casualties.
|
|
806
|
+
- Indistinguishable edits: replacing a block with unrelated content in the same position keeps its key (the end state is identical to a rewrite), and a whole-document rewrite that keeps the block count pairs blocks in order. That last one is deliberate: a translation keeps every anchor by position, which is the behavior translate flows need. The cost is that a reorder-plus-edit with balanced counts mispairs the same way, block-level and sibling-level alike.
|
|
807
|
+
- Caps: on very large ambiguous edits, evidence gathering is size- and time-capped and degrades to fresh keys rather than waiting, so near the caps, which keys survive can vary with machine speed.
|
|
808
|
+
- Refusal: a stored value that cannot survive its own serialize→parse round trip resets the whole document to the plain conversion, every key fresh except the ones `json:object` payloads carry.
|
|
809
|
+
|
|
810
|
+
#### Options
|
|
811
|
+
|
|
812
|
+
The options bag mirrors the two converters, plus a top-level `schema`: `deserialize` takes the rest of `markdownToPortableText`'s options and `serialize` takes the rest of `portableTextToMarkdown`'s. `schema` is taken once and governs both directions, because restoring keys depends on the two serializations agreeing. Pass the same `serialize` options that produced the markdown that was edited. The `deserialize` options apply to the stored value as well as the edited markdown, with two exceptions: new keys come from `deserialize.keyGenerator` (or the built-in generator), and `onDegradation` reports only on the edited markdown.
|
|
813
|
+
|
|
814
|
+
#### Observing reconciliation
|
|
815
|
+
|
|
816
|
+
Pass `onReconciliation` to see what happened to every key. The callback fires exactly once per call, synchronously, right before the function returns:
|
|
817
|
+
|
|
818
|
+
```ts
|
|
819
|
+
applyMarkdownEdit(stored, editedMarkdown, {
|
|
820
|
+
onReconciliation: (report) => {
|
|
821
|
+
if (report.keyMatching === 'skipped') {
|
|
822
|
+
// report.reason is 'round-trip-mismatch' (the stored value cannot
|
|
823
|
+
// survive its own serialize→parse round trip) or
|
|
824
|
+
// 'document-too-large'. The returned value is the plain
|
|
825
|
+
// conversion, every key fresh except the ones `json:object`
|
|
826
|
+
// payloads carry
|
|
827
|
+
return
|
|
828
|
+
}
|
|
829
|
+
report.preservedKeys // stored keys that survived, with a basis and a path
|
|
830
|
+
report.keyFallbacks // regions that got fresh keys instead of a guess
|
|
831
|
+
report.renamedKeys // keys rewritten to keep siblings unique
|
|
832
|
+
},
|
|
833
|
+
})
|
|
834
|
+
```
|
|
835
|
+
|
|
836
|
+
Every `key` and `path` in the report matches the returned value exactly, and a path segment is a string field name, a number array index, or `{_key}` for a keyed element, the same convention as editor paths. Which keys survived, the paths, `renamedKeys`, and `keyMatching` are facts of that invocation, safe to branch on. A preserved key's `basis` names the matching method (`'content-unchanged'`, `'content-moved'`, `'content-split'`, `'content-merged'`, `'same-position'`, `'similar-content'`) and is advisory: near the evidence caps it can vary with machine speed, so never branch on it. A node absent from `preservedKeys` was not restored from the stored value, whether its key is fresh or carried by a `json:object` payload. The exported `ReconciliationReport` and `ReconciliationKeyPath` types are `@beta`.
|
|
837
|
+
|
|
838
|
+
#### Concurrent edits
|
|
839
|
+
|
|
840
|
+
Reconciling an unchanged serialization returns the stored value byte for byte for the content the edit did not touch, keys, span structure, and unmappable marks included; content the edit did touch still comes back canonicalized, with its keys restored where reconciliation can trace them. `applyMarkdownEdit` does not merge concurrent edits: reconcile against the exact value that produced the markdown, and before writing the result back, check that the stored field still equals that value. If it changed while the markdown was being edited, the edit describes a document that no longer exists, and writing it would silently overwrite the newer changes: serialize the current value and redo the edit instead.
|
|
841
|
+
|
|
700
842
|
## License
|
|
701
843
|
|
|
702
844
|
MIT © [Sanity.io](https://www.sanity.io/)
|