From 46f51e47039e6674c6817ce89a5bd2147b4eae9d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Christian=20Hamburger=20Gr=C3=B8ngaard?= Date: Thu, 12 Nov 2026 09:04:40 +0000 Subject: [PATCH 01/34] chore: move @portabletext/markdown to its own repository --- .prettierignore | 2 - README.md | 2 +- apps/doc-examples/package.json | 2 +- apps/docs/public/skill.md | 4 +- .../conversion/markdown-round-tripping.mdx | 4 +- .../conversion/markdown-to-portable-text.mdx | 10 +- .../src/content/docs/ecosystem/packages.mdx | 2 +- .../docs/editor/guides/migrate-from-v7.mdx | 2 +- .../src/content/docs/rendering/markdown.mdx | 2 +- apps/playground/package.json | 2 +- apps/playground/vite.config.ts | 4 - build-config/preserve-modules.d.ts | 10 - build-config/preserve-modules.js | 35 - bundle-stats.config.ts | 8 - knip.json | 6 +- package.json | 1 - packages/markdown/CHANGELOG.md | 1155 -- packages/markdown/README.md | 987 -- .../bundle-tests/portable-text-to-markdown.ts | 1 - packages/markdown/package.config.ts | 7 - packages/markdown/package.json | 74 - .../src/apply-markdown-edit.fuzz.test.ts | 889 -- ...apply-markdown-edit.ownership.fuzz.test.ts | 1196 -- ...apply-markdown-edit.reconciliation.test.ts | 4047 ------ .../markdown/src/apply-markdown-edit.test.ts | 3679 ----- packages/markdown/src/apply-markdown-edit.ts | 3215 ----- packages/markdown/src/default-schema.ts | 225 - packages/markdown/src/escape.test.ts | 103 - packages/markdown/src/escape.ts | 43 - .../markdown/src/example-document.advanced.md | 156 - .../src/example-document.advanced.out.md | 128 - .../example-document.advanced.terse-pt.json | 60 - .../src/example-document.advanced.test.ts | 49 - packages/markdown/src/example-document.md | 237 - packages/markdown/src/example-document.out.md | 235 - .../src/example-document.terse-pt.json | 124 - .../markdown/src/example-document.test.ts | 60 - .../build-list-index-map.ts | 159 - .../degradation-messages.ts | 25 - .../degradation-report.test.ts | 93 - .../from-portable-text/degradation-report.ts | 369 - .../from-portable-text/escape-plain-text.ts | 748 -- .../list-item-first-block.ts | 26 - .../portable-text-to-markdown.ts | 549 - .../src/from-portable-text/render-node.ts | 366 - .../renderers/block-spacing.ts | 39 - .../renderers/hard-break.ts | 4 - .../from-portable-text/renderers/list-item.ts | 39 - .../src/from-portable-text/renderers/marks.ts | 145 - .../src/from-portable-text/renderers/style.ts | 81 - .../src/from-portable-text/renderers/type.ts | 561 - .../markdown/src/from-portable-text/types.ts | 267 - .../from-portable-text/validate-link.test.ts | 57 - .../src/from-portable-text/validate-link.ts | 18 - packages/markdown/src/index.ts | 63 - packages/markdown/src/key-generator.ts | 73 - .../src/markdown-to-portable-text.test.ts | 11133 ---------------- packages/markdown/src/neutral-form.ts | 84 - .../portable-text-to-markdown.fuzz.test.ts | 706 - .../src/portable-text-to-markdown.test.ts | 9145 ------------- .../to-portable-text/degradation-messages.ts | 143 - .../extract-alignment.test.ts | 44 - .../markdown-to-portable-text.ts | 3088 ----- .../markdown/src/to-portable-text/matchers.ts | 227 - packages/markdown/test-utils/mulberry32.ts | 9 - packages/markdown/tsconfig.dist.json | 9 - packages/markdown/tsconfig.json | 5 - packages/markdown/tsconfig.settings.json | 7 - packages/markdown/vitest.config.ts | 15 - pnpm-lock.yaml | 64 +- skills/portable-text-editor/SKILL.md | 60 +- 71 files changed, 63 insertions(+), 45124 deletions(-) delete mode 100644 build-config/preserve-modules.d.ts delete mode 100644 build-config/preserve-modules.js delete mode 100644 packages/markdown/CHANGELOG.md delete mode 100644 packages/markdown/README.md delete mode 100644 packages/markdown/bundle-tests/portable-text-to-markdown.ts delete mode 100644 packages/markdown/package.config.ts delete mode 100644 packages/markdown/package.json delete mode 100644 packages/markdown/src/apply-markdown-edit.fuzz.test.ts delete mode 100644 packages/markdown/src/apply-markdown-edit.ownership.fuzz.test.ts delete mode 100644 packages/markdown/src/apply-markdown-edit.reconciliation.test.ts delete mode 100644 packages/markdown/src/apply-markdown-edit.test.ts delete mode 100644 packages/markdown/src/apply-markdown-edit.ts delete mode 100644 packages/markdown/src/default-schema.ts delete mode 100644 packages/markdown/src/escape.test.ts delete mode 100644 packages/markdown/src/escape.ts delete mode 100644 packages/markdown/src/example-document.advanced.md delete mode 100644 packages/markdown/src/example-document.advanced.out.md delete mode 100644 packages/markdown/src/example-document.advanced.terse-pt.json delete mode 100644 packages/markdown/src/example-document.advanced.test.ts delete mode 100644 packages/markdown/src/example-document.md delete mode 100644 packages/markdown/src/example-document.out.md delete mode 100644 packages/markdown/src/example-document.terse-pt.json delete mode 100644 packages/markdown/src/example-document.test.ts delete mode 100644 packages/markdown/src/from-portable-text/build-list-index-map.ts delete mode 100644 packages/markdown/src/from-portable-text/degradation-messages.ts delete mode 100644 packages/markdown/src/from-portable-text/degradation-report.test.ts delete mode 100644 packages/markdown/src/from-portable-text/degradation-report.ts delete mode 100644 packages/markdown/src/from-portable-text/escape-plain-text.ts delete mode 100644 packages/markdown/src/from-portable-text/list-item-first-block.ts delete mode 100644 packages/markdown/src/from-portable-text/portable-text-to-markdown.ts delete mode 100644 packages/markdown/src/from-portable-text/render-node.ts delete mode 100644 packages/markdown/src/from-portable-text/renderers/block-spacing.ts delete mode 100644 packages/markdown/src/from-portable-text/renderers/hard-break.ts delete mode 100644 packages/markdown/src/from-portable-text/renderers/list-item.ts delete mode 100644 packages/markdown/src/from-portable-text/renderers/marks.ts delete mode 100644 packages/markdown/src/from-portable-text/renderers/style.ts delete mode 100644 packages/markdown/src/from-portable-text/renderers/type.ts delete mode 100644 packages/markdown/src/from-portable-text/types.ts delete mode 100644 packages/markdown/src/from-portable-text/validate-link.test.ts delete mode 100644 packages/markdown/src/from-portable-text/validate-link.ts delete mode 100644 packages/markdown/src/index.ts delete mode 100644 packages/markdown/src/key-generator.ts delete mode 100644 packages/markdown/src/markdown-to-portable-text.test.ts delete mode 100644 packages/markdown/src/neutral-form.ts delete mode 100644 packages/markdown/src/portable-text-to-markdown.fuzz.test.ts delete mode 100644 packages/markdown/src/portable-text-to-markdown.test.ts delete mode 100644 packages/markdown/src/to-portable-text/degradation-messages.ts delete mode 100644 packages/markdown/src/to-portable-text/extract-alignment.test.ts delete mode 100644 packages/markdown/src/to-portable-text/markdown-to-portable-text.ts delete mode 100644 packages/markdown/src/to-portable-text/matchers.ts delete mode 100644 packages/markdown/test-utils/mulberry32.ts delete mode 100644 packages/markdown/tsconfig.dist.json delete mode 100644 packages/markdown/tsconfig.json delete mode 100644 packages/markdown/tsconfig.settings.json delete mode 100644 packages/markdown/vitest.config.ts diff --git a/.prettierignore b/.prettierignore index 14b032cb68..cdf52bd8e8 100644 --- a/.prettierignore +++ b/.prettierignore @@ -10,6 +10,4 @@ apps/docs/.astro apps/docs/src/content/docs/api packages/html/src/tests/**/*.html .changeset/*.md -packages/markdown/src/example-document.out.md -packages/markdown/src/example-document.advanced.out.md packages/editor/tests/__fixtures__/wire-catalogue diff --git a/README.md b/README.md index 3d2f51121b..d65655dcc0 100644 --- a/README.md +++ b/README.md @@ -51,7 +51,7 @@ For documentation and guides, visit [portabletext.org](https://www.portabletext. | -------------------------------------------------------------------- | ------------------------------------------------------------------ | | [`@portabletext/html`](./packages/html/) | Convert HTML to Portable Text | | [`@portabletext/block-tools`](./packages/block-tools/) | Sanity-flavored HTML to Portable Text (wraps `@portabletext/html`) | -| [`@portabletext/markdown`](./packages/markdown/) | Convert Portable Text to Markdown and back again | +| [`@portabletext/markdown`](https://github.com/portabletext/markdown) | Convert Portable Text to Markdown and back again | | [`@portabletext/keyboard-shortcuts`](./packages/keyboard-shortcuts/) | Platform-aware keyboard shortcuts | | [`@portabletext/sanity-bridge`](./packages/sanity-bridge/) | Convert between Sanity schemas and Portable Text schemas | | [`@portabletext/patches`](./packages/patches/) | Apply Sanity patches to a value | diff --git a/apps/doc-examples/package.json b/apps/doc-examples/package.json index 2c5f69e5bd..1423e00aa8 100644 --- a/apps/doc-examples/package.json +++ b/apps/doc-examples/package.json @@ -12,7 +12,7 @@ "@portabletext/editor": "workspace:*", "@portabletext/html": "workspace:*", "@portabletext/keyboard-shortcuts": "workspace:*", - "@portabletext/markdown": "workspace:*", + "@portabletext/markdown": "^2.9.3", "@portabletext/patches": "workspace:*", "@portabletext/plugin-character-pair-decorator": "workspace:*", "@portabletext/plugin-decorations": "workspace:*", diff --git a/apps/docs/public/skill.md b/apps/docs/public/skill.md index 9cec432c7d..d2a30ea15a 100644 --- a/apps/docs/public/skill.md +++ b/apps/docs/public/skill.md @@ -25,7 +25,7 @@ Pick the serializer for the target. Pass the Portable Text value to it. browsers. See `/rendering/html.md`. - Vue: `@portabletext/vue`. See `/rendering/vue.md`. - Svelte: `@portabletext/svelte`. See `/rendering/svelte.md`. -- Markdown: `@portabletext/markdown`. See `/rendering/markdown.md`. +- Markdown: [`@portabletext/markdown`](https://github.com/portabletext/markdown). See `/rendering/markdown.md`. Unknown block types are skipped unless you provide a component for them, so custom types never crash a render. @@ -47,7 +47,7 @@ Behavior API (`defineBehavior`). Start at `/editor/getting-started.md`. - HTML to Portable Text: `@portabletext/block-tools` (`htmlToBlocks`). See `/conversion/html-to-portable-text.md`. -- Markdown to Portable Text: `@portabletext/markdown`. +- Markdown to Portable Text: [`@portabletext/markdown`](https://github.com/portabletext/markdown). See `/conversion/markdown-to-portable-text.md`. ## When unsure diff --git a/apps/docs/src/content/docs/conversion/markdown-round-tripping.mdx b/apps/docs/src/content/docs/conversion/markdown-round-tripping.mdx index 7c16a26760..b148fc8fa5 100644 --- a/apps/docs/src/content/docs/conversion/markdown-round-tripping.mdx +++ b/apps/docs/src/content/docs/conversion/markdown-round-tripping.mdx @@ -3,7 +3,7 @@ title: Markdown round-tripping description: What a Markdown → Portable Text → Markdown round trip preserves, normalizes, and degrades with @portabletext/markdown. --- -{/* This page is the canonical source for round-trip behavior. packages/markdown/README.md and apps/docs/src/content/docs/conversion/markdown-to-portable-text.mdx (Round-trip behavior section) both carry condensed summaries; keep all three in sync, a claim corrected here is stale there. */} +{/* This page is the canonical source for round-trip behavior. https://github.com/portabletext/markdown#readme and apps/docs/src/content/docs/conversion/markdown-to-portable-text.mdx (Round-trip behavior section) both carry condensed summaries; keep all three in sync, a claim corrected here is stale there. */} Converting Markdown to Portable Text and back with `markdownToPortableText` and `portableTextToMarkdown` isn't a lossless mirror. It's a contract with six guarantees and a short list of named exceptions. This page is for anyone evaluating a Markdown-based pipeline, an AI agent that reads or writes Markdown, an import/export tool, a migration from a Markdown-first CMS, who needs to know exactly what a round trip preserves, normalizes, or degrades. @@ -282,4 +282,4 @@ markdownToPortableText(' hello world ') - [Markdown to Portable Text](/conversion/markdown-to-portable-text/) for parsing Markdown into PT blocks - [Markdown rendering](/rendering/markdown/) for rendering PT blocks as Markdown -- [`@portabletext/markdown` on GitHub](https://github.com/portabletext/editor/tree/main/packages/markdown) for full API documentation and changelog +- [`@portabletext/markdown` on GitHub](https://github.com/portabletext/markdown) for full API documentation and changelog diff --git a/apps/docs/src/content/docs/conversion/markdown-to-portable-text.mdx b/apps/docs/src/content/docs/conversion/markdown-to-portable-text.mdx index 81445d2236..53963e3537 100644 --- a/apps/docs/src/content/docs/conversion/markdown-to-portable-text.mdx +++ b/apps/docs/src/content/docs/conversion/markdown-to-portable-text.mdx @@ -5,7 +5,7 @@ description: Convert Markdown strings into Portable Text blocks using @portablet import {PackageManagers} from 'starlight-package-managers' -{/* The schema, matcher, and supported-features tables mirror packages/markdown/README.md. Keep the twins in sync: a claim corrected in one place is stale in the other. The Round-trip behavior section is a summary; [Markdown round-tripping](/conversion/markdown-round-tripping/) is canonical. */} +{/* The schema, matcher, and supported-features tables mirror https://github.com/portabletext/markdown#readme. Keep the twins in sync: a claim corrected in one place is stale in the other. The Round-trip behavior section is a summary; [Markdown round-tripping](/conversion/markdown-round-tripping/) is canonical. */} :::note[Prerequisites] This guide covers `@portabletext/markdown` **v1.x** ([changelog](https://github.com/portabletext/editor/releases)). No framework dependencies. Works in Node.js, Deno, edge runtimes, and browsers. @@ -48,7 +48,7 @@ The default schema includes: | `blockObjects` | `code`, `image`, `horizontal-rule`, `html`, `table`, `callout` | | `inlineObjects` | `image` | -To use a custom schema, build one with `compileSchema` and `defineSchema` from `@portabletext/schema` and pass it as the `schema` option; a Sanity schema converts first through `@portabletext/sanity-bridge`. The [package README](https://github.com/portabletext/editor/tree/main/packages/markdown#schema-configuration) has the configuration recipes for both. +To use a custom schema, build one with `compileSchema` and `defineSchema` from `@portabletext/schema` and pass it as the `schema` option; a Sanity schema converts first through `@portabletext/sanity-bridge`. The [package README](https://github.com/portabletext/markdown#schema-configuration) has the configuration recipes for both. ## Matchers @@ -72,7 +72,7 @@ Matchers control how Markdown elements map to schema types. The library includes | | `html` | HTML blocks | `'html'` | | | `callout` | `> [!NOTE]`, etc. | `'callout'` | -Override a matcher when your schema uses a different name for a type (say, `'heading 1'` instead of `'h1'`): pass your own function under the matcher's group and name, resolve the type against `context.schema`, and return its name, or `undefined` to skip the element. The [package README](https://github.com/portabletext/editor/tree/main/packages/markdown#configuring-matchers) has worked matcher recipes, including the structural `table`, `list`, and `blockquote` shapes. +Override a matcher when your schema uses a different name for a type (say, `'heading 1'` instead of `'h1'`): pass your own function under the matcher's group and name, resolve the type against `context.schema`, and return its name, or `undefined` to skip the element. The [package README](https://github.com/portabletext/markdown#configuring-matchers) has worked matcher recipes, including the structural `table`, `list`, and `blockquote` shapes. ## Supported features @@ -103,7 +103,7 @@ This package uses markdown-it as its Markdown parser. Remark and unified plugins ## Reporting degradation -A construct the schema can't represent (an undeclared decorator, a table with no `table` block object, and so on) degrades to a lossier shape by default rather than failing, silently: nothing reaches the console. `onDegradation` covers what an import pipeline or an agent that can't afford to lose content silently needs: a function is called once, after the whole document has been walked, only when at least one construct degraded, with every `Degradation` (`type`, `message`, `line` when available, `snippet` when there's source text to quote) in encounter order and a canonical grouped `message`, to observe the losses. Enforce against them by throwing your own error from inside that callback instead of returning lossy Portable Text; the throw propagates out of `markdownToPortableText`. The [package README](https://github.com/portabletext/editor/tree/main/packages/markdown#reporting-degradation) has the full option shape, an enforce example, and the report object's fields. +A construct the schema can't represent (an undeclared decorator, a table with no `table` block object, and so on) degrades to a lossier shape by default rather than failing, silently: nothing reaches the console. `onDegradation` covers what an import pipeline or an agent that can't afford to lose content silently needs: a function is called once, after the whole document has been walked, only when at least one construct degraded, with every `Degradation` (`type`, `message`, `line` when available, `snippet` when there's source text to quote) in encounter order and a canonical grouped `message`, to observe the losses. Enforce against them by throwing your own error from inside that callback instead of returning lossy Portable Text; the throw propagates out of `markdownToPortableText`. The [package README](https://github.com/portabletext/markdown#reporting-degradation) has the full option shape, an enforce example, and the report object's fields. ## Round-trip behavior @@ -120,4 +120,4 @@ Converting Markdown to Portable Text and back isn't a lossless mirror: translati ## Further reading - [Markdown rendering guide](/rendering/markdown/) for converting PT blocks to Markdown strings -- [`@portabletext/markdown` on GitHub](https://github.com/portabletext/editor/tree/main/packages/markdown) for full API documentation and changelog +- [`@portabletext/markdown` on GitHub](https://github.com/portabletext/markdown) for full API documentation and changelog diff --git a/apps/docs/src/content/docs/ecosystem/packages.mdx b/apps/docs/src/content/docs/ecosystem/packages.mdx index 7a8a2710b0..5fd8c8eee2 100644 --- a/apps/docs/src/content/docs/ecosystem/packages.mdx +++ b/apps/docs/src/content/docs/ecosystem/packages.mdx @@ -82,7 +82,7 @@ Serializers convert Portable Text JSON into your target output. | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | ------------------------------------- | ----------------------------------------------------------- | | [`@portabletext/html`](https://github.com/portabletext/editor/tree/main/packages/html) | HTML → Portable Text | `npm i @portabletext/html` | | [`@portabletext/block-tools`](https://github.com/portabletext/editor/tree/main/packages/block-tools) | HTML → Portable Text (Sanity wrapper) | `npm i @portabletext/block-tools` | -| [`@portabletext/markdown`](https://github.com/portabletext/editor/tree/main/packages/markdown) | Portable Text ↔ Markdown | `npm i @portabletext/markdown` | +| [`@portabletext/markdown`](https://github.com/portabletext/markdown) | Portable Text ↔ Markdown | `npm i @portabletext/markdown` | | [`@portabletext/contentful-rich-text-to-portable-text`](https://github.com/portabletext/contentful-rich-text-to-portable-text) | Contentful → Portable Text | `npm i @portabletext/contentful-rich-text-to-portable-text` | | [`@emdash-cms/gutenberg-to-portable-text`](https://github.com/emdash-cms/emdash/tree/main/packages/gutenberg-to-portable-text) | WordPress / Gutenberg → Portable Text | `npm i @emdash-cms/gutenberg-to-portable-text` | | [`editorjs-to-portabletext`](https://github.com/LiamMartens/editorjs-to-portabletext) | Editor.js → Portable Text | `npm i editorjs-to-portabletext` | diff --git a/apps/docs/src/content/docs/editor/guides/migrate-from-v7.mdx b/apps/docs/src/content/docs/editor/guides/migrate-from-v7.mdx index 50c7965d0d..335659740a 100644 --- a/apps/docs/src/content/docs/editor/guides/migrate-from-v7.mdx +++ b/apps/docs/src/content/docs/editor/guides/migrate-from-v7.mdx @@ -118,7 +118,7 @@ The engine no longer draws its own drop indicator during block drags. Block drag The editor no longer ships a built-in `text/markdown` converter: pasting Markdown-formatted text no longer auto-parses it into Portable Text blocks, and copying no longer writes a `text/markdown` entry to the clipboard alongside `text/html` and `text/plain`. The built-in ran the markdown package's defaults with no way to configure renderers or type mappings, and markdown copy/paste is something most consumers want to configure for their own schema. `text/markdown` is also mostly a dead clipboard channel: almost nothing writes it, and markdown usually arrives as `text/plain`, so a converter keyed on `text/markdown` rarely saw a real paste. Now only apps that want markdown interop carry the dependency, configured their way; every other editor stops bundling `markdown-it` (about 145 KB minified). -To keep markdown interop, add [`@portabletext/markdown`](https://github.com/portabletext/editor/tree/main/packages/markdown) as your own dependency and register two Behaviors (via `BehaviorPlugin` or `editor.registerBehavior`). Both sides are a faithful restore: `text/markdown` goes back on the clipboard on copy, and `deserialize.data` for `text/markdown` parses it back into Portable Text blocks on paste. Since markdown usually arrives as `text/plain`, most consumers will want to widen the guard to accept that too. +To keep markdown interop, add [`@portabletext/markdown`](https://github.com/portabletext/markdown) as your own dependency and register two Behaviors (via `BehaviorPlugin` or `editor.registerBehavior`). Both sides are a faithful restore: `text/markdown` goes back on the clipboard on copy, and `deserialize.data` for `text/markdown` parses it back into Portable Text blocks on paste. Since markdown usually arrives as `text/plain`, most consumers will want to widen the guard to accept that too. `text/markdown` keeps its place ahead of `text/html` and `text/plain` in the paste priority. With no converter and no Behavior answering `deserialize.data` for it, a paste doesn't dead-end: it falls through to the next available format on the clipboard. diff --git a/apps/docs/src/content/docs/rendering/markdown.mdx b/apps/docs/src/content/docs/rendering/markdown.mdx index 5ec3d110c0..e70488939c 100644 --- a/apps/docs/src/content/docs/rendering/markdown.mdx +++ b/apps/docs/src/content/docs/rendering/markdown.mdx @@ -148,4 +148,4 @@ This package uses markdown-it as its Markdown parser. Remark and unified plugins ## Further reading - [Markdown to Portable Text](/conversion/markdown-to-portable-text/) for converting Markdown into PT blocks -- [`@portabletext/markdown` on GitHub](https://github.com/portabletext/editor/tree/main/packages/markdown) for full API documentation and changelog +- [`@portabletext/markdown` on GitHub](https://github.com/portabletext/markdown) for full API documentation and changelog diff --git a/apps/playground/package.json b/apps/playground/package.json index cc2e705c7e..d86c30ecbb 100644 --- a/apps/playground/package.json +++ b/apps/playground/package.json @@ -16,7 +16,7 @@ "@portabletext/editor": "workspace:*", "@portabletext/html": "workspace:*", "@portabletext/keyboard-shortcuts": "workspace:*", - "@portabletext/markdown": "workspace:*", + "@portabletext/markdown": "^2.9.3", "@portabletext/patches": "workspace:*", "@portabletext/plugin-decorations": "workspace:*", "@portabletext/plugin-dnd": "workspace:*", diff --git a/apps/playground/vite.config.ts b/apps/playground/vite.config.ts index 851bf40d69..be23a877f3 100644 --- a/apps/playground/vite.config.ts +++ b/apps/playground/vite.config.ts @@ -38,10 +38,6 @@ export default defineConfig({ '../../packages/keyboard-shortcuts/src', ), '@portabletext/html': path.resolve(__dirname, '../../packages/html/src'), - '@portabletext/markdown': path.resolve( - __dirname, - '../../packages/markdown/src', - ), '@portabletext/patches': path.resolve( __dirname, '../../packages/patches/src', diff --git a/build-config/preserve-modules.d.ts b/build-config/preserve-modules.d.ts deleted file mode 100644 index d80f038be1..0000000000 --- a/build-config/preserve-modules.d.ts +++ /dev/null @@ -1,10 +0,0 @@ -type ChunkFileNames = string | ((chunk: {name: string}) => string) - -export declare function preserveModules(): { - name: string - outputOptions(options: {entryFileNames?: ChunkFileNames}): { - entryFileNames: ChunkFileNames - preserveModules: boolean - preserveModulesRoot: string - } -} diff --git a/build-config/preserve-modules.js b/build-config/preserve-modules.js deleted file mode 100644 index a59e6711e0..0000000000 --- a/build-config/preserve-modules.js +++ /dev/null @@ -1,35 +0,0 @@ -/** - * Emits one file per source module instead of one flat file per entry, so a - * bundler can drop every module a consumer's imports don't reach under the - * package's `"sideEffects": false`. `@sanity/pkg-utils` has no option for - * this, so it sets Rolldown's `preserveModules` through the `plugins` - * passthrough, the same switch tsdown's `unbundle` flips. - * - * Modules from outside `src`, such as the declarations of an inlined - * devDependency, would otherwise keep their `node_modules/.pnpm/...` path - * in the output. They are written to `_vendor//...` instead. - */ -export function preserveModules() { - return { - name: 'preserve-modules', - outputOptions(options) { - const {entryFileNames = '[name].js'} = options - return { - ...options, - preserveModules: true, - preserveModulesRoot: 'src', - entryFileNames: (chunk) => - (typeof entryFileNames === 'function' - ? entryFileNames(chunk) - : entryFileNames - ).replace('[name]', vendorName(chunk.name)), - } - }, - } -} - -function vendorName(name) { - const match = - /(?:^|\/)node_modules\/(?:\.pnpm\/[^/]+\/node_modules\/)?(.+)$/.exec(name) - return match ? `_vendor/${match[1]}` : name -} diff --git a/bundle-stats.config.ts b/bundle-stats.config.ts index 09c75f056d..feb75d0c1d 100644 --- a/bundle-stats.config.ts +++ b/bundle-stats.config.ts @@ -23,14 +23,6 @@ export default defineConfig({ }, }, 'packages/keyboard-shortcuts', - { - root: 'packages/markdown', - scenarios: { - entries: { - portableTextToMarkdown: './bundle-tests/portable-text-to-markdown.ts', - }, - }, - }, 'packages/patches', 'packages/repair', 'packages/sanity-bridge', diff --git a/knip.json b/knip.json index e1426c1758..5cc36fadcb 100644 --- a/knip.json +++ b/knip.json @@ -134,11 +134,7 @@ }, "examples/*": {}, ".": { - "entry": [ - "bundle-stats.config.ts", - "build-config/*.d.ts", - ".github/scripts/*.test.ts" - ], + "entry": ["bundle-stats.config.ts", ".github/scripts/*.test.ts"], "ignoreDependencies": ["@playwright/test"], "project": ["**", ".github/scripts/**"] } diff --git a/package.json b/package.json index 687d9b0a68..f969c6cb0a 100644 --- a/package.json +++ b/package.json @@ -8,7 +8,6 @@ "build:editor": "turbo build --filter=@portabletext/editor...", "build:example-basic": "turbo build --filter=example-basic...", "build:keyboard-shortcuts": "turbo build --filter=@portabletext/keyboard-shortcuts...", - "build:markdown": "turbo build --filter=@portabletext/markdown...", "build:patches": "turbo build --filter=@portabletext/patches...", "build:playground": "turbo build --filter=playground...", "build:plugin-emoji-picker": "turbo build --filter=@portabletext/plugin-emoji-picker...", diff --git a/packages/markdown/CHANGELOG.md b/packages/markdown/CHANGELOG.md deleted file mode 100644 index e295971c47..0000000000 --- a/packages/markdown/CHANGELOG.md +++ /dev/null @@ -1,1155 +0,0 @@ -# @portabletext/markdown - -## 2.9.3 - -### Patch Changes - -- [#3636](https://github.com/portabletext/editor/pull/3636) [`bb046f7`](https://github.com/portabletext/editor/commit/bb046f75df5534aa9fd6997c7df392df9149a1e7) Thanks [@christianhg](https://github.com/christianhg)! - fix: escape a callout marker that starts a block on Markdown export - - A block whose first line is only a GFM alert marker (`[!NOTE]`, `[!TIP]`, `[!IMPORTANT]`, `[!WARNING]` or `[!CAUTION]`, in any case) now exports with the `[` escaped. A quote that starts with the marker reads back as a quote with its text instead of a callout. A plain paragraph that is only the marker also gets the backslash and reads back the same. - -- [#3612](https://github.com/portabletext/editor/pull/3612) [`60328b6`](https://github.com/portabletext/editor/commit/60328b657699e2fa814700d7c15e09f36b588713) Thanks [@christianhg](https://github.com/christianhg)! - fix: describe which objects `undeclaredObjects` checks - - The `undeclaredObjects` documentation on `ReconciliationReport` said that undeclared objects inside a table cell or another declared container are listed. They are not. Only top-level block objects and the inline objects of top-level text blocks are checked, and the fields of a declared object are left to whatever validates that object, as the README already says. - -## 2.9.2 - -### Patch Changes - -- [#3583](https://github.com/portabletext/editor/pull/3583) [`6cfdf5b`](https://github.com/portabletext/editor/commit/6cfdf5b8c0fe9827d68ce527b0f26bee5961ab62) Thanks [@rexxars](https://github.com/rexxars)! - fix: use the schema text block name when importing markdown - - `markdownToPortableText` now gives text blocks the name the schema declares for them. A schema with a renamed text block previously got `_type: 'block'` back: - - ```ts - import {markdownToPortableText} from '@portabletext/markdown' - import {compileSchema, defineSchema} from '@portabletext/schema' - - const schema = compileSchema(defineSchema({block: {name: 'myBlock'}})) - - markdownToPortableText('Hello', {schema})[0]?._type - // 'myBlock' (previously 'block') - ``` - - This applies to every text block the import creates, including blocks inside table cells and list items. - -- [#3584](https://github.com/portabletext/editor/pull/3584) [`5de0636`](https://github.com/portabletext/editor/commit/5de0636a7a62aa1b9ee1b5fa66efdab739467211) Thanks [@rexxars](https://github.com/rexxars)! - fix: stop comparing text block names to `block` when serializing markdown - - `portableTextToMarkdown` now renders text blocks with a custom `_type`, such as `myBlock`, the same way it renders `block`. Numbered list items with a custom name previously all got the `1.` marker: - - ```ts - import {portableTextToMarkdown} from '@portabletext/markdown' - - portableTextToMarkdown([ - { - _type: 'myBlock', - _key: 'a', - style: 'normal', - listItem: 'number', - level: 1, - markDefs: [], - children: [{_type: 'span', _key: 'a1', text: 'foo', marks: []}], - }, - { - _type: 'myBlock', - _key: 'b', - style: 'normal', - listItem: 'number', - level: 1, - markDefs: [], - children: [{_type: 'span', _key: 'b1', text: 'bar', marks: []}], - }, - ]) - // '1. foo\n2. bar' (previously '1. foo\n1. bar') - ``` - - A list starting deeper than level 1 is no longer indented into a code block, and text blocks in the `blockquote` style inside a callout or a structured blockquote no longer get a second `> ` prefix. An empty line between hard breaks also renders as a backslash break instead of a whitespace-only line that splits the paragraph when the markdown is parsed again. - -- [#3569](https://github.com/portabletext/editor/pull/3569) [`cacd2c9`](https://github.com/portabletext/editor/commit/cacd2c9b6a3aa6dd45282a634345bd4b3ea3ea7e) Thanks [@christianhg](https://github.com/christianhg)! - fix: write an `h1` or `h2` with a hard break as a setext heading when the document parses back unchanged - - `portableTextToMarkdown` now writes an `h1` or `h2` whose text holds a line break as a setext heading (`foo \nbar\n===`), so reading the markdown back gives the same heading instead of a heading plus a paragraph. - -## 2.9.1 - -### Patch Changes - -- [#3530](https://github.com/portabletext/editor/pull/3530) [`b31b8d6`](https://github.com/portabletext/editor/commit/b31b8d60aceca33e425178afef256eaac494f380) Thanks [@christianhg](https://github.com/christianhg)! - fix: render a hard break after an empty line as a backslash break when the document parses back unchanged - - A paragraph, blockquote or list item with an empty line inside it (two or more consecutive newlines in its text, as typed with Shift+Enter twice) now comes back from Markdown as one block. Previously the empty line rendered as a whitespace-only line, which Markdown reads as a paragraph break, so the block came back as two and `applyMarkdownEdit` skipped key matching: - - ```ts - const blocks = [ - { - _type: 'block', - _key: 'b1', - style: 'normal', - markDefs: [], - children: [{_type: 'span', _key: 's1', text: 'foo\n\nbar', marks: []}], - }, - ] - - portableTextToMarkdown(blocks) - // 'foo \n\\\nbar', previously 'foo \n \nbar' - - applyMarkdownEdit(blocks, portableTextToMarkdown(blocks)) - // Keeps `b1` and `s1`. Previously reported `round-trip-mismatch` and minted new keys - ``` - - The backslash form is used only when the whole document still parses back with that block exactly as it was and every other block as before. Otherwise the output is unchanged. Headings, nested list items and documents rendered with a custom `hardBreak` or a custom `blockSpacing` keep their current output. - -- [#3528](https://github.com/portabletext/editor/pull/3528) [`0f299d8`](https://github.com/portabletext/editor/commit/0f299d8d892c8a627138879bb22e82e3ff353ab1) Thanks [@christianhg](https://github.com/christianhg)! - fix: widen a code block's fence past a code line of backticks - - A code line made only of backticks no longer closes the code block early when the Markdown is read back. - -## 2.9.0 - -### Minor Changes - -- [#3492](https://github.com/portabletext/editor/pull/3492) [`446fc98`](https://github.com/portabletext/editor/commit/446fc9880780ad95c9ad358cb928d17ada176c02) Thanks [@christianhg](https://github.com/christianhg)! - feat: report what an edit loses or adds outside the schema in the reconciliation report - - The `ReconciliationReport` that `applyMarkdownEdit` passes to `onReconciliation` now carries `markdownLosses` and `undeclaredObjects` next to `hiddenLosses`, on both the performed and the skipped variant. `markdownLosses` lists what the edited markdown said that the result does not hold (the same `Degradation` entries `deserialize.onDegradation` receives, except `object-type-undeclared`). `undeclaredObjects` lists the objects the edit added whose type the schema does not declare, each as an `UndeclaredObject` with its `_type`, `_key`, `position`, `path` into the returned value and a display `message`. An undeclared object is kept as written, so it is not lost, but the schema does not allow it. Together with `hiddenLosses`, the three lists say whether saving the result loses content or adds content the schema does not allow: - - ```ts - applyMarkdownEdit(stored, editedMarkdown, { - onReconciliation: (report) => { - if ( - report.hiddenLosses.length > 0 || - report.markdownLosses.length > 0 || - report.undeclaredObjects.length > 0 - ) { - throw new Error('The edit cannot be saved as is') - } - }, - }) - ``` - -## 2.8.1 - -### Patch Changes - -- [#3466](https://github.com/portabletext/editor/pull/3466) [`5955959`](https://github.com/portabletext/editor/commit/5955959d1951425b3e4261a5ddeeb5a84301125f) Thanks [@christianhg](https://github.com/christianhg)! - fix: leave the blocks passed to `portableTextToMarkdown` unchanged - - `portableTextToMarkdown` no longer writes a generated `_key` into blocks that are passed in without one. The keys only exist on an internal copy now, so the output is the same, and the caller's value is left exactly as it was. - - ```ts - const blocks = [ - { - _type: 'block', - listItem: 'number', - level: 1, - children: [{_type: 'span', text: 'foo'}], - }, - ] - - portableTextToMarkdown(blocks) - - blocks[0]._key - // Previously: a random key such as '507fef1a166e' - // Now: undefined - ``` - -- [#3473](https://github.com/portabletext/editor/pull/3473) [`2290b7b`](https://github.com/portabletext/editor/commit/2290b7bd8685e146ee7c8e0374a01c6c305d1c6b) Thanks [@christianhg](https://github.com/christianhg)! - fix: check image URLs without a module-level `markdown-it` instance - - Loading `@portabletext/markdown` no longer builds a `markdown-it` parser up front just to check image URLs in `portableTextToMarkdown`. The check now runs a local copy of `markdown-it`'s default link validator, so images are accepted and rejected exactly as before, and the serializer no longer references `markdown-it` at all. - -- [#3473](https://github.com/portabletext/editor/pull/3473) [`6c85529`](https://github.com/portabletext/editor/commit/6c8552974ecb6362dcd595b5dabf0745383841dd) Thanks [@christianhg](https://github.com/christianhg)! - fix: publish one file per source module - - `@portabletext/markdown` now ships one file per source module instead of a single bundled `dist/index.js`, so bundlers drop the modules your imports don't reach. Code that only serializes no longer ships `markdown-it`: a bare `import {portableTextToMarkdown} from '@portabletext/markdown'` bundles to 12,501 bytes instead of 50,235 (esbuild `--bundle --minify`, gzip -9). Code that imports `markdownToPortableText` still needs `markdown-it` and stays about the same size (50,951 bytes before, 51,283 now). The package's exports and types are unchanged, and the files inside `dist` are not importable on their own. - -## 2.8.0 - -### Minor Changes - -- [#3462](https://github.com/portabletext/editor/pull/3462) [`a5ab030`](https://github.com/portabletext/editor/commit/a5ab0305644a06e259c7e516ffcdf2c40655423d) Thanks [@christianhg](https://github.com/christianhg)! - feat: parse `` as underline in `markdownToPortableText` - - `markdownToPortableText` now reads a bare `…` pair as the `underline` decorator, so underline written by `portableTextToMarkdown` survives the round trip. Before, the tags were dropped as inline HTML and the text lost its underline, which also meant `applyMarkdownEdit` stripped underline from every block an edit touched: - - ```ts - markdownToPortableText('Read the safety notice before use.') - // The `safety notice` span now has `marks: ['underline']`. - // Previously it had `marks: []`, with an `inline-html-dropped` degradation. - ``` - - The default schema now includes an `underline` decorator, and `marks.underline` lets you map it to a different decorator name. When the schema has no matching decorator, the text stays and an `underline` `decorator-dropped` degradation is reported. The tags are read as underline in both `html.inline` modes. Tags with attributes (``) and unpaired tags stay inline HTML, handled as before. - -### Patch Changes - -- [#3454](https://github.com/portabletext/editor/pull/3454) [`3ac8643`](https://github.com/portabletext/editor/commit/3ac8643ba722ea341ef303ae1334c2f8e85928e1) Thanks [@christianhg](https://github.com/christianhg)! - fix: honor a function `block` renderer in `portableTextToMarkdown` - - The `block` option accepts either a map of renderers by style or a single function for every block. A single function is now used. Previously it was ignored and every block rendered with the default renderers: - - ```ts - portableTextToMarkdown(blocks, { - block: ({value, children}) => `[${value.style}] ${children}`, - }) - // Now: "[h1] Title\n\n[normal] Body" - // Previously: "# Title\n\nBody" - ``` - -- [#3454](https://github.com/portabletext/editor/pull/3454) [`f1849b9`](https://github.com/portabletext/editor/commit/f1849b9c64ce0e5b841701b404660ee7c85e6c80) Thanks [@christianhg](https://github.com/christianhg)! - fix: remove the link, not the surrounding bold, when a link closes in `markdownToPortableText` - - Text that follows a link inside a bold run now stays bold, and the link ends where the Markdown link ends: - - ```ts - markdownToPortableText('**[x](https://example.com) y** z') - // Now: "x" [strong, link], " y" [strong], " z" [] - // Previously: "x" [strong, link], " y z" [link] - ``` - -- [#3448](https://github.com/portabletext/editor/pull/3448) [`92e7879`](https://github.com/portabletext/editor/commit/92e7879da46b7b4721acff968b9015e5465e499b) Thanks [@christianhg](https://github.com/christianhg)! - fix: report hidden losses on stored blocks missing from the `applyMarkdownEdit` result - - `applyMarkdownEdit`'s `hiddenLosses` now lists content the markdown could not show (an annotation, decorator, block style, or list-item kind with no renderer) on every stored block whose `_key` is missing from the returned value. Previously those blocks counted as deleted and their losses went unreported. That included a block the edit only rewrote, when it came back with a fresh key, so the hidden content was dropped silently: - - ```ts - const schema = compileSchema( - defineSchema({ - annotations: [{name: 'internalLink', fields: [{name: 'ref', type: 'string'}]}], - }), - ) - const stored = [ - { - _type: 'block', - _key: 'a', - style: 'normal', - markDefs: [{_type: 'internalLink', _key: 'm1', ref: 'doc1'}], - children: [ - {_type: 'span', _key: 'a1', text: 'Compare with ', marks: []}, - {_type: 'span', _key: 'a2', text: "last year's survey", marks: ['m1']}, - ], - }, - { - _type: 'block', - _key: 'b', - style: 'normal', - markDefs: [], - children: [{_type: 'span', _key: 'b1', text: 'Unchanged end.', marks: []}], - }, - ] - applyMarkdownEdit( - stored, - "See last year's survey for comparison.\n\nNew paragraph.\n\nUnchanged end.", - {schema, onReconciliation: (report) => console.log(report.hiddenLosses)}, - ) - // Previously logged: [] - // Now logs the dropped `internalLink` annotation on block `a` - ``` - - A plain delete of a block that held such content is now reported too, so decide whether the removal was intended before refusing the write. - -## 2.7.1 - -### Patch Changes - -- [#3432](https://github.com/portabletext/editor/pull/3432) [`e150cff`](https://github.com/portabletext/editor/commit/e150cffa703fedf003a9400d09a1b507558f3559) Thanks [@christianhg](https://github.com/christianhg)! - fix: keep a `json:object`-tagged code span with no open text block as an inline object - - `markdownToPortableText` stored a `json:object`-tagged code span as literal text when no text block was open at that point, for example in a paragraph that follows a fenced block inside the same list item, in a list the schema flattens into paragraphs. It now opens a text block there, as it does for plain text, and keeps the payload as an inline object, with the same keying and `object-type-undeclared` reporting as anywhere else. Text before the tag stays a span. A tagged code span in that position that fails to reconstruct now reports as `object-carrier-invalid`. - - ```ts - import {compileSchema, defineSchema} from '@portabletext/schema' - import {markdownToPortableText} from '@portabletext/markdown' - - const result = markdownToPortableText( - '- foo\n\n ```json:object\n {"_type": "product"}\n ```\n\n json:object`{"_type":"stockTicker","symbol":"AAPL"}`', - {schema: compileSchema(defineSchema({}))}, - ) - result.at(-1).children - // Previously: [{_type: 'span', text: 'json:object{"_type":"stockTicker","symbol":"AAPL"}', ...}] - // Now: [{_type: 'stockTicker', symbol: 'AAPL', _key: ...}] - ``` - -## 2.7.0 - -### Minor Changes - -- [#3422](https://github.com/portabletext/editor/pull/3422) [`a503975`](https://github.com/portabletext/editor/commit/a503975c5e6b5f94fd06b3fb5ed8e3ce506bd68c) Thanks [@christianhg](https://github.com/christianhg)! - feat: key `json:object` payloads and report types the schema does not declare - - `markdownToPortableText` (and `applyMarkdownEdit`, through its `deserialize` options) now gives a `json:object` fence or tagged code span a generated `_key` when its payload has none, like every other object it builds. A payload keeps its `_key` when it is a non-empty string, and an empty or non-string `_key` is replaced with a generated one. The payload's other fields are still kept as written, but when the schema does not declare its `_type` at that position (`blockObjects` for a fence, `inlineObjects` for a code span, and `blockObjects` for an object a table cell lifts to block position), `onDegradation` now reports it as `object-type-undeclared`: - - ```ts - import {compileSchema, defineSchema} from '@portabletext/schema' - import {markdownToPortableText} from '@portabletext/markdown' - - markdownToPortableText( - 'Arranged by json:object`{"_type":"mention","_ref":"person-1"}`.', - { - schema: compileSchema(defineSchema({})), - onDegradation: ({message}) => console.warn(message), - }, - ) - // Previously: the `mention` child had no `_key` and nothing was reported. - // Now: the `mention` child gets a `_key`, and onDegradation reports - // "Kept the inline `mention` object as written: the schema has no inline `mention` object" - ``` - - Without a `schema` the check runs against the default schema, so a zero-config round trip of a custom type now reports it. Code that switches over `Degradation['type']` gains a case. - -### Patch Changes - -- [#3422](https://github.com/portabletext/editor/pull/3422) [`a8084cc`](https://github.com/portabletext/editor/commit/a8084cc501ab6cfb431989d34bffa11f301363c8) Thanks [@christianhg](https://github.com/christianhg)! - fix: never generate a key that a `json:object` payload carries - - `markdownToPortableText` keeps the `_key` a `json:object` fence or tagged code span carries, but the keys it generated for everything else could repeat it, so a payload and a neighbouring block or span could end up with the same key. A carried key now stays as written, and generated keys never repeat it. Keys only have to be unique among siblings, but which nodes end up as siblings is only settled as the conversion runs (a table cell can lift an object to block level, and an undeclared list flattens into paragraphs), so generated keys skip the `_key` on every payload object in the document, whether the payload comes before or after the generated node. Keys nested inside a payload are unaffected. `applyMarkdownEdit` also no longer renames a payload whose key it had just given to a renamed duplicate earlier in the same array: - - ```ts - import {markdownToPortableText} from '@portabletext/markdown' - - let index = 0 - markdownToPortableText( - 'foo\n\n```json:object\n{"_type": "product", "_key": "k0"}\n```', - {keyGenerator: () => `k${index++}`}, - ) - // Previously: the paragraph and the `product` object were both keyed `k0`. - // Now: the paragraph is keyed `k1` and the `product` object keeps `k0`. - ``` - -## 2.6.0 - -### Minor Changes - -- [#3404](https://github.com/portabletext/editor/pull/3404) [`0339026`](https://github.com/portabletext/editor/commit/03390265bca31e3d7bd415b2a5f9d08c63eab0cd) Thanks [@christianhg](https://github.com/christianhg)! - feat: report content an edit lost without the markdown showing it - - Every `ReconciliationReport` passed to `applyMarkdownEdit`'s `onReconciliation` now carries `hiddenLosses`: one `SerializeDegradation` per annotation, decorator, block style, or list-item kind the `serialize` options could not express in markdown and that is missing from the returned value. Content `applyMarkdownEdit` restores is not listed, and neither are losses on blocks the edit deleted. A non-empty list means saving the result drops content whoever edited the markdown never saw, so a caller can refuse or retry the write: - - ```ts - import {applyMarkdownEdit} from '@portabletext/markdown' - - const edited = applyMarkdownEdit(stored, editedMarkdown, { - onReconciliation: (report) => { - if (report.hiddenLosses.length > 0) { - throw new Error(report.hiddenLosses.map((loss) => loss.message).join('; ')) - } - }, - }) - ``` - - Code that builds a `ReconciliationReport` by hand needs to add the field. - -### Patch Changes - -- [#3406](https://github.com/portabletext/editor/pull/3406) [`d3f5b26`](https://github.com/portabletext/editor/commit/d3f5b26f299772ec2df7ba19b1c6e8cfdd2a45af) Thanks [@renovate](https://github.com/apps/renovate)! - fix(deps): update dependency markdown-it to ^15.0.3 [security] - -- [#3412](https://github.com/portabletext/editor/pull/3412) [`d1753bc`](https://github.com/portabletext/editor/commit/d1753bc4bdd132f511478c597906cea7f8f65c8c) Thanks [@christianhg](https://github.com/christianhg)! - fix: keep untouched table cells intact when another cell is edited - - `applyMarkdownEdit` now returns a table cell the edit did not touch exactly as stored, even when a sibling cell in the same table changed, as long as the table's shape survives the markdown round trip. A table whose rows hold different numbers of cells does not, and its untouched cells can still lose what markdown cannot show. Previously the untouched cell was rebuilt from the markdown, so anything markdown cannot show (annotations such as internal links, custom styles and decorators) was dropped from it: - - ```ts - const stored = [ - { - _type: 'table', - _key: 't1', - rows: [ - { - _type: 'row', - _key: 'r1', - cells: [ - { - _type: 'cell', - _key: 'c1', - value: [ - { - _type: 'block', - _key: 'b1', - style: 'normal', - markDefs: [ - {_type: 'internalLink', _key: 'l1', reference: {_ref: 'foo'}}, - ], - children: [ - {_type: 'span', _key: 's1', text: 'foo', marks: ['l1']}, - ], - }, - ], - }, - { - _type: 'cell', - _key: 'c2', - value: [ - { - _type: 'block', - _key: 'b2', - style: 'normal', - markDefs: [], - children: [{_type: 'span', _key: 's2', text: 'bar', marks: []}], - }, - ], - }, - ], - }, - ], - }, - ] - const markdown = portableTextToMarkdown(stored).replace('bar', 'baz') - applyMarkdownEdit(stored, markdown) - // Cell `c1` now keeps its `internalLink` definition. - // Previously it came back with `markDefs: []`. - ``` - - The reconciliation report now lists the untouched cell and everything inside it as `content-unchanged`, the same as an untouched top-level block. Those entries report `valueChanged: false`, except where a stored node had no `_key`: the key it gets is not listed, and the nodes containing it report `valueChanged: true`. - -## 2.5.2 - -### Patch Changes - -- Updated dependencies [[`329cd2e`](https://github.com/portabletext/editor/commit/329cd2e27dc27f81ff4577e9d02c95471a56c846)]: - - @portabletext/schema@3.1.0 - -## 2.5.1 - -### Patch Changes - -- Updated dependencies [[`e81e09e`](https://github.com/portabletext/editor/commit/e81e09e5885039fd3891ae0b091d9ae0d487f293)]: - - @portabletext/schema@3.0.1 - -## 2.5.0 - -### Minor Changes - -- [#3346](https://github.com/portabletext/editor/pull/3346) [`7c4a0c7`](https://github.com/portabletext/editor/commit/7c4a0c78c09387982a8eb94c06c504658c50f14c) Thanks [@christianhg](https://github.com/christianhg)! - feat: report where and why the round trip failed when `applyMarkdownEdit` skips key matching - - When the stored value cannot survive its own serialize→parse round trip, the skipped `ReconciliationReport` now carries `mismatch`: a `type` (`'type-changed'`, `'text-changed'`, or `'block-count-changed'`), a `storedPath` to the node where the round trip first goes wrong, a readable `message`, and an optional `snippet` of the text that came back. A custom renderer that writes an inline object as `[[foo]]` reports: - - ```ts - { - keyMatching: 'skipped', - reason: 'round-trip-mismatch', - mismatch: { - type: 'text-changed', - storedPath: [{_key: 'b2'}, 'children', {_key: 'w1'}], - message: - "The block's text came back different, starting at the `wikilink` inline object", - snippet: '[[foo]]', - }, - renamedKeys: [], - } - ``` - - Unlike the other paths in the report, `storedPath` addresses the stored value you passed in, not the returned value. Match on `type`, not `message`: the message can change between releases. - -## 2.4.0 - -### Minor Changes - -- [#3274](https://github.com/portabletext/editor/pull/3274) [`6933887`](https://github.com/portabletext/editor/commit/69338875f285930b014e572b29e13266a3dd9101) Thanks [@christianhg](https://github.com/christianhg)! - feat: report `valueChanged` per preserved key in `onReconciliation` - - Each `preservedKeys` entry now carries a `valueChanged` boolean alongside `basis`, `key`, and `path`: whether the node wearing that key in the returned value differs from its stored counterpart, field order aside but array order significant. Any difference counts, including a descendant key rewritten to keep siblings unique, or a key filled in for a stored node that had none. `false` guarantees the returned node deep-equals the stored one: - - ```ts - const changed = report.preservedKeys.filter((entry) => entry.valueChanged) - // keys whose returned node differs from its stored counterpart - ``` - -## 2.3.0 - -### Minor Changes - -- [#3289](https://github.com/portabletext/editor/pull/3289) [`295a6fe`](https://github.com/portabletext/editor/commit/295a6fe32788c6d74ba12d9a5073f310b8f59d23) Thanks [@christianhg](https://github.com/christianhg)! - feat: report serialize-side degradation via `onDegradation` in `portableTextToMarkdown` - - `portableTextToMarkdown` now takes an `onDegradation` option for the losses its own default renderers fall back to: an annotation or decorator with no mark renderer (`annotation-dropped`, `decorator-dropped`), a block style with no renderer (`style-fallback`), or a list-item kind with no renderer (`list-item-fallback`). One callback, called at most once, after the whole document has been walked, only when at least one construct degraded, with each loss reported as a `SerializeDegradation`, whose `path` addresses the node the loss occurred on, from the top-level `blocks` array down (a cell's degradation carries the full path down to the offending node, not just its table's position): - - ```ts - portableTextToMarkdown(blocks, { - onDegradation: ({degradations, message}) => console.log(message), - }) - // degradations: every SerializeDegradation, in encounter order - // message: the same degradations grouped, snippeted, and sorted by block - ``` - - Providing your own renderer for the affected construct suppresses its report: `onDegradation` only fires for fallbacks the library's own default renderers produced, never for a consumer's `unknownMark`, `unknownBlockStyle`, or `unknownListItem`. `list-item-fallback` never fires under the default configuration, since the default `listItem` renderer already covers every kind: it only fires behind a consumer-supplied partial `listItem` map. - - `applyMarkdownEdit`'s `serialize` option doesn't accept `onDegradation`, since that serialization only ever runs internally to align `storedPortableText`, never on `editedMarkdown` itself. - -## 2.2.2 - -### Patch Changes - -- [#3290](https://github.com/portabletext/editor/pull/3290) [`0f95709`](https://github.com/portabletext/editor/commit/0f95709d8038a4fc6768758ae11fae6e246c0be0) Thanks [@renovate](https://github.com/apps/renovate)! - fix(deps): update dependency @sanity/diff-match-patch to ^3.2.1 - -## 2.2.1 - -### Patch Changes - -- [#3284](https://github.com/portabletext/editor/pull/3284) [`55f9bdb`](https://github.com/portabletext/editor/commit/55f9bdb0778ab8c1aa590b7d351989b5a4b81ac0) Thanks [@christianhg](https://github.com/christianhg)! - fix: upgrade markdown-it to v15, keeping fuzzy links and URL auth scanning on - - Markdown parsing upgrades to markdown-it 15, which brings upstream fixes for pathological-input parsing performance (smartquotes, link scanning) and closer CommonMark conformance in a few edge cases. Bare-URL linkification is unchanged: bare domains (`www.example.com`) and URLs with an auth part (`http://user:pass@example.com/x`) still become links. - - Also changed, at the extremes of what linkify accepts: unicode punctuation (for example an em dash) now ends a bare URL instead of being included in it, bare emails with `:`, `;`, or `,` in the local part no longer become links, and bare URLs stop matching past hard bounds (10,000 total characters, 10 subdomain labels, 100-character quoted path segments). - -## 2.2.0 - -### Minor Changes - -- [#3242](https://github.com/portabletext/editor/pull/3242) [`e4abe23`](https://github.com/portabletext/editor/commit/e4abe233743736ec15e3bbf3fcb118182beaf49d) Thanks [@christianhg](https://github.com/christianhg)! - feat: add `applyMarkdownEdit` for key-preserving markdown edits - - Edit Portable Text as markdown without losing identity: serialize a stored value, let anything edit the markdown, and convert it back with the stored `_key`s restored to everything the edit did not replace. - - ```ts - import { - applyMarkdownEdit, - portableTextToMarkdown, - } from '@portabletext/markdown' - - const markdown = portableTextToMarkdown(storedPortableText, {schema}) - // ... the markdown gets edited ... - const edited = applyMarkdownEdit(storedPortableText, editedMarkdown, { - schema, // governs both conversion directions - serialize: {}, // must match the options that produced the markdown - }) - ``` - - Keys follow the edit the way they would in an editor: unchanged and moved blocks keep their keys, rewriting a block in place keeps its key like typing over it, a split keeps the key on the first fragment, a merge on the first block, and a typo fix lands as a text change on the same span, annotation keys included. What markdown cannot express comes back on adopted content: custom fields and styles, decorators without syntax, empty paragraphs. When evidence runs out, a block gets a fresh key rather than a wrong one, and a `json:object` payload's own key never re-keys stored content. - - The options bag is the exported `ApplyMarkdownEditOptions`: one top-level `schema` governs both conversion directions, and `deserialize`/`serialize` take the two converters' remaining options, with new keys coming from `deserialize.keyGenerator`. The README's `applyMarkdownEdit` section carries the full contract: what keeps its key, what gets restored, when keys reset, and the concurrent-edit rule. - -- [#3255](https://github.com/portabletext/editor/pull/3255) [`02e2d49`](https://github.com/portabletext/editor/commit/02e2d4918e7c0a55e974f90e414a3b53aaf3b199) Thanks [@christianhg](https://github.com/christianhg)! - feat: gate the default markdown renderers on the schema in `portableTextToMarkdown` - - `portableTextToMarkdown` now accepts an optional `schema`. When set, each built-in type renderer (`callout`, `code`, `horizontal-rule`, `html`, `image`, `table`) runs only when the schema declares that type at the matching position (`blockObjects` for a block, `inlineObjects` for an inline object); an undeclared type falls back to `unknownType`, whose default output is a `json:object` fence (or tagged code span, inline) that reparses back to the same value under the same schema. Renderers you pass in `types` are never gated. The gate checks the type name only, so declare each type with its fields: `markdownToPortableText` cannot rebuild a value from a fieldless declaration. Omit `schema` and every default renderer stays active, as before. - - ```ts - import {compileSchema, defineSchema} from '@portabletext/schema' - import {portableTextToMarkdown} from '@portabletext/markdown' - - const schema = compileSchema( - defineSchema({ - blockObjects: [ - { - name: 'code', - fields: [ - {name: 'code', type: 'string'}, - {name: 'language', type: 'string'}, - ], - }, - ], - }), - ) - - portableTextToMarkdown(blocks, {schema}) - // a `code` block renders as a fenced code block; a `table` block - // (undeclared) renders as a `json:object` fence instead - ``` - - Pass the same schema to `markdownToPortableText` to keep the round trip consistent. - -- [#3266](https://github.com/portabletext/editor/pull/3266) [`fc8e2a4`](https://github.com/portabletext/editor/commit/fc8e2a4f9b3ce355bebdcf0a650aee51ea7a2001) Thanks [@christianhg](https://github.com/christianhg)! - feat: add `onReconciliation` report to `applyMarkdownEdit` - - `applyMarkdownEdit` can now report what it did with every key. A preserved key means "this node in the returned value wears its stored key". A renamed key means "this key was renamed to stay unique among siblings". A key fallback marks a place that got fresh keys instead of a guess. Concretely, for a stored value of two paragraphs around a custom object, where the edit fixes one typo: - - ```ts - import {applyMarkdownEdit} from '@portabletext/markdown' - - // stored: [block b1 'Our pick:', product p1, block b2 'Ships tomorow.'] - // edited markdown: the same document with 'tomorow' -> 'tomorrow' - applyMarkdownEdit(stored, editedMarkdown, { - onReconciliation: (report) => { - // report is: - // { - // keyMatching: 'performed', - // preservedKeys: [ - // {basis: 'content-unchanged', key: 'b1', path: [{_key: 'b1'}]}, - // {basis: 'content-unchanged', key: 's1', path: [{_key: 'b1'}, 'children', {_key: 's1'}]}, - // {basis: 'content-unchanged', key: 'p1', path: [{_key: 'p1'}]}, - // {basis: 'same-position', key: 'b2', path: [{_key: 'b2'}]}, - // {basis: 'same-position', key: 's2', path: [{_key: 'b2'}, 'children', {_key: 's2'}]}, - // ], - // keyFallbacks: [], - // renamedKeys: [], - // } - // reading it: every stored key survived, at every depth. The - // untouched nodes matched exactly ('content-unchanged'), the edited - // paragraph kept its block and span keys the way typing over it - // in an editor would ('same-position'). Other basis values name - // the other matching methods: 'content-moved', 'content-split', - // 'content-merged', 'similar-content'. - }, - }) - ``` - - What the other fields mean when they are not empty: - - - `report.keyMatching === 'skipped'`: key matching did not run, and `report.reason` says why, `'round-trip-mismatch'` (the stored value cannot survive its own serialize→parse round trip) or `'document-too-large'`. The returned value is the plain conversion, every key fresh except the ones `json:object` payloads carry, and the report carries only `reason` and `renamedKeys`. - - `keyFallbacks: [{type: 'ambiguous-region-too-large', keys: [...]}]`: one ambiguous region was too large to gather evidence for, and the listed result blocks fell back to fresh keys. `{type: 'annotation-key-conflict', path}`: one annotation definition kept its fresh key because adopting the stored one would have collided with a sibling. - - `renamedKeys: [{previousKey: 'p1', key: 'k7', path}]`: a key was rewritten to keep siblings unique, most commonly a pasted `json:object` payload duplicating a key that already exists. - - The callback fires exactly once whenever set, synchronously, right before the function returns, a skip included: that is when a caller needs it most. A node absent from `preservedKeys` was not preserved from the stored value, whether it is a fresh key or a `json:object` payload key that carried its own, and the report does not distinguish the two. Every `key` and `path` matches the returned value exactly (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, every `key` and `path`, `renamedKeys`, and `keyMatching` are facts of that invocation, safe to branch on. A preserved key's `basis` is advisory: near the evidence caps it can vary with machine speed, so never branch on it. The two exported types, `ReconciliationReport` and `ReconciliationKeyPath`, are `@beta`. - -### Patch Changes - -- [#3267](https://github.com/portabletext/editor/pull/3267) [`be2ed87`](https://github.com/portabletext/editor/commit/be2ed87b4c7a46c4e068026100ab147f1ac38a17) Thanks [@christianhg](https://github.com/christianhg)! - fix: return stored blocks verbatim when the edit did not touch them - - A block whose spans carried a mark, decorator, or custom style the markdown dialect cannot express lost that structure through a no-op edit: two spans split only by an unmappable mark came back merged into one, and the mark itself vanished, even though nothing had touched that block. `applyMarkdownEdit` now recognizes when a block's canonicalized stored form and the parsed edited markdown agree exactly, proof that everything the dialect can express is untouched and everything it cannot express was invisible to the edit, and returns that block exactly as stored: span structure, marks, mark definitions, and any custom field included, at every depth. A block the edit did touch still comes back canonicalized, same as before. - -- [#3265](https://github.com/portabletext/editor/pull/3265) [`3b2d278`](https://github.com/portabletext/editor/commit/3b2d27847eb5139b2d50dc9f3d962167342c2b91) Thanks [@christianhg](https://github.com/christianhg)! - fix: serialize images the parser would refuse as `json:object` fences - - An `image` whose `src` a Markdown parser would refuse (a `data:` URI outside `png`/`gif`/`jpeg`/`webp`, or a `javascript:`/`vbscript:`/`file:` URI) previously serialized as `![alt](src)` anyway; reparsing that markdown turned the image into literal text instead of an image object, destroying it. Such an image now serializes as a `json:object` fence (or, inline, a tagged code span), which reparses back to the identical image value. Images with an accepted `src` are unaffected. - -- [#3255](https://github.com/portabletext/editor/pull/3255) [`d39146e`](https://github.com/portabletext/editor/commit/d39146ee48a23bad4a6c312863e76ff389a1bc2c) Thanks [@christianhg](https://github.com/christianhg)! - fix: use the inline `json:object` carrier for objects inside table cells - - In `portableTextToMarkdown`, an object inside a table cell that renders through the `json:object` carrier now uses the carrier's single-line inline form instead of a block fence squashed with `
`. - - In `markdownToPortableText`, a carrier object standing alone in a table cell is placed by the schema: it comes back at block position in the cell (`cell.value`), unless the schema declares the type inline-only, in which case it stays an inline child of the cell's text block. The same placement rule now governs standalone images, so under a schema without a block-level `image`, an image alone in a cell stays inline (reported as `image-block-to-inline`) instead of being lifted to block position. - - Together the two sides make objects in table cells survive the serialize-edit-reparse round trip. - -- [#3264](https://github.com/portabletext/editor/pull/3264) [`deccf84`](https://github.com/portabletext/editor/commit/deccf846fb41efbcae9b6dccafbfd6cd101930a1) Thanks [@christianhg](https://github.com/christianhg)! - fix: emit no blank lines for empty text blocks - - A block that renders to the empty string (an empty or whitespace-only text block, or a custom renderer returning `''`) no longer leaves blank lines in `portableTextToMarkdown`'s output: `[h1 'foo', empty block, 'bar']` now serializes to `# foo\n\nbar` instead of `# foo\n\n\n\nbar`. A dropped block never survived reparsing anyway, and a custom `blockSpacing` callback now sees the pair of blocks that actually end up adjacent, never an invisible one. Two spacing consequences show in rendered HTML: two blockquotes separated only by an empty block now join into one quote with a paragraph break, and a list whose blank lines came only from empty blocks goes tight, since a skipped block no longer counts toward looseness. - - The same filter runs inside containers: callout and structured-blockquote content joins skip empty blocks, so no more blank quote-prefixed lines. In list items, the marker line goes to the first block that renders output, with two exceptions that keep the markdown reparseable: a multi-line block (a code fence, a table) keeps its later lines indented inside the item instead of escaping the list at column 0, and a nested list or, on a task item, any non-text block stays indented below a bare marker, because after `- [x] ` (or fused with `- `) it would reparse as plain words. - -- [#3242](https://github.com/portabletext/editor/pull/3242) [`e4abe23`](https://github.com/portabletext/editor/commit/e4abe233743736ec15e3bbf3fcb118182beaf49d) Thanks [@christianhg](https://github.com/christianhg)! - fix: mint sibling-unique keys even from a colliding `keyGenerator` - - `markdownToPortableText` (and everything built on it) now guards the supplied `keyGenerator`: a generator that returns an already-minted key is retried, then deterministically suffixed, so one conversion can never mint the same `_key` twice. A repeated key did not just duplicate identity, it made annotation ownership ambiguous: two mark definitions sharing a key leave every referencing span attributable to either, which no later repair can resolve. Well-behaved generators are unaffected. - -## 2.1.0 - -### Minor Changes - -- [#3240](https://github.com/portabletext/editor/pull/3240) [`bf560b9`](https://github.com/portabletext/editor/commit/bf560b99999ef3a0abbd032cb4d548b37f18e254) Thanks [@christianhg](https://github.com/christianhg)! - feat: round-trip unknown objects through `json:object` fences and code spans - - Custom objects now survive Markdown conversion in both directions. `portableTextToMarkdown` renders a block-level object as a ` ```json:object ` fence and an inline object as a `json:object`-tagged code span, and `markdownToPortableText` turns both back into the original objects, `_key` included, no schema required. Previously these objects came back as `code` blocks, and inline objects also split the block around them, so converting to Markdown and back destroyed them. - - ````md - ```json:object - {"_type": "product", "_key": "k1", "sku": "abc-123"} - ``` - - AAPL is at json:object`{"_type": "stockTicker", "_key": "k2", "symbol": "AAPL"}` right now. - ```` - - Both forms parse back to the objects in the payloads, with the surrounding text intact. - - What changes in existing output and parsing: - - - Markdown output changes for unknown objects: ` ```json ` fences become ` ```json:object `, and inline objects stay inside their line instead of breaking out. - - The `json:object` language is reserved. A `code` block with exactly that language keeps its code but loses the language when serialized. - - A ` ```json:object ` fence or tagged code span that doesn't contain a JSON object with a `_type` parses as ordinary code. - -- [#3239](https://github.com/portabletext/editor/pull/3239) [`58d1541`](https://github.com/portabletext/editor/commit/58d1541ecb42c77085c3f890dfb6befb30b2a845) Thanks [@christianhg](https://github.com/christianhg)! - feat: report degraded constructs during markdown parsing - - `markdownToPortableText` now takes an `onDegradation` option for constructs the schema can't represent (an undeclared decorator, a table with no `table` block object, a task checkbox with no `task` list, and so on). One callback, called at most once, after the whole document has been walked, only when at least one construct degraded: a `report` object holding `degradations`, every `Degradation` in encounter order (`{type: string, message: string, line?: number, snippet?: string}`, exported as `Degradation`, with `type` a literal union that grows as new degradation sites report; `snippet` is the offending construct's text, truncated to 40 characters, present whenever there's a specific piece of source text to quote), and `message`, the same degradations grouped, snippeted, and sorted by line into one human-readable string. - - ```ts - const blocks = markdownToPortableText('**a**', { - schema: compileSchema(defineSchema({})), - onDegradation: (report) => console.log(report), - }) - // blocks: a plain span reading `a`, the `strong` formatting dropped - // report: { - // degradations: [{type: 'decorator-dropped', message: 'Removed bold formatting, kept the text: the schema has no `strong` decorator', line: 1, snippet: 'a'}], - // message: 'Markdown could not be converted without loss:\n- line 1: Removed bold formatting, kept the text: the schema has no `strong` decorator ("a")', - // } - ``` - - Enforce against lossy output by throwing your own error from inside the callback; the throw propagates out of `markdownToPortableText`: - - ```ts - markdownToPortableText('**a**\n\n**b**\n\n| x |\n| - |\n| y |', { - schema: compileSchema(defineSchema({})), - onDegradation: ({message}) => { - throw new Error(message) - }, - }) - // throws Error: - // Markdown could not be converted without loss: - // - Removed bold formatting, kept the text: the schema has no `strong` decorator (2×: "a", "b") - // - line 5: Table became plain text blocks, rows and columns lost: the schema has no `table` block object - ``` - - Repeated declines of the same kind collapse into one line of `message` instead of repeating the sentence once per occurrence; `degradations` stays ungrouped, one entry per occurrence. Match on `type`: `message` is human-readable and may change between releases. - - With `onDegradation` unset, conversion still degrades silently: a library shouldn't log on its own initiative. This removes the previous behavior of a handful of style-fallback paths calling `console.warn` on their own; pass a function to `onDegradation` to observe those losses instead. - -### Patch Changes - -- [#3183](https://github.com/portabletext/editor/pull/3183) [`0da0721`](https://github.com/portabletext/editor/commit/0da07219dc83b0cb7b545a45519b2c62c7b70737) Thanks [@christianhg](https://github.com/christianhg)! - fix: escape markdown syntax in plain span text during serialization - - If your Portable Text contains text that happens to look like markdown, converting it to markdown and back used to corrupt it: the punctuation was read as formatting instead of text. `portableTextToMarkdown` now backslash-escapes such text, so it comes back as exactly the text it was. - - ```ts - // span text markdown (before) markdown (now) re-parses as - '*bar*' // *bar* \*bar\* the text `*bar*` (was: an emphasized span reading `bar`) - '# heading' // # heading \# heading the text `# heading` (was: an `h1` block) - '[x]: y' // [x]: y \[x]: y the text `[x]: y` (was: nothing, the line vanished) - ``` - - This works wherever the text sits (headings, blockquotes, list items, table cells) and also when the risky characters are split across neighboring spans. The visible change in your output: markdown for text containing such punctuation gains backslashes it didn't have before. It pastes and parses like any hand-written markdown. - - Text with the `code` decorator is never backslash-escaped. Its backtick delimiters widen instead, so the content survives verbatim even when it contains backticks: - - ```ts - // span text with the `code` decorator markdown (before) markdown (now) - 'a`b' // `a`b` (broken) ``a`b`` - '`a' // ``a` (broken) `` `a `` - ``` - - If you supply custom mark or block renderers: `children` now arrives pre-escaped. When you need the original text, read the `text` argument instead (the built-in `code` renderer does exactly that). - - Two things stay as they were. A URL with an explicit scheme (`https://…`) or an email address is not escaped: it keeps its text and simply becomes a link on the next parse. A `www.`-style address is only left alone while it contains no markdown punctuation; with it, it gets escaped like ordinary text, and the round trip keeps every character either way. And leading or trailing whitespace that markdown itself trims still trims, same as before this change. - -- [#3234](https://github.com/portabletext/editor/pull/3234) [`452fdbe`](https://github.com/portabletext/editor/commit/452fdbea5dfd8a04ced66a8e297e50b5c8c8260c) Thanks [@christianhg](https://github.com/christianhg)! - fix: parse markdown soft breaks as spaces, not line breaks - - Markdown written across several source lines, the way editors and prose tools wrap text, is one paragraph that reflows. `markdownToPortableText` used to bake each of those wraps into the text as a literal newline, exactly as if you had written a hard break, and converting back to markdown then produced real hard breaks that weren't in your document. - - Wrapped source like this: - - ```md - This paragraph is written - across two source lines. - ``` - - used to parse to the span text `'This paragraph is written\nacross two source lines.'` (a fixed line break in the content) and now parses to `'This paragraph is written across two source lines.'` (one reflowing line, which is how markdown renders it). - - Real hard breaks are unchanged: end a line with two or more spaces, or a backslash, and the span text still gets a `\n`: - - ```md - Line one\ - Line two - ``` - - still parses to `'Line one\nLine two'`. - -## 2.0.0 - -### Major Changes - -- [#3110](https://github.com/portabletext/editor/pull/3110) [`09aa881`](https://github.com/portabletext/editor/commit/09aa8813211bc26d1a75f94a2ff619fae6e132cb) Thanks [@christianhg](https://github.com/christianhg)! - feat!: drop the legacy `main` and `module` fields - - Every package now declares its entry points through the `exports` map only. Node, all maintained bundlers, and TypeScript (`moduleResolution: 'bundler'`, 'node16', or 'nodenext') resolve through `exports`; only tooling that predates `exports` support read `main` or `module` and can no longer resolve these packages. - -- [#3106](https://github.com/portabletext/editor/pull/3106) [`3ce5a1d`](https://github.com/portabletext/editor/commit/3ce5a1d00caec9593a4f8d240d05df90505ca655) Thanks [@christianhg](https://github.com/christianhg)! - feat!: require node 22.12 or later - - Node.js 22.12 or later is now required. The previous range also allowed Node.js 20.19 and later; Node.js 20 reached end of life in April 2026 and is no longer supported. `@portabletext/editor` and `@portabletext/markdown` also move to `@portabletext/to-html` v6 and `@portabletext/toolkit` v6, which carry the same Node.js requirement. - -### Patch Changes - -- Updated dependencies [[`09aa881`](https://github.com/portabletext/editor/commit/09aa8813211bc26d1a75f94a2ff619fae6e132cb), [`3ce5a1d`](https://github.com/portabletext/editor/commit/3ce5a1d00caec9593a4f8d240d05df90505ca655)]: - - @portabletext/schema@3.0.0 - -## 1.5.0 - -### Minor Changes - -- [#3073](https://github.com/portabletext/editor/pull/3073) [`b7050a7`](https://github.com/portabletext/editor/commit/b7050a78d3f6b4b1da8eb05f5c16dfcfe4eef608) Thanks [@christianhg](https://github.com/christianhg)! - feat: convert GFM tables by default - - `markdownToPortableText` now converts GFM pipe tables to a `table` block object out of the box, matching the shape `@portabletext/plugin-table` expects: `headerRows`, an optional `alignment` array, and `rows` of `row` objects holding `cell` objects with a `value` array of Portable Text blocks. This only kicks in when the schema declares a `table` block object with a `rows` field; a schema without that keeps flattening table cells into plain blocks, as before. A consumer-supplied `types.table` matcher still wins over the default. - - `portableTextToMarkdown` now renders `table` block objects back to GFM by default too, instead of a fenced JSON block. A value that isn't table-shaped falls back to the fenced JSON rendering. A consumer-supplied `types.table` renderer still wins over the default. - - `DefaultTableRenderer` now treats a missing or non-positive `headerRows` as headerless, rather than promoting the first row to a header. `markdownToPortableText` always sets `headerRows` explicitly, so converting GFM to Portable Text and back is unaffected. - -- [#3079](https://github.com/portabletext/editor/pull/3079) [`8ed2d1f`](https://github.com/portabletext/editor/commit/8ed2d1f58d431c3d87cf0ec502a6c4b74bd7a22e) Thanks [@christianhg](https://github.com/christianhg)! - feat: render code, image, horizontal rule, HTML, and callout blocks to markdown by default - - `portableTextToMarkdown` now renders `code`, `image`, `horizontal-rule`, `html`, and `callout` block objects back to Markdown by default, instead of a fenced JSON block. This matches `table`, which already rendered as GFM by default. - - `code`, `image`, `html`, and `callout` fall back to the fenced JSON rendering when a value doesn't match the shape its renderer expects, for example a consumer's own differently-shaped `code` type reaching the renderer via the same `_type` name; `horizontal-rule` always renders `---`. A consumer-supplied `types` renderer still wins over any of the defaults. - - `@portabletext/editor`'s markdown clipboard picks this up via the automatic dependency bump: copying a code block, image, horizontal rule, HTML block, or callout out of the editor now produces real Markdown instead of a fenced JSON block. - - One narrow behavioral fix rides along: when a `callout` or `DefaultBlockquoteObjectRenderer` content entry falls back to fenced JSON, the JSON is the original value; previously it carried a `style: 'normal'` field injected by the renderer. - -## 1.4.7 - -### Patch Changes - -- [#3068](https://github.com/portabletext/editor/pull/3068) [`da0503e`](https://github.com/portabletext/editor/commit/da0503e4697339c740c787d14de3b12f7a7d4a90) Thanks [@rexxars](https://github.com/rexxars)! - Fix lists that start deeper than level 1, or skip levels, serializing to broken Markdown - - `portableTextToMarkdown` indented each list item by its absolute `level`, at three spaces per level. Markdown cannot express depth that way: indentation is relative to the list item above, and a first item indented four spaces or more is a code block, not a list. - - A list item at level 3 with nothing above it produced six spaces of indent, so reading it back gave a code block instead of a list item, losing the item. A jump from level 1 to level 4 produced nine spaces, which was absorbed into the previous item as literal text rather than becoming a nested item. - - Each jump to a deeper level is now a single step of nesting, however many levels it spans, so the output is valid Markdown that reads back as the same list structure. List numbering follows the list an item is actually rendered into, rather than its `level`. - - Given a `number` item at level 3 followed by one at level 1, then a `bullet` at level 1, one at level 4, and one back at level 1: - - ```md - 1. Starts at level 3 - 2. Back to level 1 - - - Bullet level 1 - - Jumps to level 4 - - Bullet level 1 again - ``` - - Previously the first line was indented into a code block and `Jumps to level 4` was swallowed into the line above it. - - Lists that start at level 1 and change one level at a time are unaffected. - - Custom list item renderers receive a new `listDepth` option carrying the depth to indent by. `value.level` is unchanged. - -## 1.4.6 - -### Patch Changes - -- [#3062](https://github.com/portabletext/editor/pull/3062) [`1418967`](https://github.com/portabletext/editor/commit/14189673ba8cabeae7e9347d5d269341201d2541) Thanks [@renovate](https://github.com/apps/renovate)! - fix(deps): update dependency markdown-it to ^14.3.0 - -## 1.4.5 - -### Patch Changes - -- [#3042](https://github.com/portabletext/editor/pull/3042) [`cddbf04`](https://github.com/portabletext/editor/commit/cddbf041ded81ac7fe3ee6e5aed0869f014f420c) Thanks [@stipsan](https://github.com/stipsan)! - fix: add a `module` entry point - - Every package now declares `module` alongside `main`, pointing at the ESM build. - Bundlers that predate `exports` use it to pick the ESM output instead of falling - back to `main`. - -- Updated dependencies [[`cddbf04`](https://github.com/portabletext/editor/commit/cddbf041ded81ac7fe3ee6e5aed0869f014f420c)]: - - @portabletext/schema@2.2.4 - -## 1.4.4 - -### Patch Changes - -- Updated dependencies [[`dd6b40c`](https://github.com/portabletext/editor/commit/dd6b40c3a34df1added8637e4163f4cd970ac310)]: - - @portabletext/schema@2.2.3 - -## 1.4.3 - -### Patch Changes - -- [#2887](https://github.com/portabletext/editor/pull/2887) [`a570ace`](https://github.com/portabletext/editor/commit/a570ace6563c12356eb50aa5e5671c257463376e) Thanks [@christianhg](https://github.com/christianhg)! - fix: honor table `headerRows: 0` in Markdown serialization - - `portableTextToMarkdown` now respects a table's `headerRows` field. A table with `headerRows: 0` serializes with an empty Markdown header row and every row in the body, instead of silently promoting the first row to the header. `markdownToPortableText` reads an all-empty header row back as `headerRows: 0` (dropping the empty header), so a headerless table round-trips. Tables with `headerRows` of 1 or more are unchanged, the first row is the header; because GFM allows a single header row, values above 1 still flatten to one header on export while the extra rows stay on the Portable Text side. - -## 1.4.2 - -### Patch Changes - -- [#2847](https://github.com/portabletext/editor/pull/2847) [`76af976`](https://github.com/portabletext/editor/commit/76af9766fd17334a40b24140e08d114967a31645) Thanks [@renovate](https://github.com/apps/renovate)! - fix(deps): update vitest to ^4.1.9 - -- Updated dependencies [[`76af976`](https://github.com/portabletext/editor/commit/76af9766fd17334a40b24140e08d114967a31645)]: - - @portabletext/schema@2.2.2 - -## 1.4.1 - -### Patch Changes - -- Updated dependencies [[`95e2b8d`](https://github.com/portabletext/editor/commit/95e2b8d51525adf5ff16a2930aee569a6f05da8a)]: - - @portabletext/schema@2.2.1 - -## 1.4.0 - -### Minor Changes - -- [#2699](https://github.com/portabletext/editor/pull/2699) [`7b961d4`](https://github.com/portabletext/editor/commit/7b961d40cda88ea3a65380ae6dd18562503f32a4) Thanks [@christianhg](https://github.com/christianhg)! - feat: support per-column alignment on tables - - The default `table` block-object now carries an optional `alignment` field that mirrors GFM's per-column alignment markers. `markdownToPortableText` reads the colons on the delimiter row (`:---`, `:---:`, `---:`) and writes them to `alignment` as an array of `'left' | 'center' | 'right' | null`, indexed by column. `portableTextToMarkdown` does the inverse: a `null` (or missing) entry emits `---`, otherwise the entry decides which colons surround the dashes. Tables without alignment round-trip unchanged - the `alignment` field is omitted on the way in and skipped on the way out. - -### Patch Changes - -- [#2744](https://github.com/portabletext/editor/pull/2744) [`010f182`](https://github.com/portabletext/editor/commit/010f1820c0af8a4f613394eefe3169c11e486a43) Thanks [@renovate](https://github.com/apps/renovate)! - fix(deps): Update dependency markdown-it to ^14.2.0 - -- [#2732](https://github.com/portabletext/editor/pull/2732) [`409a970`](https://github.com/portabletext/editor/commit/409a97041a37bc0214f3475554b834a16d92866a) Thanks [@christianhg](https://github.com/christianhg)! - fix: stop transforming typography characters in markdown input - -## 1.3.2 - -### Patch Changes - -- [#2694](https://github.com/portabletext/editor/pull/2694) [`491d5ff`](https://github.com/portabletext/editor/commit/491d5ffedbbce814967289af44010fdd7ca017f7) Thanks [@christianhg](https://github.com/christianhg)! - fix: `DefaultTableRenderer` widens asymmetric tables to fit the widest row - - When rows had different cell counts the delimiter row was sized to the header, so a GFM parser silently dropped cells from any body row that was wider than the header. The renderer now sizes the delimiter to the widest row and pads narrower rows with empty cells so all data survives the round-trip. - -- [#2697](https://github.com/portabletext/editor/pull/2697) [`8247ec5`](https://github.com/portabletext/editor/commit/8247ec551022d4ae077c40aff475f16533b9ac12) Thanks [@christianhg](https://github.com/christianhg)! - fix: `DefaultTableRenderer` escapes characters that would break a GFM table row - - A `|` in a cell's text used to end the cell, causing all cells to the right to shift. A `\n` inside a multi-line block-object (such as a code block) used to end the row, causing all cells after the first line break to be lost. The renderer now escapes literal pipes as `\|` and replaces newlines with `
` so every cell in the source Portable Text survives the round-trip through `markdownToPortableText`. - -## 1.3.1 - -### Patch Changes - -- [#2692](https://github.com/portabletext/editor/pull/2692) [`c440b2f`](https://github.com/portabletext/editor/commit/c440b2f09fa466d7c18971e71c1010c08edc7bb5) Thanks [@christianhg](https://github.com/christianhg)! - fix: `DefaultTableRenderer` always emits a valid GFM table - - The first row is always rendered as the header, followed by the delimiter row, followed by the remaining rows as body. The `headerRows` field on the Portable Text table is ignored on serialization so that the emitted Markdown round-trips through any GFM parser. Tables with no rows render as an empty string. - -## 1.3.0 - -### Minor Changes - -- [#2628](https://github.com/portabletext/editor/pull/2628) [`3209fbb`](https://github.com/portabletext/editor/commit/3209fbb65b169cc69a0b5496c4735fa52f414cdc) Thanks [@christianhg](https://github.com/christianhg)! - feat: add `types.list` matcher to `markdownToPortableText` for list-as-container - - Lets consumers take ownership of structural list emission on the way in, mirroring the existing `types.table` pattern. When a `types.list` matcher is provided, list tokens become `{_type: 'list', kind, items: [...]}` block-objects whose `list-item.content` arrays can hold any block - text blocks, code blocks, callouts, images, even nested lists. The default flat path (text blocks with `listItem` and `level` fields) is unchanged for consumers who don't register the matcher. - - Pair with `defineContainer({type: 'list', childField: 'items'})` and `defineContainer({type: 'list-item', childField: 'content'})` in PTE v7 to get list items that hold rich content. - -- [#2604](https://github.com/portabletext/editor/pull/2604) [`334a99d`](https://github.com/portabletext/editor/commit/334a99d0d9e97185f7eef88b3ea7e2ed0eaed23f) Thanks [@christianhg](https://github.com/christianhg)! - feat: support GFM task lists in markdown round-trip - - `markdownToPortableText` now recognizes GitHub-Flavored Markdown task list items (`- [ ] foo` and `- [x] foo`) and converts them into Portable Text blocks with `listItem: 'task'` and a boolean `checked` field. `portableTextToMarkdown` renders these blocks back to the same syntax. The default schema declares the `task` list type and a `checked` block field, so consumers using the default schema get round-tripping for free. Schemas without a `task` list type fall back to `bullet` and discard the checkbox prefix. - -- [#2629](https://github.com/portabletext/editor/pull/2629) [`11822d0`](https://github.com/portabletext/editor/commit/11822d0339908d7f68b1469de181fcb5a1caad39) Thanks [@christianhg](https://github.com/christianhg)! - feat: add `types.blockquote` matcher to `markdownToPortableText` for structured blockquotes - - When a `types.blockquote` matcher is provided, plain blockquotes become structural block-objects (`{_type: 'blockquote', content: [...]}`) instead of flat text blocks with `style: 'blockquote'`. This unlocks placing code blocks, images, lists, and nested blockquotes inside a quote without losing attribution. GFM alerts (`> [!NOTE]`, `> [!TIP]`, etc.) keep producing callouts via the separate `types.callout` matcher; the two never compete on the same source. - - Without `types.blockquote`, the existing flat-block path runs unchanged. If the matcher returns `undefined` for a given blockquote, the parser falls back to the flat shape for that one. - - Adds `DefaultBlockquoteObjectRenderer` for the round-trip back to Markdown. - -### Patch Changes - -- [#2660](https://github.com/portabletext/editor/pull/2660) [`95609ac`](https://github.com/portabletext/editor/commit/95609ac8c40c9290ccf98983441da5a4c41c91ba) Thanks [@renovate](https://github.com/apps/renovate)! - fix(deps): Update dependency @mdit/plugin-alert to ^0.23.2 - -- Updated dependencies [[`239e100`](https://github.com/portabletext/editor/commit/239e100b1760c0f20fdeefa659bd8c81c749d7a7), [`c6103e0`](https://github.com/portabletext/editor/commit/c6103e005a527c8e2717d9d8ad11da49cee9e942), [`fea850c`](https://github.com/portabletext/editor/commit/fea850c5feab41097dc65f92b56e48b765257559)]: - - @portabletext/schema@2.2.0 - -## 1.2.0 - -### Minor Changes - -- [#2417](https://github.com/portabletext/editor/pull/2417) [`251c51b`](https://github.com/portabletext/editor/commit/251c51b7a731dc5008798ea1f922e3d1ad2e11d5) Thanks [@christianhg](https://github.com/christianhg)! - feat: add first-class GFM callout support - - GFM callouts (`> [!NOTE]`, `> [!TIP]`, `> [!WARNING]`, etc.) are now parsed as structured callout objects instead of regular blockquotes. A `DefaultCalloutRenderer` is also available for serializing callout objects back to GFM syntax. Consumers can customize callout handling via the `types.callout` matcher option. - - ```ts - import {markdownToPortableText} from '@portabletext/markdown' - - markdownToPortableText('> [!NOTE]\n> This is a note') - // => [{_type: 'callout', tone: 'note', content: [...]}] - ``` - - ```ts - import { - DefaultCalloutRenderer, - portableTextToMarkdown, - } from '@portabletext/markdown' - - portableTextToMarkdown(blocks, { - types: {callout: DefaultCalloutRenderer}, - }) - // => '> [!NOTE]\n> This is a note' - ``` - -## 1.1.4 - -### Patch Changes - -- [#2307](https://github.com/portabletext/editor/pull/2307) [`5031f18`](https://github.com/portabletext/editor/commit/5031f18f3d22f8acc5f4d39e6ea0db7b96d72553) Thanks [@renovate](https://github.com/apps/renovate)! - fix(deps): Update dependency @portabletext/toolkit to ^5.0.2 - -## 1.1.3 - -### Patch Changes - -- [#2193](https://github.com/portabletext/editor/pull/2193) [`c47fd7c`](https://github.com/portabletext/editor/commit/c47fd7c4478cdb5b2acfe2f681d7cb146a0996a5) Thanks [@renovate](https://github.com/apps/renovate)! - fix(deps): Update dependency markdown-it to ^14.1.1 - -## 1.1.2 - -### Patch Changes - -- [#2078](https://github.com/portabletext/editor/pull/2078) [`0a7a4cc`](https://github.com/portabletext/editor/commit/0a7a4cc5e4624f60abd919d39b9015c191cc10eb) Thanks [@renovate](https://github.com/apps/renovate)! - fix(deps): Update dependency @portabletext/toolkit to ^5.0.1 - -## 1.1.1 - -### Patch Changes - -- [#2043](https://github.com/portabletext/editor/pull/2043) [`6af9559`](https://github.com/portabletext/editor/commit/6af9559d273b85113d9eba92ca85d6322a581764) Thanks [@stipsan](https://github.com/stipsan)! - Implement `publishConfig.exports` - -- Updated dependencies [[`6af9559`](https://github.com/portabletext/editor/commit/6af9559d273b85113d9eba92ca85d6322a581764)]: - - @portabletext/schema@2.1.1 - -## 1.1.0 - -### Minor Changes - -- [#2037](https://github.com/portabletext/editor/pull/2037) [`9292b9a`](https://github.com/portabletext/editor/commit/9292b9ad69d63e43f91600e16b2ed84012449e89) Thanks [@christianhg](https://github.com/christianhg)! - feat: enable typographic transformations - - When converting Markdown to Portable Text, typographic transformations are now applied: - - Straight quotes (`"` and `'`) become curly quotes (`"`, `"`, `'`, `'`) - - `---` becomes an em-dash (`—`) - - `--` becomes an en-dash (`–`) - - `...` becomes an ellipsis (`…`) - - **Before:** - - ```md - He said "it's all in chapters 12--14"... That's right---all of it. - ``` - - Would produce text: `He said "it's all in chapters 12--14"... That's right---all of it.` - - **After:** - - Produces text: `He said "it's all in chapters 12–14"… That's right—all of it.` - -## 1.0.8 - -### Patch Changes - -- [#2035](https://github.com/portabletext/editor/pull/2035) [`ccedf45`](https://github.com/portabletext/editor/commit/ccedf450c0d8ca03b9eb7599ab137c79736ac9a4) Thanks [@christianhg](https://github.com/christianhg)! - fix: handle block elements inside list items - - Block elements (code blocks, images, HRs, blockquotes) in list items - now correctly split output and preserve surrounding content. Also - fixes code block fallback when not defined in schema. - -## 1.0.7 - -### Patch Changes - -- Updated dependencies [[`c2c566d`](https://github.com/portabletext/editor/commit/c2c566ddf3a47dcf3a089cce8375679942b920f8)]: - - @portabletext/schema@2.1.0 - -## 1.0.6 - -### Patch Changes - -- [#2014](https://github.com/portabletext/editor/pull/2014) [`d955467`](https://github.com/portabletext/editor/commit/d9554679d7c6d7d1eae172c8dc755c0b2fb7d542) Thanks [@christianhg](https://github.com/christianhg)! - fix: escape and unescape special characters links and images - - Previously, when converting Portable Text to Markdown, link texts and image alt texts containing `[`, `]`, or `\` would produce malformed Markdown: - - **Input**: - - ```json - [ - { - "_type": "block", - "children": [ - { - "_type": "span", - "text": "f[oo", - "marks": ["l0"] - } - ], - "markDefs": [ - { - "_key": "l0", - "_type": "link", - "href": "https://example.com" - } - ] - } - ] - ``` - - **Before**: - - ```md - [f[oo](https://example.com) - ``` - - **After**: - - ```md - [f\[oo](https://example.com) - ``` - - Similarly, escaped Markdown in link texts and image alt texts wasn't unescaped when converting Markdown to Portable Text. - - **Input**: - - ```md - [f\[oo](https://example.com) - ``` - - **Before**: - - ```json - [ - { - "_type": "block", - "children": [ - { - "_type": "span", - "text": "f\[oo", - "marks": ["l0"] - } - ], - "markDefs": [ - { - "_key": "l0", - "_type": "link", - "href": "https://example.com" - } - ] - } - ] - ``` - - **After**: - - ```json - [ - { - "_type": "block", - "children": [ - { - "_type": "span", - "text": "f[oo", - "marks": ["l0"] - } - ], - "markDefs": [ - { - "_key": "l0", - "_type": "link", - "href": "https://example.com" - } - ] - } - ] - ``` - - Additionally, link and image titles containing `"` or `\` are now properly escaped: - - **Input**: - - ```json - [ - { - "_type": "block", - "children": [ - { - "_type": "span", - "text": "foo", - "marks": ["l0"] - } - ], - "markDefs": [ - { - "_key": "l0", - "_type": "link", - "href": "https://example.com", - "title": "link \"title\"" - } - ] - } - ] - ``` - - **Before**: - - ```md - [foo](https://example.com "link "title"") - ``` - - **After**: - - ```md - [foo](https://example.com 'link "title"') - ``` - -## 1.0.5 - -### Patch Changes - -- [#1993](https://github.com/portabletext/editor/pull/1993) [`cf0572b`](https://github.com/portabletext/editor/commit/cf0572b93b5179b74239418a6df671530a4cf865) Thanks [@stipsan](https://github.com/stipsan)! - upgrade `@portabletext/*` to latest major - -## 1.0.4 - -### Patch Changes - -- [#1984](https://github.com/portabletext/editor/pull/1984) [`56168e5`](https://github.com/portabletext/editor/commit/56168e5a84f38bbb1ab9f2d85c8d5745b15e22da) Thanks [@stipsan](https://github.com/stipsan)! - Stop publishing src folder to npm - -- Updated dependencies [[`56168e5`](https://github.com/portabletext/editor/commit/56168e5a84f38bbb1ab9f2d85c8d5745b15e22da)]: - - @portabletext/schema@2.0.1 - -## 1.0.3 - -### Patch Changes - -- [#1941](https://github.com/portabletext/editor/pull/1941) [`0319437`](https://github.com/portabletext/editor/commit/03194370904e6a3c47d0b97f4666c21c7e7df9e0) Thanks [@christianhg](https://github.com/christianhg)! - fix: trigger release to get updated docs on npm - -## 1.0.2 - -### Patch Changes - -- [#1938](https://github.com/portabletext/editor/pull/1938) [`8a47fd5`](https://github.com/portabletext/editor/commit/8a47fd59830920c1b1b1703b94d91484d7c186db) Thanks [@christianhg](https://github.com/christianhg)! - fix: trigger release - -## 1.0.1 - -### Patch Changes - -- [#1934](https://github.com/portabletext/editor/pull/1934) [`85fae3c`](https://github.com/portabletext/editor/commit/85fae3c7ca79ff27cd5aaf09cf64fed90bfd8862) Thanks [@christianhg](https://github.com/christianhg)! - fix: remove unused `@sanity/schema` dev dependency diff --git a/packages/markdown/README.md b/packages/markdown/README.md deleted file mode 100644 index aa3ef7b15c..0000000000 --- a/packages/markdown/README.md +++ /dev/null @@ -1,987 +0,0 @@ -# `@portabletext/markdown` - -> Convert Portable Text to Markdown and back again - -## Installation - -```bash -npm install @portabletext/markdown -``` - -## Quick start - -**Markdown → Portable Text** - -```ts -import {markdownToPortableText} from '@portabletext/markdown' - -const blocks = markdownToPortableText('# Hello **world**') -``` - -```json -[ - { - "_type": "block", - "_key": "f4s8k2", - "style": "h1", - "children": [ - {"_type": "span", "_key": "a9c3x1", "text": "Hello ", "marks": []}, - {"_type": "span", "_key": "b7d2m5", "text": "world", "marks": ["strong"]} - ], - "markDefs": [] - } -] -``` - -**Portable Text → Markdown** - -```ts -import {portableTextToMarkdown} from '@portabletext/markdown' - -const markdown = portableTextToMarkdown([ - { - _type: 'block', - _key: 'f4s8k2', - style: 'h1', - children: [ - {_type: 'span', _key: 'a9c3x1', text: 'Hello ', marks: []}, - {_type: 'span', _key: 'b7d2m5', text: 'world', marks: ['strong']}, - ], - markDefs: [], - }, -]) -``` - -```md -# Hello **world** -``` - -**Edit through Markdown without losing keys** - -```ts nocheck -import {applyMarkdownEdit, portableTextToMarkdown} from '@portabletext/markdown' - -const markdown = portableTextToMarkdown(stored) -const editedMarkdown = markdown.replace('tomorow', 'tomorrow') -const edited = applyMarkdownEdit(stored, editedMarkdown) -// same content as parsing editedMarkdown, with the stored `_key`s -// kept the way the same edit in an editor would have kept them -``` - -## Supported features - -| Feature | Markdown → Portable Text | Portable Text → Markdown | -| ---------------- | ------------------------ | ------------------------ | -| Headings (h1–h6) | ✅ | ✅ | -| Paragraphs | ✅ | ✅ | -| Bold | ✅ | ✅ | -| Italic | ✅ | ✅ | -| Inline code | ✅ | ✅ | -| Strikethrough | ✅ | ✅ | -| Links | ✅ | ✅ | -| Blockquotes | ✅ | ✅ | -| Ordered lists | ✅ | ✅ | -| Unordered lists | ✅ | ✅ | -| Task lists | ✅ | ✅ | -| Nested lists | ✅ | ✅ | -| Code blocks | ✅ | ✅ | -| Horizontal rules | ✅ | ✅ | -| Images | ✅ | ✅ | -| Tables | ✅ | ✅ | -| HTML blocks | ✅ | ✅ | -| Callouts | ✅ | ✅ | - -## Round-trip behavior - -Converting Markdown to Portable Text and back isn't a lossless mirror: - -1. Translation preserves semantics, not source spelling: the first MD→PT→MD pass normalizes Markdown to one canonical spelling (autolinks become inline links, indented code becomes fenced code, soft-wrapped lines join into one, and so on). -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. -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. -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. -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` when it is a non-empty string, and a payload without one, or with an empty or non-string `_key`, gets a generated key. [`applyMarkdownEdit`](#applymarkdownedit) restores stored keys after an edit. -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. - -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 an `h3` to `h6` heading splits into a second block on reparse, since an ATX heading is single-line (an `h1` or `h2` with a hard break is written as a setext heading, underlined with `===` or `---`, wherever that parses back as the same heading); 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. - -See [Markdown round-tripping](https://www.portabletext.org/conversion/markdown-round-tripping/) on the docs site for the full contract and worked examples. - -## Usage - - - -### `markdownToPortableText` - -```ts -import {markdownToPortableText} from '@portabletext/markdown' - -const blocks = markdownToPortableText(` -# Hello World - -This is **bold** and *italic* text with a [link](https://example.com). - -- First item -- Second item -`) -``` - -```json -[ - { - "_type": "block", - "_key": "k9f2x1", - "style": "h1", - "children": [ - {"_type": "span", "_key": "s1a2b3", "text": "Hello World", "marks": []} - ], - "markDefs": [] - }, - { - "_type": "block", - "_key": "m3n4p5", - "style": "normal", - "children": [ - {"_type": "span", "_key": "s2c3d4", "text": "This is ", "marks": []}, - {"_type": "span", "_key": "s3e4f5", "text": "bold", "marks": ["strong"]}, - {"_type": "span", "_key": "s4g5h6", "text": " and ", "marks": []}, - {"_type": "span", "_key": "s5i6j7", "text": "italic", "marks": ["em"]}, - {"_type": "span", "_key": "s6k7l8", "text": " text with a ", "marks": []}, - {"_type": "span", "_key": "s7m8n9", "text": "link", "marks": ["a1b2c3"]}, - {"_type": "span", "_key": "s8o9p0", "text": ".", "marks": []} - ], - "markDefs": [ - {"_type": "link", "_key": "a1b2c3", "href": "https://example.com"} - ] - }, - { - "_type": "block", - "_key": "q1r2s3", - "style": "normal", - "listItem": "bullet", - "level": 1, - "children": [ - {"_type": "span", "_key": "s9q0r1", "text": "First item", "marks": []} - ], - "markDefs": [] - }, - { - "_type": "block", - "_key": "t4u5v6", - "style": "normal", - "listItem": "bullet", - "level": 1, - "children": [ - {"_type": "span", "_key": "s0s1t2", "text": "Second item", "marks": []} - ], - "markDefs": [] - } -] -``` - -The conversion is driven by two concepts: - -- **Schema**: Defines what Portable Text types are available (styles, lists, decorators, annotations, block objects). The library only outputs types that exist in the schema. -- **Matchers**: Control how Markdown elements map to schema types. For example, the `h1` matcher maps `# Heading` to the `'h1'` style. - -Out of the box, the library includes sensible defaults for both. Customize them to match your content model. - -### Schema configuration - -The default schema includes the following definitions: - -| Type | Values | -| --------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| `styles` | `'normal'`, `'h1'`, `'h2'`, `'h3'`, `'h4'`, `'h5'`, `'h6'`, `'blockquote'` | -| `lists` | `'number'`, `'bullet'`, `'task'` | -| `decorators` | `'strong'`, `'em'`, `'code'`, `'strike-through'`, `'underline'` | -| `annotations` | `'link'` (fields: `'href'`, `'title'`) | -| `blockObjects` | `'code'` (fields: `'language'`, `'code'`), `'image'` (fields: `'src'`, `'alt'`, `'title'`), `'horizontal-rule'`, `'html'` (fields: `'html'`), `'table'` (the canonical nested shape, see [Default behavior](#default-behavior)), `'callout'` (fields: `'tone'`, `'content'`) | -| `inlineObjects` | `'image'` (fields: `'src'`, `'alt'`, `'title'`) | - -To use a custom Schema, import `compileSchema` and `defineSchema` from `@portabletext/schema`: - -```ts nocheck -import {markdownToPortableText} from '@portabletext/markdown' -import {compileSchema, defineSchema} from '@portabletext/schema' - -markdownToPortableText(markdown, { - schema: compileSchema( - defineSchema({ - styles: [{name: 'normal'}, {name: 'heading 1'}], - }), - ), -}) -``` - -To use a Sanity schema, use `@portabletext/sanity-bridge` to convert it to a Portable Text Schema first: - -```ts nocheck -import {markdownToPortableText} from '@portabletext/markdown' -import {sanitySchemaToPortableTextSchema} from '@portabletext/sanity-bridge' -import {Schema as SanitySchema} from '@sanity/schema' - -// Compile the Sanity types, then convert the block array type -const sanitySchema = SanitySchema.compile({name: 'default', types: [richText]}) -const schema = sanitySchemaToPortableTextSchema(sanitySchema.get('richText')) - -markdownToPortableText(markdown, {schema}) -``` - -### Matchers - -Matchers map Markdown concepts to Portable Text types defined in the Schema. Each default matcher checks if a type exists in the schema and returns the appropriate value. - -| Group | Matcher | Markdown | Maps to schema type | -| ---------- | ---------------- | ----------------------- | ------------------- | -| `block` | `normal` | Paragraphs | `'normal'` | -| | `h1`–`h6` | `#` – `######` headings | `'h1'`–`'h6'` | -| | `blockquote` | `>` blockquotes | `'blockquote'` | -| `listItem` | `bullet` | `- ` or `* ` lists | `'bullet'` | -| | `number` | `1. ` ordered lists | `'number'` | -| | `task` | `- [ ]` / `- [x]` items | `'task'` | -| `marks` | `strong` | `**bold**` | `'strong'` | -| | `em` | `*italic*` | `'em'` | -| | `code` | `` `inline code` `` | `'code'` | -| | `strikeThrough` | `~~strikethrough~~` | `'strike-through'` | -| | `underline` | `underline` | `'underline'` | -| | `link` | `[text](url "title")` | `'link'` | -| `types` | `code` | Fenced code blocks | `'code'` | -| | `horizontalRule` | `---` | `'horizontal-rule'` | -| | `image` | `![alt](src)` | `'image'` | -| | `html` | HTML blocks | `'html'` | -| | `callout` | `> [!NOTE]`, etc. | `'callout'` | -| | `table` | GFM pipe tables | `'table'` | -| | `blockquote`\* | `>` blockquotes | `'blockquote'` | -| | `list`\* | `- ` or `1. ` lists | `'list'` | - -\* Opt-in, not registered by default: `blockquote` and `list` map onto structural container shapes, and the parser only produces those shapes when you register the matcher (see [Configuring matchers](#configuring-matchers)). Without them, blockquotes and lists parse to flat text blocks, which is the standard Portable Text shape for both, not a degraded fallback. - -#### Default behavior - -**Tables** (GFM pipe tables) convert by default, in the canonical shape `@portabletext/plugin-table` expects: a `table` block object (`headerRows`, `rows`), each row a `row` object (`cells`), each cell a `cell` object (`value`, an array of Portable Text blocks; a cell holding a single image becomes a standalone block-level `image` object instead of a text block wrapping it). `alignment` is a `@portabletext/markdown` extension field; `@portabletext/plugin-table` ignores it. This needs no configuration when the schema declares a `table` block object with a `rows` field (see `blockObjects` above). A schema whose `table` doesn't declare `rows`, or that doesn't declare `table` at all, produces no table object at all: the table's cell content flattens into top-level blocks, in reading order, and the table structure is discarded. - -**Images** are handled based on context: - -- Standalone images (a paragraph containing only an image) become block-level `'image'` objects -- Images mixed with text become inline `'image'` objects (if the schema includes `'image'` in `inlineObjects`) -- If neither is supported, falls back to plain text: `![alt](src)` - -The default image matcher requires the schema type to have a `'src'` field. If your `'image'` type doesn't include this field, the matcher returns `undefined`. - -**Code** is handled based on the Markdown syntax: - -- Fenced code blocks (` ``` `) become `'code'` block objects with `language` and `code` fields -- Inline code (`` ` ``) applies the `'code'` decorator to a span - -The default code block matcher requires the schema type to have a `'code'` field. If your `'code'` type doesn't include this field, the matcher returns `undefined`. - -**Links** support optional titles using `[text](url "title")` syntax. The title is captured in the `'title'` field of the `'link'` annotation. - -**Line breaks**: soft-wrapped lines within a paragraph join with a single space; a hard break (two or more trailing spaces, or a backslash, before the newline) becomes a line break within the block. - -**Nested lists** are handled automatically. Each list item block includes a `level` property indicating its nesting depth (1 for top-level, 2 for nested, etc.). - -**HTML blocks** (like `
...
`) become `'html'` block objects with the raw HTML in the `'html'` field. Inline HTML is controlled by the `html.inline` option. - -**Callouts** use the `> [!TYPE]` syntax (GFM alerts) where `TYPE` is one of `NOTE`, `TIP`, `WARNING`, `CAUTION`, or `IMPORTANT`. They become `'callout'` block objects with a `'tone'` field (the lowercased type name) and a `'content'` field (an array of Portable Text blocks). When the schema doesn't include a `'callout'` block object, the content falls back to blockquote-styled blocks. - -#### Configuring matchers - -You can provide custom matchers to change how Markdown maps to your schema. - -**Custom heading style:** If your schema uses `'heading 1'` instead of `'h1'`: - -```ts nocheck -import {markdownToPortableText} from '@portabletext/markdown' -import {compileSchema, defineSchema} from '@portabletext/schema' - -markdownToPortableText(markdown, { - schema: compileSchema( - defineSchema({ - // Your schema including a 'heading 1' style - }), - ), - block: { - h1: ({context}) => { - // Check if 'heading 1' exists in the schema - const style = context.schema.styles.find((s) => s.name === 'heading 1') - return style?.name - }, - }, -}) -``` - -> **Note:** Checking if the type exists in the schema isn't required, but it's good practice. Returning `undefined` skips unsupported types. - -**Table matcher:** GFM pipe tables convert by default (see [Default behavior](#default-behavior)). Provide your own matcher to map onto a differently-shaped `table` type: - -```ts nocheck -import {markdownToPortableText} from '@portabletext/markdown' - -markdownToPortableText(markdown, { - types: { - table: ({context, value}) => { - const tableType = context.schema.blockObjects.find( - (obj) => obj.name === 'table', - ) - if (!tableType) return undefined - - return { - _type: 'table', - _key: context.keyGenerator(), - rows: value.rows, - headerRows: value.headerRows, - } - }, - }, -}) -``` - -**List matcher:** By default, lists are emitted as flat text blocks with `listItem` and `level` fields, and adjacent blocks form a list at render time. If your schema models lists as a structural block-object instead (a `list` type with an `items` array holding `list-item` objects, each with a `content` array), provide a `types.list` matcher to opt into that shape: - -```ts nocheck -import {markdownToPortableText} from '@portabletext/markdown' - -markdownToPortableText(markdown, { - schema: schemaWithList, - types: { - list: ({context, value}) => ({ - _type: 'list', - _key: context.keyGenerator(), - kind: value.kind, // 'bullet' | 'number' | 'task' - items: value.items, // each item: {_type, _key, checked?, content: [...]} - }), - }, -}) -``` - -The matcher receives `value.items` already assembled. Each item's `content` array holds whatever blocks the markdown produced inside the item: text blocks, code blocks, callouts, images, even nested lists. `kind` is promoted to `'task'` automatically when any item carries a GFM checkbox (`- [ ]` / `- [x]`); items only carry a `checked` field when the markdown actually has one. If the matcher returns `undefined`, the parser falls back to flat-list parsing for that list. - -Without `types.list`, the existing flat-block path runs unchanged. - -**GFM task lists** (`- [ ]` / `- [x]`): Task lists are recognized when the schema declares a `task` list item; the default schema does, so task lists parse zero-config. Without a `task` definition (a custom schema that omits it), the checkbox markers are stripped from the text and the items render as their surrounding list type (bullet or number). With a `task` definition, items carrying a checkbox become text blocks with `listItem: 'task'` and a `checked: boolean` field; items without a checkbox keep their surrounding list type. - -```ts -import {markdownToPortableText} from '@portabletext/markdown' -import {compileSchema, defineSchema} from '@portabletext/schema' - -markdownToPortableText('- [x] done\n- [ ] todo', { - schema: compileSchema( - defineSchema({ - lists: [{name: 'bullet'}, {name: 'task'}], - }), - ), -}) -// → [ -// {_type: 'block', listItem: 'task', level: 1, checked: true, children: [{text: 'done', ...}], ...}, -// {_type: 'block', listItem: 'task', level: 1, checked: false, children: [{text: 'todo', ...}], ...}, -// ] -``` - -If your schema uses a different name for the task list type (e.g. `'todo'`), provide a custom `listItem.task` matcher: - -```ts nocheck -import {markdownToPortableText} from '@portabletext/markdown' -import {compileSchema, defineSchema} from '@portabletext/schema' - -markdownToPortableText(markdown, { - schema: compileSchema(defineSchema({lists: [{name: 'todo'}]})), - listItem: { - task: ({context}) => - context.schema.lists.find((list) => list.name === 'todo')?.name, - }, -}) -``` - -When emitting Portable Text back to Markdown, blocks with `listItem: 'task'` render as `- [x] ` or `- [ ] ` based on the `checked` field. - -**Blockquote matcher:** By default, blockquotes are emitted as flat text blocks with `style: 'blockquote'`, and adjacent blocks form a visual blockquote at render time. If your schema models a blockquote as a structural block-object instead (a `blockquote` type with a `content` array), provide a `types.blockquote` matcher to opt into that shape: - -```ts nocheck -import {markdownToPortableText} from '@portabletext/markdown' - -markdownToPortableText(markdown, { - schema: schemaWithBlockquote, - types: { - blockquote: ({context, value}) => ({ - _type: 'blockquote', - _key: context.keyGenerator(), - content: value.content, // array of blocks the markdown produced inside the blockquote - }), - }, -}) -``` - -The matcher receives `value.content` already assembled. The array holds whatever blocks the markdown produced inside the blockquote: text blocks, code blocks, images, even nested blockquotes. GFM alerts (`> [!NOTE]`, `> [!TIP]`, etc.) use a different token stream and produce callouts instead, so `types.blockquote` and `types.callout` can be registered side-by-side without conflict. - -If the matcher returns `undefined`, the parser falls back to flat-style by re-emitting the content blocks with `style: 'blockquote'`. - -Without `types.blockquote`, the existing flat-block path runs unchanged. - -Matchers receive: - -- `context.schema` – the compiled schema to validate against -- `context.keyGenerator` – function to generate unique keys -- `value` – the parsed Markdown data (structure depends on the matcher type) -- `isInline` – whether the element appears inline (for `ObjectMatcher` only) - -Return `undefined` to skip the element (e.g., if the type isn't in the schema). - -#### Other options - -```ts nocheck -import {markdownToPortableText} from '@portabletext/markdown' - -markdownToPortableText(markdown, { - // Custom key generator for blocks and spans - keyGenerator: () => nanoid(), - - // Configure how inline HTML is handled (default: 'skip') - html: { - inline: 'skip' | 'text', // 'skip' ignores inline HTML, 'text' converts it to plain text - }, -}) -``` - -#### Reporting degradation - -Every construct the schema can't represent (a decorator not in the schema, a table with no `table` block object, a task checkbox with no `task` list, and so on) degrades to a lossier shape rather than throwing. A ` ```json:object ` fence or tagged code span that fails to reconstruct (invalid JSON, or no string `_type`) reports too, as `object-carrier-invalid`, even on a schema that would otherwise accept everything: it's the payload that's unusable, not the schema. A fence or tagged code span that does reconstruct keeps its payload's fields as written (only a missing or unusable `_key` is filled in), even when the schema doesn't declare its `_type` at that position (`blockObjects` for a fence, `inlineObjects` for a code span, and for an object a table cell lifts to block position, `blockObjects`). Such an object reports as `object-type-undeclared`, so the caller learns that the result holds a type its schema doesn't expect. Without a `schema` the check runs against the default schema, like every other construct. `onDegradation` observes all of these, one callback covering all uses. Left unset, the conversion stays silent and returns the lossiest representation it can build, since a library shouldn't log on its own initiative. Passed a function, observe the losses: it's called once, after the whole document has been walked, only when at least one construct degraded, with a report object holding every `Degradation` in encounter order and a canonical grouped `message`. Enforce against lossy output by throwing your own error from inside that callback; the throw propagates out of `markdownToPortableText`. - -```ts nocheck -import {markdownToPortableText} from '@portabletext/markdown' - -markdownToPortableText(markdown, { - onDegradation: ({degradations, message}) => { - // degradations: every Degradation, in encounter order - // message: the same degradations grouped, snippeted, and sorted by line - logger.warn(message) - }, -}) -``` - -Each `Degradation` carries `type`, a human-readable `message`, `line` when a source line is available, and `snippet` (the offending construct's text, truncated to 40 characters) when there's a specific piece of source text to quote. Match on `type`, not `message`: the type is the stable contract, the message can change between releases. - -Enforcing means throwing your own error from inside the callback: - -```ts -import {markdownToPortableText} from '@portabletext/markdown' -import {compileSchema, defineSchema} from '@portabletext/schema' - -markdownToPortableText('# heading\n\n**bold**', { - schema: compileSchema(defineSchema({})), - onDegradation: ({message}) => { - throw new Error(message) - }, -}) -// throws Error: -// Markdown could not be converted without loss: -// - line 1: `#` heading became a normal paragraph: the schema has no `h1` style ("heading") -// - line 3: Removed bold formatting, kept the text: the schema has no `strong` decorator ("bold") -``` - -Repeated declines of the same kind (the same missing decorator on three spans, say) collapse into one line of `message` instead of repeating the sentence: `- Removed bold formatting, kept the text: the schema has no \`strong\` decorator (3×: "a", "b", "c")`. `degradations` stays ungrouped, one entry per occurrence. - -### `portableTextToMarkdown` - -```ts -import {portableTextToMarkdown} from '@portabletext/markdown' - -const markdown = portableTextToMarkdown([ - { - _type: 'block', - _key: 'k9f2x1', - style: 'h1', - children: [{_type: 'span', _key: 's1a2b3', text: 'Hello World', marks: []}], - markDefs: [], - }, - { - _type: 'block', - _key: 'm3n4p5', - style: 'normal', - children: [ - {_type: 'span', _key: 's2c3d4', text: 'This is ', marks: []}, - {_type: 'span', _key: 's3e4f5', text: 'bold', marks: ['strong']}, - {_type: 'span', _key: 's4g5h6', text: ' and ', marks: []}, - {_type: 'span', _key: 's5i6j7', text: 'italic', marks: ['em']}, - {_type: 'span', _key: 's6k7l8', text: ' text with a ', marks: []}, - {_type: 'span', _key: 's7m8n9', text: 'link', marks: ['a1b2c3']}, - {_type: 'span', _key: 's8o9p0', text: '.', marks: []}, - ], - markDefs: [{_type: 'link', _key: 'a1b2c3', href: 'https://example.com'}], - }, - { - _type: 'block', - _key: 'q1r2s3', - style: 'normal', - listItem: 'bullet', - level: 1, - children: [{_type: 'span', _key: 's9q0r1', text: 'First item', marks: []}], - markDefs: [], - }, - { - _type: 'block', - _key: 't4u5v6', - style: 'normal', - listItem: 'bullet', - level: 1, - children: [{_type: 'span', _key: 's0s1t2', text: 'Second item', marks: []}], - markDefs: [], - }, -]) -``` - -```md -# Hello World - -This is **bold** and _italic_ text with a [link](https://example.com). - -- First item -- Second item -``` - -The conversion is driven by **Renderers**: functions that render Portable Text elements to Markdown strings. The library includes default renderers for common types; provide your own for custom block types. - -#### Default renderers - -| Group | Renderer | Renders | Output | -| ------------------- | ----------------- | --------------------------------- | ------------------------ | -| `types` | `callout` | `callout` block objects | `> [!TYPE]\n> content` | -| | `code` | `code` block objects | Fenced code block | -| | `horizontal-rule` | `horizontal-rule` block objects | `---` | -| | `html` | `html` block objects | Raw HTML | -| | `image` | `image` block/inline objects | `![alt](src "title")` | -| | `table` | `table` block objects | Markdown table | -| `block` | `normal` | Paragraphs | `{children}` | -| | `h1`–`h6` | Headings | `# `–`###### ` | -| | `blockquote` | Blockquotes | `> {children}` | -| `marks` | `strong` | Bold text | `**{children}**` | -| | `em` | Italic text | `_{children}_` | -| | `code` | Inline code | `` `{children}` `` | -| | `underline` | Underlined text | `{children}` | -| | `strike-through` | Strikethrough | `~~{children}~~` | -| | `link` | Links | `[{children}](url)` | -| `listItem` | | List items (bullet, number, task) | `- `, `1. `, or `- [x] ` | -| `hardBreak` | | Line breaks within blocks | ` \n` (two spaces) | -| `blockSpacing` | | Spacing between blocks | `\n\n`, `\n`, `\n>\n` | -| `unknownType` | | Unknown block types | JSON code block | -| `unknownBlockStyle` | | Unknown block styles | `{children}` | -| `unknownListItem` | | Unknown list item types | `- {children}` | -| `unknownMark` | | Unknown marks | `{children}` | - -Unknown types render as JSON code blocks by default; unknown styles, list items, and marks pass through their children. - -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.` renderer to override how any of them serialize, or to handle a same-named type of a different shape. - -> **Note:** There's no standard Markdown syntax for underline, so the `underline` renderer writes HTML `` tags. `markdownToPortableText` reads a bare ``/`` pair back as the `underline` decorator, so underline round-trips. - -#### Configuring renderers - -Provide custom renderers to control how Portable Text renders to Markdown. - -**Custom type renderers:** Render custom block types (objects in the blocks array). A custom renderer under a default's name (see [Default renderers](#default-renderers)) replaces that default: - -```ts nocheck -import {portableTextToMarkdown} from '@portabletext/markdown' - -portableTextToMarkdown(blocks, { - types: { - // Render a custom "chart" block object - chart: ({value}) => `![${value.title}](${value.imageUrl})`, - }, -}) -``` - -**Custom block styles:** Override how block styles render: - -```ts nocheck -import {portableTextToMarkdown} from '@portabletext/markdown' - -portableTextToMarkdown(blocks, { - block: { - // Use ATX-style heading with closing hashes - h1: ({children}) => `# ${children} #`, - // Use HTML for blockquotes - blockquote: ({children}) => `
${children}
`, - }, -}) -``` - -**Built-in type renderers:** the library exports every built-in type renderer, in case you want to compose one into a different renderer or reuse it under a different type name. Two of them are exported but not registered by default: `DefaultBlockquoteObjectRenderer` and `DefaultListRenderer` render the structural container shapes (`types.blockquote`/`types.list` on the parser side) and stay opt-in, since the parser only produces those shapes when a matcher is registered for them: - -```ts nocheck -import { - DefaultBlockquoteObjectRenderer, - DefaultListRenderer, - portableTextToMarkdown, -} from '@portabletext/markdown' - -portableTextToMarkdown(blocks, { - types: { - blockquote: DefaultBlockquoteObjectRenderer, - list: DefaultListRenderer, - }, -}) -``` - -| Renderer | Expected value | Output | -| --------------------------------- | ------------------------------------------------------ | ---------------------- | -| `DefaultBlockquoteObjectRenderer` | `{content: PortableTextBlock[]}` | `> content` | -| `DefaultCalloutRenderer` | `{tone: string, content: PortableTextBlock[]}` | `> [!TYPE]\n> content` | -| `DefaultCodeBlockRenderer` | `{code: string, language?: string}` | ` ```lang\ncode\n``` ` | -| `DefaultHorizontalRuleRenderer` | (no fields required) | `---` | -| `DefaultHtmlRenderer` | `{html: string}` | Raw HTML | -| `DefaultImageRenderer` | `{src: string, alt?: string, title?: string}` | `![alt](src "title")` | -| `DefaultListRenderer` | `{kind: 'bullet' \| 'number' \| 'task', items: [...]}` | Markdown list | -| `DefaultTableRenderer` | `{rows: [...], headerRows?: number}` | Markdown table | - -#### What renderers receive - -One question decides which text field a renderer reads: **are you wrapping markdown, or emitting something else?** `children` is markdown: nested marks are rendered and literal punctuation is backslash-escaped, so it embeds in markdown output as-is (`` `**${children}**` ``). `text` is source: the raw span text exactly as stored, for renderers that emit their own delimiters or non-markdown output (code fences, HTML tags), where escapes would appear literally. The built-in `code` decorator renderer reads `text`; every other built-in reads `children`. - -**Block renderers** (`block.*`): - -- `value` – the block object -- `children` – rendered content of the block, as markdown (escaped) -- `index` – position in the blocks array - -**Mark renderers** (`marks.*`): - -- `value` – the mark definition (for annotations like links) -- `children` – the rendered marked content, as markdown (escaped) -- `text` – the raw text content (unescaped, no nested mark rendering) -- `markType` – the mark type name -- `markKey` – the mark's key (for annotations) - -**Type renderers** (`types.*`): - -- `value` – the typed object -- `index` – position in the blocks array -- `isInline` – whether it appears inline or as a block - -Use `isInline` to handle block vs inline objects differently: - -```ts nocheck -import {portableTextToMarkdown} from '@portabletext/markdown' - -portableTextToMarkdown(blocks, { - types: { - image: ({value, isInline}) => { - if (isInline) { - // Skip inline images entirely by returning empty string - return '' - } - // Render block images as full Markdown - return `![${value.alt || ''}](${value.src})` - }, - }, -}) -``` - -Return an empty string to skip rendering an element entirely. - -**List item renderer** (`listItem`): - -- `value` – the list item block -- `children` – rendered content -- `listIndex` – position in the list (for numbered lists) - -#### Handling unknown types - -The library provides fallback renderers for unknown content: - -```ts nocheck -import {portableTextToMarkdown} from '@portabletext/markdown' - -portableTextToMarkdown(blocks, { - // Called for block types not in `types` - unknownType: ({value}) => ``, - - // Called for block styles not in `block` - unknownBlockStyle: ({value, children}) => children ?? '', - - // Called for list item types not in `listItem` - unknownListItem: ({children}) => `- ${children}`, - - // Called for marks not in `marks` - unknownMark: ({children}) => children, -}) -``` - -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. - -#### Gating default renderers on a schema - -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. - -```ts nocheck -import {portableTextToMarkdown} from '@portabletext/markdown' -import {compileSchema, defineSchema} from '@portabletext/schema' - -const schema = compileSchema( - defineSchema({ - blockObjects: [ - { - name: 'code', - fields: [ - {name: 'code', type: 'string'}, - {name: 'language', type: 'string'}, - ], - }, - ], - }), -) - -portableTextToMarkdown(blocks, {schema}) -``` - -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. - -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. - -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. - -Without a `schema`, every default renderer stays active. - -#### Hard breaks - -Customize how a hard break (a `\n` inside a span's text) renders: - -```ts nocheck -import {portableTextToMarkdown} from '@portabletext/markdown' - -portableTextToMarkdown(blocks, { - // Render as HTML break instead of Markdown hard break - hardBreak: () => '
\n', - - // Or render as plain newline (no trailing spaces) - hardBreak: () => '\n', -}) -``` - -#### Block spacing - -By default, blocks are separated by double newlines (`\n\n`), with special handling for list items (single newline) and consecutive blockquotes. Customize with `blockSpacing`. It replaces the default renderer, and returning `undefined` separates the two blocks with `\n\n`, not with the default spacing. To keep the default spacing for the cases you don't handle, delegate to `DefaultBlockSpacingRenderer`: - -```ts nocheck -import { - DefaultBlockSpacingRenderer, - portableTextToMarkdown, -} from '@portabletext/markdown' - -portableTextToMarkdown(blocks, { - blockSpacing: ({current, next}) => { - // Double newline between list items instead of single - if (isListItem(current) && isListItem(next)) { - return '\n\n' - } - return DefaultBlockSpacingRenderer({current, next}) - }, -}) - -function isListItem(node: object) { - return 'listItem' in node && typeof node.listItem === 'string' -} -``` - -#### Reporting degradation - -An annotation or decorator with no mark renderer drops to plain text, a block style with no renderer falls back to a normal paragraph, and a list-item kind with no renderer falls back to a plain bullet item: each degrades rather than throwing. `onDegradation` observes those losses, called once, after the whole document has been walked, only when at least one construct degraded, with a report object holding every `SerializeDegradation` in encounter order and a canonical grouped `message`. Left unset, the conversion stays silent and returns the lossiest representation it can build. Enforce against lossy output by throwing your own error from inside that callback. The throw propagates out of `portableTextToMarkdown`. - -```ts nocheck -import {portableTextToMarkdown} from '@portabletext/markdown' - -portableTextToMarkdown(blocks, { - onDegradation: ({degradations, message}) => { - // degradations: every SerializeDegradation, in encounter order - // message: the same degradations grouped, snippeted, and sorted by block - logger.warn(message) - }, -}) -``` - -Each `SerializeDegradation` carries `type` (`annotation-dropped`, `decorator-dropped`, `style-fallback`, or `list-item-fallback`), a human-readable `message`, `path`, and `snippet` (the offending text, truncated to 40 characters) when there's a specific piece of text to quote. `path` addresses the node the loss occurred on, from the top-level `blocks` array down: an array position is `{_key: node._key}` when the node has a `_key`, the numeric index otherwise, and an object property is its field name. A degradation inside a table cell carries the full path down to the offending node, not just the table's own position. `path` is best-effort: it names the deepest node the conversion can re-find, falling back to just the top-level segment when it can't (a custom renderer cloning a node before rendering it, or a keyless nested span with nothing left to search by). Match on `type`, not `message`: the type is the stable contract, the message can change between releases, and the set of `type` values itself grows in minor releases, so compare against the values you handle rather than switching exhaustively. - -Providing your own renderer for the affected construct (a custom `unknownMark`, `unknownBlockStyle`, or `unknownListItem`, or a `listItem` map covering every kind the document uses) suppresses the report for that construct: `onDegradation` only fires for fallbacks the library's own default renderers produced. `list-item-fallback` never fires under the default configuration, since the default `listItem` renderer already covers every kind. It only fires behind a consumer-supplied partial `listItem` map. - -`applyMarkdownEdit`'s `serialize` option doesn't accept `onDegradation`: it serializes `storedPortableText` only for internal alignment, never `editedMarkdown` itself, so there's nothing there for a caller to observe losses in. - -### `applyMarkdownEdit` - -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: - -```ts -import {applyMarkdownEdit, portableTextToMarkdown} from '@portabletext/markdown' - -const stored = [ - { - _type: 'block', - _key: 'b1', - style: 'normal', - children: [{_type: 'span', _key: 's1', text: 'Ships tomorow.', marks: []}], - markDefs: [], - }, -] - -const markdown = portableTextToMarkdown(stored) -// markdown === 'Ships tomorow.'; an agent (or anything else) fixes the typo -const edited = applyMarkdownEdit(stored, 'Ships tomorrow.') -``` - -`edited`: - -```json -[ - { - "_type": "block", - "_key": "b1", - "style": "normal", - "children": [ - {"_type": "span", "_key": "s1", "text": "Ships tomorrow.", "marks": []} - ], - "markDefs": [] - } -] -``` - -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. - -#### What keeps its key - -- Unchanged and moved blocks. Repeated content pairs in order. -- 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. -- 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. -- 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. -- 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. -- A `json:object` payload without a `_key`, or with an empty or non-string one, gets a generated key, unless it matches stored content, which keeps the stored key. - -#### What gets restored - -Markdown cannot carry everything a block stores, so an adopted block gets back what the edit could not have touched: - -- 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. -- A custom style, list kind, or decorator markdown has no syntax for. -- 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. -- 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. - -#### When keys reset - -- 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. -- 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. -- 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. -- 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. - -#### Options - -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. - -#### Observing reconciliation - -Pass `onReconciliation` to see what happened to every key. The callback fires exactly once per call, synchronously, right before the function returns: - -```ts nocheck -import {applyMarkdownEdit} from '@portabletext/markdown' - -applyMarkdownEdit(stored, editedMarkdown, { - onReconciliation: (report) => { - if (report.keyMatching === 'skipped') { - // report.reason is 'round-trip-mismatch' (the stored value cannot - // survive its own serialize→parse round trip) or - // 'document-too-large'. The returned value is the plain - // conversion, every key fresh except the ones `json:object` - // payloads carry. A 'round-trip-mismatch' report also carries - // report.mismatch, naming where and why the round trip failed: - // a `type`, a `storedPath` into the stored value, a readable - // `message`, and an optional `snippet` of the text that came - // back - return - } - report.preservedKeys // stored keys that survived, with a basis, a path, and whether the value changed - report.keyFallbacks // regions that got fresh keys instead of a guess - report.renamedKeys // keys rewritten to keep siblings unique - report.hiddenLosses // stored content the markdown never showed that is missing from the result - report.markdownLosses // what the edited markdown said that the result does not hold - report.undeclaredObjects // objects the edit added whose type the schema does not declare - }, -}) -``` - -Every `key` and `path` in the report matches the returned value exactly, except `mismatch.storedPath` and each `hiddenLosses` entry's `path`, which address the stored value passed in. 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`, `keyMatching`, and `mismatch.type` are facts of that invocation, safe to branch on. Match on `mismatch.type` (`'type-changed'`, `'text-changed'`, or `'block-count-changed'`), not `mismatch.message`: the message can change between releases. 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. `valueChanged`, by contrast, is a stable fact of the invocation, safe to branch on: it is `true` when the node wearing that key differs from its stored counterpart, field order aside but array order significant. Any difference counts, including a descendant key rewritten to keep siblings unique, or a key filled in for a stored node that had none. `false` guarantees the returned node deep-equals the stored one. A node absent from `preservedKeys` was not restored from the stored value, whether its key is fresh or carried by a `json:object` payload, so a caller who needs to know whether one of those changed has to compare it directly. The next section shows how to check `json:object` payloads that way. The exported `ReconciliationReport`, `ReconciliationKeyPath` and `UndeclaredObject` types are `@beta`. - -#### Losses the markdown did not show - -Content the `serialize` options have no markdown form for (an annotation or decorator with no mark renderer, a block style or list-item kind with no renderer) never reaches whoever edits the markdown. `applyMarkdownEdit` restores it where it can. Both report variants carry `hiddenLosses`: one `SerializeDegradation` per such loss whose content is missing from the returned value, with `path` addressing the stored value. A stored block whose `_key` is missing from the returned value has all of its losses listed, whether the edit deleted it or rewrote it so far that it came back with a fresh key. Whether the removal was intended is yours to decide. - -#### Losses in the edited markdown - -The edited markdown can also say things the schema cannot hold. Both report variants carry `markdownLosses`: the `Degradation` entries `deserialize.onDegradation` receives for `editedMarkdown`, in the same order, except `object-type-undeclared`. Each one means the result differs from what the markdown said, for example a heading level the schema has no style for, a dropped decorator, a flattened table, or fields dropped from a `json:object` carrier. Your own `deserialize.onDegradation` still receives every entry, and a throw from it still propagates. - -#### Objects the schema does not declare - -A `json:object` payload whose type the schema does not declare is kept as written, so nothing is lost, but the result holds content the schema does not allow. Both report variants carry `undeclaredObjects`: one `UndeclaredObject` per such object in the returned value that the stored value did not already hold, in document order. Each entry has the object's `_type`, its `_key` when it has one, a `position` of `'block'` or `'inline'`, a `path` into the returned value, and a `message` for display, the same text as the object's `object-type-undeclared` degradation. Only top-level block objects and the inline objects of top-level text blocks are checked. The fields of a declared object, such as a table's cells or a container's own array, are not searched: what a payload holds inside its fields is validated like any other write of that object. An object the stored value already held in the same position, with the same `_type` and `_key`, is not listed, and editing the paragraph around an inline object does not list it even when the paragraph gets a fresh key. A stored object with no `_key` gets a fresh one from the round trip, so it is matched by its `_type` and content instead. Each stored object matches at most one object in the result, so adding a second object of a stored type lists the new one. - -Together the three lists say whether saving the result loses content or adds content the schema does not allow. Refuse or retry when any of them is non-empty: - -```ts nocheck -import {applyMarkdownEdit} from '@portabletext/markdown' - -const problems: Array = [] -const edited = applyMarkdownEdit(stored, editedMarkdown, { - onReconciliation: (report) => { - problems.push( - ...report.hiddenLosses.map((loss) => loss.message), - ...report.markdownLosses.map((loss) => loss.message), - ...report.undeclaredObjects.map((object) => object.message), - ) - }, -}) - -if (problems.length > 0) { - throw new Error(`The edit cannot be saved as is: ${problems.join('\n')}`) -} -``` - -#### Guarding `json:object` payloads - -An edit can change `json:object` payloads like any other content. To keep an agent away from certain objects, check the result before saving it: - -```ts nocheck -import {isDeepStrictEqual} from 'node:util' -import {applyMarkdownEdit} from '@portabletext/markdown' - -const edited = applyMarkdownEdit(stored, editedMarkdown) - -// The objects the agent must leave alone -const isProtected = (block: {_type: string}) => block._type === 'productTeaser' - -const protectedBefore = stored.filter(isProtected) -const keysBefore = new Set(protectedBefore.map((block) => block._key)) -const editedByKey = new Map(edited.map((block) => [block._key, block])) - -const changedOrDeleted = protectedBefore.filter( - (block) => !isDeepStrictEqual(block, editedByKey.get(block._key)), -) -const added = edited.filter( - (block) => isProtected(block) && !keysBefore.has(block._key), -) - -if (changedOrDeleted.length > 0 || added.length > 0) { - // Reject the edit. An agent loop can pass this message back as the retry prompt. - throw new Error('Leave the product teasers exactly as they are.') -} -``` - -The snippet checks top-level blocks. For objects inside a text block's `children` or a table cell, run the same two checks within each parent, matching parents by `_key`: objects under a parent whose key did not survive count as deleted, and objects under a new parent count as added. - -#### Concurrent edits - -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. - -## License - -MIT © [Sanity.io](https://www.sanity.io/) diff --git a/packages/markdown/bundle-tests/portable-text-to-markdown.ts b/packages/markdown/bundle-tests/portable-text-to-markdown.ts deleted file mode 100644 index 65f777e6c8..0000000000 --- a/packages/markdown/bundle-tests/portable-text-to-markdown.ts +++ /dev/null @@ -1 +0,0 @@ -export {portableTextToMarkdown} from '@portabletext/markdown' diff --git a/packages/markdown/package.config.ts b/packages/markdown/package.config.ts deleted file mode 100644 index 1023c9677d..0000000000 --- a/packages/markdown/package.config.ts +++ /dev/null @@ -1,7 +0,0 @@ -import {defineConfig} from '@sanity/pkg-utils' -import {preserveModules} from '../../build-config/preserve-modules.js' - -export default defineConfig({ - tsconfig: 'tsconfig.dist.json', - plugins: [preserveModules()], -}) diff --git a/packages/markdown/package.json b/packages/markdown/package.json deleted file mode 100644 index add4fd47fe..0000000000 --- a/packages/markdown/package.json +++ /dev/null @@ -1,74 +0,0 @@ -{ - "name": "@portabletext/markdown", - "version": "2.9.3", - "description": "Convert Portable Text to and from Markdown", - "keywords": [ - "markdown", - "portable-text" - ], - "homepage": "https://portabletext.org", - "bugs": { - "url": "https://github.com/portabletext/editor/issues" - }, - "license": "MIT", - "author": "Sanity.io ", - "repository": { - "type": "git", - "url": "git+https://github.com/portabletext/editor.git", - "directory": "packages/markdown" - }, - "files": [ - "dist" - ], - "type": "module", - "sideEffects": false, - "types": "./dist/index.d.ts", - "exports": { - ".": { - "source": "./src/index.ts", - "default": "./dist/index.js" - }, - "./package.json": "./package.json" - }, - "publishConfig": { - "exports": { - ".": "./dist/index.js", - "./package.json": "./package.json" - } - }, - "scripts": { - "build": "pkg-utils build --strict --check", - "check:types": "tsc", - "check:types:watch": "tsc --watch", - "clean": "del .turbo && del dist && del node_modules", - "dev": "pkg-utils watch", - "prepublishOnly": "turbo run build", - "test": "vitest --run", - "test:unit": "vitest --run --project unit", - "test:unit:watch": "vitest --project unit", - "test:watch": "vitest" - }, - "dependencies": { - "@mdit/plugin-alert": "^2.0.1", - "@portabletext/schema": "workspace:^", - "@portabletext/toolkit": "^6.0.0", - "@sanity/diff-match-patch": "catalog:", - "linkify-it": "^6.1.0", - "markdown-it": "^15.0.3" - }, - "devDependencies": { - "@portabletext/test": "workspace:^", - "@portabletext/types": "^4.0.2", - "@sanity/pkg-utils": "catalog:tooling", - "@sanity/tsconfig": "catalog:tooling", - "typescript": "catalog:tooling", - "vite": "catalog:tooling", - "vitest": "catalog:tooling" - }, - "engines": { - "node": ">=22.12" - }, - "inlinedDependencies": { - "@portabletext/types": "4.0.2" - } -} diff --git a/packages/markdown/src/apply-markdown-edit.fuzz.test.ts b/packages/markdown/src/apply-markdown-edit.fuzz.test.ts deleted file mode 100644 index 17b17a5ebf..0000000000 --- a/packages/markdown/src/apply-markdown-edit.fuzz.test.ts +++ /dev/null @@ -1,889 +0,0 @@ -import { - compileSchema, - defineSchema, - type PortableTextBlock, -} from '@portabletext/schema' -import {createTestKeyGenerator} from '@portabletext/test' -import {describe, expect, test} from 'vitest' -import {mulberry32} from '../test-utils/mulberry32' -import { - applyMarkdownEdit, - type ReconciliationKeyPath, - type ReconciliationReport, -} from './apply-markdown-edit' -import {portableTextToMarkdown} from './from-portable-text/portable-text-to-markdown' -import {markdownToPortableText} from './to-portable-text/markdown-to-portable-text' - -/** - * Declares every construct `generateStored` produces, `lead` and - * `highlight` included, so the fuzz exercises the same schema on both - * conversion directions instead of falling back to the full built-in - * default: a scoped schema that omitted a construct `generateStored` - * still emits would silently degrade that construct on the round - * trip (a fenced code block reverting to plain text, a bullet list - * losing its list shape) rather than exercising restoration. - */ -const schema = compileSchema( - defineSchema({ - styles: [{name: 'normal'}, {name: 'h2'}, {name: 'lead'}], - decorators: [{name: 'strong'}, {name: 'highlight'}], - lists: [{name: 'bullet'}], - blockObjects: [ - { - name: 'code', - fields: [ - {name: 'language', type: 'string'}, - {name: 'code', type: 'string'}, - ], - }, - ], - }), -) - -const WORDS = [ - 'alpha', - 'beta', - 'gamma', - 'delta', - 'epsilon', - 'omega', - 'foo', - 'bar', - 'baz', - 'fizz', - 'buzz', -] - -function pick(random: () => number, items: ReadonlyArray): T { - return items[Math.floor(random() * items.length)]! -} - -function sentence(random: () => number): string { - const length = 3 + Math.floor(random() * 6) - return Array.from({length}, () => pick(random, WORDS)).join(' ') -} - -function generateStored( - random: () => number, - keyGenerator: () => string, -): Array { - const blockCount = 1 + Math.floor(random() * 7) - return Array.from({length: blockCount}, () => { - const roll = random() - if (roll < 0.15) { - // Empty and whitespace-only text blocks have no markdown form as - // plain `normal` paragraphs: exercises the reinsertion path - // alongside every other block form. A non-normal style or a - // `listItem` renders a visible marker instead (a heading prefix, a - // list bullet), so those variants exercise ordinary reconciliation - // rather than reinsertion. - const styleRoll = random() - const style = styleRoll < 0.6 ? 'normal' : styleRoll < 0.8 ? 'h2' : 'lead' - // Paired with `h2` a list marker plus heading marker splits into - // two blocks on reparse (a document-wide skip-of-key-matching case - // covered elsewhere), so `listItem` only combines with `normal`. - const listItem = - style === 'normal' && random() < 0.3 ? 'bullet' : undefined - return { - _type: 'block', - _key: keyGenerator(), - style, - ...(listItem ? {listItem, level: 1} : {}), - markDefs: [], - children: [ - { - _type: 'span', - _key: keyGenerator(), - text: random() < 0.5 ? '' : ' ', - marks: [], - }, - ], - } as unknown as PortableTextBlock - } - if (roll < 0.25) { - return { - _type: 'product', - _key: keyGenerator(), - sku: sentence(random), - } as unknown as PortableTextBlock - } - if (roll < 0.35) { - return { - _type: 'code', - _key: keyGenerator(), - language: 'js', - code: sentence(random), - } as unknown as PortableTextBlock - } - if (roll < 0.45) { - return { - _type: 'block', - _key: keyGenerator(), - style: 'normal', - markDefs: [], - children: [ - { - _type: 'span', - _key: keyGenerator(), - text: `${sentence(random)} `, - marks: [], - }, - { - _type: 'stockTicker', - _key: keyGenerator(), - symbol: pick(random, WORDS), - }, - { - _type: 'span', - _key: keyGenerator(), - text: ` ${sentence(random)}`, - marks: [], - }, - ], - } as unknown as PortableTextBlock - } - const markRoll = random() - // `highlight` is declared in the schema but has no markdown form, - // like `alignment` below: exercises restoration of a decorator the - // round trip drops instead of carrying through the parse. - const mark = - markRoll < 0.3 ? 'strong' : markRoll < 0.5 ? 'highlight' : undefined - const children = mark - ? [ - { - _type: 'span', - _key: keyGenerator(), - text: `${sentence(random)} `, - marks: [], - }, - { - _type: 'span', - _key: keyGenerator(), - text: pick(random, WORDS), - marks: [mark], - }, - { - _type: 'span', - _key: keyGenerator(), - text: ` ${sentence(random)}`, - marks: [], - }, - ] - : [ - { - _type: 'span', - _key: keyGenerator(), - text: sentence(random), - marks: [], - }, - ] - const styleRoll = random() - // `lead` is declared in the schema but has no markdown form either: - // exercises restoration of a custom style dropped by the round trip. - const style = styleRoll < 0.2 ? 'h2' : styleRoll < 0.35 ? 'lead' : 'normal' - return { - _type: 'block', - _key: keyGenerator(), - style, - ...(random() < 0.25 ? {listItem: 'bullet', level: 1} : {}), - // Markdown has no carrier for this field: exercises restoration - // of a field that only the stored value ever held. - ...(random() < 0.3 ? {alignment: pick(random, WORDS)} : {}), - markDefs: [], - children, - } as unknown as PortableTextBlock - }) -} - -/** - * Mutates the markdown at paragraph granularity, never inside a fence: - * fence bodies carry `json:object` payload keys, and corrupting one - * would make the "keys come from the stored value" invariant - * unverifiable rather than falsifiable. - */ -function mutateMarkdown(random: () => number, markdown: string): string { - const paragraphs = markdown.split('\n\n') - - const mutations = 1 + Math.floor(random() * 3) - for (let count = 0; count < mutations; count++) { - const mutableIndexes = paragraphs - .map((paragraph, index) => ({paragraph, index})) - .filter(({paragraph}) => !paragraph.includes('```')) - .map(({index}) => index) - const roll = random() - if (roll < 0.25 && mutableIndexes.length > 0) { - // Replace one word. - const index = pick(random, mutableIndexes) - const words = paragraphs[index]!.split(' ') - if (words.length > 0) { - words[Math.floor(random() * words.length)] = pick(random, WORDS) - paragraphs[index] = words.join(' ') - } - } else if (roll < 0.4) { - // Insert a new paragraph. - paragraphs.splice( - Math.floor(random() * (paragraphs.length + 1)), - 0, - sentence(random), - ) - } else if (roll < 0.55 && paragraphs.length > 1) { - // Delete a paragraph. - paragraphs.splice(Math.floor(random() * paragraphs.length), 1) - } else if (roll < 0.7 && paragraphs.length > 1) { - // Swap two paragraphs. - const a = Math.floor(random() * paragraphs.length) - const b = Math.floor(random() * paragraphs.length) - const held = paragraphs[a]! - paragraphs[a] = paragraphs[b]! - paragraphs[b] = held - } else if (roll < 0.85) { - // Duplicate a paragraph. - const index = Math.floor(random() * paragraphs.length) - paragraphs.splice(index, 0, paragraphs[index]!) - } else if (mutableIndexes.length > 0) { - // Split a paragraph at a word boundary. - const index = pick(random, mutableIndexes) - const words = paragraphs[index]!.split(' ') - if (words.length > 2) { - const cut = 1 + Math.floor(random() * (words.length - 2)) - paragraphs.splice( - index, - 1, - words.slice(0, cut).join(' '), - words.slice(cut).join(' '), - ) - } - } - } - return paragraphs.join('\n\n') -} - -/** - * Mirrors `applyMarkdownEdit`'s own `isEmptyTextBlock`: a block whose - * text trims empty only lacks a markdown form when its own render - * comes back empty, or reparses to nothing, the same authority - * `reinsertEmptyRuns` defers to. A visible marker (a heading prefix, - * a list bullet) keeps the block through the plain parse instead of - * dropping it, so style and `listItem` alone do not decide this; the - * round trip does. - */ -function roundTripsToNothing(node: Record): boolean { - const rendered = portableTextToMarkdown( - [structuredClone(node)] as unknown as Array, - {schema}, - ) - if (rendered === '') { - return true - } - return ( - markdownToPortableText(rendered, {schema, keyGenerator: () => 'probe'}) - .length === 0 - ) -} - -/** - * An empty or whitespace-only text block cloned back in verbatim from - * a top-level stored block of the same key: markdown has no form for - * either, so its only source is `reinsertEmptyRuns`, never the plain - * parse. The verbatim-equality check (not just an empty text and a - * stored key) is what keeps this from also matching a block whose - * own edit emptied it: that block's key is stored too, but its - * content no longer matches the stored node. - */ -function isRestoredEmptyBlock( - node: unknown, - storedByKey: ReadonlyMap, -): boolean { - if (typeof node !== 'object' || node === null) { - return false - } - const record = node as Record - const key = record['_key'] - if (typeof key !== 'string' || !storedByKey.has(key)) { - return false - } - const storedNode = storedByKey.get(key) as Record - const children = storedNode['children'] - if (!Array.isArray(children)) { - return false - } - const text = children - .map((child) => - typeof (child as Record)['text'] === 'string' - ? (child as Record)['text'] - : '\uFFFC', - ) - .join('') - if (text.trim() !== '' || !roundTripsToNothing(storedNode)) { - return false - } - return JSON.stringify(node) === JSON.stringify(storedNode) -} - -/** - * Collects every `[field name, JSON value]` pair anywhere in the - * stored tree, the crude provenance oracle for a restored field: a - * field the result carries but the plain parse does not must trace - * to some stored node carrying the same name and value. - */ -function collectFieldPairs(value: unknown, pairs: Set): void { - if (Array.isArray(value)) { - for (const item of value) { - collectFieldPairs(item, pairs) - } - return - } - if (typeof value === 'object' && value !== null) { - for (const [field, fieldValue] of Object.entries(value)) { - pairs.add(`${field}:${JSON.stringify(fieldValue)}`) - collectFieldPairs(fieldValue, pairs) - } - } -} - -/** - * Every result block is either a field-wise superset of its plain-parse - * counterpart, or deep-equal to a stored block: the exact-signature - * tiers (an anchor, a unique moved leftover) return the stored block - * verbatim instead of reconciling field by field into the parsed - * shape, so such a block is judged by the second branch, not the - * first. - */ -function assertResultMatchesEditOrStored( - reconciled: ReadonlyArray, - plainParse: ReadonlyArray, - storedFieldPairs: ReadonlySet, - storedBlocksJson: ReadonlySet, -): void { - expect(reconciled.length).toBe(plainParse.length) - for (let index = 0; index < reconciled.length; index++) { - if (storedBlocksJson.has(JSON.stringify(reconciled[index]))) { - continue - } - assertFieldwiseSubsetAndProvenance( - reconciled[index], - plainParse[index], - storedFieldPairs, - ) - } -} - -/** - * Reconciliation only ever adds keys and restored fields on top of - * the plain parse, so the two stay congruent in shape (`result` and - * `plainParse` walk in lockstep) at every node: every field the plain - * parse produced survives in `result` at an equal value (`_key` - * excluded, since reconciliation's whole job is changing it) unless - * that whole field's value traces to the stored tree instead, the - * same provenance rule an extra field is held to, and every extra - * field `result` carries traces to the stored tree. - */ -function assertFieldwiseSubsetAndProvenance( - result: unknown, - plainParse: unknown, - storedFieldPairs: ReadonlySet, -): void { - if (Array.isArray(plainParse)) { - expect(Array.isArray(result)).toBe(true) - const resultArray = result as Array - expect(resultArray.length).toBe(plainParse.length) - for (let index = 0; index < plainParse.length; index++) { - assertFieldwiseSubsetAndProvenance( - resultArray[index], - plainParse[index], - storedFieldPairs, - ) - } - return - } - if (typeof plainParse === 'object' && plainParse !== null) { - expect(typeof result === 'object' && result !== null).toBe(true) - const resultNode = result as Record - const plainNode = plainParse as Record - for (const field of Object.keys(plainNode)) { - if (field === '_key') { - continue - } - expect(Object.hasOwn(resultNode, field)).toBe(true) - const resultValue = resultNode[field] - const plainValue = plainNode[field] - if ( - JSON.stringify(resultValue) !== JSON.stringify(plainValue) && - storedFieldPairs.has(`${field}:${JSON.stringify(resultValue)}`) - ) { - continue - } - assertFieldwiseSubsetAndProvenance( - resultValue, - plainValue, - storedFieldPairs, - ) - } - for (const field of Object.keys(resultNode)) { - if (field === '_key' || Object.hasOwn(plainNode, field)) { - continue - } - expect( - storedFieldPairs.has(`${field}:${JSON.stringify(resultNode[field])}`), - ).toBe(true) - } - return - } - expect(result).toEqual(plainParse) -} - -function collectKeys(value: unknown, keys: Set): void { - if (Array.isArray(value)) { - for (const item of value) { - collectKeys(item, keys) - } - return - } - if (typeof value === 'object' && value !== null) { - for (const [field, fieldValue] of Object.entries(value)) { - if (field === '_key' && typeof fieldValue === 'string') { - keys.add(fieldValue) - } else { - collectKeys(fieldValue, keys) - } - } - } -} - -function collectNodesByKey( - value: unknown, - nodesByKey: Map, -): void { - if (Array.isArray(value)) { - for (const item of value) { - collectNodesByKey(item, nodesByKey) - } - return - } - if (typeof value === 'object' && value !== null) { - const key = (value as Record)['_key'] - if (typeof key === 'string' && !nodesByKey.has(key)) { - nodesByKey.set(key, value) - } - for (const fieldValue of Object.values(value)) { - collectNodesByKey(fieldValue, nodesByKey) - } - } -} - -/** - * Deliberate copy of the engine's compare: importing it would let a - * change to what `valueChanged` measures pass silently. - */ -function nodesDeepEqual(a: unknown, b: unknown): boolean { - if (a === b) { - return true - } - if (Array.isArray(a) || Array.isArray(b)) { - if (!Array.isArray(a) || !Array.isArray(b) || a.length !== b.length) { - return false - } - return a.every((item, index) => nodesDeepEqual(item, b[index])) - } - if ( - typeof a === 'object' && - a !== null && - typeof b === 'object' && - b !== null - ) { - const aRecord = a as Record - const bRecord = b as Record - const aFields = Object.keys(aRecord) - const bFields = Object.keys(bRecord) - if (aFields.length !== bFields.length) { - return false - } - return aFields.every( - (field) => - Object.hasOwn(bRecord, field) && - nodesDeepEqual(aRecord[field], bRecord[field]), - ) - } - return false -} - -function assertSiblingKeysUnique(value: unknown): void { - if (Array.isArray(value)) { - const keys = value - .map((item) => - typeof item === 'object' && item !== null - ? (item as {_key?: unknown})._key - : undefined, - ) - .filter((key): key is string => typeof key === 'string') - expect(new Set(keys).size).toBe(keys.length) - for (const item of value) { - assertSiblingKeysUnique(item) - } - return - } - if (typeof value === 'object' && value !== null) { - for (const fieldValue of Object.values(value)) { - assertSiblingKeysUnique(fieldValue) - } - } -} - -describe('applyMarkdownEdit invariants (seeded fuzz)', () => { - const SEEDS = [1, 2, 3, 4, 5, 6, 7, 8, 9, 10] - const CASES_PER_SEED = 30 - - test.each(SEEDS)('seed %i', (seed) => { - const random = mulberry32(seed) - let nonEmptyHiddenLossReports = 0 - - for (let caseIndex = 0; caseIndex < CASES_PER_SEED; caseIndex++) { - const storedKeyGenerator = createTestKeyGenerator('stored-') - const stored = generateStored(random, storedKeyGenerator) - const storedSnapshot = structuredClone(stored) - const markdown = portableTextToMarkdown(structuredClone(stored), { - schema, - }) - const editedMarkdown = mutateMarkdown(random, markdown) - - const reconciled = applyMarkdownEdit(stored, editedMarkdown, { - schema, - deserialize: {keyGenerator: createTestKeyGenerator()}, - }) - - const storedKeys = new Set() - collectKeys(storedSnapshot, storedKeys) - const storedNodesByKey = new Map() - collectNodesByKey(storedSnapshot, storedNodesByKey) - const storedByKey = new Map( - storedSnapshot - .filter( - (node): node is PortableTextBlock & {_key: string} => - typeof (node as {_key?: unknown})['_key'] === 'string', - ) - .map((node) => [node['_key'], node]), - ) - - // Content preservation: every field the plain parse produced - // survives in the result, and every field the result adds on - // top traces to the stored value. - const plainParse = markdownToPortableText(editedMarkdown, { - schema, - keyGenerator: createTestKeyGenerator(), - }) - const storedFieldPairs = new Set() - collectFieldPairs(storedSnapshot, storedFieldPairs) - const storedBlocksJson = new Set( - storedSnapshot.map((node) => JSON.stringify(node)), - ) - // Restored empty blocks have no markdown form, so the plain parse - // never produced them: skip them here and let the key-provenance - // and uniqueness checks below cover them instead. - const reconciledWithoutRestoredEmptyBlocks = reconciled.filter( - (node) => !isRestoredEmptyBlock(node, storedByKey), - ) - assertResultMatchesEditOrStored( - reconciledWithoutRestoredEmptyBlocks, - plainParse, - storedFieldPairs, - storedBlocksJson, - ) - - // Key provenance: every key is either stored, generated fresh, - // or carried inside the markdown itself. - const resultKeys = new Set() - collectKeys(reconciled, resultKeys) - for (const key of resultKeys) { - expect( - storedKeys.has(key) || - /^k\d+$/.test(key) || - editedMarkdown.includes(key), - ).toBe(true) - } - - assertSiblingKeysUnique(reconciled) - - // Inputs are never mutated. - expect(stored).toEqual(storedSnapshot) - - // Determinism: same inputs, same output. - const again = applyMarkdownEdit(storedSnapshot, editedMarkdown, { - schema, - deserialize: {keyGenerator: createTestKeyGenerator()}, - }) - expect(again).toEqual(reconciled) - - // Instrumentation neutrality: collecting a key resolution report - // never changes the returned value, and the report it collects is - // internally consistent with that value. - let reportCallCount = 0 - let report: ReconciliationReport | undefined - const withReport = applyMarkdownEdit(storedSnapshot, editedMarkdown, { - schema, - deserialize: {keyGenerator: createTestKeyGenerator()}, - onReconciliation: (r) => { - reportCallCount++ - report = r - }, - }) - expect(withReport).toEqual(reconciled) - expect(reportCallCount).toBe(1) - assertReconciliationReportConsistency( - report!, - withReport, - storedKeys, - storedNodesByKey, - ) - assertHiddenLossesMissing(report!, withReport, storedSnapshot) - if (report!.hiddenLosses.length > 0) { - nonEmptyHiddenLossReports++ - } - } - - // Every seed's corpus edits blocks holding `lead` or `highlight`, so - // a regression that empties `hiddenLosses` cannot pass the invariant - // above vacuously. - expect(nonEmptyHiddenLossReports).toBeGreaterThanOrEqual(2) - }) -}) - -describe('the reconciliation report invariants the fuzz suite checks', () => { - test('reject a preserved key whose key or path was corrupted', () => { - const stored = [block('b1', 's1', 'alpha'), block('b2', 's2', 'beta')] - const markdown = 'beta\n\nalpha' - let report: ReconciliationReport | undefined - const result = applyMarkdownEdit(stored, markdown, { - deserialize: {keyGenerator: createTestKeyGenerator()}, - onReconciliation: (r) => { - report = r - }, - }) - const storedKeys = new Set(['b1', 's1', 'b2', 's2']) - const storedNodesByKey = new Map() - collectNodesByKey(stored, storedNodesByKey) - const producedReport = report! - if (producedReport.keyMatching !== 'performed') { - throw new Error('expected a performed report') - } - const performedReport = producedReport - - // The real report passes; every mutated copy below must fail. - assertReconciliationReportConsistency( - performedReport, - result, - storedKeys, - storedNodesByKey, - ) - - const wrongKey = structuredClone(performedReport) - wrongKey.preservedKeys[0]!.key = 'not-a-real-key' - expect(() => - assertReconciliationReportConsistency( - wrongKey, - result, - storedKeys, - storedNodesByKey, - ), - ).toThrow() - - const truncatedPath = structuredClone(performedReport) - const spanPreservedKey = truncatedPath.preservedKeys.find( - (preservedKey) => preservedKey.path.length > 1, - )! - spanPreservedKey.path = spanPreservedKey.path.slice(0, -1) - expect(() => - assertReconciliationReportConsistency( - truncatedPath, - result, - storedKeys, - storedNodesByKey, - ), - ).toThrow() - - const flippedValueChanged = structuredClone(performedReport) - flippedValueChanged.preservedKeys[0]!.valueChanged = - !flippedValueChanged.preservedKeys[0]!.valueChanged - expect(() => - assertReconciliationReportConsistency( - flippedValueChanged, - result, - storedKeys, - storedNodesByKey, - ), - ).toThrow() - }) -}) - -function block( - blockKey: string, - spanKey: string, - text: string, -): PortableTextBlock { - return { - _type: 'block', - _key: blockKey, - style: 'normal', - markDefs: [], - children: [{_type: 'span', _key: spanKey, text, marks: []}], - } -} - -/** - * Resolves a `ReconciliationKeyPath` against the value - * `applyMarkdownEdit` returned: a number segment always selects an - * array index, a string segment always selects an object field, and - * a `{_key}` segment always selects a keyed element out of the - * current array, with no guessing from runtime shape. - */ -function resolveReconciliationKeyPath( - root: unknown, - path: ReconciliationKeyPath, -): unknown { - let current = root - for (const segment of path) { - if (typeof segment === 'number') { - if (!Array.isArray(current)) { - return undefined - } - current = current[segment] - } else if (typeof segment === 'string') { - if (typeof current !== 'object' || current === null) { - return undefined - } - current = (current as Record)[segment] - } else { - if (!Array.isArray(current)) { - return undefined - } - current = current.find( - (item) => - typeof item === 'object' && - item !== null && - (item as Record)['_key'] === segment._key, - ) - } - if (current === undefined) { - return undefined - } - } - return current -} - -/** - * The report-shape invariants that must hold no matter which fuzz - * case produced the report: every `preservedKeys`/`renamedKeys` - * entry's `path` resolves to the node its `key` names, every - * preserved key was actually stored, no key is reported both - * preserved and renamed at the same path, and a rename always - * changes the key. A skipped report carries no `preservedKeys` field - * at all, so the document-wide key-matching-skip invariant needs no - * assertion here: the type rules it out. - */ -function assertReconciliationReportConsistency( - report: ReconciliationReport, - result: unknown, - storedKeys: ReadonlySet, - storedNodesByKey: ReadonlyMap, -): void { - for (const renamedKey of report.renamedKeys) { - const node = resolveReconciliationKeyPath(result, renamedKey.path) - expect( - typeof node === 'object' && - node !== null && - (node as Record)['_key'] === renamedKey.key, - ).toBe(true) - expect(renamedKey.previousKey).not.toBe(renamedKey.key) - } - - if (report.keyMatching !== 'performed') { - return - } - - for (const preservedKey of report.preservedKeys) { - const node = resolveReconciliationKeyPath(result, preservedKey.path) - expect( - typeof node === 'object' && - node !== null && - (node as Record)['_key'] === preservedKey.key, - ).toBe(true) - expect(storedKeys.has(preservedKey.key)).toBe(true) - - const storedNode = storedNodesByKey.get(preservedKey.key) - if (storedNode === undefined) { - throw new Error( - `assertReconciliationReportConsistency: no stored node found for preserved key "${preservedKey.key}"`, - ) - } - expect(preservedKey.valueChanged).toBe(!nodesDeepEqual(storedNode, node)) - } - - const preservedAtPath = new Set( - report.preservedKeys.map( - (preservedKey) => - `${JSON.stringify(preservedKey.path)}:${preservedKey.key}`, - ), - ) - for (const renamedKey of report.renamedKeys) { - expect( - preservedAtPath.has( - `${JSON.stringify(renamedKey.path)}:${renamedKey.key}`, - ), - ).toBe(false) - } -} - -/** - * Every `hiddenLosses` entry names content the returned value really - * lacks: on a performed report, no returned block wears the stored - * block's `_key`, or the one that does has another style, or none of - * its spans carries the stored span's decorator; on a skipped report, - * no returned block or span carries it at all. - * `generateStored` emits no annotation markdown drops and only list - * kinds markdown expresses, so decorators and styles are the losses - * to check. - */ -function assertHiddenLossesMissing( - report: ReconciliationReport, - result: ReadonlyArray, - stored: ReadonlyArray, -): void { - for (const hiddenLoss of report.hiddenLosses) { - const storedNode = resolveReconciliationKeyPath(stored, hiddenLoss.path) - const topSegment = hiddenLoss.path[0]! - const resultScope = - report.keyMatching === 'performed' - ? resolveReconciliationKeyPath(result, [topSegment]) - : result - if (resultScope === undefined) { - continue - } - const resultBlocks = [resultScope].flat() as Array> - if (hiddenLoss.type === 'style-fallback') { - const style = (storedNode as Record)['style'] - expect( - resultBlocks.some((resultBlock) => resultBlock['style'] === style), - ).toBe(false) - } else if (hiddenLoss.type === 'decorator-dropped') { - const marks = (storedNode as Record)[ - 'marks' - ] as Array - expect( - resultBlocks.some((resultBlock) => - ( - (resultBlock['children'] ?? []) as Array> - ).some( - (child) => - Array.isArray(child['marks']) && - marks.some((mark) => - (child['marks'] as Array).includes(mark), - ), - ), - ), - ).toBe(false) - } else { - throw new Error( - `assertHiddenLossesMissing: unexpected hidden loss type "${hiddenLoss.type}"`, - ) - } - } -} diff --git a/packages/markdown/src/apply-markdown-edit.ownership.fuzz.test.ts b/packages/markdown/src/apply-markdown-edit.ownership.fuzz.test.ts deleted file mode 100644 index 93a37e4393..0000000000 --- a/packages/markdown/src/apply-markdown-edit.ownership.fuzz.test.ts +++ /dev/null @@ -1,1196 +0,0 @@ -import { - compileSchema, - defineSchema, - type PortableTextBlock, -} from '@portabletext/schema' -import {createTestKeyGenerator} from '@portabletext/test' -import {describe, expect, test} from 'vitest' -import {mulberry32} from '../test-utils/mulberry32' -import {applyMarkdownEdit} from './apply-markdown-edit' -import {portableTextToMarkdown} from './from-portable-text/portable-text-to-markdown' - -/** - * Declares every construct the generator below produces, so ownership - * reconciliation runs the same schema on both conversion directions - * `applyMarkdownEdit` itself requires. `widget` (the undeclared block - * object stratum) is deliberately absent: it must ride the `json:object` - * fence rather than a declared renderer. - */ -const schema = compileSchema( - defineSchema({ - styles: [{name: 'normal'}, {name: 'h2'}, {name: 'h3'}, {name: 'lead'}], - // `highlight` has no markdown form: the span-mark strata exercise - // an unmappable decorator alongside `strong`/`em`, so an untouched - // block carrying one still has to come back exactly as stored. - decorators: [{name: 'strong'}, {name: 'em'}, {name: 'highlight'}], - lists: [{name: 'bullet'}, {name: 'number'}], - annotations: [{name: 'link', fields: [{name: 'href', type: 'string'}]}], - blockObjects: [ - { - name: 'code', - fields: [ - {name: 'language', type: 'string'}, - {name: 'code', type: 'string'}, - ], - }, - ], - }), -) - -function pick(random: () => number, items: ReadonlyArray): T { - return items[Math.floor(random() * items.length)]! -} - -const SENTINEL_WORDS = [ - 'lorem', - 'ipsum', - 'dolor', - 'sit', - 'amet', - 'consectetur', - 'adipiscing', - 'elit', - 'sed', - 'do', - 'eiusmod', - 'tempor', -] - -/** - * Every sentinel is globally unique within its case: a fixed-width, - * strictly increasing numeric suffix means no sentinel is ever a - * substring of another, which is what lets ownership be decided by a - * plain `.includes()` content lookup with zero alignment ambiguity. - */ -function makeSentinelFactory(caseId: number): () => string { - let counter = 0 - return () => { - const word = SENTINEL_WORDS[counter % SENTINEL_WORDS.length] - const suffix = String(counter).padStart(4, '0') - counter++ - return `b${caseId}-${word}${suffix}` - } -} - -type KeyFactory = (prefix: string) => string - -/** - * Stored keys come from a per-case deterministic counter, not - * `createTestKeyGenerator`, so the expected key for any stored node is - * writable straight from the model (`b3`, `s7`, ...) instead of having - * to be captured off a generator at generation time. - */ -function makeKeyFactory(): KeyFactory { - const counters = new Map() - return (prefix: string) => { - const next = (counters.get(prefix) ?? 0) + 1 - counters.set(prefix, next) - return `${prefix}${next}` - } -} - -function span(nextKey: KeyFactory, text: string, marks: Array) { - return {_type: 'span', _key: nextKey('s'), text, marks} -} - -type Token = {value: string; href?: string; expectedDefKey?: string} - -type ContentKind = - | 'plain' - | 'heading2' - | 'heading3' - | 'lead' - | 'decorator' - | 'link' - | 'linkBold' - | 'doubleLink' - | 'magic' - | 'code' - | 'widget' - -type EmptyBlockModel = {blockKey: string; spanKey: string; text: string} - -type ContentUnit = { - kind: ContentKind - index: number - block: PortableTextBlock - blockKey: string - tokens: Array - magic?: string - trailingEmpty?: Array - outcome: 'unchanged' | 'removed' -} - -type ListItemModel = { - block: PortableTextBlock - blockKey: string - tokens: Array -} - -type ListRunUnit = { - kind: 'listRun' - index: number - items: Array -} - -type Unit = ContentUnit | ListRunUnit - -type FreshUnit = {id: number; tokens: Array} - -type Slot = {sourceUnitIndex: number | null; text: string} - -function makeListItemBlock( - nextKey: KeyFactory, - text: string, - listItem: 'bullet' | 'number', - level: 1 | 2, -): PortableTextBlock { - return { - _type: 'block', - _key: nextKey('b'), - style: 'normal', - listItem, - level, - markDefs: [], - children: [span(nextKey, text, [])], - } as unknown as PortableTextBlock -} - -function makeUnit( - random: () => number, - sentinel: () => string, - nextKey: KeyFactory, - index: number, - previousKind: ContentKind | 'listRun' | undefined, -): Unit { - const roll = random() - - // Two adjacent list runs would join into one continuously renumbered - // list under the real serializer's block-spacing rule (consecutive - // list items always join with a single newline), which the per-unit - // rendering below cannot reproduce in isolation: forcing a plain - // paragraph here instead keeps every unit's markdown self-contained. - if (roll >= 0.94 && previousKind === 'listRun') { - const tokens: Array = [{value: sentinel()}, {value: sentinel()}] - const blockKey = nextKey('b') - const block = { - _type: 'block', - _key: blockKey, - style: 'normal', - markDefs: [], - children: [span(nextKey, tokens.map((t) => t.value).join(' '), [])], - } as unknown as PortableTextBlock - return {kind: 'plain', index, block, blockKey, tokens, outcome: 'unchanged'} - } - - if (roll < 0.15) { - const tokens: Array = [{value: sentinel()}, {value: sentinel()}] - if (random() < 0.4) { - tokens.push({value: sentinel()}) - } - const blockKey = nextKey('b') - const block = { - _type: 'block', - _key: blockKey, - style: 'normal', - markDefs: [], - children: [span(nextKey, tokens.map((t) => t.value).join(' '), [])], - } as unknown as PortableTextBlock - return {kind: 'plain', index, block, blockKey, tokens, outcome: 'unchanged'} - } - - if (roll < 0.25 || roll < 0.32) { - const style = roll < 0.25 ? 'h2' : 'h3' - const tokens: Array = [{value: sentinel()}] - if (random() < 0.5) { - tokens.push({value: sentinel()}) - } - const blockKey = nextKey('b') - const block = { - _type: 'block', - _key: blockKey, - style, - markDefs: [], - children: [span(nextKey, tokens.map((t) => t.value).join(' '), [])], - } as unknown as PortableTextBlock - return { - kind: style === 'h2' ? 'heading2' : 'heading3', - index, - block, - blockKey, - tokens, - outcome: 'unchanged', - } - } - - if (roll < 0.4) { - const tokens: Array = [{value: sentinel()}] - if (random() < 0.5) { - tokens.push({value: sentinel()}) - } - const blockKey = nextKey('b') - const block = { - _type: 'block', - _key: blockKey, - style: 'lead', - markDefs: [], - children: [span(nextKey, tokens.map((t) => t.value).join(' '), [])], - } as unknown as PortableTextBlock - return {kind: 'lead', index, block, blockKey, tokens, outcome: 'unchanged'} - } - - if (roll < 0.48) { - const markName = pick(random, ['strong', 'em', 'highlight'] as const) - const tokens: Array = [ - {value: sentinel()}, - {value: sentinel()}, - {value: sentinel()}, - ] - const blockKey = nextKey('b') - const block = { - _type: 'block', - _key: blockKey, - style: 'normal', - markDefs: [], - children: [ - span(nextKey, `${tokens[0]!.value} `, []), - span(nextKey, tokens[1]!.value, [markName]), - span(nextKey, ` ${tokens[2]!.value}`, []), - ], - } as unknown as PortableTextBlock - return { - kind: 'decorator', - index, - block, - blockKey, - tokens, - outcome: 'unchanged', - } - } - - if (roll < 0.58) { - const linkKey = nextKey('l') - const href = `https://example.com/${nextKey('href')}` - const tokens: Array = [ - {value: sentinel()}, - {value: sentinel(), href, expectedDefKey: linkKey}, - {value: sentinel()}, - ] - const blockKey = nextKey('b') - const block = { - _type: 'block', - _key: blockKey, - style: 'normal', - markDefs: [{_key: linkKey, _type: 'link', href}], - children: [ - span(nextKey, `${tokens[0]!.value} `, []), - span(nextKey, tokens[1]!.value, [linkKey]), - span(nextKey, ` ${tokens[2]!.value}`, []), - ], - } as unknown as PortableTextBlock - return {kind: 'link', index, block, blockKey, tokens, outcome: 'unchanged'} - } - - if (roll < 0.66) { - const markName = pick(random, ['strong', 'em'] as const) - const linkKey = nextKey('l') - const href = `https://example.com/${nextKey('href')}` - const tokens: Array = [ - {value: sentinel(), href, expectedDefKey: linkKey}, - {value: sentinel(), href, expectedDefKey: linkKey}, - {value: sentinel(), href, expectedDefKey: linkKey}, - ] - const blockKey = nextKey('b') - const block = { - _type: 'block', - _key: blockKey, - style: 'normal', - markDefs: [{_key: linkKey, _type: 'link', href}], - children: [ - span(nextKey, `${tokens[0]!.value} `, [linkKey]), - span(nextKey, tokens[1]!.value, [linkKey, markName]), - span(nextKey, ` ${tokens[2]!.value}`, [linkKey]), - ], - } as unknown as PortableTextBlock - return { - kind: 'linkBold', - index, - block, - blockKey, - tokens, - outcome: 'unchanged', - } - } - - if (roll < 0.74) { - const linkKeyA = nextKey('l') - const linkKeyB = nextKey('l') - const href = `https://example.com/${nextKey('href')}` - const tokens: Array = [ - {value: sentinel(), href, expectedDefKey: linkKeyA}, - {value: sentinel()}, - {value: sentinel(), href, expectedDefKey: linkKeyB}, - ] - const blockKey = nextKey('b') - const block = { - _type: 'block', - _key: blockKey, - style: 'normal', - markDefs: [ - {_key: linkKeyA, _type: 'link', href}, - {_key: linkKeyB, _type: 'link', href}, - ], - children: [ - span(nextKey, tokens[0]!.value, [linkKeyA]), - span(nextKey, ` ${tokens[1]!.value} `, []), - span(nextKey, tokens[2]!.value, [linkKeyB]), - ], - } as unknown as PortableTextBlock - return { - kind: 'doubleLink', - index, - block, - blockKey, - tokens, - outcome: 'unchanged', - } - } - - if (roll < 0.82) { - const tokens: Array = [{value: sentinel()}] - if (random() < 0.5) { - tokens.push({value: sentinel()}) - } - const magic = `magic-${sentinel()}` - const blockKey = nextKey('b') - const block = { - _type: 'block', - _key: blockKey, - style: 'normal', - magic, - markDefs: [], - children: [span(nextKey, tokens.map((t) => t.value).join(' '), [])], - } as unknown as PortableTextBlock - return { - kind: 'magic', - index, - block, - blockKey, - tokens, - magic, - outcome: 'unchanged', - } - } - - if (roll < 0.88) { - const tokens: Array = [{value: sentinel()}] - const blockKey = nextKey('b') - const block = { - _type: 'code', - _key: blockKey, - language: 'js', - code: tokens[0]!.value, - } as unknown as PortableTextBlock - return {kind: 'code', index, block, blockKey, tokens, outcome: 'unchanged'} - } - - if (roll < 0.94) { - const tokens: Array = [{value: sentinel()}] - const blockKey = nextKey('b') - const block = { - _type: 'widget', - _key: blockKey, - note: tokens[0]!.value, - } as unknown as PortableTextBlock - return { - kind: 'widget', - index, - block, - blockKey, - tokens, - outcome: 'unchanged', - } - } - - const itemCount = random() < 0.5 ? 2 : 3 - const items: Array = Array.from( - {length: itemCount}, - (_, itemIndex) => { - const listItem = pick(random, ['bullet', 'number'] as const) - const level = itemIndex === 0 ? 1 : pick(random, [1, 2] as const) - const value = sentinel() - const block = makeListItemBlock(nextKey, value, listItem, level) - return {block, blockKey: block._key as string, tokens: [{value}]} - }, - ) - return {kind: 'listRun', index, items} -} - -function renderUnit(unit: Unit): string { - if (unit.kind === 'listRun') { - return portableTextToMarkdown( - unit.items.map((item) => item.block), - {schema}, - ) - } - return portableTextToMarkdown([unit.block], {schema}) -} - -function replaceOnce(text: string, oldValue: string, newValue: string): string { - const index = text.indexOf(oldValue) - if (index === -1) { - throw new Error(`expected "${oldValue}" inside ${JSON.stringify(text)}`) - } - return text.slice(0, index) + newValue + text.slice(index + oldValue.length) -} - -type LinkCheck = {word: string; href: string; expectedDefKey: string} -type MagicCheck = {word: string; expectedMagic: string} -type WidgetCheck = {word: string; expectedKey: string; expectedNote: string} -type EmptyRunCheck = {blocks: Array} - -type CaseData = { - stored: Array - editedMarkdown: string - storedKeySet: Set - blockKeyByWord: Map - freshWords: Set - removedAnyKeys: Set - linkChecks: Array - magicChecks: Array - widgetChecks: Array - emptyRunChecks: Array - untouchedBlocks: Map - appliedOpCount: number -} - -/** - * The whole model: a stratified corpus of sentinel-tagged units, 1-3 - * scripted edits applied to their serialized markdown, and the - * ownership expectations those edits imply. Every downstream - * assertion is checked against this model, never against the - * algorithm's own output. - */ -function buildCase(random: () => number, caseId: number): CaseData { - const sentinel = makeSentinelFactory(caseId) - const nextKey = makeKeyFactory() - - const unitCount = 6 + Math.floor(random() * 5) - const units: Array = [] - for (let index = 0; index < unitCount; index++) { - const previousKind = units.at(-1)?.kind - units.push(makeUnit(random, sentinel, nextKey, index, previousKind)) - } - - for (const unit of units) { - if (unit.kind === 'listRun') { - continue - } - if (random() < 0.25) { - const count = random() < 0.5 ? 1 : 2 - const blocks: Array = [] - for (let k = 0; k < count; k++) { - const textRoll = random() - const text = textRoll < 0.4 ? '' : textRoll < 0.7 ? ' ' : '\u00a0' - blocks.push({blockKey: nextKey('e'), spanKey: nextKey('es'), text}) - } - unit.trailingEmpty = blocks - } - } - - const stored: Array = [] - const slots: Array = [] - for (const unit of units) { - if (unit.kind === 'listRun') { - for (const item of unit.items) { - stored.push(item.block) - } - slots.push({sourceUnitIndex: unit.index, text: renderUnit(unit)}) - continue - } - stored.push(unit.block) - if (unit.trailingEmpty) { - for (const empty of unit.trailingEmpty) { - stored.push({ - _type: 'block', - _key: empty.blockKey, - style: 'normal', - markDefs: [], - children: [ - {_type: 'span', _key: empty.spanKey, text: empty.text, marks: []}, - ], - } as unknown as PortableTextBlock) - } - } - slots.push({sourceUnitIndex: unit.index, text: renderUnit(unit)}) - } - - const storedKeySet = new Set() - collectAllKeys(stored, storedKeySet) - - const assembled = slots.map((s) => s.text).join('\n\n') - const direct = portableTextToMarkdown(structuredClone(stored), {schema}) - if (assembled !== direct) { - throw new Error( - `paragraph model diverged from the real serializer.\nAssembled: ${JSON.stringify(assembled)}\nDirect: ${JSON.stringify(direct)}`, - ) - } - - const usedIndexes = new Set() - const reservedIndexes = new Set() - const usedSeams = new Set() - const freshUnits: Array = [] - let freshIdCounter = -1 - - const reserve = (i: number) => { - reservedIndexes.add(i - 1) - reservedIndexes.add(i) - reservedIndexes.add(i + 1) - } - const isFreeUnit = (i: number) => - i >= 0 && i < units.length && !usedIndexes.has(i) && !reservedIndexes.has(i) - - const contentIndexes = ( - filter?: (u: ContentUnit) => boolean, - ): Array => { - const out: Array = [] - units.forEach((u, i) => { - if (u.kind === 'listRun') { - return - } - if (!isFreeUnit(i)) { - return - } - if (filter && !filter(u)) { - return - } - out.push(i) - }) - return out - } - - type RewriteTarget = - | {unitIndex: number; kind: 'content'; tokenIndex: number} - | {unitIndex: number; kind: 'listItem'; itemIndex: number} - - const rewriteTargets = (): Array => { - const out: Array = [] - units.forEach((u, i) => { - if (!isFreeUnit(i)) { - return - } - if (u.kind === 'listRun') { - u.items.forEach((_, itemIndex) => - out.push({unitIndex: i, kind: 'listItem', itemIndex}), - ) - return - } - if (u.kind === 'widget') { - return - } - u.tokens.forEach((_, tokenIndex) => - out.push({unitIndex: i, kind: 'content', tokenIndex}), - ) - }) - return out - } - - const wholeBlockTargets = (): Array => contentIndexes() - const splitTargets = (): Array => - contentIndexes((u) => u.kind === 'plain' && u.tokens.length >= 2) - const mergeTargets = (): Array => { - const out: Array = [] - for (let i = 0; i < units.length - 1; i++) { - const a = units[i]! - const b = units[i + 1]! - if (a.kind !== 'plain' || b.kind !== 'plain') { - continue - } - if (!isFreeUnit(i) || !isFreeUnit(i + 1)) { - continue - } - out.push(i) - } - return out - } - const seamCandidates = (): Array => { - const out: Array = [] - for (let i = 0; i <= units.length; i++) { - if (usedSeams.has(i)) { - continue - } - const leftOk = i === 0 || isFreeUnit(i - 1) - const rightOk = i === units.length || isFreeUnit(i) - if (leftOk && rightOk) { - out.push(i) - } - } - return out - } - const commitSeam = (seam: number) => { - usedSeams.add(seam) - if (seam - 1 >= 0) { - usedIndexes.add(seam - 1) - reserve(seam - 1) - } - if (seam < units.length) { - usedIndexes.add(seam) - reserve(seam) - } - } - const findSlotIndexForUnit = (unitIndex: number): number => - slots.findIndex((s) => s.sourceUnitIndex === unitIndex) - const seamPosition = (seam: number): number => { - if (seam === units.length) { - return slots.length - } - return slots.findIndex((s) => s.sourceUnitIndex === seam) - } - - const applyOneOp = (opType: string): boolean => { - if (opType === 'rewrite' || opType === 'append') { - const targets = rewriteTargets() - if (targets.length === 0) { - return false - } - const target = pick(random, targets) - const newWord = sentinel() - const slotIdx = findSlotIndexForUnit(target.unitIndex) - if (target.kind === 'listItem') { - const unit = units[target.unitIndex] as ListRunUnit - const item = unit.items[target.itemIndex]! - const anchor = item.tokens[0]!.value - if (opType === 'rewrite') { - slots[slotIdx]!.text = replaceOnce( - slots[slotIdx]!.text, - anchor, - newWord, - ) - item.tokens[0]!.value = newWord - } else { - slots[slotIdx]!.text = replaceOnce( - slots[slotIdx]!.text, - anchor, - `${anchor} ${newWord}`, - ) - item.tokens.push({value: newWord}) - } - } else { - const unit = units[target.unitIndex] as ContentUnit - const token = unit.tokens[target.tokenIndex]! - if (opType === 'rewrite') { - slots[slotIdx]!.text = replaceOnce( - slots[slotIdx]!.text, - token.value, - newWord, - ) - token.value = newWord - } else { - slots[slotIdx]!.text = replaceOnce( - slots[slotIdx]!.text, - token.value, - `${token.value} ${newWord}`, - ) - unit.tokens.push({ - value: newWord, - href: token.href, - expectedDefKey: token.expectedDefKey, - }) - } - } - usedIndexes.add(target.unitIndex) - reserve(target.unitIndex) - return true - } - - if (opType === 'delete') { - const targets = wholeBlockTargets() - if (targets.length === 0) { - return false - } - const target = pick(random, targets) - const slotIdx = findSlotIndexForUnit(target) - slots.splice(slotIdx, 1) - ;(units[target] as ContentUnit).outcome = 'removed' - usedIndexes.add(target) - reserve(target) - return true - } - - if (opType === 'move') { - const sourceCandidates = wholeBlockTargets() - if (sourceCandidates.length === 0) { - return false - } - const source = pick(random, sourceCandidates) - const seams = seamCandidates().filter( - (s) => s !== source && s !== source + 1, - ) - if (seams.length === 0) { - return false - } - const seam = pick(random, seams) - const slotIdx = findSlotIndexForUnit(source) - const text = slots[slotIdx]!.text - slots.splice(slotIdx, 1) - const pos = seamPosition(seam) - slots.splice(pos, 0, {sourceUnitIndex: source, text}) - usedIndexes.add(source) - reserve(source) - commitSeam(seam) - return true - } - - if (opType === 'split') { - const targets = splitTargets() - if (targets.length === 0) { - return false - } - const target = pick(random, targets) - const unit = units[target] as ContentUnit - const splitAt = 1 + Math.floor(random() * (unit.tokens.length - 1)) - const firstTokens = unit.tokens.slice(0, splitAt) - const secondTokens = unit.tokens.slice(splitAt) - unit.tokens = firstTokens - const slotIdx = findSlotIndexForUnit(target) - slots[slotIdx]!.text = firstTokens.map((t) => t.value).join(' ') - freshUnits.push({ - id: freshIdCounter--, - tokens: secondTokens.map((t) => ({value: t.value})), - }) - slots.splice(slotIdx + 1, 0, { - sourceUnitIndex: null, - text: secondTokens.map((t) => t.value).join(' '), - }) - usedIndexes.add(target) - reserve(target) - return true - } - - if (opType === 'merge') { - const targets = mergeTargets() - if (targets.length === 0) { - return false - } - const first = pick(random, targets) - const second = first + 1 - const unitA = units[first] as ContentUnit - const unitB = units[second] as ContentUnit - const slotIdxA = findSlotIndexForUnit(first) - const slotIdxB = findSlotIndexForUnit(second) - slots[slotIdxA]!.text = - `${slots[slotIdxA]!.text} ${slots[slotIdxB]!.text}` - slots.splice(slotIdxB, 1) - unitA.tokens.push(...unitB.tokens) - unitB.tokens = [] - unitB.outcome = 'removed' - usedIndexes.add(first) - usedIndexes.add(second) - reserve(first) - reserve(second) - return true - } - - if (opType === 'insert') { - const seams = seamCandidates() - if (seams.length === 0) { - return false - } - const seam = pick(random, seams) - const tokenCount = 1 + Math.floor(random() * 2) - const tokens: Array = Array.from({length: tokenCount}, () => ({ - value: sentinel(), - })) - freshUnits.push({id: freshIdCounter--, tokens}) - const pos = seamPosition(seam) - slots.splice(pos, 0, { - sourceUnitIndex: null, - text: tokens.map((t) => t.value).join(' '), - }) - commitSeam(seam) - return true - } - - return false - } - - const opCount = 1 + Math.floor(random() * 3) - let applied = 0 - let guard = 0 - while (applied < opCount && guard < 40) { - guard++ - const opType = pick(random, [ - 'rewrite', - 'append', - 'delete', - 'move', - 'split', - 'merge', - 'merge', - 'merge', - 'merge', - 'insert', - ]) - if (applyOneOp(opType)) { - applied++ - } - } - - const editedMarkdown = slots.map((s) => s.text).join('\n\n') - - const blockKeyByWord = new Map() - const freshWords = new Set() - const removedAnyKeys = new Set() - const linkChecks: Array = [] - const magicChecks: Array = [] - const widgetChecks: Array = [] - const emptyRunChecks: Array = [] - const untouchedBlocks = new Map() - - for (const unit of units) { - if (unit.kind === 'listRun') { - for (const item of unit.items) { - for (const token of item.tokens) { - blockKeyByWord.set(token.value, item.blockKey) - } - } - // No op ever targets an individual list item on its own: a - // rewrite/append marks the whole run's unit index used, so an - // untouched run means every one of its items is untouched too. - if (!usedIndexes.has(unit.index)) { - for (const item of unit.items) { - untouchedBlocks.set(item.blockKey, item.block) - } - } - continue - } - if (unit.outcome === 'removed') { - removedAnyKeys.add(unit.blockKey) - if (unit.trailingEmpty) { - for (const empty of unit.trailingEmpty) { - removedAnyKeys.add(empty.blockKey) - } - } - continue - } - for (const token of unit.tokens) { - blockKeyByWord.set(token.value, unit.blockKey) - if (token.href) { - linkChecks.push({ - word: token.value, - href: token.href, - expectedDefKey: token.expectedDefKey!, - }) - } - } - if (unit.kind === 'magic') { - magicChecks.push({ - word: unit.tokens[0]!.value, - expectedMagic: unit.magic!, - }) - } - if (unit.kind === 'widget') { - widgetChecks.push({ - word: unit.tokens[0]!.value, - expectedKey: unit.blockKey, - expectedNote: unit.tokens[0]!.value, - }) - } - if (unit.trailingEmpty) { - emptyRunChecks.push({blocks: unit.trailingEmpty}) - } - // No op ever targets this unit: no rewrite touched its text, no - // move relocated it, nothing merged or split it away. The block - // the algorithm returns for its key should be the stored block, - // not merely a block whose sentinel words still resolve there. - if (!usedIndexes.has(unit.index)) { - untouchedBlocks.set(unit.blockKey, unit.block) - } - } - for (const fresh of freshUnits) { - for (const token of fresh.tokens) { - freshWords.add(token.value) - } - } - - return { - stored, - editedMarkdown, - storedKeySet, - blockKeyByWord, - freshWords, - removedAnyKeys, - linkChecks, - magicChecks, - widgetChecks, - emptyRunChecks, - untouchedBlocks, - appliedOpCount: applied, - } -} - -function textOf(node: Record): string | undefined { - if (Array.isArray(node['children'])) { - return (node['children'] as Array>) - .map((child) => - typeof child['text'] === 'string' ? (child['text'] as string) : '', - ) - .join('') - } - if (typeof node['code'] === 'string') { - return node['code'] as string - } - if (typeof node['note'] === 'string') { - return node['note'] as string - } - return undefined -} - -function findOwningNode( - result: ReadonlyArray>, - word: string, -): Record | undefined { - return result.find((node) => { - const text = textOf(node) - return typeof text === 'string' && text.includes(word) - }) -} - -function findOwningSpan( - node: Record, - word: string, -): Record | undefined { - const children = node['children'] - if (!Array.isArray(children)) { - return undefined - } - return (children as Array>).find( - (child) => - typeof child['text'] === 'string' && - (child['text'] as string).includes(word), - ) -} - -function collectAllKeys(value: unknown, keys: Set): void { - if (Array.isArray(value)) { - for (const item of value) { - collectAllKeys(item, keys) - } - return - } - if (typeof value === 'object' && value !== null) { - for (const [field, fieldValue] of Object.entries(value)) { - if (field === '_key' && typeof fieldValue === 'string') { - keys.add(fieldValue) - } else { - collectAllKeys(fieldValue, keys) - } - } - } -} - -function assertSiblingKeysUnique(value: unknown): void { - if (Array.isArray(value)) { - const keys = value - .map((item) => - typeof item === 'object' && item !== null - ? (item as {_key?: unknown})._key - : undefined, - ) - .filter((key): key is string => typeof key === 'string') - expect(new Set(keys).size).toBe(keys.length) - for (const item of value) { - assertSiblingKeysUnique(item) - } - return - } - if (typeof value === 'object' && value !== null) { - for (const fieldValue of Object.values(value)) { - assertSiblingKeysUnique(fieldValue) - } - } -} - -/** - * The ownership oracle: checks the algorithm's output against the - * model built alongside the scripted edits, never against the - * algorithm's own reasoning. Every assertion here failing on real - * input is a finding, not a model bug, unless noted otherwise in the - * findings report. - */ -function assertOwnership( - caseData: CaseData, - result: ReadonlyArray>, -): void { - for (const [word, expectedKey] of caseData.blockKeyByWord) { - const node = findOwningNode(result, word) - expect( - node?.['_key'], - `sentinel "${word}" should sit on block "${expectedKey}"`, - ).toBe(expectedKey) - } - - for (const word of caseData.freshWords) { - const node = findOwningNode(result, word) - expect( - node?.['_key'], - `fresh sentinel "${word}" should be present`, - ).not.toBe(undefined) - expect( - caseData.storedKeySet.has(node?.['_key'] as string), - `fresh sentinel "${word}" should not carry a stored key`, - ).toBe(false) - } - - const resultKeys = new Set() - collectAllKeys(result, resultKeys) - for (const key of caseData.removedAnyKeys) { - expect( - resultKeys.has(key), - `removed key "${key}" should not appear anywhere`, - ).toBe(false) - } - - for (const check of caseData.linkChecks) { - const node = findOwningNode(result, check.word) ?? {} - const span = findOwningSpan(node, check.word) ?? {} - expect( - span['text'], - `link sentinel "${check.word}" should resolve to a span`, - ).not.toBe(undefined) - const markDefs = node['markDefs'] - const marks = span['marks'] - const linkDef = Array.isArray(markDefs) - ? (markDefs as Array>).find( - (def) => - Array.isArray(marks) && - marks.includes(def['_key']) && - def['_type'] === 'link', - ) - : undefined - expect( - linkDef?.['href'], - `sentinel "${check.word}" should resolve through marks to a link with href "${check.href}"`, - ).toBe(check.href) - expect(linkDef?.['_key']).toBe(check.expectedDefKey) - } - - for (const check of caseData.magicChecks) { - const node = findOwningNode(result, check.word) - expect( - node?.['magic'], - `magic sentinel "${check.word}" should carry "${check.expectedMagic}"`, - ).toBe(check.expectedMagic) - } - - for (const check of caseData.widgetChecks) { - const node = findOwningNode(result, check.word) - expect( - node?.['_key'], - `widget sentinel "${check.word}" should be present`, - ).toBe(check.expectedKey) - expect(node?.['note']).toBe(check.expectedNote) - } - - for (const run of caseData.emptyRunChecks) { - for (const block of run.blocks) { - const node = result.find((n) => n['_key'] === block.blockKey) - expect(node).toEqual({ - _type: 'block', - _key: block.blockKey, - style: 'normal', - markDefs: [], - children: [ - {_type: 'span', _key: block.spanKey, text: block.text, marks: []}, - ], - }) - } - } - - for (const [key, storedBlock] of caseData.untouchedBlocks) { - const node = result.find((n) => n['_key'] === key) - expect( - node, - `untouched block "${key}" should come back exactly as stored`, - ).toEqual(storedBlock) - } -} - -function runCase(caseData: CaseData): Array { - return applyMarkdownEdit(caseData.stored, caseData.editedMarkdown, { - schema, - deserialize: {keyGenerator: createTestKeyGenerator()}, - }) as unknown as Array -} - -describe('applyMarkdownEdit ownership (seeded model-based fuzz)', () => { - const SEEDS = [1, 2, 3, 4, 5, 6, 7, 8, 9, 10] - const CASES_PER_SEED = 30 - - test.each(SEEDS)('seed %i', (seed) => { - const random = mulberry32(seed) - - for (let caseIndex = 0; caseIndex < CASES_PER_SEED; caseIndex++) { - const caseId = seed * 1000 + caseIndex - const caseData = buildCase(random, caseId) - const storedSnapshot = structuredClone(caseData.stored) - - const result = runCase(caseData) - - assertOwnership( - caseData, - result as unknown as Array>, - ) - assertSiblingKeysUnique(result) - - // Inputs are never mutated. - expect(caseData.stored).toEqual(storedSnapshot) - - // Determinism: same inputs, same output. - const again = applyMarkdownEdit(storedSnapshot, caseData.editedMarkdown, { - schema, - deserialize: {keyGenerator: createTestKeyGenerator()}, - }) - expect(again).toEqual(result) - } - }) - - test('the oracle rejects a swapped-key corruption of a real result (falsifiability)', () => { - const random = mulberry32(4242) - const caseData = buildCase(random, 4242_000) - const result = runCase(caseData) as unknown as Array< - Record - > - - // The real result passes. - assertOwnership(caseData, result) - - const corrupted = structuredClone(result) - expect(corrupted.length).toBeGreaterThanOrEqual(2) - const firstKey = corrupted[0]!['_key'] - corrupted[0]!['_key'] = corrupted[1]!['_key'] - corrupted[1]!['_key'] = firstKey - - expect(() => assertOwnership(caseData, corrupted)).toThrow() - }) -}) - -const LARGE_CAMPAIGN_SIZE = Number(process.env['OWNERSHIP_FUZZ_CASES'] ?? 0) - -test.skipIf(LARGE_CAMPAIGN_SIZE <= 0)( - 'applyMarkdownEdit ownership large campaign (OWNERSHIP_FUZZ_CASES)', - () => { - const random = mulberry32(424242) - for (let index = 0; index < LARGE_CAMPAIGN_SIZE; index++) { - const caseData = buildCase(random, 424242_000 + index) - const storedSnapshot = structuredClone(caseData.stored) - const result = runCase(caseData) - assertOwnership( - caseData, - result as unknown as Array>, - ) - assertSiblingKeysUnique(result) - expect(caseData.stored).toEqual(storedSnapshot) - } - }, -) diff --git a/packages/markdown/src/apply-markdown-edit.reconciliation.test.ts b/packages/markdown/src/apply-markdown-edit.reconciliation.test.ts deleted file mode 100644 index d1254a37c6..0000000000 --- a/packages/markdown/src/apply-markdown-edit.reconciliation.test.ts +++ /dev/null @@ -1,4047 +0,0 @@ -import { - compileSchema, - defineSchema, - type PortableTextBlock, -} from '@portabletext/schema' -import {createTestKeyGenerator} from '@portabletext/test' -import {describe, expect, test, vi} from 'vitest' -import { - applyMarkdownEdit, - type ReconciliationReport, -} from './apply-markdown-edit' -import {portableTextToMarkdown} from './from-portable-text/portable-text-to-markdown' -import {DefaultUnknownTypeRenderer} from './from-portable-text/renderers/type' - -function block( - blockKey: string, - spanKey: string, - text: string, -): PortableTextBlock { - return { - _type: 'block', - _key: blockKey, - style: 'normal', - markDefs: [], - children: [{_type: 'span', _key: spanKey, text, marks: []}], - } -} - -describe('the reconciliation report', () => { - test('a typo fix reports the untouched siblings preserved as content-unchanged and the edited block preserved as same-position', () => { - const keyGenerator = createTestKeyGenerator() - const stored = [ - block('b1', 's1', 'Our pick:'), - {_type: 'product', _key: 'p1', sku: 'abc-123'}, - block('b2', 's2', 'Ships tomorow.'), - ] - const markdown = portableTextToMarkdown(structuredClone(stored)).replace( - 'tomorow', - 'tomorrow', - ) - let report!: ReconciliationReport - applyMarkdownEdit(stored, markdown, { - deserialize: {keyGenerator}, - onReconciliation: (r) => { - report = r - }, - }) - expect(report).toEqual({ - keyMatching: 'performed', - preservedKeys: [ - { - basis: 'content-unchanged', - key: 'b1', - path: [{_key: 'b1'}], - valueChanged: false, - }, - { - basis: 'content-unchanged', - key: 's1', - path: [{_key: 'b1'}, 'children', {_key: 's1'}], - valueChanged: false, - }, - { - basis: 'content-unchanged', - key: 'p1', - path: [{_key: 'p1'}], - valueChanged: false, - }, - { - basis: 'same-position', - key: 'b2', - path: [{_key: 'b2'}], - valueChanged: true, - }, - { - basis: 'same-position', - key: 's2', - path: [{_key: 'b2'}, 'children', {_key: 's2'}], - valueChanged: true, - }, - ], - keyFallbacks: [], - renamedKeys: [], - hiddenLosses: [], - markdownLosses: [], - undeclaredObjects: [], - }) - }) - - test('an edited `json:object` payload reports `valueChanged` on its preserved key', () => { - const keyGenerator = createTestKeyGenerator() - const stored = [ - block('b1', 's1', 'text'), - {_type: 'product', _key: 'p1', sku: 'abc'}, - ] - const markdown = portableTextToMarkdown(structuredClone(stored)).replace( - '"sku": "abc"', - '"sku": "xyz"', - ) - let report!: ReconciliationReport - const result = applyMarkdownEdit(stored, markdown, { - deserialize: {keyGenerator}, - onReconciliation: (r) => { - report = r - }, - }) - expect(result).toEqual([ - block('b1', 's1', 'text'), - {_type: 'product', _key: 'p1', sku: 'xyz'}, - ]) - expect(report).toEqual({ - keyMatching: 'performed', - preservedKeys: [ - { - basis: 'content-unchanged', - key: 'b1', - path: [{_key: 'b1'}], - valueChanged: false, - }, - { - basis: 'content-unchanged', - key: 's1', - path: [{_key: 'b1'}, 'children', {_key: 's1'}], - valueChanged: false, - }, - { - basis: 'same-position', - key: 'p1', - path: [{_key: 'p1'}], - valueChanged: true, - }, - ], - keyFallbacks: [], - renamedKeys: [], - hiddenLosses: [], - markdownLosses: [], - undeclaredObjects: [], - }) - }) - - test('a stored span with no `_key` reports `valueChanged: true` once key filling mints one', () => { - const keyGenerator = createTestKeyGenerator() - const stored = [ - { - _type: 'block', - _key: 'b1', - style: 'normal', - markDefs: [], - children: [{_type: 'span', text: 'foo', marks: []}], - }, - block('b2', 's2', 'bar'), - ] - const markdown = portableTextToMarkdown(structuredClone(stored)).replace( - 'bar', - 'baz', - ) - let report!: ReconciliationReport - const result = applyMarkdownEdit(stored, markdown, { - deserialize: {keyGenerator}, - onReconciliation: (r) => { - report = r - }, - }) - expect(result).toEqual([ - { - _type: 'block', - _key: 'b1', - style: 'normal', - markDefs: [], - children: [{_type: 'span', _key: 'k1', text: 'foo', marks: []}], - }, - block('b2', 's2', 'baz'), - ]) - expect(report).toEqual({ - keyMatching: 'performed', - preservedKeys: [ - { - basis: 'content-unchanged', - key: 'b1', - path: [{_key: 'b1'}], - valueChanged: true, - }, - { - basis: 'content-unchanged', - key: 'k1', - path: [{_key: 'b1'}, 'children', {_key: 'k1'}], - valueChanged: true, - }, - { - basis: 'same-position', - key: 'b2', - path: [{_key: 'b2'}], - valueChanged: true, - }, - { - basis: 'same-position', - key: 's2', - path: [{_key: 'b2'}, 'children', {_key: 's2'}], - valueChanged: true, - }, - ], - keyFallbacks: [], - renamedKeys: [], - hiddenLosses: [], - markdownLosses: [], - undeclaredObjects: [], - }) - }) - - test('a moved block reports `valueChanged: false` when only its position changed', () => { - const keyGenerator = createTestKeyGenerator() - const stored = [block('b1', 's1', 'alpha'), block('b2', 's2', 'beta')] - const markdown = 'beta\n\nalpha' - let report!: ReconciliationReport - applyMarkdownEdit(stored, markdown, { - deserialize: {keyGenerator}, - onReconciliation: (r) => { - report = r - }, - }) - expect(report).toEqual({ - keyMatching: 'performed', - preservedKeys: [ - { - basis: 'content-unchanged', - key: 'b2', - path: [{_key: 'b2'}], - valueChanged: false, - }, - { - basis: 'content-unchanged', - key: 's2', - path: [{_key: 'b2'}, 'children', {_key: 's2'}], - valueChanged: false, - }, - { - basis: 'content-moved', - key: 'b1', - path: [{_key: 'b1'}], - valueChanged: false, - }, - { - basis: 'content-unchanged', - key: 's1', - path: [{_key: 'b1'}, 'children', {_key: 's1'}], - valueChanged: false, - }, - ], - keyFallbacks: [], - renamedKeys: [], - hiddenLosses: [], - markdownLosses: [], - undeclaredObjects: [], - }) - }) - - test('a moved block reports its key preserved as content-moved', () => { - const keyGenerator = createTestKeyGenerator() - const stored = [block('b1', 's1', 'alpha'), block('b2', 's2', 'beta')] - const markdown = 'beta\n\nalpha' - let report!: ReconciliationReport - applyMarkdownEdit(stored, markdown, { - deserialize: {keyGenerator}, - onReconciliation: (r) => { - report = r - }, - }) - expect(report).toEqual({ - keyMatching: 'performed', - preservedKeys: [ - { - basis: 'content-unchanged', - key: 'b2', - path: [{_key: 'b2'}], - valueChanged: false, - }, - { - basis: 'content-unchanged', - key: 's2', - path: [{_key: 'b2'}, 'children', {_key: 's2'}], - valueChanged: false, - }, - { - basis: 'content-moved', - key: 'b1', - path: [{_key: 'b1'}], - valueChanged: false, - }, - { - basis: 'content-unchanged', - key: 's1', - path: [{_key: 'b1'}, 'children', {_key: 's1'}], - valueChanged: false, - }, - ], - keyFallbacks: [], - renamedKeys: [], - hiddenLosses: [], - markdownLosses: [], - undeclaredObjects: [], - }) - }) - - test('a split reports the surviving fragment preserved as content-split', () => { - const keyGenerator = createTestKeyGenerator() - const stored = [block('b1', 's1', 'alphabeta')] - const markdown = 'alpha\n\nbeta' - let report!: ReconciliationReport - applyMarkdownEdit(stored, markdown, { - deserialize: {keyGenerator}, - onReconciliation: (r) => { - report = r - }, - }) - expect(report).toEqual({ - keyMatching: 'performed', - preservedKeys: [ - { - basis: 'content-split', - key: 'b1', - path: [{_key: 'b1'}], - valueChanged: true, - }, - { - basis: 'same-position', - key: 's1', - path: [{_key: 'b1'}, 'children', {_key: 's1'}], - valueChanged: true, - }, - ], - keyFallbacks: [], - renamedKeys: [], - hiddenLosses: [], - markdownLosses: [], - undeclaredObjects: [], - }) - }) - - test('a merge reports the surviving block preserved as content-merged', () => { - const keyGenerator = createTestKeyGenerator() - const stored = [block('b1', 's1', 'alpha'), block('b2', 's2', 'beta')] - const markdown = 'alphabeta' - let report!: ReconciliationReport - applyMarkdownEdit(stored, markdown, { - deserialize: {keyGenerator}, - onReconciliation: (r) => { - report = r - }, - }) - expect(report).toEqual({ - keyMatching: 'performed', - preservedKeys: [ - { - basis: 'content-merged', - key: 'b1', - path: [{_key: 'b1'}], - valueChanged: true, - }, - { - basis: 'same-position', - key: 's1', - path: [{_key: 'b1'}, 'children', {_key: 's1'}], - valueChanged: true, - }, - ], - keyFallbacks: [], - renamedKeys: [], - hiddenLosses: [], - markdownLosses: [], - undeclaredObjects: [], - }) - }) - - test('an unequal-gap edit reports a mutual-best match as similar-content', () => { - const keyGenerator = createTestKeyGenerator() - const stored = [ - block('b1', 's1', 'the quick brown fox jumps'), - block( - 'b2', - 's2', - 'over the lazy dog and quietly disappeared into the night', - ), - ] - const markdown = [ - 'the quick brown fox jumps', - 'new paragraph inserted', - 'over the lazy dog and quiet disappeared into the night', - ].join('\n\n') - let report!: ReconciliationReport - applyMarkdownEdit(stored, markdown, { - deserialize: {keyGenerator}, - onReconciliation: (r) => { - report = r - }, - }) - expect(report).toEqual({ - keyMatching: 'performed', - preservedKeys: [ - { - basis: 'content-unchanged', - key: 'b1', - path: [{_key: 'b1'}], - valueChanged: false, - }, - { - basis: 'content-unchanged', - key: 's1', - path: [{_key: 'b1'}, 'children', {_key: 's1'}], - valueChanged: false, - }, - { - basis: 'similar-content', - key: 'b2', - path: [{_key: 'b2'}], - valueChanged: true, - }, - { - basis: 'same-position', - key: 's2', - path: [{_key: 'b2'}, 'children', {_key: 's2'}], - valueChanged: true, - }, - ], - keyFallbacks: [], - renamedKeys: [], - hiddenLosses: [], - markdownLosses: [], - undeclaredObjects: [], - }) - }) - - test('a whole-document skip of key matching reports round-trip-mismatch with no preserved keys', () => { - const keyGenerator = createTestKeyGenerator() - const stored = [ - block('b1', 's1', 'foo'), - { - _type: 'block', - _key: 'b2', - style: 'h3', - markDefs: [], - children: [{_type: 'span', _key: 's2', text: 'bar\nbaz', marks: []}], - }, - ] - const markdown = portableTextToMarkdown(structuredClone(stored)) - let report!: ReconciliationReport - const result = applyMarkdownEdit(stored, markdown, { - deserialize: {keyGenerator}, - onReconciliation: (r) => { - report = r - }, - }) - expect(result).toEqual([ - block('k0', 'k1', 'foo'), - { - _type: 'block', - _key: 'k2', - style: 'h3', - markDefs: [], - children: [{_type: 'span', _key: 'k3', text: 'bar', marks: []}], - }, - block('k4', 'k5', 'baz'), - ]) - expect(report).toEqual({ - keyMatching: 'skipped', - reason: 'round-trip-mismatch', - mismatch: { - type: 'text-changed', - storedPath: [{_key: 'b2'}, 'children', {_key: 's2'}], - message: - "The block's text came back different, starting at the text `\\nbaz`", - }, - renamedKeys: [], - hiddenLosses: [], - markdownLosses: [], - undeclaredObjects: [], - }) - }) - - test('a round-trip-mismatch from an inline object reparsing as text reports the inline object', () => { - const keyGenerator = createTestKeyGenerator() - const stored = [ - block('b1', 's1', 'foo'), - { - _type: 'block', - _key: 'b2', - style: 'normal', - markDefs: [], - children: [ - {_type: 'span', _key: 's2', text: 'bar ', marks: []}, - {_type: 'wikilink', _key: 'w1'}, - ], - }, - block('b3', 's3', 'baz'), - ] - const serialize = {types: {wikilink: () => '[[foo]]'}} - const markdown = portableTextToMarkdown(structuredClone(stored), serialize) - let report!: ReconciliationReport - const result = applyMarkdownEdit(stored, markdown, { - serialize, - deserialize: {keyGenerator}, - onReconciliation: (r) => { - report = r - }, - }) - expect(result).toEqual([ - block('k0', 'k1', 'foo'), - block('k2', 'k3', 'bar [[foo]]'), - block('k4', 'k5', 'baz'), - ]) - expect(report).toEqual({ - keyMatching: 'skipped', - reason: 'round-trip-mismatch', - mismatch: { - type: 'text-changed', - storedPath: [{_key: 'b2'}, 'children', {_key: 'w1'}], - message: - "The block's text came back different, starting at the `wikilink` inline object", - snippet: '[[foo]]', - }, - renamedKeys: [], - hiddenLosses: [], - markdownLosses: [], - undeclaredObjects: [], - }) - }) - - test('a round-trip-mismatch from a mark renderer rewriting span text reports the span', () => { - const keyGenerator = createTestKeyGenerator() - const stored = [ - block('b1', 's1', 'foo'), - { - _type: 'block', - _key: 'b2', - style: 'normal', - markDefs: [], - children: [ - {_type: 'span', _key: 's2', text: 'foo ', marks: []}, - {_type: 'span', _key: 's3', text: 'bar', marks: ['strong']}, - {_type: 'span', _key: 's4', text: ' baz', marks: []}, - ], - }, - block('b3', 's5', 'baz'), - ] - const serialize = { - marks: { - strong: ({children}: {children: string}) => children.toUpperCase(), - }, - } - const markdown = portableTextToMarkdown(structuredClone(stored), serialize) - let report!: ReconciliationReport - const result = applyMarkdownEdit(stored, markdown, { - serialize, - deserialize: {keyGenerator}, - onReconciliation: (r) => { - report = r - }, - }) - expect(result).toEqual([ - block('k0', 'k1', 'foo'), - block('k2', 'k3', 'foo BAR baz'), - block('k4', 'k5', 'baz'), - ]) - expect(report).toEqual({ - keyMatching: 'skipped', - reason: 'round-trip-mismatch', - mismatch: { - type: 'text-changed', - storedPath: [{_key: 'b2'}, 'children', {_key: 's3'}], - message: - "The block's text came back different, starting at the text `bar`", - snippet: 'BAR baz', - }, - renamedKeys: [], - hiddenLosses: [], - markdownLosses: [], - undeclaredObjects: [], - }) - }) - - test('a round-trip-mismatch past leading whitespace reports the child at the untrimmed offset', () => { - const keyGenerator = createTestKeyGenerator() - const stored = [ - block('b1', 's1', 'foo'), - { - _type: 'block', - _key: 'b2', - style: 'normal', - markDefs: [], - children: [ - {_type: 'span', _key: 's2', text: ' foo ', marks: []}, - {_type: 'span', _key: 's3', text: 'bar ', marks: []}, - {_type: 'wikilink', _key: 'w1'}, - ], - }, - ] - const serialize = {types: {wikilink: () => '[[baz]]'}} - const markdown = portableTextToMarkdown(structuredClone(stored), serialize) - let report!: ReconciliationReport - const result = applyMarkdownEdit(stored, markdown, { - serialize, - deserialize: {keyGenerator}, - onReconciliation: (r) => { - report = r - }, - }) - expect(result).toEqual([ - block('k0', 'k1', 'foo'), - block('k2', 'k3', 'foo bar [[baz]]'), - ]) - expect(report).toEqual({ - keyMatching: 'skipped', - reason: 'round-trip-mismatch', - mismatch: { - type: 'text-changed', - storedPath: [{_key: 'b2'}, 'children', {_key: 'w1'}], - message: - "The block's text came back different, starting at the `wikilink` inline object", - snippet: '[[baz]]', - }, - renamedKeys: [], - hiddenLosses: [], - markdownLosses: [], - undeclaredObjects: [], - }) - }) - - test('a round-trip-mismatch where the stored text is a prefix of what came back reports the block', () => { - const keyGenerator = createTestKeyGenerator() - const stored = [ - { - _type: 'block', - _key: 'b1', - style: 'normal', - markDefs: [], - children: [ - {_type: 'span', _key: 's1', text: 'foo', marks: []}, - {_type: 'span', _key: 's2', text: 'bar', marks: ['strong']}, - ], - }, - ] - const serialize = { - marks: {strong: ({children}: {children: string}) => `${children} baz`}, - } - const markdown = portableTextToMarkdown(structuredClone(stored), serialize) - let report!: ReconciliationReport - const result = applyMarkdownEdit(stored, markdown, { - serialize, - deserialize: {keyGenerator}, - onReconciliation: (r) => { - report = r - }, - }) - expect(result).toEqual([block('k0', 'k1', 'foobar baz')]) - expect(report).toEqual({ - keyMatching: 'skipped', - reason: 'round-trip-mismatch', - mismatch: { - type: 'text-changed', - storedPath: [{_key: 'b1'}], - message: "The block's text came back with more text at the end", - snippet: 'baz', - }, - renamedKeys: [], - hiddenLosses: [], - markdownLosses: [], - undeclaredObjects: [], - }) - }) - - test('a round-trip-mismatch from one inline object going missing reports the position where the text first differs without claiming a loss', () => { - const keyGenerator = createTestKeyGenerator() - const stored = [ - { - _type: 'block', - _key: 'b1', - style: 'normal', - markDefs: [], - children: [ - {_type: 'span', _key: 's1', text: 'foo ', marks: []}, - {_type: 'wikilink', _key: 'w1'}, - {_type: 'wikilink', _key: 'w2'}, - ], - }, - ] - const serialize = { - types: { - wikilink: (options: Parameters[0]) => - options.value._key === 'w1' - ? '' - : DefaultUnknownTypeRenderer(options), - }, - } - const markdown = portableTextToMarkdown(structuredClone(stored), serialize) - let report!: ReconciliationReport - const result = applyMarkdownEdit(stored, markdown, { - serialize, - deserialize: {keyGenerator}, - onReconciliation: (r) => { - report = r - }, - }) - expect(result).toEqual([ - { - _type: 'block', - _key: 'k0', - style: 'normal', - markDefs: [], - children: [ - {_type: 'span', _key: 'k1', text: 'foo ', marks: []}, - {_type: 'wikilink', _key: 'w2'}, - ], - }, - ]) - expect(report).toEqual({ - keyMatching: 'skipped', - reason: 'round-trip-mismatch', - mismatch: { - type: 'text-changed', - storedPath: [{_key: 'b1'}, 'children', {_key: 'w2'}], - message: - "The block's text came back different, starting at the `wikilink` inline object", - }, - renamedKeys: [], - hiddenLosses: [], - markdownLosses: [], - undeclaredObjects: [], - }) - }) - - test('a round-trip-mismatch from a changed emoji keeps the message and snippet on whole code points', () => { - const keyGenerator = createTestKeyGenerator() - const stored = [ - { - _type: 'block', - _key: 'b1', - style: 'normal', - markDefs: [], - children: [ - {_type: 'span', _key: 's1', text: 'foo ', marks: []}, - {_type: 'span', _key: 's2', text: '😀', marks: ['strong']}, - ], - }, - ] - const serialize = { - marks: { - strong: ({children}: {children: string}) => - children.replace('😀', '😁'), - }, - } - const markdown = portableTextToMarkdown(structuredClone(stored), serialize) - let report!: ReconciliationReport - const result = applyMarkdownEdit(stored, markdown, { - serialize, - deserialize: {keyGenerator}, - onReconciliation: (r) => { - report = r - }, - }) - expect(result).toEqual([block('k0', 'k1', 'foo 😁')]) - expect(report).toEqual({ - keyMatching: 'skipped', - reason: 'round-trip-mismatch', - mismatch: { - type: 'text-changed', - storedPath: [{_key: 'b1'}, 'children', {_key: 's2'}], - message: - "The block's text came back different, starting at the text `😀`", - snippet: '😁', - }, - renamedKeys: [], - hiddenLosses: [], - markdownLosses: [], - undeclaredObjects: [], - }) - }) - - test('a round-trip-mismatch from an emoji changing only its high surrogate keeps the message and snippet on whole code points', () => { - const keyGenerator = createTestKeyGenerator() - const stored = [ - { - _type: 'block', - _key: 'b1', - style: 'normal', - markDefs: [], - children: [ - {_type: 'span', _key: 's1', text: 'foo ', marks: []}, - {_type: 'span', _key: 's2', text: '😀 bar', marks: ['strong']}, - ], - }, - ] - const serialize = { - marks: { - strong: ({children}: {children: string}) => - children.replace('😀', '🈀'), - }, - } - const markdown = portableTextToMarkdown(structuredClone(stored), serialize) - let report!: ReconciliationReport - const result = applyMarkdownEdit(stored, markdown, { - serialize, - deserialize: {keyGenerator}, - onReconciliation: (r) => { - report = r - }, - }) - expect(result).toEqual([block('k0', 'k1', 'foo 🈀 bar')]) - expect(report).toEqual({ - keyMatching: 'skipped', - reason: 'round-trip-mismatch', - mismatch: { - type: 'text-changed', - storedPath: [{_key: 'b1'}, 'children', {_key: 's2'}], - message: - "The block's text came back different, starting at the text `😀 bar`", - snippet: '🈀 bar', - }, - renamedKeys: [], - hiddenLosses: [], - markdownLosses: [], - undeclaredObjects: [], - }) - }) - - test('a round-trip-mismatch whose round-tripped text differs only by an inline object reports no snippet', () => { - const keyGenerator = createTestKeyGenerator() - const stored = [ - { - _type: 'block', - _key: 'b1', - style: 'normal', - markDefs: [], - children: [ - {_type: 'span', _key: 's1', text: 'foo ', marks: []}, - {_type: 'span', _key: 's2', text: 'bar', marks: ['strong']}, - {_type: 'wikilink', _key: 'w1'}, - ], - }, - ] - const serialize = {marks: {strong: () => ''}} - const markdown = portableTextToMarkdown(structuredClone(stored), serialize) - let report!: ReconciliationReport - const result = applyMarkdownEdit(stored, markdown, { - serialize, - deserialize: {keyGenerator}, - onReconciliation: (r) => { - report = r - }, - }) - expect(result).toEqual([ - { - _type: 'block', - _key: 'k0', - style: 'normal', - markDefs: [], - children: [ - {_type: 'span', _key: 'k1', text: 'foo ', marks: []}, - {_type: 'wikilink', _key: 'w1'}, - ], - }, - ]) - expect(report).toEqual({ - keyMatching: 'skipped', - reason: 'round-trip-mismatch', - mismatch: { - type: 'text-changed', - storedPath: [{_key: 'b1'}, 'children', {_key: 's2'}], - message: - "The block's text came back different, starting at the text `bar`", - }, - renamedKeys: [], - hiddenLosses: [], - markdownLosses: [], - undeclaredObjects: [], - }) - }) - - test('a round-trip-mismatch from a block object reparsing as a text block reports the block object', () => { - const keyGenerator = createTestKeyGenerator() - const stored = [ - block('b1', 's1', 'foo'), - {_type: 'callout', _key: 'c1', text: 'bar'}, - block('b3', 's3', 'baz'), - ] - const serialize = { - types: { - callout: ({value}: {value: {text: string}}) => value.text, - }, - } - const markdown = portableTextToMarkdown(structuredClone(stored), serialize) - let report!: ReconciliationReport - const result = applyMarkdownEdit(stored, markdown, { - serialize, - deserialize: {keyGenerator}, - onReconciliation: (r) => { - report = r - }, - }) - expect(result).toEqual([ - block('k0', 'k1', 'foo'), - block('k2', 'k3', 'bar'), - block('k4', 'k5', 'baz'), - ]) - expect(report).toEqual({ - keyMatching: 'skipped', - reason: 'round-trip-mismatch', - mismatch: { - type: 'type-changed', - storedPath: [{_key: 'c1'}], - message: 'The `callout` block came back as a `block`', - snippet: 'bar', - }, - renamedKeys: [], - hiddenLosses: [], - markdownLosses: [], - undeclaredObjects: [], - }) - }) - - test('a round-trip-mismatch on a keyless block reports indexes in the stored value, counting empty blocks', () => { - const keyGenerator = createTestKeyGenerator() - const stored = [ - block('b1', 's1', 'foo'), - block('e1', 'es1', ''), - { - _type: 'block', - style: 'h1', - markDefs: [], - children: [ - {_type: 'span', _key: 's2', text: 'bar', marks: []}, - {_type: 'span', text: '\nbaz', marks: []}, - ], - } as unknown as PortableTextBlock, - ] - const markdown = portableTextToMarkdown(structuredClone(stored)) - let report!: ReconciliationReport - const result = applyMarkdownEdit(stored, markdown, { - deserialize: {keyGenerator}, - onReconciliation: (r) => { - report = r - }, - }) - expect(result).toEqual([ - block('k0', 'k1', 'foo'), - { - _type: 'block', - _key: 'k2', - style: 'h1', - markDefs: [], - children: [{_type: 'span', _key: 'k3', text: 'bar', marks: []}], - }, - block('k4', 'k5', 'baz'), - ]) - expect(report).toEqual({ - keyMatching: 'skipped', - reason: 'round-trip-mismatch', - mismatch: { - type: 'text-changed', - storedPath: [2, 'children', 1], - message: - "The block's text came back different, starting at the text `\\nbaz`", - }, - renamedKeys: [], - hiddenLosses: [], - markdownLosses: [], - undeclaredObjects: [], - }) - }) - - test('a round-trip-mismatch that drops stored blocks reports the first block past the surviving prefix', () => { - const keyGenerator = createTestKeyGenerator() - const stored = [ - block('b1', 's1', 'foo'), - {_type: 'table', _key: 't1', rows: []}, - {_type: 'table', _key: 't2', rows: []}, - ] - const markdown = portableTextToMarkdown(structuredClone(stored)) - let report!: ReconciliationReport - const result = applyMarkdownEdit(stored, markdown, { - deserialize: {keyGenerator}, - onReconciliation: (r) => { - report = r - }, - }) - expect(result).toEqual([block('k0', 'k1', 'foo')]) - expect(report).toEqual({ - keyMatching: 'skipped', - reason: 'round-trip-mismatch', - mismatch: { - type: 'block-count-changed', - storedPath: [{_key: 't1'}], - message: '2 blocks did not come back, starting with the `table` block', - }, - renamedKeys: [], - hiddenLosses: [], - markdownLosses: [], - undeclaredObjects: [], - }) - }) - - test('a round-trip-mismatch that adds blocks reports the last stored block', () => { - const keyGenerator = createTestKeyGenerator() - const stored = [ - block('b1', 's1', 'foo'), - {_type: 'html', _key: 'h1', html: '
bar
\n\nbaz'}, - ] - const markdown = portableTextToMarkdown(structuredClone(stored)) - let report!: ReconciliationReport - const result = applyMarkdownEdit(stored, markdown, { - deserialize: {keyGenerator}, - onReconciliation: (r) => { - report = r - }, - }) - expect(result).toEqual([ - block('k0', 'k1', 'foo'), - {_type: 'html', _key: 'k2', html: '
bar
'}, - block('k3', 'k4', 'baz'), - ]) - expect(report).toEqual({ - keyMatching: 'skipped', - reason: 'round-trip-mismatch', - mismatch: { - type: 'block-count-changed', - storedPath: [{_key: 'h1'}], - message: 'The round trip added 1 block after the `html` block', - snippet: 'baz', - }, - renamedKeys: [], - hiddenLosses: [], - markdownLosses: [], - undeclaredObjects: [], - }) - }) - - test('a round-trip-mismatch where every stored block is an empty paragraph reports the last stored block', () => { - const stored = [block('e1', 'es1', ''), block('e2', 'es2', '')] - const serialize = { - block: {normal: () => ' '}, - blockSpacing: () => '\n\n---\n\n', - } - const markdown = portableTextToMarkdown(structuredClone(stored), serialize) - let report!: ReconciliationReport - const result = applyMarkdownEdit(stored, markdown, { - serialize, - deserialize: {keyGenerator: createTestKeyGenerator()}, - onReconciliation: (r) => { - report = r - }, - }) - expect(result).toEqual([{_type: 'horizontal-rule', _key: 'k0'}]) - expect( - applyMarkdownEdit(stored, markdown, { - serialize, - deserialize: {keyGenerator: createTestKeyGenerator()}, - }), - ).toEqual(result) - expect(report).toEqual({ - keyMatching: 'skipped', - reason: 'round-trip-mismatch', - mismatch: { - type: 'block-count-changed', - storedPath: [{_key: 'e2'}], - message: 'The empty paragraphs came back as 1 block', - }, - renamedKeys: [], - hiddenLosses: [], - markdownLosses: [], - undeclaredObjects: [], - }) - }) - - test('a round-trip-mismatch reports the earliest failing block, a text mismatch before a later type mismatch', () => { - const keyGenerator = createTestKeyGenerator() - const stored = [ - block('b1', 's1', 'foo'), - { - _type: 'block', - _key: 'b2', - style: 'normal', - markDefs: [], - children: [ - {_type: 'span', _key: 's2', text: 'bar ', marks: []}, - {_type: 'mention', _key: 'm1'}, - ], - }, - {_type: 'callout', _key: 'c1', text: 'baz'}, - ] - const serialize = { - types: { - mention: () => '[[foo]]', - callout: ({value}: {value: {text: string}}) => value.text, - }, - } - const markdown = portableTextToMarkdown(structuredClone(stored), serialize) - let report!: ReconciliationReport - const result = applyMarkdownEdit(stored, markdown, { - serialize, - deserialize: {keyGenerator}, - onReconciliation: (r) => { - report = r - }, - }) - expect(result).toEqual([ - block('k0', 'k1', 'foo'), - block('k2', 'k3', 'bar [[foo]]'), - block('k4', 'k5', 'baz'), - ]) - expect(report).toEqual({ - keyMatching: 'skipped', - reason: 'round-trip-mismatch', - mismatch: { - type: 'text-changed', - storedPath: [{_key: 'b2'}, 'children', {_key: 'm1'}], - message: - "The block's text came back different, starting at the `mention` inline object", - snippet: '[[foo]]', - }, - renamedKeys: [], - hiddenLosses: [], - markdownLosses: [], - undeclaredObjects: [], - }) - }) - - test('a markDef collision reports the losing definition as a key fallback, not a renamed key', () => { - let calls = 0 - const keyGenerator = () => { - const index = calls++ - return index === 1 ? 'a1' : `fresh${index}` - } - const stored = [ - { - _type: 'block', - _key: 'b1', - style: 'normal', - markDefs: [{_type: 'link', _key: 'a1', href: 'https://old'}], - children: [{_type: 'span', _key: 's1', text: 'old', marks: ['a1']}], - }, - ] - const markdown = '[new](https://new) [old](https://old)' - let report!: ReconciliationReport - const result = applyMarkdownEdit(stored, markdown, { - deserialize: {keyGenerator}, - onReconciliation: (r) => { - report = r - }, - }) - expect(result).toEqual([ - { - _type: 'block', - _key: 'b1', - style: 'normal', - markDefs: [ - {_type: 'link', _key: 'a1', href: 'https://new'}, - {_type: 'link', _key: 'fresh4', href: 'https://old'}, - ], - children: [ - {_type: 'span', _key: 'fresh2', text: 'new', marks: ['a1']}, - {_type: 'span', _key: 'fresh3', text: ' ', marks: []}, - {_type: 'span', _key: 's1', text: 'old', marks: ['fresh4']}, - ], - }, - ]) - expect(report).toEqual({ - keyMatching: 'performed', - preservedKeys: [ - { - basis: 'same-position', - key: 'b1', - path: [{_key: 'b1'}], - valueChanged: true, - }, - { - basis: 'content-unchanged', - key: 's1', - path: [{_key: 'b1'}, 'children', {_key: 's1'}], - valueChanged: true, - }, - ], - keyFallbacks: [ - { - type: 'annotation-key-conflict', - path: [{_key: 'b1'}, 'markDefs', {_key: 'fresh4'}], - }, - ], - renamedKeys: [], - hiddenLosses: [], - markdownLosses: [], - undeclaredObjects: [], - }) - }) - - test('a fresh key colliding with an adopted key reports the rewrite as a renamed key', () => { - let calls = 0 - const keyGenerator = () => (calls++ === 0 ? 'b1' : `fresh${calls}`) - const stored = [block('b1', 's1', 'alpha')] - const markdown = 'brand new paragraph\n\nalpha' - let report!: ReconciliationReport - const result = applyMarkdownEdit(stored, markdown, { - deserialize: {keyGenerator}, - onReconciliation: (r) => { - report = r - }, - }) - expect(result).toEqual([ - block('fresh5', 'fresh2', 'brand new paragraph'), - block('b1', 's1', 'alpha'), - ]) - expect(report).toEqual({ - keyMatching: 'performed', - preservedKeys: [ - { - basis: 'content-unchanged', - key: 'b1', - path: [{_key: 'b1'}], - valueChanged: false, - }, - { - basis: 'content-unchanged', - key: 's1', - path: [{_key: 'b1'}, 'children', {_key: 's1'}], - valueChanged: false, - }, - ], - keyFallbacks: [], - renamedKeys: [ - {previousKey: 'b1', key: 'fresh5', path: [{_key: 'fresh5'}]}, - ], - hiddenLosses: [], - markdownLosses: [], - undeclaredObjects: [], - }) - }) - - test('span-level preserved keys inside an edited block are reported at every depth', () => { - const keyGenerator = createTestKeyGenerator() - const stored = [ - { - _type: 'block', - _key: 'b1', - style: 'normal', - markDefs: [], - children: [ - {_type: 'span', _key: 's1', text: 'foo ', marks: []}, - {_type: 'span', _key: 's2', text: 'bar', marks: ['strong']}, - {_type: 'span', _key: 's3', text: ' baz', marks: []}, - ], - }, - ] - const markdown = portableTextToMarkdown(structuredClone(stored)).replace( - 'baz', - 'fizz', - ) - let report!: ReconciliationReport - applyMarkdownEdit(stored, markdown, { - deserialize: {keyGenerator}, - onReconciliation: (r) => { - report = r - }, - }) - expect(report).toEqual({ - keyMatching: 'performed', - preservedKeys: [ - { - basis: 'same-position', - key: 'b1', - path: [{_key: 'b1'}], - valueChanged: true, - }, - { - basis: 'content-unchanged', - key: 's1', - path: [{_key: 'b1'}, 'children', {_key: 's1'}], - valueChanged: false, - }, - { - basis: 'content-unchanged', - key: 's2', - path: [{_key: 'b1'}, 'children', {_key: 's2'}], - valueChanged: false, - }, - { - basis: 'same-position', - key: 's3', - path: [{_key: 'b1'}, 'children', {_key: 's3'}], - valueChanged: true, - }, - ], - keyFallbacks: [], - renamedKeys: [], - hiddenLosses: [], - markdownLosses: [], - undeclaredObjects: [], - }) - }) - - test('a nested keyed node under a keyless `json:object` wrapper reports its renamed key', () => { - const keyGenerator = createTestKeyGenerator() - const stored = [block('b1', 's1', 'alpha')] - const fence = [ - '```json:object', - JSON.stringify({ - _type: 'widget', - _key: 'w1', - rows: [ - { - cells: [ - {_key: 'dup', label: 'a'}, - {_key: 'dup', label: 'b'}, - ], - }, - ], - }), - '```', - ].join('\n') - const markdown = `${portableTextToMarkdown(structuredClone(stored))}\n\n${fence}` - let report!: ReconciliationReport - const result = applyMarkdownEdit(stored, markdown, { - deserialize: {keyGenerator}, - onReconciliation: (r) => { - report = r - }, - }) - expect(result).toEqual([ - block('b1', 's1', 'alpha'), - { - _type: 'widget', - _key: 'w1', - rows: [ - { - cells: [ - {_key: 'dup', label: 'a'}, - {_key: 'k2', label: 'b'}, - ], - }, - ], - }, - ]) - expect(report).toEqual({ - keyMatching: 'performed', - preservedKeys: [ - { - basis: 'content-unchanged', - key: 'b1', - path: [{_key: 'b1'}], - valueChanged: false, - }, - { - basis: 'content-unchanged', - key: 's1', - path: [{_key: 'b1'}, 'children', {_key: 's1'}], - valueChanged: false, - }, - ], - keyFallbacks: [], - renamedKeys: [ - { - previousKey: 'dup', - key: 'k2', - path: [{_key: 'w1'}, 'rows', 0, 'cells', {_key: 'k2'}], - }, - ], - hiddenLosses: [], - markdownLosses: [], - undeclaredObjects: [ - { - _type: 'widget', - _key: 'w1', - position: 'block', - path: [{_key: 'w1'}], - message: - 'Kept the block-level `widget` object as written: the schema has no block-level `widget` object', - }, - ], - }) - }) - - test('a node renamed after adopting a duplicated stored key is not listed as preserved', () => { - const keyGenerator = createTestKeyGenerator() - const stored = [ - { - _type: 'block', - _key: 'dup', - style: 'normal', - markDefs: [], - children: [{_type: 'span', _key: 's1', text: 'alpha', marks: []}], - }, - { - _type: 'block', - _key: 'dup', - style: 'normal', - markDefs: [], - children: [{_type: 'span', _key: 's2', text: 'beta', marks: []}], - }, - ] - let report!: ReconciliationReport - const result = applyMarkdownEdit(stored, 'alpha\n\nbeta', { - deserialize: {keyGenerator}, - onReconciliation: (r) => { - report = r - }, - }) - expect(result).toEqual([ - { - _type: 'block', - _key: 'dup', - style: 'normal', - markDefs: [], - children: [{_type: 'span', _key: 's1', text: 'alpha', marks: []}], - }, - { - _type: 'block', - _key: 'k4', - style: 'normal', - markDefs: [], - children: [{_type: 'span', _key: 's2', text: 'beta', marks: []}], - }, - ]) - expect(report).toEqual({ - keyMatching: 'performed', - preservedKeys: [ - { - basis: 'content-unchanged', - key: 'dup', - path: [{_key: 'dup'}], - valueChanged: false, - }, - { - basis: 'content-unchanged', - key: 's1', - path: [{_key: 'dup'}, 'children', {_key: 's1'}], - valueChanged: false, - }, - { - basis: 'content-unchanged', - key: 's2', - path: [{_key: 'k4'}, 'children', {_key: 's2'}], - valueChanged: false, - }, - ], - keyFallbacks: [], - renamedKeys: [{previousKey: 'dup', key: 'k4', path: [{_key: 'k4'}]}], - hiddenLosses: [], - markdownLosses: [], - undeclaredObjects: [], - }) - }) - - test("a renamed key inside a keyless top-level `json:object` payload reports under the payload's generated key", () => { - const keyGenerator = createTestKeyGenerator() - const stored = [block('b1', 's1', 'alpha')] - const fence = [ - '```json:object', - JSON.stringify({ - _type: 'widget', - rows: [ - { - cells: [ - {_key: 'dup', label: 'a'}, - {_key: 'dup', label: 'b'}, - ], - }, - ], - }), - '```', - ].join('\n') - const markdown = `${portableTextToMarkdown(structuredClone(stored))}\n\n${fence}` - let report!: ReconciliationReport - const result = applyMarkdownEdit(stored, markdown, { - deserialize: {keyGenerator}, - onReconciliation: (r) => { - report = r - }, - }) - expect(result).toEqual([ - block('b1', 's1', 'alpha'), - { - _type: 'widget', - _key: 'k2', - rows: [ - { - cells: [ - {_key: 'dup', label: 'a'}, - {_key: 'k3', label: 'b'}, - ], - }, - ], - }, - ]) - expect(report).toEqual({ - keyMatching: 'performed', - preservedKeys: [ - { - basis: 'content-unchanged', - key: 'b1', - path: [{_key: 'b1'}], - valueChanged: false, - }, - { - basis: 'content-unchanged', - key: 's1', - path: [{_key: 'b1'}, 'children', {_key: 's1'}], - valueChanged: false, - }, - ], - keyFallbacks: [], - renamedKeys: [ - { - previousKey: 'dup', - key: 'k3', - path: [{_key: 'k2'}, 'rows', 0, 'cells', {_key: 'k3'}], - }, - ], - hiddenLosses: [], - markdownLosses: [], - undeclaredObjects: [ - { - _type: 'widget', - _key: 'k2', - position: 'block', - path: [{_key: 'k2'}], - message: - 'Kept the block-level `widget` object as written: the schema has no block-level `widget` object', - }, - ], - }) - }) - - test('a gap over the similarity pair cap reports ambiguous-region-too-large with the affected keys', () => { - const keyGenerator = createTestKeyGenerator() - const stored = manyBlocks(51, 'stored') - const markdown = manyParagraphs(52, 'edited') - let report!: ReconciliationReport - const result = applyMarkdownEdit(stored, markdown, { - deserialize: {keyGenerator}, - onReconciliation: (r) => { - report = r - }, - }) - const editedKeys = result.map((node) => (node as {_key: string})._key) - expect(editedKeys).toHaveLength(52) - expect(report).toEqual({ - keyMatching: 'performed', - preservedKeys: [], - keyFallbacks: [{type: 'ambiguous-region-too-large', keys: editedKeys}], - renamedKeys: [], - hiddenLosses: [], - markdownLosses: [], - undeclaredObjects: [], - }) - }) - - test('a preserved mark definition is reported alongside the spans and block that reference it', () => { - const keyGenerator = createTestKeyGenerator() - const stored = [ - { - _type: 'block', - _key: 'b1', - style: 'normal', - markDefs: [{_type: 'link', _key: 'link1', href: 'https://example.com'}], - children: [ - {_type: 'span', _key: 's1', text: 'Read the ', marks: []}, - {_type: 'span', _key: 's2', text: 'docs', marks: ['link1']}, - {_type: 'span', _key: 's3', text: ' today', marks: []}, - ], - }, - ] - const markdown = portableTextToMarkdown(structuredClone(stored)).replace( - 'today', - 'now', - ) - let report!: ReconciliationReport - applyMarkdownEdit(stored, markdown, { - deserialize: {keyGenerator}, - onReconciliation: (r) => { - report = r - }, - }) - expect(report).toEqual({ - keyMatching: 'performed', - preservedKeys: [ - { - basis: 'same-position', - key: 'b1', - path: [{_key: 'b1'}], - valueChanged: true, - }, - { - basis: 'content-unchanged', - key: 's1', - path: [{_key: 'b1'}, 'children', {_key: 's1'}], - valueChanged: false, - }, - { - basis: 'content-unchanged', - key: 's2', - path: [{_key: 'b1'}, 'children', {_key: 's2'}], - valueChanged: false, - }, - { - basis: 'same-position', - key: 's3', - path: [{_key: 'b1'}, 'children', {_key: 's3'}], - valueChanged: true, - }, - { - basis: 'content-unchanged', - key: 'link1', - path: [{_key: 'b1'}, 'markDefs', {_key: 'link1'}], - valueChanged: false, - }, - ], - keyFallbacks: [], - renamedKeys: [], - hiddenLosses: [], - markdownLosses: [], - undeclaredObjects: [], - }) - }) - - test('a reinserted empty-block run reports its whole subtree as content-unchanged', () => { - const keyGenerator = createTestKeyGenerator() - const stored = [ - block('b1', 's1', 'first paragraph'), - block('empty1', 'es1', ''), - block('b2', 's2', 'second paragraph'), - ] - const markdown = portableTextToMarkdown(structuredClone(stored)).replace( - 'second', - 'SECOND', - ) - let report!: ReconciliationReport - applyMarkdownEdit(stored, markdown, { - deserialize: {keyGenerator}, - onReconciliation: (r) => { - report = r - }, - }) - expect(report).toEqual({ - keyMatching: 'performed', - preservedKeys: [ - { - basis: 'content-unchanged', - key: 'b1', - path: [{_key: 'b1'}], - valueChanged: false, - }, - { - basis: 'content-unchanged', - key: 's1', - path: [{_key: 'b1'}, 'children', {_key: 's1'}], - valueChanged: false, - }, - { - basis: 'content-unchanged', - key: 'empty1', - path: [{_key: 'empty1'}], - valueChanged: false, - }, - { - basis: 'content-unchanged', - key: 'es1', - path: [{_key: 'empty1'}, 'children', {_key: 'es1'}], - valueChanged: false, - }, - { - basis: 'same-position', - key: 'b2', - path: [{_key: 'b2'}], - valueChanged: true, - }, - { - basis: 'same-position', - key: 's2', - path: [{_key: 'b2'}, 'children', {_key: 's2'}], - valueChanged: true, - }, - ], - keyFallbacks: [], - renamedKeys: [], - hiddenLosses: [], - markdownLosses: [], - undeclaredObjects: [], - }) - }) - - test('a reinserted empty-block run with a duplicate-keyed child reports the subtree change on the block', () => { - const keyGenerator = createTestKeyGenerator() - const stored = [ - block('b1', 's1', 'first paragraph'), - { - _type: 'block', - _key: 'empty1', - style: 'normal', - markDefs: [], - children: [ - {_type: 'span', _key: 'dup', text: '', marks: []}, - {_type: 'span', _key: 'dup', text: '', marks: []}, - ], - }, - ] - const markdown = portableTextToMarkdown(structuredClone(stored)) - let report!: ReconciliationReport - const result = applyMarkdownEdit(stored, markdown, { - deserialize: {keyGenerator}, - onReconciliation: (r) => { - report = r - }, - }) - expect(result).toEqual([ - block('b1', 's1', 'first paragraph'), - { - _type: 'block', - _key: 'empty1', - style: 'normal', - markDefs: [], - children: [ - {_type: 'span', _key: 'dup', text: '', marks: []}, - {_type: 'span', _key: 'k2', text: '', marks: []}, - ], - }, - ]) - expect(report).toEqual({ - keyMatching: 'performed', - preservedKeys: [ - { - basis: 'content-unchanged', - key: 'b1', - path: [{_key: 'b1'}], - valueChanged: false, - }, - { - basis: 'content-unchanged', - key: 's1', - path: [{_key: 'b1'}, 'children', {_key: 's1'}], - valueChanged: false, - }, - { - basis: 'content-unchanged', - key: 'empty1', - path: [{_key: 'empty1'}], - valueChanged: true, - }, - { - basis: 'content-unchanged', - key: 'dup', - path: [{_key: 'empty1'}, 'children', {_key: 'dup'}], - valueChanged: false, - }, - ], - keyFallbacks: [], - renamedKeys: [ - { - previousKey: 'dup', - key: 'k2', - path: [{_key: 'empty1'}, 'children', {_key: 'k2'}], - }, - ], - hiddenLosses: [], - markdownLosses: [], - undeclaredObjects: [], - }) - }) - - test('a verbatim-returned anchored block reports its stored keys preserved at every depth', () => { - const schema = compileSchema( - defineSchema({decorators: [{name: 'strong'}, {name: 'highlight'}]}), - ) - const keyGenerator = createTestKeyGenerator() - const stored = [ - { - _type: 'block', - _key: 'b1', - style: 'normal', - markDefs: [], - children: [ - {_type: 'span', _key: 's1', text: 'hello ', marks: ['highlight']}, - {_type: 'span', _key: 's2', text: 'world', marks: []}, - ], - }, - block('b2', 's3', 'beta'), - ] - const markdown = portableTextToMarkdown(structuredClone(stored), {schema}) - let report!: ReconciliationReport - applyMarkdownEdit(stored, markdown.replace('beta', 'gamma'), { - schema, - deserialize: {keyGenerator}, - onReconciliation: (r) => { - report = r - }, - }) - expect(report).toEqual({ - keyMatching: 'performed', - preservedKeys: [ - { - basis: 'content-unchanged', - key: 'b1', - path: [{_key: 'b1'}], - valueChanged: false, - }, - { - basis: 'content-unchanged', - key: 's1', - path: [{_key: 'b1'}, 'children', {_key: 's1'}], - valueChanged: false, - }, - { - basis: 'content-unchanged', - key: 's2', - path: [{_key: 'b1'}, 'children', {_key: 's2'}], - valueChanged: false, - }, - { - basis: 'same-position', - key: 'b2', - path: [{_key: 'b2'}], - valueChanged: true, - }, - { - basis: 'same-position', - key: 's3', - path: [{_key: 'b2'}, 'children', {_key: 's3'}], - valueChanged: true, - }, - ], - keyFallbacks: [], - renamedKeys: [], - hiddenLosses: [], - markdownLosses: [], - undeclaredObjects: [], - }) - }) - - test('a verbatim-returned moved block reports its root content-moved and its children content-unchanged', () => { - const schema = compileSchema( - defineSchema({decorators: [{name: 'strong'}, {name: 'highlight'}]}), - ) - const stored = [ - { - _type: 'block', - _key: 'b1', - style: 'normal', - markDefs: [], - children: [ - {_type: 'span', _key: 's1', text: 'hello ', marks: ['highlight']}, - {_type: 'span', _key: 's2', text: 'world', marks: []}, - ], - }, - block('b2', 's3', 'beta'), - ] - const markdown = portableTextToMarkdown(structuredClone(stored), {schema}) - const [first, second] = markdown.split('\n\n') - const edited = `${second}\n\n${first}` - let report!: ReconciliationReport - applyMarkdownEdit(stored, edited, { - schema, - deserialize: {keyGenerator: createTestKeyGenerator()}, - onReconciliation: (r) => { - report = r - }, - }) - expect(report).toEqual({ - keyMatching: 'performed', - preservedKeys: [ - { - basis: 'content-unchanged', - key: 'b2', - path: [{_key: 'b2'}], - valueChanged: false, - }, - { - basis: 'content-unchanged', - key: 's3', - path: [{_key: 'b2'}, 'children', {_key: 's3'}], - valueChanged: false, - }, - { - basis: 'content-moved', - key: 'b1', - path: [{_key: 'b1'}], - valueChanged: false, - }, - { - basis: 'content-unchanged', - key: 's1', - path: [{_key: 'b1'}, 'children', {_key: 's1'}], - valueChanged: false, - }, - { - basis: 'content-unchanged', - key: 's2', - path: [{_key: 'b1'}, 'children', {_key: 's2'}], - valueChanged: false, - }, - ], - keyFallbacks: [], - renamedKeys: [], - hiddenLosses: [], - markdownLosses: [], - undeclaredObjects: [], - }) - }) - - test('an edited block whose stored annotation has no markdown form reports the dropped annotation as a hidden loss', () => { - const keyGenerator = createTestKeyGenerator() - const stored = [internalLinkBlock('b1'), block('b2', 's4', 'bar')] - const markdown = portableTextToMarkdown(structuredClone(stored)).replace( - 'baz.', - 'baz now.', - ) - let report!: ReconciliationReport - const result = applyMarkdownEdit(stored, markdown, { - deserialize: {keyGenerator}, - onReconciliation: (r) => { - report = r - }, - }) - expect(result).toEqual([ - block('b1', 'k1', 'Read foo before baz now.'), - block('b2', 's4', 'bar'), - ]) - expect(report).toEqual({ - keyMatching: 'performed', - preservedKeys: [ - { - basis: 'same-position', - key: 'b1', - path: [{_key: 'b1'}], - valueChanged: true, - }, - { - basis: 'content-unchanged', - key: 'b2', - path: [{_key: 'b2'}], - valueChanged: false, - }, - { - basis: 'content-unchanged', - key: 's4', - path: [{_key: 'b2'}, 'children', {_key: 's4'}], - valueChanged: false, - }, - ], - keyFallbacks: [], - renamedKeys: [], - hiddenLosses: [ - { - type: 'annotation-dropped', - message: - 'Removed the `internalLink` annotation, kept its text: no `internalLink` mark renderer', - path: [{_key: 'b1'}, 'children', {_key: 's2'}], - snippet: 'foo', - }, - ], - markdownLosses: [], - undeclaredObjects: [], - }) - }) - - test('a stored annotation with no markdown form on a block the edit left alone is not a hidden loss', () => { - const keyGenerator = createTestKeyGenerator() - const stored = [internalLinkBlock('b1'), block('b2', 's4', 'bar')] - const markdown = portableTextToMarkdown(structuredClone(stored)).replace( - 'bar', - 'qux', - ) - let report!: ReconciliationReport - const result = applyMarkdownEdit(stored, markdown, { - deserialize: {keyGenerator}, - onReconciliation: (r) => { - report = r - }, - }) - expect(result).toEqual([internalLinkBlock('b1'), block('b2', 's4', 'qux')]) - expect(report).toEqual({ - keyMatching: 'performed', - preservedKeys: [ - { - basis: 'content-unchanged', - key: 'b1', - path: [{_key: 'b1'}], - valueChanged: false, - }, - { - basis: 'content-unchanged', - key: 'm1', - path: [{_key: 'b1'}, 'markDefs', {_key: 'm1'}], - valueChanged: false, - }, - { - basis: 'content-unchanged', - key: 's1', - path: [{_key: 'b1'}, 'children', {_key: 's1'}], - valueChanged: false, - }, - { - basis: 'content-unchanged', - key: 's2', - path: [{_key: 'b1'}, 'children', {_key: 's2'}], - valueChanged: false, - }, - { - basis: 'content-unchanged', - key: 's3', - path: [{_key: 'b1'}, 'children', {_key: 's3'}], - valueChanged: false, - }, - { - basis: 'same-position', - key: 'b2', - path: [{_key: 'b2'}], - valueChanged: true, - }, - { - basis: 'same-position', - key: 's4', - path: [{_key: 'b2'}, 'children', {_key: 's4'}], - valueChanged: true, - }, - ], - keyFallbacks: [], - renamedKeys: [], - hiddenLosses: [], - markdownLosses: [], - undeclaredObjects: [], - }) - }) - - test('a rewritten block that comes back with a fresh key next to an inserted block reports its dropped annotation as a hidden loss', () => { - const keyGenerator = createTestKeyGenerator() - const stored = [internalLinkBlock('b1'), block('b2', 's4', 'bar')] - let report!: ReconciliationReport - const result = applyMarkdownEdit( - stored, - 'See foo for comparison.\n\nqux\n\nbar', - { - deserialize: {keyGenerator}, - onReconciliation: (r) => { - report = r - }, - }, - ) - expect(result).toEqual([ - block('k0', 'k1', 'See foo for comparison.'), - block('k2', 'k3', 'qux'), - block('b2', 's4', 'bar'), - ]) - expect(report).toEqual({ - keyMatching: 'performed', - preservedKeys: [ - { - basis: 'content-unchanged', - key: 'b2', - path: [{_key: 'b2'}], - valueChanged: false, - }, - { - basis: 'content-unchanged', - key: 's4', - path: [{_key: 'b2'}, 'children', {_key: 's4'}], - valueChanged: false, - }, - ], - keyFallbacks: [], - renamedKeys: [], - hiddenLosses: [ - { - type: 'annotation-dropped', - message: - 'Removed the `internalLink` annotation, kept its text: no `internalLink` mark renderer', - path: [{_key: 'b1'}, 'children', {_key: 's2'}], - snippet: 'foo', - }, - ], - markdownLosses: [], - undeclaredObjects: [], - }) - }) - - test('a deleted block whose stored annotation has no markdown form reports the dropped annotation as a hidden loss', () => { - const keyGenerator = createTestKeyGenerator() - const stored = [internalLinkBlock('b1'), block('b2', 's4', 'bar')] - let report!: ReconciliationReport - const result = applyMarkdownEdit(stored, 'bar', { - deserialize: {keyGenerator}, - onReconciliation: (r) => { - report = r - }, - }) - expect(result).toEqual([block('b2', 's4', 'bar')]) - expect(report).toEqual({ - keyMatching: 'performed', - preservedKeys: [ - { - basis: 'content-unchanged', - key: 'b2', - path: [{_key: 'b2'}], - valueChanged: false, - }, - { - basis: 'content-unchanged', - key: 's4', - path: [{_key: 'b2'}, 'children', {_key: 's4'}], - valueChanged: false, - }, - ], - keyFallbacks: [], - renamedKeys: [], - hiddenLosses: [ - { - type: 'annotation-dropped', - message: - 'Removed the `internalLink` annotation, kept its text: no `internalLink` mark renderer', - path: [{_key: 'b1'}, 'children', {_key: 's2'}], - snippet: 'foo', - }, - ], - markdownLosses: [], - undeclaredObjects: [], - }) - }) - - test('a deleted block that held nothing markdown cannot show reports no hidden loss', () => { - const keyGenerator = createTestKeyGenerator() - const stored = [internalLinkBlock('b1'), block('b2', 's4', 'bar')] - let report!: ReconciliationReport - const result = applyMarkdownEdit(stored, 'Read foo before baz.', { - deserialize: {keyGenerator}, - onReconciliation: (r) => { - report = r - }, - }) - expect(result).toEqual([internalLinkBlock('b1')]) - expect(report).toEqual({ - keyMatching: 'performed', - preservedKeys: [ - { - basis: 'content-unchanged', - key: 'b1', - path: [{_key: 'b1'}], - valueChanged: false, - }, - { - basis: 'content-unchanged', - key: 'm1', - path: [{_key: 'b1'}, 'markDefs', {_key: 'm1'}], - valueChanged: false, - }, - { - basis: 'content-unchanged', - key: 's1', - path: [{_key: 'b1'}, 'children', {_key: 's1'}], - valueChanged: false, - }, - { - basis: 'content-unchanged', - key: 's2', - path: [{_key: 'b1'}, 'children', {_key: 's2'}], - valueChanged: false, - }, - { - basis: 'content-unchanged', - key: 's3', - path: [{_key: 'b1'}, 'children', {_key: 's3'}], - valueChanged: false, - }, - ], - keyFallbacks: [], - renamedKeys: [], - hiddenLosses: [], - markdownLosses: [], - undeclaredObjects: [], - }) - }) - - test('a whole-document skip of key matching reports every stored loss as a hidden loss', () => { - const keyGenerator = createTestKeyGenerator() - const stored = [ - internalLinkBlock('b1'), - { - _type: 'block', - _key: 'b2', - style: 'h3', - markDefs: [], - children: [{_type: 'span', _key: 's4', text: 'bar\nqux', marks: []}], - }, - ] - const markdown = portableTextToMarkdown(structuredClone(stored)) - let report!: ReconciliationReport - const result = applyMarkdownEdit(stored, markdown, { - deserialize: {keyGenerator}, - onReconciliation: (r) => { - report = r - }, - }) - expect(result).toEqual([ - block('k0', 'k1', 'Read foo before baz.'), - { - _type: 'block', - _key: 'k2', - style: 'h3', - markDefs: [], - children: [{_type: 'span', _key: 'k3', text: 'bar', marks: []}], - }, - block('k4', 'k5', 'qux'), - ]) - expect(report).toEqual({ - keyMatching: 'skipped', - reason: 'round-trip-mismatch', - mismatch: { - type: 'text-changed', - storedPath: [{_key: 'b2'}, 'children', {_key: 's4'}], - message: - "The block's text came back different, starting at the text `\\nqux`", - }, - renamedKeys: [], - hiddenLosses: [ - { - type: 'annotation-dropped', - message: - 'Removed the `internalLink` annotation, kept its text: no `internalLink` mark renderer', - path: [{_key: 'b1'}, 'children', {_key: 's2'}], - snippet: 'foo', - }, - ], - markdownLosses: [], - undeclaredObjects: [], - }) - }) - - test('an edited block whose custom style and decorator markdown cannot show were restored reports no hidden loss', () => { - const keyGenerator = createTestKeyGenerator() - const leadBlock = { - _type: 'block', - _key: 'b1', - style: 'lead', - markDefs: [], - children: [ - {_type: 'span', _key: 's1', text: 'foo baz.', marks: ['highlight']}, - ], - } - const stored = [leadBlock, block('b2', 's2', 'bar')] - const markdown = portableTextToMarkdown(structuredClone(stored)).replace( - 'baz.', - 'baz now.', - ) - let report!: ReconciliationReport - const result = applyMarkdownEdit(stored, markdown, { - deserialize: {keyGenerator}, - onReconciliation: (r) => { - report = r - }, - }) - expect(result).toEqual([ - { - _type: 'block', - _key: 'b1', - style: 'lead', - markDefs: [], - children: [ - { - _type: 'span', - _key: 's1', - text: 'foo baz now.', - marks: ['highlight'], - }, - ], - }, - block('b2', 's2', 'bar'), - ]) - expect(report).toEqual({ - keyMatching: 'performed', - preservedKeys: [ - { - basis: 'same-position', - key: 'b1', - path: [{_key: 'b1'}], - valueChanged: true, - }, - { - basis: 'same-position', - key: 's1', - path: [{_key: 'b1'}, 'children', {_key: 's1'}], - valueChanged: true, - }, - { - basis: 'content-unchanged', - key: 'b2', - path: [{_key: 'b2'}], - valueChanged: false, - }, - { - basis: 'content-unchanged', - key: 's2', - path: [{_key: 'b2'}, 'children', {_key: 's2'}], - valueChanged: false, - }, - ], - keyFallbacks: [], - renamedKeys: [], - hiddenLosses: [], - markdownLosses: [], - undeclaredObjects: [], - }) - }) - - test('an edit to one cell of a ragged table reports a loss in an untouched sibling cell', () => { - const keyGenerator = createTestKeyGenerator() - const stored = [ - { - _type: 'table', - _key: 't1', - headerRows: 0, - rows: [ - { - _type: 'row', - _key: 'r1', - cells: [ - {_type: 'cell', _key: 'c1', value: [internalLinkBlock('cb1')]}, - {_type: 'cell', _key: 'c2', value: [block('cb2', 's4', 'bar')]}, - ], - }, - { - _type: 'row', - _key: 'r2', - cells: [ - {_type: 'cell', _key: 'c3', value: [block('cb3', 's5', 'qux')]}, - {_type: 'cell', _key: 'c4', value: [block('cb4', 's6', 'quux')]}, - {_type: 'cell', _key: 'c5', value: [block('cb5', 's7', 'corge')]}, - ], - }, - ], - }, - ] - const markdown = portableTextToMarkdown(structuredClone(stored)).replace( - 'bar', - 'bar now', - ) - let report!: ReconciliationReport - const result = applyMarkdownEdit(stored, markdown, { - deserialize: {keyGenerator}, - onReconciliation: (r) => { - report = r - }, - }) - expect(result).toEqual([ - { - _type: 'table', - _key: 't1', - headerRows: 0, - rows: [ - { - _type: 'row', - _key: 'r1', - cells: [ - { - _type: 'cell', - _key: 'k11', - value: [block('k9', 'k10', 'Read foo before baz.')], - }, - { - _type: 'cell', - _key: 'k14', - value: [block('k12', 'k13', 'bar now')], - }, - {_type: 'cell', _key: 'k17', value: [block('k15', 'k16', '')]}, - ], - }, - { - _type: 'row', - _key: 'r2', - cells: [ - {_type: 'cell', _key: 'c3', value: [block('cb3', 's5', 'qux')]}, - {_type: 'cell', _key: 'c4', value: [block('cb4', 's6', 'quux')]}, - {_type: 'cell', _key: 'c5', value: [block('cb5', 's7', 'corge')]}, - ], - }, - ], - }, - ]) - expect(report).toEqual({ - keyMatching: 'performed', - preservedKeys: [ - { - basis: 'same-position', - key: 't1', - path: [{_key: 't1'}], - valueChanged: true, - }, - { - basis: 'same-position', - key: 'r1', - path: [{_key: 't1'}, 'rows', {_key: 'r1'}], - valueChanged: true, - }, - { - basis: 'content-unchanged', - key: 'r2', - path: [{_key: 't1'}, 'rows', {_key: 'r2'}], - valueChanged: false, - }, - { - basis: 'content-unchanged', - key: 'c3', - path: [{_key: 't1'}, 'rows', {_key: 'r2'}, 'cells', {_key: 'c3'}], - valueChanged: false, - }, - { - basis: 'content-unchanged', - key: 'cb3', - path: [ - {_key: 't1'}, - 'rows', - {_key: 'r2'}, - 'cells', - {_key: 'c3'}, - 'value', - {_key: 'cb3'}, - ], - valueChanged: false, - }, - { - basis: 'content-unchanged', - key: 's5', - path: [ - {_key: 't1'}, - 'rows', - {_key: 'r2'}, - 'cells', - {_key: 'c3'}, - 'value', - {_key: 'cb3'}, - 'children', - {_key: 's5'}, - ], - valueChanged: false, - }, - { - basis: 'content-unchanged', - key: 'c4', - path: [{_key: 't1'}, 'rows', {_key: 'r2'}, 'cells', {_key: 'c4'}], - valueChanged: false, - }, - { - basis: 'content-unchanged', - key: 'cb4', - path: [ - {_key: 't1'}, - 'rows', - {_key: 'r2'}, - 'cells', - {_key: 'c4'}, - 'value', - {_key: 'cb4'}, - ], - valueChanged: false, - }, - { - basis: 'content-unchanged', - key: 's6', - path: [ - {_key: 't1'}, - 'rows', - {_key: 'r2'}, - 'cells', - {_key: 'c4'}, - 'value', - {_key: 'cb4'}, - 'children', - {_key: 's6'}, - ], - valueChanged: false, - }, - { - basis: 'content-unchanged', - key: 'c5', - path: [{_key: 't1'}, 'rows', {_key: 'r2'}, 'cells', {_key: 'c5'}], - valueChanged: false, - }, - { - basis: 'content-unchanged', - key: 'cb5', - path: [ - {_key: 't1'}, - 'rows', - {_key: 'r2'}, - 'cells', - {_key: 'c5'}, - 'value', - {_key: 'cb5'}, - ], - valueChanged: false, - }, - { - basis: 'content-unchanged', - key: 's7', - path: [ - {_key: 't1'}, - 'rows', - {_key: 'r2'}, - 'cells', - {_key: 'c5'}, - 'value', - {_key: 'cb5'}, - 'children', - {_key: 's7'}, - ], - valueChanged: false, - }, - ], - keyFallbacks: [], - renamedKeys: [], - hiddenLosses: [ - { - type: 'annotation-dropped', - message: - 'Removed the `internalLink` annotation, kept its text: no `internalLink` mark renderer', - path: [ - {_key: 't1'}, - 'rows', - {_key: 'r1'}, - 'cells', - {_key: 'c1'}, - 'value', - {_key: 'cb1'}, - 'children', - {_key: 's2'}, - ], - snippet: 'foo', - }, - ], - markdownLosses: [], - undeclaredObjects: [], - }) - }) - - test('an inline object carrying a lost annotation key in its own `marks` does not hide the loss of that annotation', () => { - const keyGenerator = createTestKeyGenerator() - const stored = [ - { - _type: 'block', - _key: 'b1', - style: 'normal', - markDefs: [ - { - _type: 'internalLink', - _key: 'm1', - reference: {_type: 'reference', _ref: 'foo'}, - }, - ], - children: [ - {_type: 'span', _key: 's1', text: 'foo', marks: ['m1']}, - {_type: 'span', _key: 's2', text: ' bar ', marks: []}, - {_type: 'wikilink', _key: 'w1', marks: ['m1']}, - ], - }, - ] - const markdown = portableTextToMarkdown(structuredClone(stored)).replace( - 'bar', - 'bar [](foo)', - ) - let report!: ReconciliationReport - const result = applyMarkdownEdit(stored, markdown, { - deserialize: { - keyGenerator, - marks: { - link: ({context, value}) => ({ - _type: 'internalLink', - _key: context.keyGenerator(), - reference: {_type: 'reference', _ref: value.href}, - }), - }, - }, - onReconciliation: (r) => { - report = r - }, - }) - expect(result).toEqual([ - { - _type: 'block', - _key: 'b1', - style: 'normal', - markDefs: [ - { - _type: 'internalLink', - _key: 'm1', - reference: {_type: 'reference', _ref: 'foo'}, - }, - ], - children: [ - {_type: 'span', _key: 'k1', text: 'foo bar ', marks: []}, - {_type: 'wikilink', _key: 'w1', marks: ['m1']}, - ], - }, - ]) - expect(report).toEqual({ - keyMatching: 'performed', - preservedKeys: [ - { - basis: 'same-position', - key: 'b1', - path: [{_key: 'b1'}], - valueChanged: true, - }, - { - basis: 'content-unchanged', - key: 'w1', - path: [{_key: 'b1'}, 'children', {_key: 'w1'}], - valueChanged: false, - }, - { - basis: 'content-unchanged', - key: 'm1', - path: [{_key: 'b1'}, 'markDefs', {_key: 'm1'}], - valueChanged: false, - }, - ], - keyFallbacks: [], - renamedKeys: [], - hiddenLosses: [ - { - type: 'annotation-dropped', - message: - 'Removed the `internalLink` annotation, kept its text: no `internalLink` mark renderer', - path: [{_key: 'b1'}, 'children', {_key: 's1'}], - snippet: 'foo', - }, - ], - markdownLosses: [], - undeclaredObjects: [], - }) - }) - - test('an unchanged table whose cells reuse a span key reports no hidden loss for the decorator on the second cell', () => { - const keyGenerator = createTestKeyGenerator() - const table = { - _type: 'table', - _key: 't1', - headerRows: 0, - rows: [ - { - _type: 'row', - _key: 'r1', - cells: [ - {_type: 'cell', _key: 'c1', value: [block('cb1', 's1', 'foo')]}, - { - _type: 'cell', - _key: 'c2', - value: [ - { - _type: 'block', - _key: 'cb2', - style: 'normal', - markDefs: [], - children: [ - { - _type: 'span', - _key: 's1', - text: 'bar', - marks: ['highlight'], - }, - ], - }, - ], - }, - ], - }, - ], - } - const stored = [table, block('b2', 's2', 'baz')] - const markdown = portableTextToMarkdown(structuredClone(stored)).replace( - 'baz', - 'qux', - ) - let report!: ReconciliationReport - const result = applyMarkdownEdit(stored, markdown, { - deserialize: {keyGenerator}, - onReconciliation: (r) => { - report = r - }, - }) - expect(result).toEqual([table, block('b2', 's2', 'qux')]) - expect(report).toEqual({ - keyMatching: 'performed', - preservedKeys: [ - { - basis: 'content-unchanged', - key: 't1', - path: [{_key: 't1'}], - valueChanged: false, - }, - { - basis: 'content-unchanged', - key: 'r1', - path: [{_key: 't1'}, 'rows', {_key: 'r1'}], - valueChanged: false, - }, - { - basis: 'content-unchanged', - key: 'c1', - path: [{_key: 't1'}, 'rows', {_key: 'r1'}, 'cells', {_key: 'c1'}], - valueChanged: false, - }, - { - basis: 'content-unchanged', - key: 'cb1', - path: [ - {_key: 't1'}, - 'rows', - {_key: 'r1'}, - 'cells', - {_key: 'c1'}, - 'value', - {_key: 'cb1'}, - ], - valueChanged: false, - }, - { - basis: 'content-unchanged', - key: 's1', - path: [ - {_key: 't1'}, - 'rows', - {_key: 'r1'}, - 'cells', - {_key: 'c1'}, - 'value', - {_key: 'cb1'}, - 'children', - {_key: 's1'}, - ], - valueChanged: false, - }, - { - basis: 'content-unchanged', - key: 'c2', - path: [{_key: 't1'}, 'rows', {_key: 'r1'}, 'cells', {_key: 'c2'}], - valueChanged: false, - }, - { - basis: 'content-unchanged', - key: 'cb2', - path: [ - {_key: 't1'}, - 'rows', - {_key: 'r1'}, - 'cells', - {_key: 'c2'}, - 'value', - {_key: 'cb2'}, - ], - valueChanged: false, - }, - { - basis: 'content-unchanged', - key: 's1', - path: [ - {_key: 't1'}, - 'rows', - {_key: 'r1'}, - 'cells', - {_key: 'c2'}, - 'value', - {_key: 'cb2'}, - 'children', - {_key: 's1'}, - ], - valueChanged: false, - }, - { - basis: 'same-position', - key: 'b2', - path: [{_key: 'b2'}], - valueChanged: true, - }, - { - basis: 'same-position', - key: 's2', - path: [{_key: 'b2'}, 'children', {_key: 's2'}], - valueChanged: true, - }, - ], - keyFallbacks: [], - renamedKeys: [], - hiddenLosses: [], - markdownLosses: [], - undeclaredObjects: [], - }) - }) - - test('an edit to one table cell reports the untouched sibling cell content-unchanged at every depth', () => { - const keyGenerator = createTestKeyGenerator() - const stored = [ - { - _type: 'table', - _key: 't1', - headerRows: 0, - rows: [ - { - _type: 'row', - _key: 'r1', - cells: [ - { - _type: 'cell', - _key: 'c1', - value: [ - { - _type: 'block', - _key: 'cb1', - style: 'normal', - markDefs: [ - { - _type: 'internalLink', - _key: 'm1', - reference: {_type: 'reference', _ref: 'foo'}, - }, - ], - children: [ - {_type: 'span', _key: 's1', text: 'Read ', marks: []}, - {_type: 'span', _key: 's2', text: 'foo', marks: ['m1']}, - { - _type: 'span', - _key: 's3', - text: ' before baz.', - marks: [], - }, - ], - }, - ], - }, - {_type: 'cell', _key: 'c2', value: [block('cb2', 's4', 'bar')]}, - ], - }, - ], - }, - block('b2', 's5', 'qux'), - ] - const markdown = portableTextToMarkdown(structuredClone(stored)).replace( - 'bar', - 'bar now', - ) - let report!: ReconciliationReport - applyMarkdownEdit(stored, markdown, { - deserialize: {keyGenerator}, - onReconciliation: (r) => { - report = r - }, - }) - expect(report).toEqual({ - keyMatching: 'performed', - preservedKeys: [ - { - basis: 'same-position', - key: 't1', - path: [{_key: 't1'}], - valueChanged: true, - }, - { - basis: 'same-position', - key: 'r1', - path: [{_key: 't1'}, 'rows', {_key: 'r1'}], - valueChanged: true, - }, - { - basis: 'content-unchanged', - key: 'c1', - path: [{_key: 't1'}, 'rows', {_key: 'r1'}, 'cells', {_key: 'c1'}], - valueChanged: false, - }, - { - basis: 'content-unchanged', - key: 'cb1', - path: [ - {_key: 't1'}, - 'rows', - {_key: 'r1'}, - 'cells', - {_key: 'c1'}, - 'value', - {_key: 'cb1'}, - ], - valueChanged: false, - }, - { - basis: 'content-unchanged', - key: 'm1', - path: [ - {_key: 't1'}, - 'rows', - {_key: 'r1'}, - 'cells', - {_key: 'c1'}, - 'value', - {_key: 'cb1'}, - 'markDefs', - {_key: 'm1'}, - ], - valueChanged: false, - }, - { - basis: 'content-unchanged', - key: 's1', - path: [ - {_key: 't1'}, - 'rows', - {_key: 'r1'}, - 'cells', - {_key: 'c1'}, - 'value', - {_key: 'cb1'}, - 'children', - {_key: 's1'}, - ], - valueChanged: false, - }, - { - basis: 'content-unchanged', - key: 's2', - path: [ - {_key: 't1'}, - 'rows', - {_key: 'r1'}, - 'cells', - {_key: 'c1'}, - 'value', - {_key: 'cb1'}, - 'children', - {_key: 's2'}, - ], - valueChanged: false, - }, - { - basis: 'content-unchanged', - key: 's3', - path: [ - {_key: 't1'}, - 'rows', - {_key: 'r1'}, - 'cells', - {_key: 'c1'}, - 'value', - {_key: 'cb1'}, - 'children', - {_key: 's3'}, - ], - valueChanged: false, - }, - { - basis: 'same-position', - key: 'c2', - path: [{_key: 't1'}, 'rows', {_key: 'r1'}, 'cells', {_key: 'c2'}], - valueChanged: true, - }, - { - basis: 'same-position', - key: 'cb2', - path: [ - {_key: 't1'}, - 'rows', - {_key: 'r1'}, - 'cells', - {_key: 'c2'}, - 'value', - {_key: 'cb2'}, - ], - valueChanged: true, - }, - { - basis: 'same-position', - key: 's4', - path: [ - {_key: 't1'}, - 'rows', - {_key: 'r1'}, - 'cells', - {_key: 'c2'}, - 'value', - {_key: 'cb2'}, - 'children', - {_key: 's4'}, - ], - valueChanged: true, - }, - { - basis: 'content-unchanged', - key: 'b2', - path: [{_key: 'b2'}], - valueChanged: false, - }, - { - basis: 'content-unchanged', - key: 's5', - path: [{_key: 'b2'}, 'children', {_key: 's5'}], - valueChanged: false, - }, - ], - keyFallbacks: [], - renamedKeys: [], - hiddenLosses: [], - markdownLosses: [], - undeclaredObjects: [], - }) - }) - - test('an edit to one table cell reports an untouched sibling cell holding a plain link content-unchanged', () => { - const keyGenerator = createTestKeyGenerator() - const stored = [ - { - _type: 'table', - _key: 't1', - headerRows: 0, - rows: [ - { - _type: 'row', - _key: 'r1', - cells: [ - { - _type: 'cell', - _key: 'c1', - value: [ - { - _type: 'block', - _key: 'cb1', - style: 'normal', - markDefs: [ - {_type: 'link', _key: 'm1', href: 'https://example.com'}, - ], - children: [ - {_type: 'span', _key: 's1', text: 'Read ', marks: []}, - {_type: 'span', _key: 's2', text: 'foo', marks: ['m1']}, - { - _type: 'span', - _key: 's3', - text: ' before baz.', - marks: [], - }, - ], - }, - ], - }, - {_type: 'cell', _key: 'c2', value: [block('cb2', 's4', 'bar')]}, - ], - }, - ], - }, - ] - const markdown = portableTextToMarkdown(structuredClone(stored)).replace( - 'bar', - 'bar now', - ) - let report!: ReconciliationReport - applyMarkdownEdit(stored, markdown, { - deserialize: {keyGenerator}, - onReconciliation: (r) => { - report = r - }, - }) - expect(report).toEqual({ - keyMatching: 'performed', - preservedKeys: [ - { - basis: 'same-position', - key: 't1', - path: [{_key: 't1'}], - valueChanged: true, - }, - { - basis: 'same-position', - key: 'r1', - path: [{_key: 't1'}, 'rows', {_key: 'r1'}], - valueChanged: true, - }, - { - basis: 'content-unchanged', - key: 'c1', - path: [{_key: 't1'}, 'rows', {_key: 'r1'}, 'cells', {_key: 'c1'}], - valueChanged: false, - }, - { - basis: 'content-unchanged', - key: 'cb1', - path: [ - {_key: 't1'}, - 'rows', - {_key: 'r1'}, - 'cells', - {_key: 'c1'}, - 'value', - {_key: 'cb1'}, - ], - valueChanged: false, - }, - { - basis: 'content-unchanged', - key: 'm1', - path: [ - {_key: 't1'}, - 'rows', - {_key: 'r1'}, - 'cells', - {_key: 'c1'}, - 'value', - {_key: 'cb1'}, - 'markDefs', - {_key: 'm1'}, - ], - valueChanged: false, - }, - { - basis: 'content-unchanged', - key: 's1', - path: [ - {_key: 't1'}, - 'rows', - {_key: 'r1'}, - 'cells', - {_key: 'c1'}, - 'value', - {_key: 'cb1'}, - 'children', - {_key: 's1'}, - ], - valueChanged: false, - }, - { - basis: 'content-unchanged', - key: 's2', - path: [ - {_key: 't1'}, - 'rows', - {_key: 'r1'}, - 'cells', - {_key: 'c1'}, - 'value', - {_key: 'cb1'}, - 'children', - {_key: 's2'}, - ], - valueChanged: false, - }, - { - basis: 'content-unchanged', - key: 's3', - path: [ - {_key: 't1'}, - 'rows', - {_key: 'r1'}, - 'cells', - {_key: 'c1'}, - 'value', - {_key: 'cb1'}, - 'children', - {_key: 's3'}, - ], - valueChanged: false, - }, - { - basis: 'same-position', - key: 'c2', - path: [{_key: 't1'}, 'rows', {_key: 'r1'}, 'cells', {_key: 'c2'}], - valueChanged: true, - }, - { - basis: 'same-position', - key: 'cb2', - path: [ - {_key: 't1'}, - 'rows', - {_key: 'r1'}, - 'cells', - {_key: 'c2'}, - 'value', - {_key: 'cb2'}, - ], - valueChanged: true, - }, - { - basis: 'same-position', - key: 's4', - path: [ - {_key: 't1'}, - 'rows', - {_key: 'r1'}, - 'cells', - {_key: 'c2'}, - 'value', - {_key: 'cb2'}, - 'children', - {_key: 's4'}, - ], - valueChanged: true, - }, - ], - keyFallbacks: [], - renamedKeys: [], - hiddenLosses: [], - markdownLosses: [], - undeclaredObjects: [], - }) - }) - - test('a keyless block in an untouched table cell is not reported as a preserved key once key filling mints one', () => { - const keyGenerator = createTestKeyGenerator() - const stored = [ - { - _type: 'table', - _key: 't1', - headerRows: 0, - rows: [ - { - _type: 'row', - _key: 'r1', - cells: [ - { - _type: 'cell', - _key: 'c1', - value: [ - { - _type: 'block', - style: 'normal', - markDefs: [ - { - _type: 'internalLink', - _key: 'm1', - reference: {_type: 'reference', _ref: 'foo'}, - }, - ], - children: [ - {_type: 'span', _key: 's1', text: 'Read ', marks: []}, - {_type: 'span', _key: 's2', text: 'foo', marks: ['m1']}, - { - _type: 'span', - _key: 's3', - text: ' before baz.', - marks: [], - }, - ], - }, - ], - }, - {_type: 'cell', _key: 'c2', value: [block('cb2', 's4', 'bar')]}, - ], - }, - ], - }, - ] - const markdown = portableTextToMarkdown(structuredClone(stored)).replace( - 'bar', - 'bar now', - ) - let report!: ReconciliationReport - const result = applyMarkdownEdit(stored, markdown, { - deserialize: {keyGenerator}, - onReconciliation: (r) => { - report = r - }, - }) - expect(result).toEqual([ - { - _type: 'table', - _key: 't1', - headerRows: 0, - rows: [ - { - _type: 'row', - _key: 'r1', - cells: [ - { - _type: 'cell', - _key: 'c1', - value: [ - { - _type: 'block', - _key: 'k6', - style: 'normal', - markDefs: [ - { - _type: 'internalLink', - _key: 'm1', - reference: {_type: 'reference', _ref: 'foo'}, - }, - ], - children: [ - {_type: 'span', _key: 's1', text: 'Read ', marks: []}, - {_type: 'span', _key: 's2', text: 'foo', marks: ['m1']}, - { - _type: 'span', - _key: 's3', - text: ' before baz.', - marks: [], - }, - ], - }, - ], - }, - { - _type: 'cell', - _key: 'c2', - value: [block('cb2', 's4', 'bar now')], - }, - ], - }, - ], - }, - ]) - expect(report).toEqual({ - keyMatching: 'performed', - preservedKeys: [ - { - basis: 'same-position', - key: 't1', - path: [{_key: 't1'}], - valueChanged: true, - }, - { - basis: 'same-position', - key: 'r1', - path: [{_key: 't1'}, 'rows', {_key: 'r1'}], - valueChanged: true, - }, - { - basis: 'content-unchanged', - key: 'c1', - path: [{_key: 't1'}, 'rows', {_key: 'r1'}, 'cells', {_key: 'c1'}], - valueChanged: true, - }, - { - basis: 'content-unchanged', - key: 'm1', - path: [ - {_key: 't1'}, - 'rows', - {_key: 'r1'}, - 'cells', - {_key: 'c1'}, - 'value', - {_key: 'k6'}, - 'markDefs', - {_key: 'm1'}, - ], - valueChanged: false, - }, - { - basis: 'content-unchanged', - key: 's1', - path: [ - {_key: 't1'}, - 'rows', - {_key: 'r1'}, - 'cells', - {_key: 'c1'}, - 'value', - {_key: 'k6'}, - 'children', - {_key: 's1'}, - ], - valueChanged: false, - }, - { - basis: 'content-unchanged', - key: 's2', - path: [ - {_key: 't1'}, - 'rows', - {_key: 'r1'}, - 'cells', - {_key: 'c1'}, - 'value', - {_key: 'k6'}, - 'children', - {_key: 's2'}, - ], - valueChanged: false, - }, - { - basis: 'content-unchanged', - key: 's3', - path: [ - {_key: 't1'}, - 'rows', - {_key: 'r1'}, - 'cells', - {_key: 'c1'}, - 'value', - {_key: 'k6'}, - 'children', - {_key: 's3'}, - ], - valueChanged: false, - }, - { - basis: 'same-position', - key: 'c2', - path: [{_key: 't1'}, 'rows', {_key: 'r1'}, 'cells', {_key: 'c2'}], - valueChanged: true, - }, - { - basis: 'same-position', - key: 'cb2', - path: [ - {_key: 't1'}, - 'rows', - {_key: 'r1'}, - 'cells', - {_key: 'c2'}, - 'value', - {_key: 'cb2'}, - ], - valueChanged: true, - }, - { - basis: 'same-position', - key: 's4', - path: [ - {_key: 't1'}, - 'rows', - {_key: 'r1'}, - 'cells', - {_key: 'c2'}, - 'value', - {_key: 'cb2'}, - 'children', - {_key: 's4'}, - ], - valueChanged: true, - }, - ], - keyFallbacks: [], - renamedKeys: [], - hiddenLosses: [], - markdownLosses: [], - undeclaredObjects: [], - }) - }) - - test('leaving onReconciliation unset changes nothing about the returned value', () => { - const stored = [block('b1', 's1', 'alpha'), block('b2', 's2', 'beta')] - const markdown = 'beta\n\nalpha' - const withoutReport = applyMarkdownEdit(stored, markdown, { - deserialize: {keyGenerator: createTestKeyGenerator()}, - }) - const withReport = applyMarkdownEdit(stored, markdown, { - deserialize: {keyGenerator: createTestKeyGenerator()}, - onReconciliation: () => {}, - }) - expect(withReport).toEqual(withoutReport) - }) - - test('onReconciliation fires exactly once, including when key matching is skipped', () => { - const onReconciliation = vi.fn() - const stored = [block('b1', 's1', 'alpha'), block('b2', 's2', 'beta')] - applyMarkdownEdit(stored, 'beta\n\nalpha', { - deserialize: {keyGenerator: createTestKeyGenerator()}, - onReconciliation, - }) - expect(onReconciliation).toHaveBeenCalledTimes(1) - - onReconciliation.mockClear() - const refusingStored = [ - { - _type: 'block', - _key: 'b1', - style: 'h3', - markDefs: [], - children: [{_type: 'span', _key: 's1', text: 'one\ntwo', marks: []}], - }, - ] - applyMarkdownEdit( - refusingStored, - portableTextToMarkdown(structuredClone(refusingStored)), - { - deserialize: {keyGenerator: createTestKeyGenerator()}, - onReconciliation, - }, - ) - expect(onReconciliation).toHaveBeenCalledTimes(1) - }) - - test('a heading level the schema has no style for is a markdown loss the caller still observes through onDegradation', () => { - const keyGenerator = createTestKeyGenerator() - const schema = compileSchema(defineSchema({styles: [{name: 'normal'}]})) - const stored = [block('b1', 's1', 'foo')] - const onDegradation = vi.fn() - let report!: ReconciliationReport - const result = applyMarkdownEdit(stored, 'foo\n\n### bar', { - schema, - deserialize: {keyGenerator, onDegradation}, - onReconciliation: (r) => { - report = r - }, - }) - expect(result).toEqual([block('b1', 's1', 'foo'), block('k2', 'k3', 'bar')]) - expect(onDegradation.mock.calls).toEqual([ - [ - { - degradations: [ - { - type: 'style-fallback', - message: - '`###` heading became a normal paragraph: the schema has no `h3` style', - line: 3, - snippet: 'bar', - }, - ], - message: expect.stringContaining('h3'), - }, - ], - ]) - expect(report).toEqual({ - keyMatching: 'performed', - preservedKeys: [ - { - basis: 'content-unchanged', - key: 'b1', - path: [{_key: 'b1'}], - valueChanged: false, - }, - { - basis: 'content-unchanged', - key: 's1', - path: [{_key: 'b1'}, 'children', {_key: 's1'}], - valueChanged: false, - }, - ], - keyFallbacks: [], - renamedKeys: [], - hiddenLosses: [], - markdownLosses: [ - { - type: 'style-fallback', - message: - '`###` heading became a normal paragraph: the schema has no `h3` style', - line: 3, - snippet: 'bar', - }, - ], - undeclaredObjects: [], - }) - }) - - test('a caller onDegradation that throws still makes applyMarkdownEdit throw', () => { - const schema = compileSchema(defineSchema({styles: [{name: 'normal'}]})) - const onReconciliation = vi.fn() - expect(() => - applyMarkdownEdit([block('b1', 's1', 'foo')], 'foo\n\n### bar', { - schema, - deserialize: { - keyGenerator: createTestKeyGenerator(), - onDegradation: ({message}) => { - throw new Error(message) - }, - }, - onReconciliation, - }), - ).toThrow('the schema has no `h3` style') - expect(onReconciliation).not.toHaveBeenCalled() - }) - - test('a `json:object` fence adding an undeclared block object is an undeclared object, not a markdown loss', () => { - const keyGenerator = createTestKeyGenerator() - const stored = [block('b1', 's1', 'foo')] - const markdown = [ - 'foo', - '', - '```json:object', - '{"_type": "product", "_key": "p1"}', - '```', - ].join('\n') - const onDegradation = vi.fn() - let report!: ReconciliationReport - const result = applyMarkdownEdit(stored, markdown, { - deserialize: {keyGenerator, onDegradation}, - onReconciliation: (r) => { - report = r - }, - }) - expect(result).toEqual([ - block('b1', 's1', 'foo'), - {_type: 'product', _key: 'p1'}, - ]) - expect(onDegradation.mock.calls).toEqual([ - [ - { - degradations: [ - { - type: 'object-type-undeclared', - message: - 'Kept the block-level `product` object as written: the schema has no block-level `product` object', - line: 3, - snippet: '{"_type": "product", "_key": "p1"}', - }, - ], - message: expect.stringContaining('product'), - }, - ], - ]) - expect(report).toEqual({ - keyMatching: 'performed', - preservedKeys: [ - { - basis: 'content-unchanged', - key: 'b1', - path: [{_key: 'b1'}], - valueChanged: false, - }, - { - basis: 'content-unchanged', - key: 's1', - path: [{_key: 'b1'}, 'children', {_key: 's1'}], - valueChanged: false, - }, - ], - keyFallbacks: [], - renamedKeys: [], - hiddenLosses: [], - markdownLosses: [], - undeclaredObjects: [ - { - _type: 'product', - _key: 'p1', - position: 'block', - path: [{_key: 'p1'}], - message: - 'Kept the block-level `product` object as written: the schema has no block-level `product` object', - }, - ], - }) - }) - - test('a stored undeclared block object the edit keeps is not an undeclared object', () => { - const keyGenerator = createTestKeyGenerator() - const stored = [ - block('b1', 's1', 'foo'), - {_type: 'product', _key: 'p1', sku: 'abc'}, - ] - const markdown = portableTextToMarkdown(structuredClone(stored)).replace( - 'foo', - 'bar', - ) - let report!: ReconciliationReport - const result = applyMarkdownEdit(stored, markdown, { - deserialize: {keyGenerator}, - onReconciliation: (r) => { - report = r - }, - }) - expect(result).toEqual([ - block('b1', 's1', 'bar'), - {_type: 'product', _key: 'p1', sku: 'abc'}, - ]) - expect(report).toEqual({ - keyMatching: 'performed', - preservedKeys: [ - { - basis: 'same-position', - key: 'b1', - path: [{_key: 'b1'}], - valueChanged: true, - }, - { - basis: 'same-position', - key: 's1', - path: [{_key: 'b1'}, 'children', {_key: 's1'}], - valueChanged: true, - }, - { - basis: 'content-unchanged', - key: 'p1', - path: [{_key: 'p1'}], - valueChanged: false, - }, - ], - keyFallbacks: [], - renamedKeys: [], - hiddenLosses: [], - markdownLosses: [], - undeclaredObjects: [], - }) - }) - - test('a stored keyless undeclared object the edit keeps is not an undeclared object, though the round trip gives it a key', () => { - const keyGenerator = createTestKeyGenerator() - const stored = [ - block('b1', 's1', 'foo'), - {_type: 'product', sku: 'abc'} as unknown as PortableTextBlock, - ] - const markdown = portableTextToMarkdown(structuredClone(stored)).replace( - 'foo', - 'bar', - ) - let report!: ReconciliationReport - applyMarkdownEdit(stored, markdown, { - deserialize: {keyGenerator}, - onReconciliation: (r) => { - report = r - }, - }) - expect(report.undeclaredObjects).toEqual([]) - }) - - test('an undeclared object inside a declared container is not checked, stored or added', () => { - const keyGenerator = createTestKeyGenerator() - const schema = containerSchema() - const stored = [ - block('b1', 's1', 'foo'), - { - _type: 'container', - items: [{_type: 'legacy', _key: 'l1'}], - } as unknown as PortableTextBlock, - ] - const markdown = portableTextToMarkdown(structuredClone(stored), { - schema, - }).replace('foo', 'bar') - let report!: ReconciliationReport - applyMarkdownEdit(stored, markdown, { - schema, - deserialize: {keyGenerator}, - onReconciliation: (r) => { - report = r - }, - }) - expect(report.undeclaredObjects).toEqual([]) - }) - - test('a second undeclared object of a stored type is an undeclared object, the stored one is not', () => { - const keyGenerator = createTestKeyGenerator() - const stored = [ - block('b1', 's1', 'foo'), - {_type: 'product', sku: 'abc'} as unknown as PortableTextBlock, - ] - const markdown = [ - portableTextToMarkdown(structuredClone(stored)), - '', - '```json:object', - JSON.stringify({_type: 'product', _key: 'p2', sku: 'def'}), - '```', - ].join('\n') - let report!: ReconciliationReport - applyMarkdownEdit(stored, markdown, { - deserialize: {keyGenerator}, - onReconciliation: (r) => { - report = r - }, - }) - expect(report.undeclaredObjects).toEqual([ - { - _type: 'product', - _key: 'p2', - position: 'block', - path: [{_key: 'p2'}], - message: - 'Kept the block-level `product` object as written: the schema has no block-level `product` object', - }, - ]) - }) - - test('an undeclared object the edit adds inside a declared container is not an undeclared object, since fields of declared objects are not searched', () => { - const keyGenerator = createTestKeyGenerator() - const schema = containerSchema() - const stored = [ - block('b1', 's1', 'foo'), - { - _type: 'container', - _key: 'c1', - items: [ - {_type: 'entry', _key: 'e1'}, - {_type: 'legacy', _key: 'l1'}, - ], - }, - ] - const markdown = [ - 'foo', - '', - '```json:object', - JSON.stringify({ - _type: 'container', - _key: 'c1', - items: [ - {_type: 'entry', _key: 'e1'}, - {_type: 'legacy', _key: 'l1'}, - {_type: 'product', _key: 'p1'}, - ], - }), - '```', - ].join('\n') - let report!: ReconciliationReport - const result = applyMarkdownEdit(stored, markdown, { - schema, - deserialize: {keyGenerator}, - onReconciliation: (r) => { - report = r - }, - }) - expect(result).toEqual([ - block('b1', 's1', 'foo'), - { - _type: 'container', - _key: 'c1', - items: [ - {_type: 'entry', _key: 'e1'}, - {_type: 'legacy', _key: 'l1'}, - {_type: 'product', _key: 'p1'}, - ], - }, - ]) - expect(report.undeclaredObjects).toEqual([]) - }) - - test('an undeclared inline object added to a paragraph is an undeclared object at its inline path', () => { - const keyGenerator = createTestKeyGenerator() - const stored = [block('b1', 's1', 'foo')] - let report!: ReconciliationReport - const result = applyMarkdownEdit( - stored, - 'foo json:object`{"_type":"mention","_key":"m1"}`', - { - deserialize: {keyGenerator}, - onReconciliation: (r) => { - report = r - }, - }, - ) - expect(result).toEqual([ - { - _type: 'block', - _key: 'b1', - style: 'normal', - markDefs: [], - children: [ - {_type: 'span', _key: 'k1', text: 'foo ', marks: []}, - {_type: 'mention', _key: 'm1'}, - ], - }, - ]) - expect(report.markdownLosses).toEqual([]) - expect(report.undeclaredObjects).toEqual([ - { - _type: 'mention', - _key: 'm1', - position: 'inline', - path: [{_key: 'b1'}, 'children', {_key: 'm1'}], - message: - 'Kept the inline `mention` object as written: the schema has no inline `mention` object', - }, - ]) - }) - - test('a stored undeclared inline object survives an edit that gives its paragraph a fresh key without being an undeclared object', () => { - const keyGenerator = createTestKeyGenerator() - const stored: Array = [ - { - _type: 'block', - _key: 'b1', - style: 'normal', - markDefs: [], - children: [ - {_type: 'span', _key: 's1', text: 'foo ', marks: []}, - {_type: 'mention', _key: 'm1'}, - {_type: 'span', _key: 's2', text: ' bar', marks: []}, - ], - }, - ] - const markdown = portableTextToMarkdown(structuredClone(stored)).replace( - 'foo', - 'Completely different words', - ) - let report!: ReconciliationReport - const result = applyMarkdownEdit(stored, `baz\n\n${markdown}`, { - deserialize: {keyGenerator}, - onReconciliation: (r) => { - report = r - }, - }) - expect(result).toEqual([ - block('k0', 'k1', 'baz'), - { - _type: 'block', - _key: 'k2', - style: 'normal', - markDefs: [], - children: [ - { - _type: 'span', - _key: 'k3', - text: 'Completely different words ', - marks: [], - }, - {_type: 'mention', _key: 'm1'}, - {_type: 'span', _key: 'k4', text: ' bar', marks: []}, - ], - }, - ]) - expect(report.undeclaredObjects).toEqual([]) - }) - - test('a callout the edit adds is not an undeclared object', () => { - const keyGenerator = createTestKeyGenerator() - const stored = [block('b1', 's1', 'foo')] - const markdown = ['foo', '', '> [!NOTE]', '> bar'].join('\n') - let report!: ReconciliationReport - const result = applyMarkdownEdit(stored, markdown, { - deserialize: {keyGenerator}, - onReconciliation: (r) => { - report = r - }, - }) - expect(result).toEqual([ - block('b1', 's1', 'foo'), - { - _key: 'k4', - _type: 'callout', - tone: 'note', - content: [ - { - _type: 'block', - _key: 'k2', - style: 'blockquote', - markDefs: [], - children: [{_type: 'span', _key: 'k3', text: 'bar', marks: []}], - }, - ], - }, - ]) - expect(report.markdownLosses).toEqual([]) - expect(report.undeclaredObjects).toEqual([]) - }) - - test('a skipped report carries markdown losses and undeclared objects', () => { - const keyGenerator = createTestKeyGenerator() - const stored = [ - { - _type: 'block', - _key: 'b1', - style: 'h4', - markDefs: [], - children: [{_type: 'span', _key: 's1', text: 'foo\nbar', marks: []}], - }, - ] - const markdown = [ - portableTextToMarkdown(structuredClone(stored)), - '### baz', - '```json:object', - '{"_type": "product", "_key": "p1"}', - '```', - ].join('\n\n') - let report!: ReconciliationReport - applyMarkdownEdit(stored, markdown, { - schema: compileSchema( - defineSchema({styles: [{name: 'normal'}, {name: 'h4'}]}), - ), - deserialize: {keyGenerator}, - onReconciliation: (r) => { - report = r - }, - }) - expect(report).toEqual({ - keyMatching: 'skipped', - reason: 'round-trip-mismatch', - mismatch: { - type: 'text-changed', - storedPath: [{_key: 'b1'}, 'children', {_key: 's1'}], - message: - "The block's text came back different, starting at the text `\\nbar`", - }, - renamedKeys: [], - hiddenLosses: [], - markdownLosses: [ - { - type: 'style-fallback', - message: - '`###` heading became a normal paragraph: the schema has no `h3` style', - line: 4, - snippet: 'baz', - }, - ], - undeclaredObjects: [ - { - _type: 'product', - _key: 'p1', - position: 'block', - path: [{_key: 'p1'}], - message: - 'Kept the block-level `product` object as written: the schema has no block-level `product` object', - }, - ], - }) - }) -}) - -function containerSchema() { - return compileSchema( - defineSchema({ - blockObjects: [ - { - name: 'container', - fields: [ - { - name: 'items', - type: 'array', - of: [ - {type: 'block'}, - {type: 'object', name: 'entry', fields: []}, - ], - }, - ], - }, - ], - }), - ) -} - -function internalLinkBlock(blockKey: string): PortableTextBlock { - return { - _type: 'block', - _key: blockKey, - style: 'normal', - markDefs: [ - { - _type: 'internalLink', - _key: 'm1', - reference: {_type: 'reference', _ref: 'foo'}, - }, - ], - children: [ - {_type: 'span', _key: 's1', text: 'Read ', marks: []}, - {_type: 'span', _key: 's2', text: 'foo', marks: ['m1']}, - {_type: 'span', _key: 's3', text: ' before baz.', marks: []}, - ], - } -} - -function manyBlocks(count: number, prefix: string): Array { - return Array.from({length: count}, (_, index) => - block( - `${prefix}b${index}`, - `${prefix}s${index}`, - `${prefix} filler text ${index}`, - ), - ) -} - -function manyParagraphs(count: number, prefix: string): string { - return Array.from( - {length: count}, - (_, index) => `${prefix} filler text ${index}`, - ).join('\n\n') -} diff --git a/packages/markdown/src/apply-markdown-edit.test.ts b/packages/markdown/src/apply-markdown-edit.test.ts deleted file mode 100644 index 2a4011e8d6..0000000000 --- a/packages/markdown/src/apply-markdown-edit.test.ts +++ /dev/null @@ -1,3679 +0,0 @@ -import { - compileSchema, - defineSchema, - type PortableTextBlock, -} from '@portabletext/schema' -import {createTestKeyGenerator} from '@portabletext/test' -import {describe, expect, test, vi} from 'vitest' -import { - applyMarkdownEdit, - type ReconciliationReport, -} from './apply-markdown-edit' -import {defaultTableObjectDefinition} from './default-schema' -import {portableTextToMarkdown} from './from-portable-text/portable-text-to-markdown' - -function block( - blockKey: string, - spanKey: string, - text: string, -): PortableTextBlock { - return { - _type: 'block', - _key: blockKey, - style: 'normal', - markDefs: [], - children: [{_type: 'span', _key: spanKey, text, marks: []}], - } -} - -describe(applyMarkdownEdit.name, () => { - test('a fence key differing from the matched stored block loses to the stored key', () => { - const keyGenerator = createTestKeyGenerator() - const stored = [{_type: 'product', _key: 'p1', sku: 'a'}] - const fence = [ - '```json:object', - '{"_type": "product", "_key": "p9", "sku": "z"}', - '```', - ].join('\n') - expect( - applyMarkdownEdit(stored, fence, {deserialize: {keyGenerator}}), - ).toEqual([{_type: 'product', _key: 'p1', sku: 'z'}]) - }) - - test('a document-wide refusal drops stored empty blocks instead of adopting their keys', () => { - const keyGenerator = createTestKeyGenerator() - // A heading carrying `listItem` serializes to `- ## title text`, which - // reparses as two blocks: the round trip changes the node count, so - // the origin trace refuses and nothing may adopt, including the - // trailing empty block, whose anchor (the payload's verbatim key) - // would otherwise still be found in the result. - const stored = [ - { - _type: 'block', - _key: 'b1', - style: 'h2', - listItem: 'bullet', - level: 1, - markDefs: [], - children: [{_type: 'span', _key: 's1', text: 'title text', marks: []}], - }, - {_type: 'product', _key: 'p1', sku: 'abc-123'}, - block('e1', 'es1', ''), - ] - const markdown = portableTextToMarkdown(structuredClone(stored)) - expect(markdown).toEqual( - '- ## title text\n\n```json:object\n{\n "_type": "product",\n "_key": "p1",\n "sku": "abc-123"\n}\n```', - ) - expect( - applyMarkdownEdit(stored, markdown, {deserialize: {keyGenerator}}), - ).toEqual([ - { - _type: 'block', - _key: 'k0', - style: 'normal', - markDefs: [], - children: [{_type: 'span', _key: 'k1', text: '', marks: []}], - listItem: 'bullet', - level: 1, - }, - { - _type: 'block', - _key: 'k2', - style: 'h2', - markDefs: [], - children: [{_type: 'span', _key: 'k3', text: 'title text', marks: []}], - }, - {_type: 'product', _key: 'p1', sku: 'abc-123'}, - ]) - }) - - test('an empty custom-styled block reinserts like an empty paragraph', () => { - const keyGenerator = createTestKeyGenerator() - const stored = [ - block('b1', 's1', 'alpha'), - { - _type: 'block', - _key: 'e1', - style: 'lead', - markDefs: [], - children: [{_type: 'span', _key: 'es1', text: ' ', marks: []}], - }, - block('b2', 's2', 'beta'), - ] - const markdown = portableTextToMarkdown(structuredClone(stored)) - expect( - applyMarkdownEdit(stored, markdown.replace('beta', 'gamma'), { - deserialize: {keyGenerator}, - }), - ).toEqual([ - block('b1', 's1', 'alpha'), - { - _type: 'block', - _key: 'e1', - style: 'lead', - markDefs: [], - children: [{_type: 'span', _key: 'es1', text: ' ', marks: []}], - }, - block('b2', 's2', 'gamma'), - ]) - }) - - test('a block of non-breaking spaces round-trips as content and keeps every key', () => { - const keyGenerator = createTestKeyGenerator() - const stored = [ - block('b1', 's1', 'alpha'), - { - _type: 'block', - _key: 'e1', - style: 'lead', - markDefs: [], - children: [{_type: 'span', _key: 'es1', text: '\u00a0', marks: []}], - }, - block('b2', 's2', 'beta'), - ] - const markdown = portableTextToMarkdown(structuredClone(stored)) - expect( - applyMarkdownEdit(stored, markdown.replace('beta', 'gamma'), { - deserialize: {keyGenerator}, - }), - ).toEqual([ - block('b1', 's1', 'alpha'), - { - _type: 'block', - _key: 'e1', - style: 'lead', - markDefs: [], - children: [{_type: 'span', _key: 'es1', text: '\u00a0', marks: []}], - }, - block('b2', 's2', 'gamma'), - ]) - }) - - test('inserting a new link keeps the sibling annotated spans and their keys', () => { - const keyGenerator = createTestKeyGenerator() - const stored = [ - { - _type: 'block', - _key: 'b1', - style: 'normal', - markDefs: [ - {_type: 'link', _key: 'la', href: 'https://a.test'}, - {_type: 'link', _key: 'lb', href: 'https://b.test'}, - ], - children: [ - {_type: 'span', _key: 's1', text: 'alpha', marks: ['la']}, - {_type: 'span', _key: 's2', text: ' mid ', marks: []}, - {_type: 'span', _key: 's3', text: 'beta', marks: ['lb']}, - ], - }, - ] - const markdown = portableTextToMarkdown(structuredClone(stored)) - expect( - applyMarkdownEdit(stored, `[new](https://c.test) ${markdown}`, { - deserialize: {keyGenerator}, - }), - ).toEqual([ - { - _type: 'block', - _key: 'b1', - style: 'normal', - markDefs: [ - {_type: 'link', _key: 'k1', href: 'https://c.test'}, - {_type: 'link', _key: 'la', href: 'https://a.test'}, - {_type: 'link', _key: 'lb', href: 'https://b.test'}, - ], - children: [ - {_type: 'span', _key: 'k2', text: 'new', marks: ['k1']}, - {_type: 'span', _key: 'k3', text: ' ', marks: []}, - {_type: 'span', _key: 's1', text: 'alpha', marks: ['la']}, - {_type: 'span', _key: 's2', text: ' mid ', marks: []}, - {_type: 'span', _key: 's3', text: 'beta', marks: ['lb']}, - ], - }, - ]) - }) - - test('an empty run reinserts next to the adopted anchor, not a pasted duplicate', () => { - const keyGenerator = createTestKeyGenerator() - const stored = [ - block('b1', 's1', 'alpha'), - {_type: 'product', _key: 'p1', sku: 'abc'}, - block('e1', 'es1', ''), - ] - const fence = [ - '```json:object', - '{"_type": "product", "_key": "p1", "sku": "abc"}', - '```', - ].join('\n') - // paste a copy of the fence at the top; the original stays in place - const edited = `${fence}\n\nalpha\n\n${fence}` - expect( - applyMarkdownEdit(stored, edited, {deserialize: {keyGenerator}}), - ).toEqual([ - {_type: 'product', _key: 'k2', sku: 'abc'}, - block('b1', 's1', 'alpha'), - {_type: 'product', _key: 'p1', sku: 'abc'}, - block('e1', 'es1', ''), - ]) - }) - - test('an unchanged document gets every key back', () => { - const keyGenerator = createTestKeyGenerator() - const stored = [ - block('b1', 's1', 'alpha'), - {_type: 'product', _key: 'p1', sku: 'abc-123'}, - block('b2', 's2', 'beta'), - ] - const markdown = portableTextToMarkdown(structuredClone(stored)) - expect( - applyMarkdownEdit(stored, markdown, {deserialize: {keyGenerator}}), - ).toEqual(stored) - }) - - test('a one-word fix keeps the block key and the span key', () => { - const keyGenerator = createTestKeyGenerator() - const stored = [ - block('b1', 's1', 'Our pick:'), - {_type: 'product', _key: 'p1', sku: 'abc-123'}, - block('b2', 's2', 'Ships tomorow.'), - ] - const markdown = portableTextToMarkdown(structuredClone(stored)).replace( - 'tomorow', - 'tomorrow', - ) - expect( - applyMarkdownEdit(stored, markdown, {deserialize: {keyGenerator}}), - ).toEqual([ - block('b1', 's1', 'Our pick:'), - {_type: 'product', _key: 'p1', sku: 'abc-123'}, - block('b2', 's2', 'Ships tomorrow.'), - ]) - }) - - test('a typo fix next to a strong span lands as a text change on the same span', () => { - const keyGenerator = createTestKeyGenerator() - const stored = [ - { - _type: 'block', - _key: 'b1', - style: 'normal', - markDefs: [], - children: [ - {_type: 'span', _key: 's1', text: 'foo ', marks: []}, - {_type: 'span', _key: 's2', text: 'bar', marks: ['strong']}, - {_type: 'span', _key: 's3', text: ' baz', marks: []}, - ], - }, - ] - const markdown = portableTextToMarkdown(structuredClone(stored)).replace( - 'baz', - 'fizz', - ) - expect( - applyMarkdownEdit(stored, markdown, {deserialize: {keyGenerator}}), - ).toEqual([ - { - _type: 'block', - _key: 'b1', - style: 'normal', - markDefs: [], - children: [ - {_type: 'span', _key: 's1', text: 'foo ', marks: []}, - {_type: 'span', _key: 's2', text: 'bar', marks: ['strong']}, - {_type: 'span', _key: 's3', text: ' fizz', marks: []}, - ], - }, - ]) - }) - - test('a no-op edit keeps a stored split between two same-mark spans, not their merged parse', () => { - const keyGenerator = createTestKeyGenerator() - const stored = [ - { - _type: 'block', - _key: 'b1', - style: 'normal', - markDefs: [], - children: [ - {_type: 'span', _key: 's1', text: 'foo', marks: []}, - {_type: 'span', _key: 's2', text: 'bar', marks: []}, - ], - }, - ] - const markdown = portableTextToMarkdown(structuredClone(stored)) - expect(markdown).toEqual('foobar') - expect( - applyMarkdownEdit(stored, markdown, {deserialize: {keyGenerator}}), - ).toEqual(stored) - }) - - test('a merge past the span-pair cap does not adopt the key; below the cap it still does', () => { - const belowCap = applyMarkdownEdit( - [alternatingSpansBlock('b1', 3, 'small')], - alternatingMarkdown(2, 0, 'small0small1'), - {deserialize: {keyGenerator: createTestKeyGenerator()}}, - ) - expect(belowCap).toEqual([ - { - _type: 'block', - _key: 'b1', - style: 'normal', - markDefs: [], - children: [ - {_type: 'span', _key: 'smalls0', text: 'small0small1', marks: []}, - {_type: 'span', _key: 'smalls2', text: 'edited1', marks: ['strong']}, - ], - }, - ]) - - const overCap = applyMarkdownEdit( - [alternatingSpansBlock('b1', 51, 'stored')], - alternatingMarkdown(61, 30, 'stored0stored1'), - {deserialize: {keyGenerator: createTestKeyGenerator()}}, - ) - expect( - (overCap[0] as {children: Array<{_key: string}>}).children.map( - (child) => child._key, - ), - ).toEqual(Array.from({length: 61}, (_, index) => `k${index + 1}`)) - }) - - test('two identical annotations keep their keys, pairing in order', () => { - const keyGenerator = createTestKeyGenerator() - const stored = [ - { - _type: 'block', - _key: 'b1', - style: 'normal', - markDefs: [ - {_type: 'link', _key: 'a1', href: 'https://same.example'}, - {_type: 'link', _key: 'a2', href: 'https://same.example'}, - ], - children: [ - {_type: 'span', _key: 's1', text: 'first', marks: ['a1']}, - {_type: 'span', _key: 's2', text: ' and ', marks: []}, - {_type: 'span', _key: 's3', text: 'second', marks: ['a2']}, - ], - }, - ] - const markdown = portableTextToMarkdown(structuredClone(stored)) - expect( - applyMarkdownEdit(stored, markdown, {deserialize: {keyGenerator}}), - ).toEqual(stored) - }) - - test('two identical annotations with a dropped field keep their keys', () => { - const keyGenerator = createTestKeyGenerator() - const stored = [ - { - _type: 'block', - _key: 'b1', - style: 'normal', - markDefs: [ - { - _type: 'link', - _key: 'a1', - href: 'https://same.example', - rel: 'nofollow', - }, - { - _type: 'link', - _key: 'a2', - href: 'https://same.example', - rel: 'nofollow', - }, - ], - children: [ - {_type: 'span', _key: 's1', text: 'first', marks: ['a1']}, - {_type: 'span', _key: 's2', text: ' and ', marks: []}, - {_type: 'span', _key: 's3', text: 'second', marks: ['a2']}, - ], - }, - ] - const markdown = portableTextToMarkdown(structuredClone(stored)) - expect( - applyMarkdownEdit(stored, markdown, {deserialize: {keyGenerator}}), - ).toEqual(stored) - }) - - test('an unchanged link keeps its annotation key and reference', () => { - const keyGenerator = createTestKeyGenerator() - const stored = [ - { - _type: 'block', - _key: 'b1', - style: 'normal', - markDefs: [{_type: 'link', _key: 'a1', href: 'https://example.com'}], - children: [ - {_type: 'span', _key: 's1', text: 'visit ', marks: []}, - {_type: 'span', _key: 's2', text: 'here', marks: ['a1']}, - ], - }, - ] - const markdown = portableTextToMarkdown(structuredClone(stored)) - expect( - applyMarkdownEdit(stored, markdown, {deserialize: {keyGenerator}}), - ).toEqual(stored) - }) - - test('a changed link keeps the block, span, and annotation keys', () => { - const keyGenerator = createTestKeyGenerator() - const stored = [ - { - _type: 'block', - _key: 'b1', - style: 'normal', - markDefs: [ - {_type: 'link', _key: 'a1', href: 'https://example.com/old'}, - ], - children: [{_type: 'span', _key: 's1', text: 'here', marks: ['a1']}], - }, - ] - const markdown = portableTextToMarkdown(structuredClone(stored)).replace( - '/old', - '/new', - ) - const result = applyMarkdownEdit(stored, markdown, { - deserialize: {keyGenerator}, - }) - expect(result).toEqual([ - { - _type: 'block', - _key: 'b1', - style: 'normal', - markDefs: [ - {_type: 'link', _key: 'a1', href: 'https://example.com/new'}, - ], - children: [{_type: 'span', _key: 's1', text: 'here', marks: ['a1']}], - }, - ]) - }) - - test('an adopted markDef key colliding with a sibling fresh key does not misattach a span', () => { - let calls = 0 - const keyGenerator = () => { - const index = calls++ - return index === 1 ? 'a1' : `fresh${index}` - } - const stored = [ - { - _type: 'block', - _key: 'b1', - style: 'normal', - markDefs: [{_type: 'link', _key: 'a1', href: 'https://old'}], - children: [{_type: 'span', _key: 's1', text: 'old', marks: ['a1']}], - }, - ] - const markdown = '[new](https://new) [old](https://old)' - const result = applyMarkdownEdit(stored, markdown, { - deserialize: {keyGenerator}, - }) - expect(result).toEqual([ - { - _type: 'block', - _key: 'b1', - style: 'normal', - markDefs: [ - {_type: 'link', _key: 'a1', href: 'https://new'}, - {_type: 'link', _key: 'fresh4', href: 'https://old'}, - ], - children: [ - {_type: 'span', _key: 'fresh2', text: 'new', marks: ['a1']}, - {_type: 'span', _key: 'fresh3', text: ' ', marks: []}, - {_type: 'span', _key: 's1', text: 'old', marks: ['fresh4']}, - ], - }, - ]) - }) - - test('a colliding key generator cannot mint two annotations with one key', () => { - const stored: Array = [] - let calls = 0 - const keyGenerator = () => { - const index = calls++ - return index === 1 || index === 4 ? 'x' : `fresh${index}` - } - const markdown = '[a](https://one) [b](https://two)' - const result = applyMarkdownEdit(stored, markdown, { - deserialize: {keyGenerator}, - }) - expect(result).toEqual([ - { - _type: 'block', - _key: 'fresh0', - style: 'normal', - markDefs: [ - {_type: 'link', _key: 'x', href: 'https://one'}, - {_type: 'link', _key: 'fresh5', href: 'https://two'}, - ], - children: [ - {_type: 'span', _key: 'fresh2', text: 'a', marks: ['x']}, - {_type: 'span', _key: 'fresh3', text: ' ', marks: []}, - {_type: 'span', _key: 'fresh6', text: 'b', marks: ['fresh5']}, - ], - }, - ]) - }) - - test('a colliding generator keeps multi-span annotations attached to their own links', () => { - let calls = 0 - const keyGenerator = () => { - const index = calls++ - return index < 6 ? 'x' : `fresh${index}` - } - expect( - applyMarkdownEdit([], '[**a** b](https://one) [c](https://two)', { - deserialize: {keyGenerator}, - }), - ).toEqual([ - { - _type: 'block', - _key: 'x', - style: 'normal', - markDefs: [ - {_key: 'x-2', _type: 'link', href: 'https://one'}, - {_key: 'fresh9', _type: 'link', href: 'https://two'}, - ], - children: [ - {_type: 'span', _key: 'fresh6', text: 'a', marks: ['x-2', 'strong']}, - {_type: 'span', _key: 'fresh7', text: ' b', marks: ['x-2']}, - {_type: 'span', _key: 'fresh8', text: ' ', marks: []}, - {_type: 'span', _key: 'fresh10', text: 'c', marks: ['fresh9']}, - ], - }, - ]) - }) - - test('a moved block keeps its key', () => { - const keyGenerator = createTestKeyGenerator() - const stored = [block('b1', 's1', 'alpha'), block('b2', 's2', 'beta')] - const markdown = 'beta\n\nalpha' - expect( - applyMarkdownEdit(stored, markdown, {deserialize: {keyGenerator}}), - ).toEqual([block('b2', 's2', 'beta'), block('b1', 's1', 'alpha')]) - }) - - test('a split keeps the key on the first fragment, like pressing enter', () => { - const keyGenerator = createTestKeyGenerator() - const stored = [block('b1', 's1', 'alphabeta')] - const markdown = 'alpha\n\nbeta' - expect( - applyMarkdownEdit(stored, markdown, {deserialize: {keyGenerator}}), - ).toEqual([block('b1', 's1', 'alpha'), block('k2', 'k3', 'beta')]) - }) - - test('a merge keeps the first block key, like pressing backspace', () => { - const keyGenerator = createTestKeyGenerator() - const stored = [block('b1', 's1', 'alpha'), block('b2', 's2', 'beta')] - const markdown = 'alphabeta' - expect( - applyMarkdownEdit(stored, markdown, {deserialize: {keyGenerator}}), - ).toEqual([block('b1', 's1', 'alphabeta')]) - }) - - test('repeated content pairs in order; the copy keeps a new key', () => { - const keyGenerator = createTestKeyGenerator() - const stored = [block('b1', 's1', 'same')] - const markdown = 'same\n\nsame' - expect( - applyMarkdownEdit(stored, markdown, {deserialize: {keyGenerator}}), - ).toEqual([block('b1', 's1', 'same'), block('k2', 'k3', 'same')]) - }) - - test('a similarity tie gets a new key instead of guessing between candidates', () => { - const keyGenerator = createTestKeyGenerator() - const stored = [ - block('b1', 's1', 'prefix cat suffix'), - block('b2', 's2', 'prefix cut suffix'), - ] - const markdown = 'prefix cot suffix' - expect( - applyMarkdownEdit(stored, markdown, {deserialize: {keyGenerator}}), - ).toEqual([block('k0', 'k1', 'prefix cot suffix')]) - }) - - test('a rewrite in place keeps the block and span keys, like typing over a paragraph', () => { - const keyGenerator = createTestKeyGenerator() - const stored = [block('b1', 's1', 'the quick brown fox jumps')] - const markdown = 'an entirely different sentence now' - expect( - applyMarkdownEdit(stored, markdown, {deserialize: {keyGenerator}}), - ).toEqual([block('b1', 's1', 'an entirely different sentence now')]) - }) - - test('an edited `json:object` payload keeps the key it carries', () => { - const keyGenerator = createTestKeyGenerator() - const stored = [{_type: 'product', _key: 'p1', sku: 'old'}] - const markdown = portableTextToMarkdown(structuredClone(stored)).replace( - 'old', - 'new', - ) - expect( - applyMarkdownEdit(stored, markdown, {deserialize: {keyGenerator}}), - ).toEqual([{_type: 'product', _key: 'p1', sku: 'new'}]) - }) - - test('an edit to a `json:object` block keeps a changed `_key` inside a nested object field', () => { - const keyGenerator = createTestKeyGenerator() - const stored = [ - { - _type: 'widget', - _key: 'w1', - title: 'foo', - items: [ - { - _type: 'item', - _key: 'i1', - details: {_key: 'old', label: 'baz'}, - }, - ], - }, - ] - const markdown = portableTextToMarkdown(structuredClone(stored)) - .replace('"foo"', '"bar"') - .replace('"old"', '"new"') - expect( - applyMarkdownEdit(stored, markdown, {deserialize: {keyGenerator}}), - ).toEqual([ - { - _type: 'widget', - _key: 'w1', - title: 'bar', - items: [ - { - _type: 'item', - _key: 'i1', - details: {_key: 'new', label: 'baz'}, - }, - ], - }, - ]) - }) - - test('an edit to a `json:object` block keeps a changed member `_key` in a typed array nested under an object field', () => { - const keyGenerator = createTestKeyGenerator() - const stored = [ - { - _type: 'widget', - _key: 'w1', - title: 'foo', - items: [ - { - _type: 'item', - _key: 'i1', - details: {items: [{_type: 'item', _key: 'old', label: 'baz'}]}, - }, - ], - }, - ] - const markdown = portableTextToMarkdown(structuredClone(stored)) - .replace('"foo"', '"bar"') - .replace('"old"', '"new"') - expect( - applyMarkdownEdit(stored, markdown, {deserialize: {keyGenerator}}), - ).toEqual([ - { - _type: 'widget', - _key: 'w1', - title: 'bar', - items: [ - { - _type: 'item', - _key: 'i1', - details: {items: [{_type: 'item', _key: 'new', label: 'baz'}]}, - }, - ], - }, - ]) - }) - - test('an edit to a `json:object` block keeps a changed member `_key` in a typed array inside a mark definition', () => { - const keyGenerator = createTestKeyGenerator() - const stored = [ - { - _type: 'widget', - _key: 'w1', - title: 'foo', - items: [ - { - _type: 'item', - _key: 'i1', - markDefs: [ - { - _type: 'note', - _key: 'n1', - items: [{_type: 'item', _key: 'old', label: 'baz'}], - }, - ], - }, - ], - }, - ] - const markdown = portableTextToMarkdown(structuredClone(stored)) - .replace('"foo"', '"bar"') - .replace('"old"', '"new"') - expect( - applyMarkdownEdit(stored, markdown, {deserialize: {keyGenerator}}), - ).toEqual([ - { - _type: 'widget', - _key: 'w1', - title: 'bar', - items: [ - { - _type: 'item', - _key: 'i1', - markDefs: [ - { - _type: 'note', - _key: 'n1', - items: [{_type: 'item', _key: 'new', label: 'baz'}], - }, - ], - }, - ], - }, - ]) - }) - - test('an edit to a `json:object` block keeps a literal mark spelled like an annotation reference', () => { - const keyGenerator = createTestKeyGenerator() - const item = (marks: Array) => ({ - _type: 'item', - _key: 'i1', - markDefs: [{_type: 'note', _key: 'n1', label: 'foo'}], - children: [{_type: 'span', _key: 's1', text: 'foo', marks}], - }) - const stored = [ - {_type: 'widget', _key: 'w1', title: 'foo', items: [item(['n1'])]}, - ] - const edited = [ - { - _type: 'widget', - _key: 'w1', - title: 'bar', - items: [item(['@annotation:0:{"_type":"note","label":"foo"}'])], - }, - ] - expect( - applyMarkdownEdit( - stored, - portableTextToMarkdown(structuredClone(edited)), - { - deserialize: {keyGenerator}, - }, - ), - ).toEqual([ - { - _type: 'widget', - _key: 'w1', - title: 'bar', - items: [ - { - _type: 'item', - _key: 'i1', - markDefs: [{_type: 'note', _key: 'n1', label: 'foo'}], - children: [ - { - _type: 'span', - _key: 's1', - text: 'foo', - marks: ['@annotation:0:{"_type":"note","label":"foo"}'], - }, - ], - }, - ], - }, - ]) - }) - - test('an unchanged table gets its whole subtree of keys back', () => { - const keyGenerator = createTestKeyGenerator() - const stored = [ - { - _type: 'table', - _key: 'tbl1', - headerRows: 1, - rows: [ - { - _type: 'row', - _key: 'r1', - cells: [ - { - _type: 'cell', - _key: 'c1', - value: [block('cb1', 'cs1', 'one')], - }, - { - _type: 'cell', - _key: 'c2', - value: [block('cb2', 'cs2', 'two')], - }, - ], - }, - ], - }, - ] - const markdown = portableTextToMarkdown(structuredClone(stored)) - expect( - applyMarkdownEdit(stored, markdown, {deserialize: {keyGenerator}}), - ).toEqual(stored) - }) - - test('the top-level schema reaches both conversion directions', () => { - const keyGenerator = createTestKeyGenerator() - const schema = compileSchema(defineSchema({})) - const table = { - _type: 'table', - _key: 'tbl1', - headerRows: 1, - rows: [ - { - _type: 'row', - _key: 'r1', - cells: [ - {_type: 'cell', _key: 'c1', value: [block('cb1', 'cs1', 'one')]}, - {_type: 'cell', _key: 'c2', value: [block('cb2', 'cs2', 'two')]}, - ], - }, - ], - } - const heading = { - _type: 'block', - _key: 'b1', - style: 'h1', - markDefs: [], - children: [ - { - _type: 'span', - _key: 's1', - text: 'a wonderfully long heading about the world of portable text', - marks: [], - }, - ], - } - const stored = [table, heading] - const markdown = `${portableTextToMarkdown(structuredClone(stored), { - schema, - }).replace('world', 'globe')}\n\n| added | table |\n| ----- | ----- |` - expect( - applyMarkdownEdit(stored, markdown, { - schema, - deserialize: {keyGenerator}, - }), - ).toEqual([ - table, - { - _type: 'block', - _key: 'b1', - style: 'h1', - markDefs: [], - children: [ - { - _type: 'span', - _key: 's1', - text: 'a wonderfully long heading about the globe of portable text', - marks: [], - }, - ], - }, - { - _type: 'block', - _key: 'k2', - style: 'normal', - markDefs: [], - children: [{_type: 'span', _key: 'k3', text: 'added', marks: []}], - }, - { - _type: 'block', - _key: 'k5', - style: 'normal', - markDefs: [], - children: [{_type: 'span', _key: 'k6', text: 'table', marks: []}], - }, - ]) - }) - - test('editing text around an inline object keeps the block key and the object key', () => { - const keyGenerator = createTestKeyGenerator() - const stored = [ - { - _type: 'block', - _key: 'b1', - style: 'normal', - markDefs: [], - children: [ - {_type: 'span', _key: 's1', text: 'AAPL is at ', marks: []}, - {_type: 'stockTicker', _key: 't1', symbol: 'AAPL'}, - {_type: 'span', _key: 's2', text: ' right now!', marks: []}, - ], - }, - ] - const markdown = portableTextToMarkdown(structuredClone(stored)).replace( - 'right now!', - 'right now.', - ) - expect( - applyMarkdownEdit(stored, markdown, {deserialize: {keyGenerator}}), - ).toEqual([ - { - _type: 'block', - _key: 'b1', - style: 'normal', - markDefs: [], - children: [ - {_type: 'span', _key: 's1', text: 'AAPL is at ', marks: []}, - {_type: 'stockTicker', _key: 't1', symbol: 'AAPL'}, - {_type: 'span', _key: 's2', text: ' right now.', marks: []}, - ], - }, - ]) - }) - - test('an h1 with a hard break round-trips with its keys', () => { - const keyGenerator = createTestKeyGenerator() - const stored = [ - { - _type: 'block', - _key: 'b1', - style: 'h1', - markDefs: [], - children: [{_type: 'span', _key: 's1', text: 'one\ntwo', marks: []}], - }, - ] - const markdown = portableTextToMarkdown(structuredClone(stored)) - expect( - applyMarkdownEdit(stored, markdown, {deserialize: {keyGenerator}}), - ).toEqual([ - { - _type: 'block', - _key: 'b1', - style: 'h1', - markDefs: [], - children: [{_type: 'span', _key: 's1', text: 'one\ntwo', marks: []}], - }, - ]) - }) - - test('an h3 with a hard break cannot be traced, so keys reset', () => { - const keyGenerator = createTestKeyGenerator() - const stored = [ - { - _type: 'block', - _key: 'b1', - style: 'h3', - markDefs: [], - children: [{_type: 'span', _key: 's1', text: 'one\ntwo', marks: []}], - }, - ] - const markdown = portableTextToMarkdown(structuredClone(stored)) - expect( - applyMarkdownEdit(stored, markdown, {deserialize: {keyGenerator}}), - ).toEqual([ - { - _type: 'block', - _key: 'k0', - style: 'h3', - markDefs: [], - children: [{_type: 'span', _key: 'k1', text: 'one', marks: []}], - }, - { - _type: 'block', - _key: 'k2', - style: 'normal', - markDefs: [], - children: [{_type: 'span', _key: 'k3', text: 'two', marks: []}], - }, - ]) - }) - - test('an empty line between hard breaks round-trips with its keys', () => { - const keyGenerator = createTestKeyGenerator() - const stored: Array = [ - { - _type: 'block', - _key: keyGenerator(), - style: 'normal', - markDefs: [], - children: [ - {_type: 'span', _key: keyGenerator(), text: 'foo\n\nbar', marks: []}, - ], - }, - ] - let report!: ReconciliationReport - const result = applyMarkdownEdit( - stored, - portableTextToMarkdown(structuredClone(stored)), - { - deserialize: {keyGenerator}, - onReconciliation: (r) => { - report = r - }, - }, - ) - expect(result).toEqual([ - { - _type: 'block', - _key: 'k0', - style: 'normal', - markDefs: [], - children: [{_type: 'span', _key: 'k1', text: 'foo\n\nbar', marks: []}], - }, - ]) - expect(report).toEqual({ - keyMatching: 'performed', - preservedKeys: [ - { - basis: 'content-unchanged', - key: 'k0', - path: [{_key: 'k0'}], - valueChanged: false, - }, - { - basis: 'content-unchanged', - key: 'k1', - path: [{_key: 'k0'}, 'children', {_key: 'k1'}], - valueChanged: false, - }, - ], - keyFallbacks: [], - renamedKeys: [], - hiddenLosses: [], - markdownLosses: [], - undeclaredObjects: [], - }) - }) - - test('two empty lines between hard breaks round-trips with its keys', () => { - const keyGenerator = createTestKeyGenerator() - const stored: Array = [ - { - _type: 'block', - _key: keyGenerator(), - style: 'normal', - markDefs: [], - children: [ - { - _type: 'span', - _key: keyGenerator(), - text: 'foo\n\n\nbar', - marks: [], - }, - ], - }, - ] - let report!: ReconciliationReport - const result = applyMarkdownEdit( - stored, - portableTextToMarkdown(structuredClone(stored)), - { - deserialize: {keyGenerator}, - onReconciliation: (r) => { - report = r - }, - }, - ) - expect(result).toEqual([ - { - _type: 'block', - _key: 'k0', - style: 'normal', - markDefs: [], - children: [ - {_type: 'span', _key: 'k1', text: 'foo\n\n\nbar', marks: []}, - ], - }, - ]) - expect(report).toEqual({ - keyMatching: 'performed', - preservedKeys: [ - { - basis: 'content-unchanged', - key: 'k0', - path: [{_key: 'k0'}], - valueChanged: false, - }, - { - basis: 'content-unchanged', - key: 'k1', - path: [{_key: 'k0'}, 'children', {_key: 'k1'}], - valueChanged: false, - }, - ], - keyFallbacks: [], - renamedKeys: [], - hiddenLosses: [], - markdownLosses: [], - undeclaredObjects: [], - }) - }) - - test('an empty line between hard breaks in a blockquote round-trips with its keys', () => { - const keyGenerator = createTestKeyGenerator() - const stored: Array = [ - { - _type: 'block', - _key: keyGenerator(), - style: 'blockquote', - markDefs: [], - children: [ - {_type: 'span', _key: keyGenerator(), text: 'foo\n\nbar', marks: []}, - ], - }, - ] - let report!: ReconciliationReport - const result = applyMarkdownEdit( - stored, - portableTextToMarkdown(structuredClone(stored)), - { - deserialize: {keyGenerator}, - onReconciliation: (r) => { - report = r - }, - }, - ) - expect(result).toEqual([ - { - _type: 'block', - _key: 'k0', - style: 'blockquote', - markDefs: [], - children: [{_type: 'span', _key: 'k1', text: 'foo\n\nbar', marks: []}], - }, - ]) - expect(report).toEqual({ - keyMatching: 'performed', - preservedKeys: [ - { - basis: 'content-unchanged', - key: 'k0', - path: [{_key: 'k0'}], - valueChanged: false, - }, - { - basis: 'content-unchanged', - key: 'k1', - path: [{_key: 'k0'}, 'children', {_key: 'k1'}], - valueChanged: false, - }, - ], - keyFallbacks: [], - renamedKeys: [], - hiddenLosses: [], - markdownLosses: [], - undeclaredObjects: [], - }) - }) - - test('two empty lines between hard breaks in a blockquote round-trips with its keys', () => { - const keyGenerator = createTestKeyGenerator() - const stored: Array = [ - { - _type: 'block', - _key: keyGenerator(), - style: 'blockquote', - markDefs: [], - children: [ - { - _type: 'span', - _key: keyGenerator(), - text: 'foo\n\n\nbar', - marks: [], - }, - ], - }, - ] - let report!: ReconciliationReport - const result = applyMarkdownEdit( - stored, - portableTextToMarkdown(structuredClone(stored)), - { - deserialize: {keyGenerator}, - onReconciliation: (r) => { - report = r - }, - }, - ) - expect(result).toEqual([ - { - _type: 'block', - _key: 'k0', - style: 'blockquote', - markDefs: [], - children: [ - {_type: 'span', _key: 'k1', text: 'foo\n\n\nbar', marks: []}, - ], - }, - ]) - expect(report).toEqual({ - keyMatching: 'performed', - preservedKeys: [ - { - basis: 'content-unchanged', - key: 'k0', - path: [{_key: 'k0'}], - valueChanged: false, - }, - { - basis: 'content-unchanged', - key: 'k1', - path: [{_key: 'k0'}, 'children', {_key: 'k1'}], - valueChanged: false, - }, - ], - keyFallbacks: [], - renamedKeys: [], - hiddenLosses: [], - markdownLosses: [], - undeclaredObjects: [], - }) - }) - - test('an empty line between hard breaks in a list item round-trips with its keys', () => { - const keyGenerator = createTestKeyGenerator() - const stored: Array = [ - { - _type: 'block', - _key: keyGenerator(), - style: 'normal', - listItem: 'bullet', - level: 1, - markDefs: [], - children: [ - {_type: 'span', _key: keyGenerator(), text: 'foo\n\nbar', marks: []}, - ], - }, - ] - let report!: ReconciliationReport - const result = applyMarkdownEdit( - stored, - portableTextToMarkdown(structuredClone(stored)), - { - deserialize: {keyGenerator}, - onReconciliation: (r) => { - report = r - }, - }, - ) - expect(result).toEqual([ - { - _type: 'block', - _key: 'k0', - style: 'normal', - listItem: 'bullet', - level: 1, - markDefs: [], - children: [{_type: 'span', _key: 'k1', text: 'foo\n\nbar', marks: []}], - }, - ]) - expect(report).toEqual({ - keyMatching: 'performed', - preservedKeys: [ - { - basis: 'content-unchanged', - key: 'k0', - path: [{_key: 'k0'}], - valueChanged: false, - }, - { - basis: 'content-unchanged', - key: 'k1', - path: [{_key: 'k0'}, 'children', {_key: 'k1'}], - valueChanged: false, - }, - ], - keyFallbacks: [], - renamedKeys: [], - hiddenLosses: [], - markdownLosses: [], - undeclaredObjects: [], - }) - }) - - test('two empty lines between hard breaks in a list item round-trips with its keys', () => { - const keyGenerator = createTestKeyGenerator() - const stored: Array = [ - { - _type: 'block', - _key: keyGenerator(), - style: 'normal', - listItem: 'bullet', - level: 1, - markDefs: [], - children: [ - { - _type: 'span', - _key: keyGenerator(), - text: 'foo\n\n\nbar', - marks: [], - }, - ], - }, - ] - let report!: ReconciliationReport - const result = applyMarkdownEdit( - stored, - portableTextToMarkdown(structuredClone(stored)), - { - deserialize: {keyGenerator}, - onReconciliation: (r) => { - report = r - }, - }, - ) - expect(result).toEqual([ - { - _type: 'block', - _key: 'k0', - style: 'normal', - listItem: 'bullet', - level: 1, - markDefs: [], - children: [ - {_type: 'span', _key: 'k1', text: 'foo\n\n\nbar', marks: []}, - ], - }, - ]) - expect(report).toEqual({ - keyMatching: 'performed', - preservedKeys: [ - { - basis: 'content-unchanged', - key: 'k0', - path: [{_key: 'k0'}], - valueChanged: false, - }, - { - basis: 'content-unchanged', - key: 'k1', - path: [{_key: 'k0'}, 'children', {_key: 'k1'}], - valueChanged: false, - }, - ], - keyFallbacks: [], - renamedKeys: [], - hiddenLosses: [], - markdownLosses: [], - undeclaredObjects: [], - }) - }) - - test('empty markdown means an empty value', () => { - const keyGenerator = createTestKeyGenerator() - expect( - applyMarkdownEdit([block('b1', 's1', 'alpha')], '', { - deserialize: {keyGenerator}, - }), - ).toEqual([]) - }) - - test('editing one table cell keeps the whole table subtree of keys', () => { - const keyGenerator = createTestKeyGenerator() - const stored = [ - { - _type: 'table', - _key: 'tbl1', - headerRows: 1, - rows: [ - { - _type: 'row', - _key: 'r1', - cells: [ - {_type: 'cell', _key: 'c1', value: [block('cb1', 'cs1', 'one')]}, - {_type: 'cell', _key: 'c2', value: [block('cb2', 'cs2', 'two')]}, - ], - }, - ], - }, - ] - const markdown = portableTextToMarkdown(structuredClone(stored)).replace( - 'two', - 'TWO', - ) - expect( - applyMarkdownEdit(stored, markdown, {deserialize: {keyGenerator}}), - ).toEqual([ - { - _type: 'table', - _key: 'tbl1', - headerRows: 1, - rows: [ - { - _type: 'row', - _key: 'r1', - cells: [ - {_type: 'cell', _key: 'c1', value: [block('cb1', 'cs1', 'one')]}, - {_type: 'cell', _key: 'c2', value: [block('cb2', 'cs2', 'TWO')]}, - ], - }, - ], - }, - ]) - }) - - test('two edited cells in one row each keep their keys', () => { - const keyGenerator = createTestKeyGenerator() - const stored = [ - { - _type: 'table', - _key: 'tbl1', - headerRows: 1, - rows: [ - { - _type: 'row', - _key: 'r1', - cells: [ - {_type: 'cell', _key: 'c1', value: [block('cb1', 'cs1', 'one')]}, - {_type: 'cell', _key: 'c2', value: [block('cb2', 'cs2', 'two')]}, - { - _type: 'cell', - _key: 'c3', - value: [block('cb3', 'cs3', 'three')], - }, - ], - }, - ], - }, - ] - const markdown = portableTextToMarkdown(structuredClone(stored)) - .replace('two', 'TWO') - .replace('three', 'THREE') - expect( - applyMarkdownEdit(stored, markdown, {deserialize: {keyGenerator}}), - ).toEqual([ - { - _type: 'table', - _key: 'tbl1', - headerRows: 1, - rows: [ - { - _type: 'row', - _key: 'r1', - cells: [ - {_type: 'cell', _key: 'c1', value: [block('cb1', 'cs1', 'one')]}, - {_type: 'cell', _key: 'c2', value: [block('cb2', 'cs2', 'TWO')]}, - { - _type: 'cell', - _key: 'c3', - value: [block('cb3', 'cs3', 'THREE')], - }, - ], - }, - ], - }, - ]) - }) - - test('two edited rows keep their row and cell keys', () => { - const keyGenerator = createTestKeyGenerator() - const stored = [ - { - _type: 'table', - _key: 'tbl1', - headerRows: 1, - rows: [ - { - _type: 'row', - _key: 'r1', - cells: [ - {_type: 'cell', _key: 'c1', value: [block('cb1', 'cs1', 'one')]}, - {_type: 'cell', _key: 'c2', value: [block('cb2', 'cs2', 'two')]}, - ], - }, - { - _type: 'row', - _key: 'r2', - cells: [ - { - _type: 'cell', - _key: 'c3', - value: [block('cb3', 'cs3', 'three')], - }, - {_type: 'cell', _key: 'c4', value: [block('cb4', 'cs4', 'four')]}, - ], - }, - { - _type: 'row', - _key: 'r3', - cells: [ - {_type: 'cell', _key: 'c5', value: [block('cb5', 'cs5', 'five')]}, - {_type: 'cell', _key: 'c6', value: [block('cb6', 'cs6', 'six')]}, - ], - }, - ], - }, - ] - const markdown = portableTextToMarkdown(structuredClone(stored)) - .replace('two', 'TWO') - .replace('six', 'SIX') - expect( - applyMarkdownEdit(stored, markdown, {deserialize: {keyGenerator}}), - ).toEqual([ - { - _type: 'table', - _key: 'tbl1', - headerRows: 1, - rows: [ - { - _type: 'row', - _key: 'r1', - cells: [ - {_type: 'cell', _key: 'c1', value: [block('cb1', 'cs1', 'one')]}, - {_type: 'cell', _key: 'c2', value: [block('cb2', 'cs2', 'TWO')]}, - ], - }, - { - _type: 'row', - _key: 'r2', - cells: [ - { - _type: 'cell', - _key: 'c3', - value: [block('cb3', 'cs3', 'three')], - }, - {_type: 'cell', _key: 'c4', value: [block('cb4', 'cs4', 'four')]}, - ], - }, - { - _type: 'row', - _key: 'r3', - cells: [ - {_type: 'cell', _key: 'c5', value: [block('cb5', 'cs5', 'five')]}, - {_type: 'cell', _key: 'c6', value: [block('cb6', 'cs6', 'SIX')]}, - ], - }, - ], - }, - ]) - }) - - test('a reorder-plus-edit of siblings with balanced counts mispairs, the accepted trade', () => { - const schema = compileSchema( - defineSchema({blockObjects: [defaultTableObjectDefinition]}), - ) - const stored = [ - { - _type: 'table', - _key: 't1', - headerRows: 0, - rows: [ - { - _type: 'row', - _key: 'r1', - cells: [ - {_type: 'cell', _key: 'c1', value: [block('cb1', 'cs1', 'one')]}, - {_type: 'cell', _key: 'c2', value: [block('cb2', 'cs2', 'two')]}, - { - _type: 'cell', - _key: 'c3', - value: [block('cb3', 'cs3', 'three')], - }, - ], - }, - ], - }, - ] - // The edited row swaps the content of cells two and three and edits - // both, so the residual zip (equal counts, position order) pairs - // each stored cell key with whichever edited cell landed in that - // cell's original position, not with the text it descends from: - // `c2` (originally "two") ends up on "THREEx", `c3` on "TWOx". - const markdown = [ - '| | | |', - '| --- | --- | --- |', - '| one | THREEx | TWOx |', - ].join('\n') - expect( - applyMarkdownEdit(stored, markdown, { - schema, - deserialize: {keyGenerator: createTestKeyGenerator()}, - }), - ).toEqual([ - { - _type: 'table', - _key: 't1', - headerRows: 0, - rows: [ - { - _type: 'row', - _key: 'r1', - cells: [ - {_type: 'cell', _key: 'c1', value: [block('cb1', 'cs1', 'one')]}, - { - _type: 'cell', - _key: 'c2', - value: [block('cb2', 'cs2', 'THREEx')], - }, - { - _type: 'cell', - _key: 'c3', - value: [block('cb3', 'cs3', 'TWOx')], - }, - ], - }, - ], - }, - ]) - }) - - test('two edited spans in one block each keep their keys', () => { - const keyGenerator = createTestKeyGenerator() - const stored = [ - { - _type: 'block', - _key: 'b1', - style: 'normal', - markDefs: [], - children: [ - {_type: 'span', _key: 's1', text: 'foo ', marks: []}, - {_type: 'span', _key: 's2', text: 'bar', marks: ['strong']}, - {_type: 'span', _key: 's3', text: ' baz', marks: []}, - ], - }, - ] - const markdown = portableTextToMarkdown(structuredClone(stored)) - .replace('foo', 'FOO') - .replace('baz', 'BAZ') - expect( - applyMarkdownEdit(stored, markdown, {deserialize: {keyGenerator}}), - ).toEqual([ - { - _type: 'block', - _key: 'b1', - style: 'normal', - markDefs: [], - children: [ - {_type: 'span', _key: 's1', text: 'FOO ', marks: []}, - {_type: 'span', _key: 's2', text: 'bar', marks: ['strong']}, - {_type: 'span', _key: 's3', text: ' BAZ', marks: []}, - ], - }, - ]) - }) - - test('two edited spans next to a deleted span lose their keys for lack of evidence', () => { - const keyGenerator = createTestKeyGenerator() - const stored = [ - { - _type: 'block', - _key: 'b1', - style: 'normal', - markDefs: [], - children: [ - {_type: 'span', _key: 's1', text: 'foo ', marks: []}, - {_type: 'span', _key: 's2', text: 'bar', marks: ['strong']}, - {_type: 'span', _key: 's3', text: ' baz ', marks: []}, - {_type: 'span', _key: 's4', text: 'qux', marks: ['em']}, - ], - }, - ] - const markdown = portableTextToMarkdown(structuredClone(stored)) - .replace('foo', 'FOO') - .replace('baz ', 'BAZ') - .replace('_qux_', '') - expect( - applyMarkdownEdit(stored, markdown, {deserialize: {keyGenerator}}), - ).toEqual([ - { - _type: 'block', - _key: 'b1', - style: 'normal', - markDefs: [], - children: [ - {_type: 'span', _key: 'k1', text: 'FOO ', marks: []}, - {_type: 'span', _key: 's2', text: 'bar', marks: ['strong']}, - {_type: 'span', _key: 'k3', text: ' BAZ', marks: []}, - ], - }, - ]) - }) - - test('a copy-pasted `json:object` block does not duplicate its key', () => { - const keyGenerator = createTestKeyGenerator() - const stored = [ - block('b1', 's1', 'alpha'), - {_type: 'product', _key: 'p1', sku: 'a'}, - ] - const fence = [ - '```json:object', - '{"_type": "product", "_key": "p1", "sku": "b"}', - '```', - ].join('\n') - const markdown = `${portableTextToMarkdown(structuredClone(stored))}\n\n${fence}` - expect( - applyMarkdownEdit(stored, markdown, {deserialize: {keyGenerator}}), - ).toEqual([ - block('b1', 's1', 'alpha'), - {_type: 'product', _key: 'p1', sku: 'a'}, - {_type: 'product', _key: 'k2', sku: 'b'}, - ]) - }) - - test('a count-preserving scramble of the serialization adopts nothing', () => { - const keyGenerator = createTestKeyGenerator() - const stored = [ - { - _type: 'block', - _key: 'b1', - style: 'h3', - markDefs: [], - children: [{_type: 'span', _key: 's1', text: 'one\ntwo', marks: []}], - }, - block('b2', 's2', 'plain text'), - block('b3', 's3', ''), - ] - const markdown = portableTextToMarkdown(structuredClone(stored)) - const reconciled = applyMarkdownEdit(stored, markdown, { - deserialize: {keyGenerator}, - }) - expect(reconciled).toEqual([ - { - _type: 'block', - _key: 'k0', - style: 'h3', - markDefs: [], - children: [{_type: 'span', _key: 'k1', text: 'one', marks: []}], - }, - block('k2', 'k3', 'two'), - block('k4', 'k5', 'plain text'), - ]) - }) - - test('rewriting every block in place keeps every key pairwise', () => { - const keyGenerator = createTestKeyGenerator() - const stored = [ - block('b1', 's1', 'first paragraph here'), - block('b2', 's2', 'second paragraph here'), - ] - const markdown = 'premier paragraphe ici\n\ndeuxieme paragraphe ici' - expect( - applyMarkdownEdit(stored, markdown, {deserialize: {keyGenerator}}), - ).toEqual([ - block('b1', 's1', 'premier paragraphe ici'), - block('b2', 's2', 'deuxieme paragraphe ici'), - ]) - }) - - test('a paragraph-to-heading conversion next to an edit keeps both keys', () => { - const keyGenerator = createTestKeyGenerator() - const stored = [ - block('b1', 's1', 'a title'), - block('b2', 's2', 'some prose here'), - ] - const markdown = '# a title\n\nsome prose there' - const reconciled = applyMarkdownEdit(stored, markdown, { - deserialize: {keyGenerator}, - }) - expect(reconciled).toEqual([ - { - _type: 'block', - _key: 'b1', - style: 'h1', - markDefs: [], - children: [{_type: 'span', _key: 's1', text: 'a title', marks: []}], - }, - block('b2', 's2', 'some prose there'), - ]) - }) - - test('a soft-wrap join is a merge: the first block key survives', () => { - const keyGenerator = createTestKeyGenerator() - const stored = [block('b1', 's1', 'alpha'), block('b2', 's2', 'beta')] - const markdown = 'alpha\nbeta' - expect( - applyMarkdownEdit(stored, markdown, {deserialize: {keyGenerator}}), - ).toEqual([block('b1', 's1', 'alpha beta')]) - }) - - test('a split with an empty first fragment keeps the key on the text', () => { - const keyGenerator = createTestKeyGenerator() - const stored = [ - { - _type: 'block', - _key: 'b1', - style: 'h1', - markDefs: [], - children: [{_type: 'span', _key: 's1', text: 'alphabeta', marks: []}], - }, - ] - const markdown = '#\n\nalphabeta' - const reconciled = applyMarkdownEdit(stored, markdown, { - deserialize: {keyGenerator}, - }) - expect(reconciled).toEqual([ - { - _type: 'block', - _key: 'k0', - style: 'h1', - markDefs: [], - children: [{_type: 'span', _key: 'k1', text: '', marks: []}], - }, - { - _type: 'block', - _key: 'b1', - style: 'normal', - markDefs: [], - children: [{_type: 'span', _key: 's1', text: 'alphabeta', marks: []}], - }, - ]) - }) - - test('short blocks in an unequal gap lose their keys for lack of evidence', () => { - const keyGenerator = createTestKeyGenerator() - const stored = [block('b1', 's1', 'ab'), block('b2', 's2', 'cd')] - const markdown = 'ax\n\ncx\n\nnew trailing paragraph' - expect( - applyMarkdownEdit(stored, markdown, {deserialize: {keyGenerator}}), - ).toEqual([ - block('k0', 'k1', 'ax'), - block('k2', 'k3', 'cx'), - block('k4', 'k5', 'new trailing paragraph'), - ]) - }) - - test('an unequal gap past the similarity pair cap gets fresh keys everywhere', () => { - const keyGenerator = createTestKeyGenerator() - const stored = manyBlocks(51, 'stored') - const markdown = manyParagraphs(61, 'edited') - const reconciled = applyMarkdownEdit(stored, markdown, { - deserialize: {keyGenerator}, - }) - expect(reconciled.map((node) => (node as {_key: string})._key)).toEqual( - Array.from({length: 61}, (_, index) => `k${index * 2}`), - ) - }) - - test('a genuine split past the concatenation pair cap does not adopt', () => { - const splitSource = block('split-b', 'split-s', 'alphabeta') - - const belowCap = applyMarkdownEdit([splitSource], 'alpha\n\nbeta', { - deserialize: {keyGenerator: createTestKeyGenerator()}, - }) - expect(belowCap).toEqual([ - block('split-b', 'split-s', 'alpha'), - block('k2', 'k3', 'beta'), - ]) - - const stored = [splitSource, ...manyBlocks(50, 'stored')] - const markdown = ['alpha', 'beta', manyParagraphs(59, 'edited')].join( - '\n\n', - ) - const overCap = applyMarkdownEdit(stored, markdown, { - deserialize: {keyGenerator: createTestKeyGenerator()}, - }) - expect(overCap.map((node) => (node as {_key: string})._key)).toEqual( - Array.from({length: 61}, (_, index) => `k${index * 2}`), - ) - }) - - test('an edited list item keeps its key; siblings keep theirs', () => { - const keyGenerator = createTestKeyGenerator() - const listBlock = ( - blockKey: string, - spanKey: string, - text: string, - level: number, - ): PortableTextBlock => ({ - _type: 'block', - _key: blockKey, - style: 'normal', - listItem: 'bullet', - level, - markDefs: [], - children: [{_type: 'span', _key: spanKey, text, marks: []}], - }) - const stored = [ - listBlock('l1', 'ls1', 'first item', 1), - listBlock('l2', 'ls2', 'second item', 2), - listBlock('l3', 'ls3', 'third item', 1), - ] - const markdown = portableTextToMarkdown(structuredClone(stored)).replace( - 'second item', - 'second thing', - ) - expect( - applyMarkdownEdit(stored, markdown, {deserialize: {keyGenerator}}), - ).toEqual([ - listBlock('l1', 'ls1', 'first item', 1), - listBlock('l2', 'ls2', 'second thing', 2), - listBlock('l3', 'ls3', 'third item', 1), - ]) - }) - - test('an edited code block keeps its key', () => { - const keyGenerator = createTestKeyGenerator() - const stored = [ - {_type: 'code', _key: 'code1', language: 'js', code: 'const a = 1'}, - ] - const markdown = portableTextToMarkdown(structuredClone(stored)).replace( - 'a = 1', - 'a = 2', - ) - expect( - applyMarkdownEdit(stored, markdown, {deserialize: {keyGenerator}}), - ).toEqual([ - {_type: 'code', _key: 'code1', language: 'js', code: 'const a = 2'}, - ]) - }) - - test('an unchanged code block holding a line of backticks keeps its key', () => { - const keyGenerator = createTestKeyGenerator() - const stored = [ - {_type: 'code', _key: 'code1', code: 'foo\n```\nbar'}, - {_type: 'code', _key: 'code2', code: '````'}, - ] - let report!: ReconciliationReport - expect( - applyMarkdownEdit( - stored, - portableTextToMarkdown(structuredClone(stored)), - { - deserialize: {keyGenerator}, - onReconciliation: (reconciliationReport) => { - report = reconciliationReport - }, - }, - ), - ).toEqual(stored) - expect(report).toEqual({ - keyMatching: 'performed', - preservedKeys: [ - { - basis: 'content-unchanged', - key: 'code1', - path: [{_key: 'code1'}], - valueChanged: false, - }, - { - basis: 'content-unchanged', - key: 'code2', - path: [{_key: 'code2'}], - valueChanged: false, - }, - ], - keyFallbacks: [], - renamedKeys: [], - hiddenLosses: [], - markdownLosses: [], - undeclaredObjects: [], - }) - }) - - test('repeated horizontal rules pair in order around an edit', () => { - const keyGenerator = createTestKeyGenerator() - const stored = [ - {_type: 'horizontal-rule', _key: 'hr1'}, - block('b1', 's1', 'middle'), - {_type: 'horizontal-rule', _key: 'hr2'}, - ] - const markdown = portableTextToMarkdown(structuredClone(stored)).replace( - 'middle', - 'centre', - ) - expect( - applyMarkdownEdit(stored, markdown, {deserialize: {keyGenerator}}), - ).toEqual([ - {_type: 'horizontal-rule', _key: 'hr1'}, - block('b1', 's1', 'centre'), - {_type: 'horizontal-rule', _key: 'hr2'}, - ]) - }) - - test('bolding a word keeps the block key; the split spans get new keys', () => { - const keyGenerator = createTestKeyGenerator() - const stored = [block('b1', 's1', 'foo bar baz')] - const markdown = 'foo **bar** baz' - expect( - applyMarkdownEdit(stored, markdown, {deserialize: {keyGenerator}}), - ).toEqual([ - { - _type: 'block', - _key: 'b1', - style: 'normal', - markDefs: [], - children: [ - {_type: 'span', _key: 'k1', text: 'foo ', marks: []}, - {_type: 'span', _key: 'k2', text: 'bar', marks: ['strong']}, - {_type: 'span', _key: 'k3', text: ' baz', marks: []}, - ], - }, - ]) - }) - - test('editing words next to an underlined span keeps the underline', () => { - const keyGenerator = createTestKeyGenerator() - const onDegradation = vi.fn() - const stored = [safetyNoticeBlock(), block('b2', 's4', 'foo bar')] - const markdown = portableTextToMarkdown(structuredClone(stored)).replace( - 'before use.', - 'before first use.', - ) - expect( - applyMarkdownEdit(stored, markdown, { - deserialize: {keyGenerator, onDegradation}, - }), - ).toEqual([ - { - _type: 'block', - _key: 'b1', - style: 'normal', - markDefs: [], - children: [ - {_type: 'span', _key: 's1', text: 'Read the ', marks: []}, - { - _type: 'span', - _key: 's2', - text: 'safety notice', - marks: ['underline'], - }, - {_type: 'span', _key: 's3', text: ' before first use.', marks: []}, - ], - }, - { - _type: 'block', - _key: 'b2', - style: 'normal', - markDefs: [], - children: [{_type: 'span', _key: 's4', text: 'foo bar', marks: []}], - }, - ]) - expect(onDegradation).not.toHaveBeenCalled() - }) - - test('rewriting underlined words keeps the underline on the new words', () => { - const keyGenerator = createTestKeyGenerator() - const onDegradation = vi.fn() - const stored = [safetyNoticeBlock(), block('b2', 's4', 'foo bar')] - const markdown = portableTextToMarkdown(structuredClone(stored)).replace( - 'safety notice', - 'safety warning', - ) - expect( - applyMarkdownEdit(stored, markdown, { - deserialize: {keyGenerator, onDegradation}, - }), - ).toEqual([ - { - _type: 'block', - _key: 'b1', - style: 'normal', - markDefs: [], - children: [ - {_type: 'span', _key: 's1', text: 'Read the ', marks: []}, - { - _type: 'span', - _key: 's2', - text: 'safety warning', - marks: ['underline'], - }, - {_type: 'span', _key: 's3', text: ' before use.', marks: []}, - ], - }, - { - _type: 'block', - _key: 'b2', - style: 'normal', - markDefs: [], - children: [{_type: 'span', _key: 's4', text: 'foo bar', marks: []}], - }, - ]) - expect(onDegradation).not.toHaveBeenCalled() - }) - - test('underlining a word in another block adds the underline there', () => { - const keyGenerator = createTestKeyGenerator() - const onDegradation = vi.fn() - const stored = [safetyNoticeBlock(), block('b2', 's4', 'foo bar')] - const markdown = portableTextToMarkdown(structuredClone(stored)).replace( - 'foo bar', - 'foo bar', - ) - expect( - applyMarkdownEdit(stored, markdown, { - deserialize: {keyGenerator, onDegradation}, - }), - ).toEqual([ - { - _type: 'block', - _key: 'b1', - style: 'normal', - markDefs: [], - children: [ - {_type: 'span', _key: 's1', text: 'Read the ', marks: []}, - { - _type: 'span', - _key: 's2', - text: 'safety notice', - marks: ['underline'], - }, - {_type: 'span', _key: 's3', text: ' before use.', marks: []}, - ], - }, - { - _type: 'block', - _key: 'b2', - style: 'normal', - markDefs: [], - children: [ - {_type: 'span', _key: 'k5', text: 'foo ', marks: []}, - {_type: 'span', _key: 'k6', text: 'bar', marks: ['underline']}, - ], - }, - ]) - expect(onDegradation).not.toHaveBeenCalled() - }) - - test('removing the underline tags removes the underline', () => { - const keyGenerator = createTestKeyGenerator() - const onDegradation = vi.fn() - const stored = [safetyNoticeBlock(), block('b2', 's4', 'foo bar')] - const markdown = portableTextToMarkdown(structuredClone(stored)).replace( - 'safety notice', - 'safety notice', - ) - expect( - applyMarkdownEdit(stored, markdown, { - deserialize: {keyGenerator, onDegradation}, - }), - ).toEqual([ - { - _type: 'block', - _key: 'b1', - style: 'normal', - markDefs: [], - children: [ - { - _type: 'span', - _key: 's1', - text: 'Read the safety notice before use.', - marks: [], - }, - ], - }, - { - _type: 'block', - _key: 'b2', - style: 'normal', - markDefs: [], - children: [{_type: 'span', _key: 's4', text: 'foo bar', marks: []}], - }, - ]) - expect(onDegradation).not.toHaveBeenCalled() - }) - - test('two links in one block each keep their annotation keys', () => { - const keyGenerator = createTestKeyGenerator() - const stored = [ - { - _type: 'block', - _key: 'b1', - style: 'normal', - markDefs: [ - {_type: 'link', _key: 'a1', href: 'https://one.example'}, - {_type: 'link', _key: 'a2', href: 'https://two.example'}, - ], - children: [ - {_type: 'span', _key: 's1', text: 'one', marks: ['a1']}, - {_type: 'span', _key: 's2', text: ' and ', marks: []}, - {_type: 'span', _key: 's3', text: 'two', marks: ['a2']}, - ], - }, - ] - const markdown = portableTextToMarkdown(structuredClone(stored)).replace( - ' and ', - ' plus ', - ) - expect( - applyMarkdownEdit(stored, markdown, {deserialize: {keyGenerator}}), - ).toEqual([ - { - _type: 'block', - _key: 'b1', - style: 'normal', - markDefs: [ - {_type: 'link', _key: 'a1', href: 'https://one.example'}, - {_type: 'link', _key: 'a2', href: 'https://two.example'}, - ], - children: [ - {_type: 'span', _key: 's1', text: 'one', marks: ['a1']}, - {_type: 'span', _key: 's2', text: ' plus ', marks: []}, - {_type: 'span', _key: 's3', text: 'two', marks: ['a2']}, - ], - }, - ]) - }) - - test('a one-word edit in a 120-block document keeps every other key', () => { - const keyGenerator = createTestKeyGenerator() - const stored = Array.from({length: 120}, (_, index) => - block(`b${index}`, `s${index}`, `unique paragraph number ${index}`), - ) - const markdown = portableTextToMarkdown(structuredClone(stored)).replace( - 'unique paragraph number 60', - 'unique paragraph number sixty', - ) - expect( - applyMarkdownEdit(stored, markdown, {deserialize: {keyGenerator}}), - ).toEqual( - Array.from({length: 120}, (_, index) => - block( - `b${index}`, - `s${index}`, - index === 60 - ? 'unique paragraph number sixty' - : `unique paragraph number ${index}`, - ), - ), - ) - }) - - test('a typo, a move, and an insertion in one edit each resolve correctly', () => { - const keyGenerator = createTestKeyGenerator() - const stored = [ - block('b1', 's1', 'alpha section text'), - block('b2', 's2', 'beta section text'), - block('b3', 's3', 'gamma section text'), - ] - const markdown = [ - 'gamma section text', - '', - 'alpha section test', - '', - 'a brand new paragraph', - '', - 'beta section text', - ].join('\n') - expect( - applyMarkdownEdit(stored, markdown, {deserialize: {keyGenerator}}), - ).toEqual([ - block('b3', 's3', 'gamma section text'), - block('b1', 's1', 'alpha section test'), - block('k4', 'k5', 'a brand new paragraph'), - block('b2', 's2', 'beta section text'), - ]) - }) - - test('a fresh key colliding with an adopted key is regenerated', () => { - let calls = 0 - const keyGenerator = () => (calls++ === 0 ? 'b1' : `fresh${calls}`) - const stored = [block('b1', 's1', 'alpha')] - const markdown = 'brand new paragraph\n\nalpha' - const reconciled = applyMarkdownEdit(stored, markdown, { - deserialize: {keyGenerator}, - }) - expect(reconciled).toEqual([ - block('fresh5', 'fresh2', 'brand new paragraph'), - block('b1', 's1', 'alpha'), - ]) - }) - - test('an edited inline object payload keeps the key it carries', () => { - const keyGenerator = createTestKeyGenerator() - const stored = [ - { - _type: 'block', - _key: 'b1', - style: 'normal', - markDefs: [], - children: [ - {_type: 'span', _key: 's1', text: 'price: ', marks: []}, - {_type: 'stockTicker', _key: 't1', symbol: 'AAPL'}, - ], - }, - ] - const markdown = portableTextToMarkdown(structuredClone(stored)).replace( - 'AAPL', - 'MSFT', - ) - expect( - applyMarkdownEdit(stored, markdown, {deserialize: {keyGenerator}}), - ).toEqual([ - { - _type: 'block', - _key: 'b1', - style: 'normal', - markDefs: [], - children: [ - {_type: 'span', _key: 's1', text: 'price: ', marks: []}, - {_type: 'stockTicker', _key: 't1', symbol: 'MSFT'}, - ], - }, - ]) - }) - - test('a keyless inline payload gets a generated key through reconciliation', () => { - const keyGenerator = createTestKeyGenerator() - const storedKeyGenerator = createTestKeyGenerator('stored-') - const stored = [ - block(storedKeyGenerator(), storedKeyGenerator(), 'before after'), - ] - const markdown = - 'before json:object`{"_type":"stockTicker","symbol":"AAPL"}` after' - expect( - applyMarkdownEdit(stored, markdown, {deserialize: {keyGenerator}}), - ).toEqual([ - { - _type: 'block', - _key: 'stored-k0', - style: 'normal', - markDefs: [], - children: [ - {_type: 'span', _key: 'k1', text: 'before ', marks: []}, - {_type: 'stockTicker', _key: 'k2', symbol: 'AAPL'}, - {_type: 'span', _key: 'k3', text: ' after', marks: []}, - ], - }, - ]) - }) - - test('a keyless inline payload matching a stored inline object keeps the stored key', () => { - const keyGenerator = createTestKeyGenerator() - const storedKeyGenerator = createTestKeyGenerator('stored-') - const stored = [ - { - _type: 'block', - _key: storedKeyGenerator(), - style: 'normal', - markDefs: [], - children: [ - { - _type: 'span', - _key: storedKeyGenerator(), - text: 'price: ', - marks: [], - }, - {_type: 'stockTicker', _key: storedKeyGenerator(), symbol: 'AAPL'}, - ], - }, - ] - const markdown = - 'cost: json:object`{"_type":"stockTicker","symbol":"AAPL"}`' - expect( - applyMarkdownEdit(stored, markdown, {deserialize: {keyGenerator}}), - ).toEqual([ - { - _type: 'block', - _key: 'stored-k0', - style: 'normal', - markDefs: [], - children: [ - {_type: 'span', _key: 'stored-k1', text: 'cost: ', marks: []}, - {_type: 'stockTicker', _key: 'stored-k2', symbol: 'AAPL'}, - ], - }, - ]) - }) - - test('a keyless `json:object` fence matching a stored block object keeps the stored key', () => { - const keyGenerator = createTestKeyGenerator() - const storedKeyGenerator = createTestKeyGenerator('stored-') - const stored = [{_type: 'product', _key: storedKeyGenerator(), sku: 'a'}] - const fence = [ - '```json:object', - '{"_type": "product", "sku": "b"}', - '```', - ].join('\n') - expect( - applyMarkdownEdit(stored, fence, {deserialize: {keyGenerator}}), - ).toEqual([{_type: 'product', _key: 'stored-k0', sku: 'b'}]) - }) - - test('two inline objects in one block each keep their keys through an edit', () => { - const keyGenerator = createTestKeyGenerator() - const stored = [ - { - _type: 'block', - _key: 'b1', - style: 'normal', - markDefs: [], - children: [ - {_type: 'span', _key: 's1', text: 'from ', marks: []}, - {_type: 'stockTicker', _key: 't1', symbol: 'AAPL'}, - {_type: 'span', _key: 's2', text: ' to ', marks: []}, - {_type: 'stockTicker', _key: 't2', symbol: 'MSFT'}, - {_type: 'span', _key: 's3', text: ' today!', marks: []}, - ], - }, - ] - const markdown = portableTextToMarkdown(structuredClone(stored)).replace( - 'today!', - 'tomorrow.', - ) - expect( - applyMarkdownEdit(stored, markdown, {deserialize: {keyGenerator}}), - ).toEqual([ - { - _type: 'block', - _key: 'b1', - style: 'normal', - markDefs: [], - children: [ - {_type: 'span', _key: 's1', text: 'from ', marks: []}, - {_type: 'stockTicker', _key: 't1', symbol: 'AAPL'}, - {_type: 'span', _key: 's2', text: ' to ', marks: []}, - {_type: 'stockTicker', _key: 't2', symbol: 'MSFT'}, - {_type: 'span', _key: 's3', text: ' tomorrow.', marks: []}, - ], - }, - ]) - }) - - test('an inline object survives a full rewrite of its block', () => { - const keyGenerator = createTestKeyGenerator() - const stored = [ - { - _type: 'block', - _key: 'b1', - style: 'normal', - markDefs: [], - children: [ - { - _type: 'span', - _key: 's1', - text: 'the old wording around ', - marks: [], - }, - {_type: 'stockTicker', _key: 't1', symbol: 'AAPL'}, - ], - }, - ] - const markdown = - 'completely new wording json:object`{"_type":"stockTicker","_key":"t1","symbol":"AAPL"}` here' - expect( - applyMarkdownEdit(stored, markdown, {deserialize: {keyGenerator}}), - ).toEqual([ - { - _type: 'block', - _key: 'b1', - style: 'normal', - markDefs: [], - children: [ - { - _type: 'span', - _key: 'k1', - text: 'completely new wording ', - marks: [], - }, - {_type: 'stockTicker', _key: 't1', symbol: 'AAPL'}, - {_type: 'span', _key: 'k2', text: ' here', marks: []}, - ], - }, - ]) - }) - - test('a stored list block without a key does not get one added in place', () => { - const keyGenerator = createTestKeyGenerator() - const stored = [ - { - _type: 'block', - style: 'normal', - listItem: 'bullet', - level: 1, - markDefs: [], - children: [{_type: 'span', text: 'first item', marks: []}], - }, - ] as unknown as Array - const storedSnapshot = structuredClone(stored) - const markdown = portableTextToMarkdown(structuredClone(stored)) - const result = applyMarkdownEdit(stored, markdown, { - deserialize: {keyGenerator}, - }) - expect(stored).toEqual(storedSnapshot) - expect(result).toEqual([ - { - _type: 'block', - _key: 'k0', - style: 'normal', - listItem: 'bullet', - level: 1, - markDefs: [], - children: [{_type: 'span', _key: 'k1', text: 'first item', marks: []}], - }, - ]) - }) - - test('onDegradation does not see the internal canonicalization of the stored value', () => { - const keyGenerator = createTestKeyGenerator() - const onDegradation = vi.fn() - const schema = compileSchema(defineSchema({styles: [{name: 'normal'}]})) - const stored = [ - { - _type: 'block', - _key: 'b1', - style: 'h1', - markDefs: [], - children: [ - {_type: 'span', _key: 's1', text: 'heading text', marks: []}, - ], - }, - ] - const markdown = 'clean paragraph' - applyMarkdownEdit(stored, markdown, { - schema, - deserialize: {keyGenerator, onDegradation}, - }) - expect(onDegradation).not.toHaveBeenCalled() - }) - - test('onDegradation still fires when the edited markdown itself degrades', () => { - const keyGenerator = createTestKeyGenerator() - const onDegradation = vi.fn() - const schema = compileSchema(defineSchema({styles: [{name: 'normal'}]})) - const stored = [block('b1', 's1', 'plain text')] - const markdown = '# heading text' - applyMarkdownEdit(stored, markdown, { - schema, - deserialize: {keyGenerator, onDegradation}, - }) - expect(onDegradation).toHaveBeenCalledTimes(1) - }) - - test('onDegradation reports an inline `json:object` payload whose type the schema does not declare', () => { - const keyGenerator = createTestKeyGenerator() - const storedKeyGenerator = createTestKeyGenerator('stored-') - const onDegradation = vi.fn() - const schema = compileSchema(defineSchema({})) - const stored = [ - block( - storedKeyGenerator(), - storedKeyGenerator(), - 'Group visits are arranged by phone.', - ), - ] - const markdown = - 'Group visits are arranged by json:object`{"_type":"mention","_ref":"person-marta-lind"}`.' - expect( - applyMarkdownEdit(stored, markdown, { - schema, - deserialize: {keyGenerator, onDegradation}, - }), - ).toEqual([ - { - _type: 'block', - _key: 'stored-k0', - style: 'normal', - markDefs: [], - children: [ - { - _type: 'span', - _key: 'k1', - text: 'Group visits are arranged by ', - marks: [], - }, - {_type: 'mention', _key: 'k2', _ref: 'person-marta-lind'}, - {_type: 'span', _key: 'k3', text: '.', marks: []}, - ], - }, - ]) - expect(onDegradation.mock.calls).toEqual([ - [ - { - degradations: [ - { - type: 'object-type-undeclared', - message: - 'Kept the inline `mention` object as written: the schema has no inline `mention` object', - line: 1, - snippet: '{"_type":"mention","_ref":"person-marta-...', - }, - ], - message: [ - 'Markdown could not be converted without loss:', - '- line 1: Kept the inline `mention` object as written: the schema has no inline `mention` object ("{"_type":"mention","_ref":"person-marta-...")', - ].join('\n'), - }, - ], - ]) - }) - - test('a sneaked-in `serialize.onDegradation` (an untyped caller) is never invoked, even when the stored value has an unknown annotation', () => { - const keyGenerator = createTestKeyGenerator() - const onDegradation = vi.fn() - const stored = [ - { - _type: 'block', - _key: 'b1', - style: 'normal', - markDefs: [ - {_type: 'customLink', _key: 'l1', href: 'https://example.com'}, - ], - children: [{_type: 'span', _key: 's1', text: 'foo', marks: ['l1']}], - }, - ] - - applyMarkdownEdit(stored, 'bar', { - deserialize: {keyGenerator}, - serialize: {onDegradation}, - } as Parameters[2]) - - expect(onDegradation).not.toHaveBeenCalled() - }) - - test('the `serialize` option type omits `onDegradation`', () => { - const onDegradation = vi.fn() - const stored: Array = [] - - applyMarkdownEdit(stored, 'foo', { - deserialize: {keyGenerator: createTestKeyGenerator()}, - // @ts-expect-error `serialize` excludes `onDegradation` (see - // `ApplyMarkdownEditOptions`) - serialize: {onDegradation}, - }) - }) - - test('renaming a duplicate carried key never takes a key a later fence carries', () => { - const keyGenerator = createTestKeyGenerator() - const stored: Array = [] - const fence = (key: string) => - ['```json:object', `{"_type": "product", "_key": "${key}"}`, '```'].join( - '\n', - ) - const markdown = [fence('a'), fence('a'), fence('k0')].join('\n\n') - expect( - applyMarkdownEdit(stored, markdown, {deserialize: {keyGenerator}}), - ).toEqual([ - {_type: 'product', _key: 'a'}, - {_type: 'product', _key: 'k1'}, - {_type: 'product', _key: 'k0'}, - ]) - }) - - test('two fences carrying the same key stay unique against a constant keyGenerator', () => { - const stored: Array = [] - const fence = (key: string, sku: string) => - [ - '```json:object', - `{"_type": "product", "_key": "${key}", "sku": "${sku}"}`, - '```', - ].join('\n') - const markdown = `${fence('constant', 'a')}\n\n${fence('constant', 'b')}` - expect( - applyMarkdownEdit(stored, markdown, { - deserialize: {keyGenerator: () => 'constant'}, - }), - ).toEqual([ - {_type: 'product', _key: 'constant', sku: 'a'}, - {_type: 'product', _key: 'constant_1', sku: 'b'}, - ]) - }) - - test('duplicate keys nested inside a plain-object field come out unique', () => { - const stored: Array = [] - const keyGenerator = createTestKeyGenerator() - const fence = [ - '```json:object', - '{"_type": "product", "_key": "p1", "meta": {"items": [{"_type": "item", "_key": "d1", "label": "a"}, {"_type": "item", "_key": "d1", "label": "b"}]}}', - '```', - ].join('\n') - expect( - applyMarkdownEdit(stored, fence, {deserialize: {keyGenerator}}), - ).toEqual([ - { - _type: 'product', - _key: 'p1', - meta: { - items: [ - {_type: 'item', _key: 'd1', label: 'a'}, - {_type: 'item', _key: 'k0', label: 'b'}, - ], - }, - }, - ]) - }) - - test('an adopted text block gets its markdown-inexpressible fields back', () => { - const keyGenerator = createTestKeyGenerator() - const stored = [ - { - ...block('b1', 's1', 'Ships tomorow.'), - alignment: 'center', - }, - ] - const markdown = portableTextToMarkdown(structuredClone(stored)).replace( - 'tomorow', - 'tomorrow', - ) - expect( - applyMarkdownEdit(stored, markdown, {deserialize: {keyGenerator}}), - ).toEqual([ - { - ...block('b1', 's1', 'Ships tomorrow.'), - alignment: 'center', - }, - ]) - }) - - test('inexpressible fields are restored on spans, markDefs, rows, and cells', () => { - const schema = compileSchema( - defineSchema({ - decorators: [{name: 'strong'}], - annotations: [{name: 'link', fields: [{name: 'href', type: 'string'}]}], - blockObjects: [defaultTableObjectDefinition], - }), - ) - const fixture = (cellText: string) => [ - { - _type: 'block', - _key: 'b1', - style: 'normal', - markDefs: [ - { - _type: 'link', - _key: 'a1', - href: 'https://x.example', - rel: 'nofollow', - }, - ], - children: [ - {_type: 'span', _key: 's1', text: 'plain ', marks: [], emphasis: 0.7}, - {_type: 'span', _key: 's2', text: 'linked', marks: ['a1']}, - {_type: 'span', _key: 's3', text: ' and ', marks: []}, - {_type: 'span', _key: 's4', text: 'tail', marks: ['strong']}, - ], - }, - { - _type: 'table', - _key: 't1', - headerRows: 1, - rows: [ - { - _type: 'row', - _key: 'r1', - rowTint: 'blue', - cells: [ - { - _type: 'cell', - _key: 'c1', - colspan: 2, - value: [ - { - _type: 'block', - _key: 'cb1', - style: 'normal', - markDefs: [], - children: [ - {_type: 'span', _key: 'cs1', text: cellText, marks: []}, - ], - }, - ], - }, - ], - }, - ], - }, - ] - const stored = fixture('cell') - const markdown = portableTextToMarkdown(structuredClone(stored), { - schema, - }).replace('cell', 'CELL') - expect( - applyMarkdownEdit(stored, markdown, { - schema, - deserialize: {keyGenerator: createTestKeyGenerator()}, - }), - ).toEqual(fixture('CELL')) - }) - - test('a typed-object array field the dialect drops entirely comes back verbatim', () => { - const keyGenerator = createTestKeyGenerator() - const stored = [ - { - _type: 'block', - _key: 'b1', - style: 'normal', - markDefs: [], - footnotes: [{_type: 'footnote', _key: 'f1', note: 'x'}], - children: [{_type: 'span', _key: 's1', text: 'alpha', marks: []}], - }, - ] - const markdown = portableTextToMarkdown(structuredClone(stored)).replace( - 'alpha', - 'ALPHA', - ) - expect( - applyMarkdownEdit(stored, markdown, {deserialize: {keyGenerator}}), - ).toEqual([ - { - _type: 'block', - _key: 'b1', - style: 'normal', - markDefs: [], - footnotes: [{_type: 'footnote', _key: 'f1', note: 'x'}], - children: [{_type: 'span', _key: 's1', text: 'ALPHA', marks: []}], - }, - ]) - }) - - test('an adopted table gets its markdown-inexpressible fields back', () => { - const keyGenerator = createTestKeyGenerator() - const stored = [ - { - _type: 'table', - _key: 'tbl1', - headerRows: 1, - headerColumns: 2, - rows: [ - { - _type: 'row', - _key: 'r1', - cells: [ - { - _type: 'cell', - _key: 'c1', - value: [block('cb1', 'cs1', 'one')], - }, - { - _type: 'cell', - _key: 'c2', - value: [block('cb2', 'cs2', 'two')], - }, - ], - }, - ], - }, - ] - const markdown = portableTextToMarkdown(structuredClone(stored)).replace( - 'two', - 'TWO', - ) - expect( - applyMarkdownEdit(stored, markdown, {deserialize: {keyGenerator}}), - ).toEqual([ - { - _type: 'table', - _key: 'tbl1', - headerRows: 1, - headerColumns: 2, - rows: [ - { - _type: 'row', - _key: 'r1', - cells: [ - { - _type: 'cell', - _key: 'c1', - value: [block('cb1', 'cs1', 'one')], - }, - { - _type: 'cell', - _key: 'c2', - value: [block('cb2', 'cs2', 'TWO')], - }, - ], - }, - ], - }, - ]) - }) - - test('a field the markdown expresses stays removed when the edit removes it', () => { - const keyGenerator = createTestKeyGenerator() - const stored = [ - {_type: 'code', _key: 'code1', language: 'js', code: 'const a = 1'}, - ] - const markdown = portableTextToMarkdown(structuredClone(stored)).replace( - '```js', - '```', - ) - expect( - applyMarkdownEdit(stored, markdown, {deserialize: {keyGenerator}}), - ).toEqual([{_type: 'code', _key: 'code1', code: 'const a = 1'}]) - }) - - test('callout content keeps its style through the quote-syntax round trip', () => { - const keyGenerator = createTestKeyGenerator() - const schema = compileSchema( - defineSchema({ - blockObjects: [ - { - name: 'callout', - fields: [ - {name: 'tone', type: 'string'}, - {name: 'content', type: 'array'}, - ], - }, - ], - styles: [{name: 'normal'}, {name: 'blockquote'}], - }), - ) - const stored = [ - { - _type: 'callout', - _key: 'doc6', - tone: 'note', - content: [ - { - _type: 'block', - _key: 'doc4', - style: 'normal', - markDefs: [], - children: [{_type: 'span', _key: 'doc5', text: 'aef', marks: []}], - }, - ], - }, - ] - const markdown = portableTextToMarkdown(structuredClone(stored), { - schema, - }).replace('aef', 'aefX') - expect( - applyMarkdownEdit(stored, markdown, { - schema, - deserialize: {keyGenerator}, - }), - ).toEqual([ - { - _type: 'callout', - _key: 'doc6', - tone: 'note', - content: [ - { - _type: 'block', - _key: 'doc4', - style: 'normal', - markDefs: [], - children: [{_type: 'span', _key: 'doc5', text: 'aefX', marks: []}], - }, - ], - }, - ]) - }) - - test('a declared type with a misshapen structure becomes a `json:object` fence and stays editable', () => { - const keyGenerator = createTestKeyGenerator() - const schema = compileSchema( - defineSchema({ - blockObjects: [ - { - name: 'callout', - fields: [ - {name: 'tone', type: 'string'}, - {name: 'indhold', type: 'array'}, - ], - }, - ], - }), - ) - const calloutWithRenamedContent = (text: string) => [ - { - _type: 'callout', - _key: 'co1', - tone: 'note', - indhold: [ - { - _type: 'block', - _key: 'ib1', - style: 'normal', - markDefs: [], - children: [{_type: 'span', _key: 'is1', text, marks: []}], - }, - ], - }, - ] - const stored = calloutWithRenamedContent('hej verden') - const markdown = portableTextToMarkdown(structuredClone(stored), {schema}) - expect(markdown.startsWith('```json:object')).toBe(true) - expect( - applyMarkdownEdit(stored, markdown.replace('hej verden', 'hej Danmark'), { - schema, - deserialize: {keyGenerator}, - }), - ).toEqual(calloutWithRenamedContent('hej Danmark')) - }) - - test('markdown syntax targeting an undeclared field vanishes without touching identity', () => { - const keyGenerator = createTestKeyGenerator() - const schema = compileSchema( - defineSchema({ - blockObjects: [ - { - name: 'code', - fields: [ - {name: 'code', type: 'string'}, - {name: 'sprog', type: 'string'}, - ], - }, - ], - }), - ) - const stored = [ - {_type: 'code', _key: 'c1', code: 'const a = 1', sprog: 'js'}, - ] - const markdown = portableTextToMarkdown(structuredClone(stored), { - schema, - }).replace('```\n', '```typescript\n') - expect( - applyMarkdownEdit(stored, markdown, { - schema, - deserialize: {keyGenerator}, - }), - ).toEqual(stored) - }) - - test('an adopted block keeps its custom style', () => { - const schema = compileSchema( - defineSchema({styles: [{name: 'normal'}, {name: 'lead'}]}), - ) - const keyGenerator = createTestKeyGenerator() - const stored = [ - { - _type: 'block', - _key: 'b1', - style: 'lead', - markDefs: [], - children: [ - {_type: 'span', _key: 's1', text: 'lead paragraph', marks: []}, - ], - }, - { - _type: 'block', - _key: 'b2', - style: 'normal', - markDefs: [], - children: [ - {_type: 'span', _key: 's2', text: 'normal paragraph', marks: []}, - ], - }, - ] - const markdown = portableTextToMarkdown(structuredClone(stored), { - schema, - }).replace('normal paragraph', 'normal PARAGRAPH') - expect( - applyMarkdownEdit(stored, markdown, { - schema, - deserialize: {keyGenerator}, - }), - ).toEqual([ - { - _type: 'block', - _key: 'b1', - style: 'lead', - markDefs: [], - children: [ - {_type: 'span', _key: 's1', text: 'lead paragraph', marks: []}, - ], - }, - { - _type: 'block', - _key: 'b2', - style: 'normal', - markDefs: [], - children: [ - {_type: 'span', _key: 's2', text: 'normal PARAGRAPH', marks: []}, - ], - }, - ]) - }) - - test('a declared decorator with no markdown form survives on an adopted span', () => { - const schema = compileSchema( - defineSchema({decorators: [{name: 'strong'}, {name: 'highlight'}]}), - ) - const keyGenerator = createTestKeyGenerator() - const stored = [ - { - _type: 'block', - _key: 'b1', - style: 'normal', - markDefs: [], - children: [ - {_type: 'span', _key: 's1', text: 'plain ', marks: []}, - {_type: 'span', _key: 's2', text: 'bold', marks: ['strong']}, - {_type: 'span', _key: 's3', text: ' note', marks: ['highlight']}, - ], - }, - ] - const markdown = portableTextToMarkdown(structuredClone(stored), {schema}) - expect( - applyMarkdownEdit(stored, markdown, { - schema, - deserialize: {keyGenerator}, - }), - ).toEqual(stored) - }) - - test('an edit that genuinely changes the style keeps the edit, not the stored style', () => { - const schema = compileSchema( - defineSchema({styles: [{name: 'normal'}, {name: 'lead'}, {name: 'h1'}]}), - ) - const keyGenerator = createTestKeyGenerator() - const stored = [ - { - _type: 'block', - _key: 'b1', - style: 'lead', - markDefs: [], - children: [ - {_type: 'span', _key: 's1', text: 'headline text', marks: []}, - ], - }, - ] - const markdown = '# headline text' - expect( - applyMarkdownEdit(stored, markdown, { - schema, - deserialize: {keyGenerator}, - }), - ).toEqual([ - { - _type: 'block', - _key: 'b1', - style: 'h1', - markDefs: [], - children: [ - {_type: 'span', _key: 's1', text: 'headline text', marks: []}, - ], - }, - ]) - }) - - test('restoration skips levels where the canonical counterpart is untrustworthy', () => { - const keyGenerator = createTestKeyGenerator() - const stored = [ - { - _type: 'block', - _key: 'b1', - style: 'normal', - alignment: 'center', - markDefs: [], - children: [ - { - _type: 'span', - _key: 's1', - text: 'foo', - marks: ['strong'], - emphasis: 'high', - }, - {_type: 'span', _key: 's2', text: 'bar', marks: ['strong']}, - {_type: 'span', _key: 's3', text: ' baz', marks: []}, - ], - }, - ] - const markdown = portableTextToMarkdown(structuredClone(stored)).replace( - 'baz', - 'buzz', - ) - expect( - applyMarkdownEdit(stored, markdown, {deserialize: {keyGenerator}}), - ).toEqual([ - { - _type: 'block', - _key: 'b1', - style: 'normal', - alignment: 'center', - markDefs: [], - children: [ - {_type: 'span', _key: 's1', text: 'foobar', marks: ['strong']}, - {_type: 'span', _key: 's3', text: ' buzz', marks: []}, - ], - }, - ]) - }) - - test('a restored structured field is cloned, not shared, and the stored value is untouched', () => { - const keyGenerator = createTestKeyGenerator() - const stored = [ - { - ...block('b1', 's1', 'Ships tomorow.'), - customField: [ - {_key: 'x1', label: 'one'}, - {_key: 'x2', label: 'two'}, - ], - }, - ] - const storedSnapshot = structuredClone(stored) - const markdown = portableTextToMarkdown(structuredClone(stored)).replace( - 'tomorow', - 'tomorrow', - ) - const result = applyMarkdownEdit(stored, markdown, { - deserialize: {keyGenerator}, - }) - expect(stored).toEqual(storedSnapshot) - expect((result[0] as Record)['customField']).toEqual( - (stored[0] as Record)['customField'], - ) - expect((result[0] as Record)['customField']).not.toBe( - (stored[0] as Record)['customField'], - ) - }) - - test('inputs are not mutated and reconciliation is idempotent', () => { - const stored = [ - block('b1', 's1', 'alpha'), - {_type: 'product', _key: 'p1', sku: 'abc-123'}, - ] - const storedSnapshot = structuredClone(stored) - const markdown = portableTextToMarkdown(structuredClone(stored)) - - const once = applyMarkdownEdit(stored, markdown, { - deserialize: {keyGenerator: createTestKeyGenerator()}, - }) - expect(stored).toEqual(storedSnapshot) - - const twice = applyMarkdownEdit(stored, portableTextToMarkdown(once), { - deserialize: {keyGenerator: createTestKeyGenerator()}, - }) - expect(twice).toEqual(once) - }) - - test('empty blocks survive an unchanged serialization with every key intact', () => { - const keyGenerator = createTestKeyGenerator() - const stored = [ - block('b1', 's1', 'first paragraph'), - block('empty1', 'es1', ''), - block('b2', 's2', 'second paragraph'), - block('empty2', 'es2', ''), - block('empty3', 'es3', ''), - block('b3', 's3', 'third paragraph'), - ] - const markdown = portableTextToMarkdown(structuredClone(stored)) - expect( - applyMarkdownEdit(stored, markdown, {deserialize: {keyGenerator}}), - ).toEqual(stored) - }) - - test('empty blocks survive an edit with all keys intact', () => { - const keyGenerator = createTestKeyGenerator() - const stored = [ - block('b1', 's1', 'first paragraph'), - block('empty1', 'es1', ''), - block('b2', 's2', 'second paragraph'), - block('empty2', 'es2', ''), - block('empty3', 'es3', ''), - block('b3', 's3', 'third paragraph'), - ] - const markdown = portableTextToMarkdown(structuredClone(stored)).replace( - 'second', - 'SECOND', - ) - expect( - applyMarkdownEdit(stored, markdown, {deserialize: {keyGenerator}}), - ).toEqual([ - block('b1', 's1', 'first paragraph'), - block('empty1', 'es1', ''), - block('b2', 's2', 'SECOND paragraph'), - block('empty2', 'es2', ''), - block('empty3', 'es3', ''), - block('b3', 's3', 'third paragraph'), - ]) - }) - - test('a whitespace-only block survives the loop with every key intact', () => { - const keyGenerator = createTestKeyGenerator() - const stored = [ - block('b1', 's1', 'first'), - block('ws1', 'wss1', ' '), - block('b2', 's2', 'second'), - ] - const markdown = portableTextToMarkdown(structuredClone(stored)) - expect( - applyMarkdownEdit(stored, markdown, {deserialize: {keyGenerator}}), - ).toEqual(stored) - }) - - test('a whitespace-only block survives an edit with all keys intact', () => { - const keyGenerator = createTestKeyGenerator() - const stored = [ - block('b1', 's1', 'first'), - block('ws1', 'wss1', ' '), - block('b2', 's2', 'second'), - ] - const markdown = portableTextToMarkdown(structuredClone(stored)).replace( - 'second', - 'SECOND', - ) - expect( - applyMarkdownEdit(stored, markdown, {deserialize: {keyGenerator}}), - ).toEqual([ - block('b1', 's1', 'first'), - block('ws1', 'wss1', ' '), - block('b2', 's2', 'SECOND'), - ]) - }) - - test('an empty run whose anchor was deleted goes with it', () => { - const keyGenerator = createTestKeyGenerator() - const stored = [ - block('b1', 's1', 'alpha'), - block('empty1', 'es1', ''), - block( - 'b2', - 's2', - 'a distinctively long beta paragraph that nothing else resembles', - ), - ] - const markdown = - 'a distinctively long beta paragraph that nothing else resembles' - expect( - applyMarkdownEdit(stored, markdown, {deserialize: {keyGenerator}}), - ).toEqual([ - block( - 'b2', - 's2', - 'a distinctively long beta paragraph that nothing else resembles', - ), - ]) - }) - - test('leading empty blocks anchor to the first following block', () => { - const keyGenerator = createTestKeyGenerator() - const stored = [ - block('empty1', 'es1', ''), - block('b1', 's1', 'content here'), - ] - const markdown = portableTextToMarkdown(structuredClone(stored)) - expect( - applyMarkdownEdit(stored, markdown, {deserialize: {keyGenerator}}), - ).toEqual(stored) - }) - - test('an empty heading is not treated as an empty block, since it renders visibly', () => { - const keyGenerator = createTestKeyGenerator() - const stored = [ - block('b1', 's1', 'alpha'), - { - _type: 'block', - _key: 'h1', - style: 'h2', - markDefs: [], - children: [{_type: 'span', _key: 'hs1', text: '', marks: []}], - }, - block('b2', 's2', 'beta'), - ] - const markdown = portableTextToMarkdown(structuredClone(stored)).replace( - 'beta', - 'gamma', - ) - expect( - applyMarkdownEdit(stored, markdown, {deserialize: {keyGenerator}}), - ).toEqual([ - block('b1', 's1', 'alpha'), - { - _type: 'block', - _key: 'h1', - style: 'h2', - markDefs: [], - children: [{_type: 'span', _key: 'hs1', text: '', marks: []}], - }, - block('b2', 's2', 'gamma'), - ]) - }) - - test('a whitespace-only bullet list item is not treated as an empty block, since it renders visibly', () => { - const keyGenerator = createTestKeyGenerator() - const stored = [ - block('b1', 's1', 'alpha'), - { - _type: 'block', - _key: 'li1', - style: 'normal', - listItem: 'bullet', - level: 1, - markDefs: [], - children: [{_type: 'span', _key: 'lis1', text: ' ', marks: []}], - }, - block('b2', 's2', 'beta'), - ] - const markdown = portableTextToMarkdown(structuredClone(stored)).replace( - 'beta', - 'gamma', - ) - expect( - applyMarkdownEdit(stored, markdown, {deserialize: {keyGenerator}}), - ).toEqual([ - block('b1', 's1', 'alpha'), - { - _type: 'block', - _key: 'li1', - style: 'normal', - markDefs: [], - children: [{_type: 'span', _key: 'lis1', text: ' ', marks: []}], - listItem: 'bullet', - level: 1, - }, - block('b2', 's2', 'gamma'), - ]) - }) - - test('a no-op edit leaves a block whose marks the dialect cannot express with its span structure verbatim', () => { - const schema = compileSchema( - defineSchema({decorators: [{name: 'strong'}, {name: 'highlight'}]}), - ) - const keyGenerator = createTestKeyGenerator() - const stored = [ - { - _type: 'block', - _key: 'b1', - style: 'normal', - markDefs: [], - children: [ - {_type: 'span', _key: 's1', text: 'hello ', marks: ['highlight']}, - {_type: 'span', _key: 's2', text: 'world', marks: []}, - ], - }, - ] - const markdown = portableTextToMarkdown(structuredClone(stored), {schema}) - expect(markdown).toEqual('hello world') - expect( - applyMarkdownEdit(stored, markdown, { - schema, - deserialize: {keyGenerator}, - }), - ).toEqual(stored) - }) - - test('an edit to a neighboring block leaves an untouched unmappable-mark block verbatim', () => { - const schema = compileSchema( - defineSchema({decorators: [{name: 'strong'}, {name: 'highlight'}]}), - ) - const keyGenerator = createTestKeyGenerator() - const stored = [ - { - _type: 'block', - _key: 'b1', - style: 'normal', - markDefs: [], - children: [ - {_type: 'span', _key: 's1', text: 'hello ', marks: ['highlight']}, - {_type: 'span', _key: 's2', text: 'world', marks: []}, - ], - }, - block('b2', 's3', 'beta'), - ] - const markdown = portableTextToMarkdown(structuredClone(stored), {schema}) - expect( - applyMarkdownEdit(stored, markdown.replace('beta', 'gamma'), { - schema, - deserialize: {keyGenerator}, - }), - ).toEqual([stored[0], block('b2', 's3', 'gamma')]) - }) - - test('an edit to one table cell leaves an annotated sibling cell verbatim', () => { - const keyGenerator = createTestKeyGenerator() - const stored = [ - { - _type: 'table', - _key: 't1', - headerRows: 0, - rows: [ - { - _type: 'row', - _key: 'r1', - cells: [ - { - _type: 'cell', - _key: 'c1', - value: [ - { - _type: 'block', - _key: 'cb1', - style: 'normal', - markDefs: [ - { - _type: 'internalLink', - _key: 'm1', - reference: {_type: 'reference', _ref: 'foo'}, - }, - ], - children: [ - {_type: 'span', _key: 's1', text: 'Read ', marks: []}, - {_type: 'span', _key: 's2', text: 'foo', marks: ['m1']}, - { - _type: 'span', - _key: 's3', - text: ' before baz.', - marks: [], - }, - ], - }, - ], - }, - {_type: 'cell', _key: 'c2', value: [block('cb2', 's4', 'bar')]}, - ], - }, - ], - }, - block('b2', 's5', 'qux'), - ] - const markdown = portableTextToMarkdown(structuredClone(stored)).replace( - 'bar', - 'bar now', - ) - expect( - applyMarkdownEdit(stored, markdown, {deserialize: {keyGenerator}}), - ).toEqual([ - { - _type: 'table', - _key: 't1', - headerRows: 0, - rows: [ - { - _type: 'row', - _key: 'r1', - cells: [ - { - _type: 'cell', - _key: 'c1', - value: [ - { - _type: 'block', - _key: 'cb1', - style: 'normal', - markDefs: [ - { - _type: 'internalLink', - _key: 'm1', - reference: {_type: 'reference', _ref: 'foo'}, - }, - ], - children: [ - {_type: 'span', _key: 's1', text: 'Read ', marks: []}, - {_type: 'span', _key: 's2', text: 'foo', marks: ['m1']}, - { - _type: 'span', - _key: 's3', - text: ' before baz.', - marks: [], - }, - ], - }, - ], - }, - { - _type: 'cell', - _key: 'c2', - value: [block('cb2', 's4', 'bar now')], - }, - ], - }, - ], - }, - block('b2', 's5', 'qux'), - ]) - }) - - test('a moved block with unmappable-mark spans keeps its span structure', () => { - const schema = compileSchema( - defineSchema({decorators: [{name: 'strong'}, {name: 'highlight'}]}), - ) - const keyGenerator = createTestKeyGenerator() - const stored = [ - { - _type: 'block', - _key: 'b1', - style: 'normal', - markDefs: [], - children: [ - {_type: 'span', _key: 's1', text: 'hello ', marks: ['highlight']}, - {_type: 'span', _key: 's2', text: 'world', marks: []}, - ], - }, - block('b2', 's3', 'beta'), - ] - const markdown = portableTextToMarkdown(structuredClone(stored), {schema}) - const [first, second] = markdown.split('\n\n') - const edited = `${second}\n\n${first}` - expect( - applyMarkdownEdit(stored, edited, { - schema, - deserialize: {keyGenerator}, - }), - ).toEqual([stored[1], stored[0]]) - }) - - test('a no-op edit returns the stored value byte for byte, decorator spans and a payload block included', () => { - const schema = compileSchema( - defineSchema({decorators: [{name: 'strong'}, {name: 'highlight'}]}), - ) - const keyGenerator = createTestKeyGenerator() - const stored = [ - { - _type: 'block', - _key: 'b1', - style: 'normal', - markDefs: [], - children: [ - {_type: 'span', _key: 's1', text: 'hello ', marks: ['highlight']}, - {_type: 'span', _key: 's2', text: 'world', marks: []}, - ], - }, - {_type: 'product', _key: 'p1', sku: 'abc-123'}, - block('empty1', 'es1', ''), - block('b2', 's3', 'tail'), - ] - const markdown = portableTextToMarkdown(structuredClone(stored), {schema}) - expect( - applyMarkdownEdit(stored, markdown, { - schema, - deserialize: {keyGenerator}, - }), - ).toEqual(stored) - }) -}) - -function alternatingSpansBlock( - blockKey: string, - count: number, - prefix: string, -): PortableTextBlock { - return { - _type: 'block', - _key: blockKey, - style: 'normal', - markDefs: [], - children: Array.from({length: count}, (_, index) => ({ - _type: 'span', - _key: `${prefix}s${index}`, - text: `${prefix}${index}`, - marks: index % 2 === 0 ? [] : ['strong'], - })), - } -} - -// Adjacent same-mark spans merge at parse time, so the tokens making up -// one target span must alternate marks to stay separate; `mergedAt` -// substitutes a text that is the concatenation of two stored spans, -// giving the pair search a genuine merge to find. -function alternatingMarkdown( - count: number, - mergedAt: number, - mergedText: string, -): string { - return Array.from({length: count}, (_, index) => - index === mergedAt ? mergedText : `edited${index}`, - ) - .map((text, index) => (index % 2 === 0 ? text : `**${text}**`)) - .join('') -} - -function manyBlocks(count: number, prefix: string): Array { - return Array.from({length: count}, (_, index) => - block( - `${prefix}b${index}`, - `${prefix}s${index}`, - `${prefix} filler text ${index}`, - ), - ) -} - -function manyParagraphs(count: number, prefix: string): string { - return Array.from( - {length: count}, - (_, index) => `${prefix} filler text ${index}`, - ).join('\n\n') -} - -function safetyNoticeBlock(): PortableTextBlock { - return { - _type: 'block', - _key: 'b1', - style: 'normal', - markDefs: [], - children: [ - {_type: 'span', _key: 's1', text: 'Read the ', marks: []}, - {_type: 'span', _key: 's2', text: 'safety notice', marks: ['underline']}, - {_type: 'span', _key: 's3', text: ' before use.', marks: []}, - ], - } -} diff --git a/packages/markdown/src/apply-markdown-edit.ts b/packages/markdown/src/apply-markdown-edit.ts deleted file mode 100644 index 2b3eda67ca..0000000000 --- a/packages/markdown/src/apply-markdown-edit.ts +++ /dev/null @@ -1,3215 +0,0 @@ -import type {PortableTextBlock, Schema} from '@portabletext/schema' -import {cleanupEfficiency, makeDiff} from '@sanity/diff-match-patch' -import {defaultSchema} from './default-schema' -import { - truncateSnippet, - type SerializeDegradation, - type SerializeDegradationContent, -} from './from-portable-text/degradation-report' -import { - portableTextToMarkdown, - portableTextToMarkdownWithLostContent, -} from './from-portable-text/portable-text-to-markdown' -import {defaultKeyGenerator} from './key-generator' -import { - buildAliasMap, - encodeNeutral, - isTypedObjectArray, - neutralForm, -} from './neutral-form' -import {degradationMessage} from './to-portable-text/degradation-messages' -import { - markdownToPortableText, - type Degradation, -} from './to-portable-text/markdown-to-portable-text' - -/** - * @public - */ -export type ApplyMarkdownEditOptions = { - /** - * One compiled schema governing both conversion directions, the same - * object the standalone converters accept. Taking it once is what - * keeps the internal canonical dialect consistent: serializing and - * reparsing under different schemas would change node counts or - * types, refuse the origin trace, and reset keys document-wide. - */ - schema?: Schema - /** - * Options for the markdown → Portable Text conversion of - * `editedMarkdown` (matchers, `keyGenerator`). The same options - * also govern the internal canonicalization of `storedPortableText` - * used to align the two documents, except `onDegradation`, which is - * scoped to `editedMarkdown` alone: `keyGenerator` supplies every - * fresh key the function mints, both for new content and for the - * sibling-uniqueness key-rename sweep. - */ - deserialize?: Omit< - NonNullable[1]>, - 'schema' - > - /** - * Options for the Portable Text → markdown conversion. Pass the same - * options that produced the markdown that was edited: key - * resolution aligns the edit against a fresh canonical serialization - * of `storedPortableText`, so the two serializations must agree for - * that alignment to be meaningful. Excludes `onDegradation`: this - * serialization only ever runs internally, to canonicalize - * `storedPortableText` for alignment, never on `editedMarkdown` or its - * result, so there is nothing here for a caller to observe losses in. - */ - serialize?: Omit< - NonNullable[1]>, - 'schema' | 'onDegradation' - > - /** - * Reports how stored `_key`s were reconciled onto the converted - * value. Reconciliation here is key restoration against the exact - * snapshot that produced the markdown, never a merge of concurrent - * edits: those are the caller's job (see the README's Concurrent - * edits section). Called at most once, and exactly once whenever the - * option is set and `applyMarkdownEdit` returns (a conversion that - * throws never reports), synchronously, immediately before the - * return, including when key matching is skipped outright: that is - * when a caller needs the report most. A node absent from a performed report's - * `preservedKeys` was not preserved from the stored value, whether - * it is a fresh key or a `json:object` payload key that carried its - * own; the report does not distinguish the two. Which keys - * survived, every `key` and `path`, `renamedKeys`, and a skipped - * report's `reason`, and a `round-trip-mismatch` report's - * `mismatch.type` and `mismatch.storedPath` are facts of that - * invocation, safe to branch on for that invocation, and so is - * `hiddenLosses`: every entry names content that is missing from the - * returned value although the markdown never showed it, and content - * the edit's restoration put back is not listed. A stored block - * whose `_key` is absent from the returned value has all of its - * losses listed, whether the edit deleted it or rewrote it past - * recognition: whether that removal was intended is the caller's - * call. `serialize.onDegradation` stays excluded: `hiddenLosses` is - * where serialize losses that matter to the returned value surface. - * `markdownLosses` and `undeclaredObjects` are facts of the invocation - * too. - * - * @beta - */ - onReconciliation?: (report: ReconciliationReport) => void -} - -/** - * A path segment into the value `applyMarkdownEdit` returns (except in - * a skipped report's `mismatch.storedPath`, which addresses the stored - * value passed in instead, as every `hiddenLosses` entry's `path` - * does): a string field name - * for container nesting (`rows`, `cells`, `value`, and the like), - * `{_key}` wherever the path lands on a keyed array element, - * and a number wherever it lands on a keyless one instead. A block's - * own path is a single `{_key}` segment, or a single number for a - * keyless top-level block; a child's path is - * `[{_key: }, 'children', {_key: }]`. A keyless - * node (a `json:object` payload without a `_key` of its own, at the - * top level or wrapping keyed content in a nested array) contributes - * its index as a number segment instead of a `{_key}` segment. - * - * @beta - */ -export type ReconciliationKeyPath = Array<{_key: string} | string | number> - -/** - * What `applyMarkdownEdit` did with every key, delivered through - * `onReconciliation`. `keyMatching: 'skipped'` means key matching gave - * up on the whole document rather than at some local point: the - * returned value is the plain markdown→Portable Text conversion, so - * every key in it is fresh except a `json:object` payload key carried - * through the markdown verbatim, and no preserved key or local key - * fallback can have happened. `preservedKeys` and `renamedKeys` list - * their entries in document order, each block before its children and - * its mark definitions after them; `keyFallbacks` groups by kind - * instead: `ambiguous-region-too-large` entries in the order their - * gaps were resolved, then `annotation-key-conflict` entries. `basis` - * names the matching method that preserved a key, not the edit's - * semantics: a swap reports one block `content-moved` and its partner - * `content-unchanged`. The report carries no edit intent: a deleted - * stored node is not listed at all, so derive deletions as stored - * keys minus the keys `applyMarkdownEdit` returned. Both variants carry - * `hiddenLosses`: stored content the `serialize` options could not - * express in markdown that is also missing from the returned value, so - * whoever edited the markdown never saw it go. Both also carry - * `markdownLosses`, what the edited markdown said that the returned - * value does not hold, and `undeclaredObjects`, objects the edit added - * whose type the schema does not declare. Undeclared objects are kept - * as written, so they are not lost: they are content the schema does - * not allow. Refuse or retry the write when any of the three lists is - * non-empty and what it lists matters. - * - * @beta - */ -export type ReconciliationReport = - | { - keyMatching: 'performed' - /** - * One entry per node whose stored `_key` key resolution - * preserved, at every depth (blocks, spans and other inline - * children, and mark definitions). A node absent from this list - * did not have its key preserved from the stored value: that - * covers both a freshly generated key and a `json:object` - * payload key carried through the markdown verbatim, which the - * report does not distinguish. `basis` names the tier that - * matched: `content-unchanged` for an exact content match (a - * block-level anchor, a uniquely matched child, a preserved - * empty-block run, or a mark definition uniquely matched by - * content); `content-moved` for a unique leftover matched - * across gaps; `content-split` for a fragment that kept its - * source block's key; `content-merged` for a merge that kept - * its first contributor's key; `same-position` for an - * equal-count in-order pairing (also a same-content - * mark-definition tie, or a single leftover mark definition - * paired positionally); `similar-content` for a mutual-best - * match under an unequal count. - */ - preservedKeys: Array<{ - basis: - | 'content-unchanged' - | 'content-moved' - | 'content-split' - | 'content-merged' - | 'same-position' - | 'similar-content' - key: string - path: ReconciliationKeyPath - /** - * Whether the node wearing this key in the returned value - * differs from its stored counterpart, field order aside but - * array order significant. Any difference counts, including a - * descendant key rewritten to keep siblings unique, or a key - * filled in for a stored node that had none. `false` - * guarantees the returned node deep-equals the stored one. - * Unlike `basis`, this is a stable fact of the invocation, - * safe to branch on. - */ - valueChanged: boolean - }> - /** - * One entry per local point where key resolution gave up rather - * than adopt, while the rest of the document still resolves - * keys: `ambiguous-region-too-large` when a gap's residual - * similarity pairing crossed the evidence pair cap, listing the - * affected result block keys that fell back to fresh; - * `annotation-key-conflict` when an adopted mark definition key - * would have collided with a sibling. The span-merge pair cap, - * the per-diff similarity timeout, and the gap's earlier - * concatenation-search cap degrade the same way but are not - * reported: `ambiguous-region-too-large` covers the residual - * similarity tier only (a gap that trips the concatenation cap - * trips it too). - */ - keyFallbacks: Array< - | {type: 'ambiguous-region-too-large'; keys: Array} - | {type: 'annotation-key-conflict'; path: ReconciliationKeyPath} - > - /** - * One entry per key `applyMarkdownEdit` rewrote to keep - * siblings unique, most commonly a `json:object` payload - * duplicating a key already present elsewhere in the document. - */ - renamedKeys: Array<{ - previousKey: string - key: string - path: ReconciliationKeyPath - }> - /** - * One entry per serialize degradation of the stored value (an - * annotation, decorator, block style, or list-item kind the - * `serialize` options have no renderer for, see - * `SerializeDegradation`) whose content is missing from the - * returned value: the markdown never showed it, so the edit could - * not have removed it on purpose. A degradation the edit's - * restoration repaired is not listed. In stored document order; - * `path` addresses the stored value, like `SerializeDegradation`'s. - * Each entry is checked against the returned node at the same key - * path: the top-level block by `_key`, then down to the stored - * text block that held the degraded node, located by identity - * rather than by `path` (which can name a sibling reusing the - * span's `_key`). Where a custom renderer rendered a copy of that - * text block, the whole top-level block is searched instead. An - * annotation counts as kept when that block's `markDefs` holds an - * equal definition (`_key` aside) that one of its spans carries; a - * decorator when one of its spans carries it; a style or list-item - * kind when the block's own `style` or `listItem` matches. Only - * `span` children count: an inline object's own `marks` field - * carries nothing. A stored top-level block whose `_key` is absent - * from the returned value has every one of its losses listed, - * whether the edit deleted it or rewrote it so far that it came - * back with a fresh key: the returned value does not carry its - * content under that key, and whether the edit meant to remove it - * is for the caller to decide. A nested node whose - * own path no longer resolves inside a surviving top-level block - * is listed: it came back with a fresh key, and fresh content - * comes from the markdown alone, which never had it. A keyless - * stored top-level block (a number as `path`'s first segment) has - * no key to correspond by, so its entries are checked against the - * whole returned value, as on a skipped report. A stored empty - * text block restored next to its neighbor corresponds by its own - * `_key`, whether or not `preservedKeys` lists it. - */ - hiddenLosses: Array - /** - * One entry per deserialize degradation of `editedMarkdown`, the - * same entries `deserialize.onDegradation` receives and in the - * same encounter order, except `object-type-undeclared`: the - * returned value differs from what the markdown said (a heading - * level the schema has no style for, a dropped decorator, a - * flattened table, fields dropped from a `json:object` carrier, - * an invalid carrier, and the like). An undeclared object is - * kept as written, so it is listed in `undeclaredObjects` instead - * when the edit introduced it. Only the parse of `editedMarkdown` - * counts, never the internal canonicalization of the stored value. - */ - markdownLosses: Array - /** - * One entry per object in the returned value whose type the - * `schema` does not declare and that the stored value did not - * already hold, in document order. Only top-level block objects - * and the inline objects of top-level text blocks are checked. The - * fields of a declared object, such as a table's cells or a - * container's own array, are not searched: what a `json:object` - * payload holds inside its fields is validated like any other - * write of that object. An object counts as already held when - * the stored value has one with the same `_type` and `_key`, at - * the same position (`block` or `inline`), in the same array: for - * an inline object that is the Portable Text array holding its - * block, whatever the block's own key, since editing a paragraph - * can give its block a fresh key. `path` addresses the returned - * value. - */ - undeclaredObjects: Array - } - | { - keyMatching: 'skipped' - /** - * The stored value's own serialize→parse round trip changed its - * node count, type sequence, or per-position text. A fact of - * that invocation, safe to branch on. - */ - reason: 'round-trip-mismatch' - /** - * Where and why the round trip first went wrong. The scan walks - * the stored blocks in document order, skipping empty paragraphs - * and comparing text without leading and trailing whitespace, and - * stops at the first block whose type or text came back - * different. - */ - mismatch: { - /** - * `type-changed` when the first failing block came back as - * another type, `text-changed` when it kept its type but its - * text came back different (a custom renderer writing syntax - * the parser reads back as other text, say). Both apply even - * when the round trip also changed the block count. - * `block-count-changed` when every compared block came back - * intact but the round trip dropped or added blocks, or when - * every stored block is an empty paragraph. A fact of that - * invocation, safe to branch on. Match on `type`, not - * `message`. - */ - type: 'type-changed' | 'text-changed' | 'block-count-changed' - /** - * Unlike every other path in the report but `hiddenLosses`', - * this one addresses the stored value as passed, not the - * returned value: a - * `{_key}` segment for a keyed node, its index for a keyless - * one, empty paragraphs counted. For `text-changed` it reaches - * the child (span or inline object) where the text first - * differs, `[{_key: 'b2'}, 'children', {_key: 'w1'}]`, or stays - * on the block when the stored text is a prefix of what came - * back. Otherwise it names a block: the failing block for - * `type-changed`, and for `block-count-changed` the first stored - * block with no counterpart when blocks were dropped, the last - * compared stored block when blocks were added, or the last - * stored block when every stored block is an empty paragraph. - * A fact of that invocation, safe to branch on. - */ - storedPath: ReconciliationKeyPath - /** - * Human-readable, naming where the round trip first went - * wrong. May change between releases. - */ - message: string - /** - * The span text that came back, inline objects skipped, - * truncated to 40 characters with an ellipsis: for - * `text-changed`, starting where it first differs, otherwise - * the round-tripped block's text (the first added block when - * blocks were added). Absent when there is no span text to - * quote, such as a block object, dropped blocks, or text that - * went missing. - */ - snippet?: string - } - /** - * One entry per key `applyMarkdownEdit` rewrote to keep - * siblings unique, most commonly a `json:object` payload - * duplicating a key already present elsewhere in the document. - * The sibling-uniqueness pass still runs on a skipped document: - * a pasted duplicate `json:object` fence key still gets - * renamed even though nothing else adopted. - */ - renamedKeys: Array<{ - previousKey: string - key: string - path: ReconciliationKeyPath - }> - /** - * One entry per serialize degradation of the stored value (an - * annotation, decorator, block style, or list-item kind the - * `serialize` options have no renderer for, see - * `SerializeDegradation`) whose content is missing from the - * returned value: the markdown never showed it, so the edit could - * not have removed it on purpose. A degradation the edit's - * restoration repaired is not listed. In stored document order; - * `path` addresses the stored value, like `SerializeDegradation`'s. - * Key matching was skipped, so nothing corresponds by key and - * nothing was restored: an entry is listed when no text block - * anywhere in the returned value carries its content (an equal - * annotation definition, `_key` aside, carried by a span of the - * same block; the decorator on some span; the style or list-item - * kind on some block). Content found elsewhere in the document - * masks a loss, so on a skipped report an empty list is weaker - * than on a performed one. - */ - hiddenLosses: Array - /** - * One entry per deserialize degradation of `editedMarkdown`, the - * same entries `deserialize.onDegradation` receives and in the - * same encounter order, except `object-type-undeclared`: the - * returned value differs from what the markdown said (a heading - * level the schema has no style for, a dropped decorator, a - * flattened table, fields dropped from a `json:object` carrier, - * an invalid carrier, and the like). An undeclared object is - * kept as written, so it is listed in `undeclaredObjects` instead - * when the edit introduced it. Only the parse of `editedMarkdown` - * counts, never the internal canonicalization of the stored value. - */ - markdownLosses: Array - /** - * One entry per object in the returned value whose type the - * `schema` does not declare and that the stored value did not - * already hold, in document order. Only top-level block objects - * and the inline objects of top-level text blocks are checked. The - * fields of a declared object, such as a table's cells or a - * container's own array, are not searched: what a `json:object` - * payload holds inside its fields is validated like any other - * write of that object. An object counts as already held when - * the stored value has one with the same `_type` and `_key`, at - * the same position (`block` or `inline`), in the same array: for - * an inline object that is the Portable Text array holding its - * block, whatever the block's own key, since editing a paragraph - * can give its block a fresh key. `path` addresses the returned - * value. - */ - undeclaredObjects: Array - } - | { - keyMatching: 'skipped' - /** - * The document holds more distinct block forms than alignment - * can token. A fact of that invocation, safe to branch on. - */ - reason: 'document-too-large' - /** - * One entry per key `applyMarkdownEdit` rewrote to keep - * siblings unique, most commonly a `json:object` payload - * duplicating a key already present elsewhere in the document. - * The sibling-uniqueness pass still runs on a skipped document: - * a pasted duplicate `json:object` fence key still gets - * renamed even though nothing else adopted. - */ - renamedKeys: Array<{ - previousKey: string - key: string - path: ReconciliationKeyPath - }> - /** - * One entry per serialize degradation of the stored value (an - * annotation, decorator, block style, or list-item kind the - * `serialize` options have no renderer for, see - * `SerializeDegradation`) whose content is missing from the - * returned value: the markdown never showed it, so the edit could - * not have removed it on purpose. A degradation the edit's - * restoration repaired is not listed. In stored document order; - * `path` addresses the stored value, like `SerializeDegradation`'s. - * Key matching was skipped, so nothing corresponds by key and - * nothing was restored: an entry is listed when no text block - * anywhere in the returned value carries its content (an equal - * annotation definition, `_key` aside, carried by a span of the - * same block; the decorator on some span; the style or list-item - * kind on some block). Content found elsewhere in the document - * masks a loss, so on a skipped report an empty list is weaker - * than on a performed one. - */ - hiddenLosses: Array - /** - * One entry per deserialize degradation of `editedMarkdown`, the - * same entries `deserialize.onDegradation` receives and in the - * same encounter order, except `object-type-undeclared`: the - * returned value differs from what the markdown said (a heading - * level the schema has no style for, a dropped decorator, a - * flattened table, fields dropped from a `json:object` carrier, - * an invalid carrier, and the like). An undeclared object is - * kept as written, so it is listed in `undeclaredObjects` instead - * when the edit introduced it. Only the parse of `editedMarkdown` - * counts, never the internal canonicalization of the stored value. - */ - markdownLosses: Array - /** - * One entry per object in the returned value whose type the - * `schema` does not declare and that the stored value did not - * already hold, in document order. Only top-level block objects - * and the inline objects of top-level text blocks are checked. The - * fields of a declared object, such as a table's cells or a - * container's own array, are not searched: what a `json:object` - * payload holds inside its fields is validated like any other - * write of that object. An object counts as already held when - * the stored value has one with the same `_type` and `_key`, at - * the same position (`block` or `inline`), in the same array: for - * an inline object that is the Portable Text array holding its - * block, whatever the block's own key, since editing a paragraph - * can give its block a fresh key. `path` addresses the returned - * value. - */ - undeclaredObjects: Array - } - -/** - * An object of a type the `schema` does not declare, in a - * `ReconciliationReport`'s `undeclaredObjects`. `position` is `inline` - * for a top-level text block's child and `block` for a top-level block - * object. `path` addresses the returned value, like every - * `ReconciliationKeyPath` in the report: `[{_key}]` for a top-level - * block object, `[{_key: }, 'children', - * {_key: }]` for an inline object. `_key` is absent when the - * object has none. `message` is the same text as the - * `object-type-undeclared` degradation for the object, for display: it - * can change between releases, so do not match on it. - * - * @beta - */ -export type UndeclaredObject = { - _type: string - _key?: string - position: 'block' | 'inline' - path: ReconciliationKeyPath - message: string -} - -/** - * Deliberately high pending calibration against real agent edit - * traces: a wrong match moves anchors onto unrelated text, a fresh - * key resets one block. - */ -const MIN_BLOCK_SIMILARITY = 0.8 - -/** - * Caps for the split, merge, and similarity tiers: above this many - * pairs, or past this per-diff time, evidence gathering stops and the - * affected blocks get new keys, the safe failure mode. Without the - * caps a single large unequal gap of long unique texts is quadratic in - * both concatenation search and similarity scoring, and runs for tens - * of seconds. - */ -const MAX_SIMILARITY_PAIRS = 2500 -const SIMILARITY_DIFF_TIMEOUT_SECONDS = 0.05 - -/** - * Bounded attempts at a caller-supplied `keyGenerator` before falling - * back to deterministic suffixing: a generator that always returns - * the same value (a constant stub, a buggy sequence) would otherwise - * spin the collision loop forever. - */ -const MAX_KEY_GENERATOR_ATTEMPTS = 3 - -/** - * One block, one UTF-16 code unit: past this many distinct block - * forms the token alphabet would wrap into collisions and surrogate - * territory, so alignment gives up and every key stays fresh. - */ -const MAX_DISTINCT_BLOCK_FORMS = 55_000 - -/** - * Converts edited markdown to Portable Text, restores stored keys, and - * restores fields the markdown dialect cannot express (dropped by - * serialization, so the edit could not have touched them); a field - * markdown does express follows the edit. The same rule covers a - * custom style or decorator markdown has no syntax for, coerced to a - * built-in on the round trip. An empty or whitespace-only text block - * is the same case taken to the whole block: markdown has no form for - * either, so it is restored next to its surviving neighbor and - * dropped along with that neighbor if the neighbor does not survive. - * Keys aim for what the - * same edit would have produced in an editor: - * unchanged, moved, and rewritten-in-place content keeps its keys - * (rewriting a paragraph in place keeps its identity, like typing over - * it), a split keeps the key on its first non-empty fragment, a merge - * keeps the first source block's key, and a `json:object` payload keeps - * the key it carries, unless reconciliation matches it to stored - * content, which takes the stored key even over a differing key in the - * payload. When an insertion or deletion makes positions ambiguous, - * only clear similarity evidence adopts a key and everything else gets - * a new one; gathering that evidence is time-capped, so on very large - * ambiguous edits the set of adopted keys can differ across machine - * speeds, degrading toward fresh keys. - * Output keys are unique among siblings. The function does not mutate - * `storedPortableText` and returns a value, not patches. The `schema` - * is taken once and governs both directions; pass the same `serialize` - * options that produced the markdown that was edited. A throwing or - * stateful custom matcher or renderer propagates or degrades matching - * respectively. - * Reconciliation never merges concurrent edits: compare the stored - * field against the live document before writing the result back. - * The trades in one line: same-position replacement inherits identity, - * a count-preserving rewrite pairs positionally (block-level and - * sibling-level alike), and evidence gathering is capped, degrading to - * fresh keys. - * - * @public - */ -export function applyMarkdownEdit( - storedPortableText: ReadonlyArray, - editedMarkdown: string, - options?: ApplyMarkdownEditOptions, -): Array { - const onReconciliation = options?.onReconciliation - const recorder: ReconciliationRecorder | undefined = onReconciliation - ? { - preservation: new WeakMap(), - renamePreviousKey: new WeakMap(), - annotationKeyConflicts: [], - ambiguousRegionGroups: [], - skip: undefined, - } - : undefined - const markdownLosses: Array = [] - const callerOnDegradation = options?.deserialize?.onDegradation - const result = structuredClone( - markdownToPortableText(editedMarkdown, { - ...options?.deserialize, - schema: options?.schema, - onDegradation: (report) => { - markdownLosses.push( - ...report.degradations.filter( - // The deserializer keeps an undeclared object as written, so - // it is no loss of what the markdown said: - // `undeclaredObjects` reports the ones the edit introduces. - (degradation) => degradation.type !== 'object-type-undeclared', - ), - ) - callerOnDegradation?.(report) - }, - }), - ) as unknown as Array - const {canonical, degradations} = canonicalizeStored( - storedPortableText, - options, - ) - const nonEmptyStored = nonEmptyBlocks(storedPortableText, options) - const originOf = traceOrigins( - storedPortableText, - nonEmptyStored, - canonical, - recorder, - ) - const adoptedNodes: AdoptedNodes = new WeakSet() - if (originOf) { - const alignment = alignBlocks(canonical, result, recorder) - if (alignment) { - adoptAnchors(alignment.anchors, originOf, result, adoptedNodes, recorder) - const gaps = adoptMoves( - alignment.gaps, - canonical, - result, - originOf, - adoptedNodes, - recorder, - ) - for (const gap of gaps) { - resolveGap( - gap.storedIndexes, - gap.editedIndexes, - canonical, - result, - originOf, - adoptedNodes, - recorder, - ) - } - // Inside the successful trace only: a document-wide skip of key - // matching means nothing adopts, and a reinserted empty block - // would adopt its stored key past that skip, since its anchor - // can still be found whenever a `json:object` payload carries - // the neighboring key through the markdown verbatim. - reinsertEmptyRuns( - storedPortableText, - result, - adoptedNodes, - options, - recorder, - ) - } - } - enforceSiblingKeyUniqueness( - result, - options?.deserialize?.keyGenerator ?? defaultKeyGenerator, - adoptedNodes, - recorder, - ) - if (recorder && onReconciliation) { - onReconciliation( - buildReconciliationReport(result, recorder, { - hiddenLosses: findHiddenLosses( - degradations, - result, - recorder.skip ? 'skipped' : 'performed', - ), - markdownLosses, - undeclaredObjects: findIntroducedUndeclaredObjects( - storedPortableText, - result, - options?.schema ?? defaultSchema, - ), - }), - ) - } - return result as unknown as Array -} - -type Node = Record - -/** - * Adoption targets, so the deduplication pass can tell an adopted - * (authoritative) key from a verbatim payload duplicate. - */ -type AdoptedNodes = WeakSet - -type PreservationBasis = Extract< - ReconciliationReport, - {keyMatching: 'performed'} ->['preservedKeys'][number]['basis'] - -/** - * Accumulates key resolution decisions against node identity as the - * passes make them; keys and paths are only meaningful once the - * sibling-uniqueness pass has settled every `_key`, so materializing - * the public report happens afterward, in `buildReconciliationReport`. - * `undefined` when the caller passed no `onReconciliation`, so every - * call site can skip its recording work with a plain optional-chain. - */ -type ReconciliationRecorder = { - preservation: WeakMap< - Node, - {basis: PreservationBasis; storedCounterpart: Node} - > - renamePreviousKey: WeakMap - annotationKeyConflicts: Array - ambiguousRegionGroups: Array> - skip: - | {reason: 'round-trip-mismatch'; mismatch: RoundTripMismatch} - | {reason: 'document-too-large'} - | undefined -} - -type RoundTripMismatch = Extract< - ReconciliationReport, - {reason: 'round-trip-mismatch'} ->['mismatch'] - -/** - * Re-expresses the stored value in the parser's dialect by serializing - * it and parsing it right back: the parser merges same-mark sibling - * spans, reorders marks, collapses list levels, and fills defaults, so - * content the edit never touched only deep-equals its parsed - * counterpart after both sides have been through the same round trip. - * The replacement keys are positional (`__canonical_`), so a match - * against canonical node `n` can be traded back for stored node `n`'s - * real `_key`. - */ -function canonicalizeStored( - stored: ReadonlyArray, - options: ApplyMarkdownEditOptions | undefined, -): {canonical: Array; degradations: Array} { - const storedClone = structuredClone(stored) as Array - const {markdown: storedMarkdown, degradations} = - portableTextToMarkdownWithLostContent(storedClone, { - ...stripOnDegradation(options?.serialize), - schema: options?.schema, - }) - const {onDegradation, ...canonicalDeserializeOptions} = - options?.deserialize ?? {} - let canonicalKeyCounter = 0 - const canonical = markdownToPortableText(storedMarkdown, { - ...canonicalDeserializeOptions, - schema: options?.schema, - keyGenerator: () => `__canonical_${canonicalKeyCounter++}`, - }) as unknown as Array - return {canonical, degradations} -} - -type LostContentDegradation = SerializeDegradation & SerializeDegradationContent - -function stripOnDegradation( - value: T | undefined, -): Omit { - const {onDegradation: _onDegradation, ...rest} = (value ?? {}) as T & { - onDegradation?: unknown - } - return rest -} - -/** - * Positional pairing between `stored` and `canonical` is only - * trustworthy when serialization preserved the node count and the - * type sequence; when it did not (heading hard-break splits, lossy - * table normalization), no key can be traced back to its owner, so - * nothing adopts. `stored` is already the non-empty subsequence: empty - * text blocks have no markdown form, so `canonical` never carries them - * either, and `reinsertEmptyRuns` restores them afterward. - */ -function traceOrigins( - storedPortableText: ReadonlyArray, - stored: ReadonlyArray, - canonical: ReadonlyArray, - recorder: ReconciliationRecorder | undefined, -): ((canonicalIndex: number) => Node) | undefined { - if (canonical.length !== stored.length) { - recordRoundTripMismatch(storedPortableText, stored, canonical, recorder) - return undefined - } - for (let index = 0; index < stored.length; index++) { - if ((stored[index] as Node)['_type'] !== canonical[index]?.['_type']) { - recordRoundTripMismatch(storedPortableText, stored, canonical, recorder) - return undefined - } - } - for (let index = 0; index < stored.length; index++) { - if ( - !textSurvivesRoundTrip( - stored[index] as unknown as Node, - canonical[index]!, - ) - ) { - recordRoundTripMismatch(storedPortableText, stored, canonical, recorder) - return undefined - } - } - return (canonicalIndex: number): Node => - stored[canonicalIndex] as unknown as Node -} - -function textSurvivesRoundTrip(storedNode: Node, canonicalNode: Node): boolean { - if (!(isTextBlock(storedNode) && isTextBlock(canonicalNode))) { - return true - } - // CommonMark trims whitespace on reparse, so text identity compares - // trimmed text. - return blockText(storedNode).trim() === blockText(canonicalNode).trim() -} - -function recordRoundTripMismatch( - storedPortableText: ReadonlyArray, - stored: ReadonlyArray, - canonical: ReadonlyArray, - recorder: ReconciliationRecorder | undefined, -): void { - if (!recorder) { - return - } - const commonLength = Math.min(stored.length, canonical.length) - let failingIndex = 0 - while ( - failingIndex < commonLength && - (stored[failingIndex] as Node)['_type'] === - canonical[failingIndex]!['_type'] && - textSurvivesRoundTrip( - stored[failingIndex] as unknown as Node, - canonical[failingIndex]!, - ) - ) { - failingIndex++ - } - const storedIndexes: Array = [] - storedPortableText.forEach((node, index) => { - if (node === stored[storedIndexes.length]) { - storedIndexes.push(index) - } - }) - const blockSegmentAt = ( - nonEmptyIndex: number, - ): ReconciliationKeyPath[number] => { - const storedIndex = - stored.length === 0 - ? storedPortableText.length - 1 - : storedIndexes[nonEmptyIndex]! - return pathSegment( - storedPortableText[storedIndex] as unknown as Node, - storedIndex, - ) - } - recorder.skip = { - reason: 'round-trip-mismatch', - mismatch: - failingIndex < commonLength - ? positionMismatch( - stored[failingIndex] as unknown as Node, - canonical[failingIndex]!, - blockSegmentAt(failingIndex), - ) - : blockCountMismatch(stored, canonical, blockSegmentAt), - } -} - -function positionMismatch( - storedNode: Node, - canonicalNode: Node, - blockSegment: ReconciliationKeyPath[number], -): RoundTripMismatch { - const storedType = String(storedNode['_type']) - const canonicalType = String(canonicalNode['_type']) - if (storedType !== canonicalType) { - return withSnippet( - { - type: 'type-changed', - storedPath: [blockSegment], - message: `${describeBlock(storedType)} came back as ${withArticle(`\`${canonicalType}\``, canonicalType)}`, - }, - roundTrippedSnippet(canonicalNode), - ) - } - const storedText = blockText(storedNode) - const storedTrimmed = storedText.trim() - const canonicalText = blockText(canonicalNode) - const canonicalTrimmed = canonicalText.trim() - const sharedLength = Math.min(storedTrimmed.length, canonicalTrimmed.length) - let differsAt = 0 - while ( - differsAt < sharedLength && - storedTrimmed[differsAt] === canonicalTrimmed[differsAt] - ) { - differsAt++ - } - if ( - splitsSurrogatePair(storedTrimmed, differsAt) || - splitsSurrogatePair(canonicalTrimmed, differsAt) - ) { - differsAt-- - } - const child = - differsAt < storedTrimmed.length - ? childAtOffset(storedNode, leadingWhitespace(storedText) + differsAt) - : undefined - return withSnippet( - { - type: 'text-changed', - storedPath: child - ? [blockSegment, 'children', pathSegment(child.node, child.index)] - : [blockSegment], - message: child - ? `The block's text came back different, starting at ${describeChildFrom(child)}` - : "The block's text came back with more text at the end", - }, - truncateSnippet( - spanTextFrom( - canonicalNode, - leadingWhitespace(canonicalText) + differsAt, - ).trim(), - ), - ) -} - -function blockCountMismatch( - stored: ReadonlyArray, - canonical: ReadonlyArray, - blockSegmentAt: (nonEmptyIndex: number) => ReconciliationKeyPath[number], -): RoundTripMismatch { - if (stored.length === 0) { - return withSnippet( - { - type: 'block-count-changed', - storedPath: [blockSegmentAt(0)], - message: `The empty paragraphs came back as ${blockCount(canonical.length)}`, - }, - roundTrippedSnippet(canonical[0]!), - ) - } - if (stored.length > canonical.length) { - const firstDropped = stored[canonical.length] as unknown as Node - return { - type: 'block-count-changed', - storedPath: [blockSegmentAt(canonical.length)], - message: `${blockCount(stored.length - canonical.length)} did not come back, starting with ${lowercaseFirst(describeBlock(String(firstDropped['_type'])))}`, - } - } - const lastStored = stored[stored.length - 1] as unknown as Node - return withSnippet( - { - type: 'block-count-changed', - storedPath: [blockSegmentAt(stored.length - 1)], - message: `The round trip added ${blockCount(canonical.length - stored.length)} after ${lowercaseFirst(describeBlock(String(lastStored['_type'])))}`, - }, - roundTrippedSnippet(canonical[stored.length]!), - ) -} - -function childAtOffset( - block: Node, - offset: number, -): {node: Node; index: number; offset: number} | undefined { - const children = block['children'] - if (!isTypedObjectArray(children)) { - return undefined - } - let start = 0 - for (let index = 0; index < children.length; index++) { - const end = start + childText(children[index]!).length - if (offset < end) { - return {node: children[index]!, index, offset: offset - start} - } - start = end - } - return undefined -} - -function describeChildFrom(child: {node: Node; offset: number}): string { - const text = child.node['text'] - return typeof text === 'string' - ? `the text \`${truncateSnippet(text.slice(child.offset))}\`` - : `the \`${String(child.node['_type'])}\` inline object` -} - -function spanTextFrom(block: Node, offset: number): string { - const children = block['children'] - if (!isTypedObjectArray(children)) { - return '' - } - let start = 0 - let text = '' - for (const child of children) { - const childLength = childText(child).length - if (typeof child['text'] === 'string' && start + childLength > offset) { - text += child['text'].slice(Math.max(0, offset - start)) - } - start += childLength - } - return text -} - -function leadingWhitespace(text: string): number { - return text.length - text.trimStart().length -} - -function splitsSurrogatePair(text: string, offset: number): boolean { - return ( - offset > 0 && - isHighSurrogate(text.charCodeAt(offset - 1)) && - isLowSurrogate(text.charCodeAt(offset)) - ) -} - -function isHighSurrogate(codeUnit: number): boolean { - return codeUnit >= 0xd800 && codeUnit <= 0xdbff -} - -function isLowSurrogate(codeUnit: number): boolean { - return codeUnit >= 0xdc00 && codeUnit <= 0xdfff -} - -function pathSegment(node: Node, index: number): ReconciliationKeyPath[number] { - const key = node['_key'] - return typeof key === 'string' ? {_key: key} : index -} - -function roundTrippedSnippet(canonicalNode: Node): string | undefined { - return isTextBlock(canonicalNode) - ? truncateSnippet(spanTextFrom(canonicalNode, 0).trim()) - : undefined -} - -function withSnippet( - mismatch: RoundTripMismatch, - snippet: string | undefined, -): RoundTripMismatch { - return snippet === undefined ? mismatch : {...mismatch, snippet} -} - -function describeBlock(type: string): string { - return type === 'block' ? 'The text block' : `The \`${type}\` block` -} - -function lowercaseFirst(text: string): string { - return `${text.charAt(0).toLowerCase()}${text.slice(1)}` -} - -function withArticle(phrase: string, word: string): string { - return `${/^[aeiou]/i.test(word) ? 'an' : 'a'} ${phrase}` -} - -function blockCount(count: number): string { - return count === 1 ? '1 block' : `${count} blocks` -} - -type Anchor = {canonicalIndex: number; resultIndex: number} -type Gap = {storedIndexes: Array; editedIndexes: Array} - -/** - * Equal runs become anchor pairs (so repeated content pairs - * first-to-first when adopted), and the delete/insert runs between - * them form the gaps. - */ -function alignBlocks( - canonical: ReadonlyArray, - result: ReadonlyArray, - recorder: ReconciliationRecorder | undefined, -): {anchors: Array; gaps: Array} | undefined { - const tokenByNeutral = new Map() - const tokenOf = (node: Node): string => { - const neutral = neutralForm(node) - let token = tokenByNeutral.get(neutral) - if (token === undefined) { - token = String.fromCharCode(tokenByNeutral.size + 1) - tokenByNeutral.set(neutral, token) - } - return token - } - const canonicalTokens = canonical.map(tokenOf).join('') - const resultTokens = result.map(tokenOf).join('') - if (tokenByNeutral.size > MAX_DISTINCT_BLOCK_FORMS) { - if (recorder) { - recorder.skip = {reason: 'document-too-large'} - } - return undefined - } - // The tenth distinct block form draws `'\n'` as its token, and past - // 100 tokens per side `makeDiff`'s line-mode heuristic would split on - // it, making alignment shape depend on which block form drew that - // character. Blocks are already atomic, so line mode adds nothing. - const diffs = makeDiff(canonicalTokens, resultTokens, {checkLines: false}) - - const anchors: Array = [] - const gaps: Array = [] - let gap: Gap = {storedIndexes: [], editedIndexes: []} - const flushGap = () => { - if (gap.storedIndexes.length > 0 || gap.editedIndexes.length > 0) { - gaps.push(gap) - gap = {storedIndexes: [], editedIndexes: []} - } - } - - let canonicalIndex = 0 - let resultIndex = 0 - for (const [operation, text] of diffs) { - if (operation === 0) { - flushGap() - for (let offset = 0; offset < text.length; offset++) { - anchors.push({canonicalIndex, resultIndex}) - canonicalIndex++ - resultIndex++ - } - } else if (operation === -1) { - for (let offset = 0; offset < text.length; offset++) { - gap.storedIndexes.push(canonicalIndex++) - } - } else { - for (let offset = 0; offset < text.length; offset++) { - gap.editedIndexes.push(resultIndex++) - } - } - } - flushGap() - - return {anchors, gaps} -} - -function adoptAnchors( - anchors: ReadonlyArray, - originOf: (canonicalIndex: number) => Node, - result: Array, - adoptedNodes: AdoptedNodes, - recorder: ReconciliationRecorder | undefined, -): void { - for (const anchor of anchors) { - result[anchor.resultIndex] = adoptVerbatim( - originOf(anchor.canonicalIndex), - result[anchor.resultIndex]!, - adoptedNodes, - 'content-unchanged', - recorder, - ) - } -} - -/** - * An anchor or a unique exact leftover pairs a canonical block against - * a parsed one that share the same neutral form (that is what put them - * in the same equal-diff run or the same neutral-form bucket), so - * everything the dialect can express is untouched and everything it - * cannot express was invisible to the edit. The stored subtree is the - * truth at every depth, spans, marks, markDefs, and any field the - * dialect drops, so adoption replaces the whole node rather than - * reconciling into the parsed shape (which would otherwise, for - * instance, keep a parser-side span merge that collapsed an - * unmappable mark boundary the edit never touched). `restoreFields` - * and per-child reconciliation are skipped entirely: a verbatim clone - * of the stored node is already complete. - */ -function adoptVerbatim( - original: Node, - target: Node, - adoptedNodes: AdoptedNodes, - basis: PreservationBasis, - recorder: ReconciliationRecorder | undefined, - filledKeys: 'report-filled-keys' | 'omit-filled-keys' = 'report-filled-keys', -): Node { - const clone = structuredClone(original) - fillMissingKeysFromTarget(clone, target) - markSubtreeAdopted(clone, adoptedNodes) - if (recorder) { - // The subtree tags every keyed node `content-unchanged`, the root - // included. A non-default basis overrides the root only: a moved - // block's children were not themselves moved. - const storedCounterpart = structuredClone(original) - tagSubtreePreservedAgainst( - clone, - storedCounterpart, - recorder, - filledKeys === 'omit-filled-keys', - ) - if (basis !== 'content-unchanged' && typeof clone['_key'] === 'string') { - recorder.preservation.set(clone, {basis, storedCounterpart}) - } - } - return clone -} - -/** - * A stored node practically always carries its own `_key`; when it - * genuinely does not, there is nothing to adopt, so the clone keeps - * whatever key the plain parse already minted at the corresponding - * position, the same key adoption would have left in place. The walk - * follows both trees positionally (not by content matching, which is - * exactly what an exact-signature match already guarantees agrees at - * every position the two sides both still have). - */ -function fillMissingKeysFromTarget(clone: Node, target: Node): void { - if (typeof clone['_key'] !== 'string' && typeof target['_key'] === 'string') { - clone['_key'] = target['_key'] - } - for (const field of Object.keys(clone)) { - const cloneValue = clone[field] - const targetValue = target[field] - if (isTypedObjectArray(cloneValue) && isTypedObjectArray(targetValue)) { - const length = Math.min(cloneValue.length, targetValue.length) - for (let index = 0; index < length; index++) { - fillMissingKeysFromTarget(cloneValue[index]!, targetValue[index]!) - } - } else if ( - typeof cloneValue === 'object' && - cloneValue !== null && - !Array.isArray(cloneValue) && - typeof targetValue === 'object' && - targetValue !== null && - !Array.isArray(targetValue) - ) { - fillMissingKeysFromTarget(cloneValue as Node, targetValue as Node) - } - } -} - -/** - * Every keyed node in a verbatim clone is adopted, not only its root: - * the sibling-key-uniqueness pass recurses into every nested keyed - * array, and an adopted-first ordering there only favors a clone's - * descendants if they are themselves marked adopted. - */ -function markSubtreeAdopted(node: Node, adoptedNodes: AdoptedNodes): void { - adoptedNodes.add(node) - for (const value of Object.values(node)) { - if ( - Array.isArray(value) && - value.every((item) => typeof item === 'object' && item !== null) && - value.length > 0 - ) { - for (const child of value as Array) { - markSubtreeAdopted(child, adoptedNodes) - } - } else if (typeof value === 'object' && value !== null) { - markSubtreeAdopted(value as Node, adoptedNodes) - } - } -} - -/** - * Moves: content that left one gap and reappeared in another. Unique - * exact pairs across all gaps adopt before any gap-local pairing can - * consume the keys they need. - */ -function adoptMoves( - gaps: ReadonlyArray, - canonical: ReadonlyArray, - result: Array, - originOf: (canonicalIndex: number) => Node, - adoptedNodes: AdoptedNodes, - recorder: ReconciliationRecorder | undefined, -): Array { - const consumedStored = new Set() - const consumedEdited = new Set() - const storedLeftovers = gaps.flatMap((g) => g.storedIndexes) - const editedLeftovers = gaps.flatMap((g) => g.editedIndexes) - const storedByNeutral = new Map>() - for (const index of storedLeftovers) { - const neutral = neutralForm(canonical[index]!) - storedByNeutral.set(neutral, [ - ...(storedByNeutral.get(neutral) ?? []), - index, - ]) - } - const editedByNeutral = new Map>() - for (const index of editedLeftovers) { - const neutral = neutralForm(result[index]!) - editedByNeutral.set(neutral, [ - ...(editedByNeutral.get(neutral) ?? []), - index, - ]) - } - for (const [neutral, storedIndexes] of storedByNeutral) { - const editedIndexes = editedByNeutral.get(neutral) - if ( - storedIndexes.length !== 1 || - !editedIndexes || - editedIndexes.length !== 1 - ) { - continue - } - consumedStored.add(storedIndexes[0]!) - consumedEdited.add(editedIndexes[0]!) - const clone = adoptVerbatim( - originOf(storedIndexes[0]!), - result[editedIndexes[0]!]!, - adoptedNodes, - 'content-moved', - recorder, - ) - result[editedIndexes[0]!] = clone - } - - return gaps.map((currentGap) => ({ - storedIndexes: currentGap.storedIndexes.filter( - (index) => !consumedStored.has(index), - ), - editedIndexes: currentGap.editedIndexes.filter( - (index) => !consumedEdited.has(index), - ), - })) -} - -/** - * Gap policy, in order: split/merge survivor (the first fragment or - * first source block keeps the key, matching what pressing enter or - * backspace does in the editor), positional zip for equal counts - * (typing over a paragraph keeps its identity), similarity for - * unequal counts (an insertion or deletion shifted positions, so - * position lies and only mutual unique best evidence adopts). - */ -function resolveGap( - storedIndexes: Array, - editedIndexes: Array, - canonical: ReadonlyArray, - result: ReadonlyArray, - originOf: (canonicalIndex: number) => Node, - adoptedNodes: AdoptedNodes, - recorder: ReconciliationRecorder | undefined, -): void { - const remainingStored = new Set(storedIndexes) - const remainingEdited = new Set(editedIndexes) - - // The concatenation search below is quadratic in the gap's size, so - // it shares the similarity tier's pair cap: past it, splits and - // merges get skipped too rather than adopted at tens-of-seconds cost. - const withinConcatenationCap = - storedIndexes.length * editedIndexes.length <= MAX_SIMILARITY_PAIRS - - // Splits: one stored block's text equals the concatenation of - // adjacent edited blocks. - if (withinConcatenationCap) { - for (const storedIndex of storedIndexes) { - if (!remainingStored.has(storedIndex)) { - continue - } - const storedBlock = canonical[storedIndex]! - if (!isTextBlock(storedBlock)) { - continue - } - const fragments = findConcatenation( - blockText(storedBlock), - editedIndexes.filter((index) => remainingEdited.has(index)), - result, - ) - if (fragments) { - remainingStored.delete(storedIndex) - for (const fragment of fragments) { - remainingEdited.delete(fragment) - } - const survivor = - fragments.find( - (fragment) => blockText(result[fragment]!).length > 0, - ) ?? fragments[0]! - adoptNode( - originOf(storedIndex), - canonical[storedIndex], - result[survivor]!, - adoptedNodes, - 'content-split', - recorder, - ) - } - } - } - - // Merges: one edited block's text equals the concatenation of - // adjacent stored blocks. The first source block survives. - if (withinConcatenationCap) { - for (const editedIndex of editedIndexes) { - if (!remainingEdited.has(editedIndex)) { - continue - } - const editedBlock = result[editedIndex]! - if (!isTextBlock(editedBlock)) { - continue - } - const sources = findConcatenation( - blockText(editedBlock), - storedIndexes.filter((index) => remainingStored.has(index)), - canonical, - ) - if (sources) { - remainingEdited.delete(editedIndex) - for (const source of sources) { - remainingStored.delete(source) - } - adoptNode( - originOf(sources[0]!), - canonical[sources[0]!], - result[editedIndex]!, - adoptedNodes, - 'content-merged', - recorder, - ) - } - } - } - - const storedRest = [...remainingStored] - const editedRest = [...remainingEdited] - - if (storedRest.length === editedRest.length) { - for (let offset = 0; offset < storedRest.length; offset++) { - const storedBlock = canonical[storedRest[offset]!]! - const editedBlock = result[editedRest[offset]!]! - if (storedBlock['_type'] === editedBlock['_type']) { - adoptNode( - originOf(storedRest[offset]!), - canonical[storedRest[offset]!], - editedBlock, - adoptedNodes, - 'same-position', - recorder, - ) - } - } - return - } - - if (storedRest.length * editedRest.length > MAX_SIMILARITY_PAIRS) { - if (recorder) { - recorder.ambiguousRegionGroups.push( - editedRest.map((editedIndex) => result[editedIndex]!), - ) - } - return - } - - const scores = new Map() - for (const storedIndex of storedRest) { - for (const editedIndex of editedRest) { - const score = blockSimilarity( - canonical[storedIndex]!, - result[editedIndex]!, - ) - if (score >= MIN_BLOCK_SIMILARITY) { - scores.set(`${storedIndex}:${editedIndex}`, score) - } - } - } - for (const storedIndex of storedRest) { - const best = uniqueBest(editedRest, (editedIndex) => - scores.get(`${storedIndex}:${editedIndex}`), - ) - if (best === undefined) { - continue - } - const bestBack = uniqueBest(storedRest, (otherStoredIndex) => - scores.get(`${otherStoredIndex}:${best}`), - ) - if (bestBack !== storedIndex) { - continue - } - adoptNode( - originOf(storedIndex), - canonical[storedIndex], - result[best]!, - adoptedNodes, - 'similar-content', - recorder, - ) - } -} - -/** - * Fragments join with nothing or a single space, since a markdown - * merge is often a soft-wrap join that inserts one ("alpha\nbeta" - * parses to "alpha beta"). - */ -function findConcatenation( - wholeText: string, - candidateIndexes: Array, - nodes: ReadonlyArray, -): Array | undefined { - if (wholeText.length === 0) { - return undefined - } - for (const joiner of ['', ' ']) { - for (let start = 0; start < candidateIndexes.length; start++) { - let concatenated = '' - const used: Array = [] - for ( - let position = start; - position < candidateIndexes.length; - position++ - ) { - const index = candidateIndexes[position]! - if (position > start && candidateIndexes[position - 1] !== index - 1) { - break - } - if (!isTextBlock(nodes[index]!)) { - break - } - concatenated = - used.length === 0 - ? blockText(nodes[index]!) - : concatenated + joiner + blockText(nodes[index]!) - used.push(index) - if (concatenated.length > wholeText.length) { - break - } - if (concatenated === wholeText && used.length > 1) { - return used - } - } - } - } - return undefined -} - -/** - * Adopts the original node's `_key`, its markdown-inexpressible - * fields, then its `markDefs` before its other keyed children, since - * `span.marks` references need the adopted `markDefs` keys already in - * place. - */ -function adoptNode( - original: Node, - canonicalCounterpart: Node | undefined, - target: Node, - adoptedNodes: AdoptedNodes, - basis: PreservationBasis, - recorder: ReconciliationRecorder | undefined, -): void { - adoptedNodes.add(target) - if (typeof original['_key'] === 'string') { - target['_key'] = original['_key'] - recorder?.preservation.set(target, {basis, storedCounterpart: original}) - } - - restoreFields(original, canonicalCounterpart, target) - - const markDefKeyMap = adoptMarkDefs( - original, - canonicalCounterpart, - target, - recorder, - ) - rewriteMarkReferences(target, markDefKeyMap) - - for (const field of Object.keys(target)) { - if (field === 'markDefs') { - continue - } - const originalChildren = original[field] - const targetChildren = target[field] - if ( - !isTypedObjectArray(originalChildren) || - !isTypedObjectArray(targetChildren) - ) { - continue - } - const canonicalChildren = canonicalChildArray( - canonicalCounterpart, - field, - originalChildren, - ) - - const matchedOriginal = new Set() - const matchedTarget = new Set() - const isSpanLevel = field === 'children' - // Children reference their parent block's `markDefs`, so their - // neutral forms alias marks against the parent, not themselves. - const originalGroups = isSpanLevel - ? groupByNeutralForm( - originalChildren, - matchedOriginal, - buildAliasMap(original), - ) - : groupByForm( - originalChildren.map( - (child, index) => canonicalChildren?.[index] ?? child, - ), - matchedOriginal, - adoptionForm, - ) - const targetGroups = isSpanLevel - ? groupByNeutralForm(targetChildren, matchedTarget, buildAliasMap(target)) - : groupByForm(targetChildren, matchedTarget, adoptionForm) - - for (const [form, originalIndexes] of originalGroups) { - const targetIndexes = targetGroups.get(form) - if ( - originalIndexes.length !== 1 || - !targetIndexes || - targetIndexes.length !== 1 - ) { - continue - } - const originalIndex = originalIndexes[0]! - const targetIndex = targetIndexes[0]! - matchedOriginal.add(originalIndex) - matchedTarget.add(targetIndex) - if (isSpanLevel) { - // A verbatim span would keep stored `marks` keys that point at - // `markDefs` this edited block may not have adopted. - adoptNode( - originalChildren[originalIndex]!, - canonicalChildren?.[originalIndex], - targetChildren[targetIndex]!, - adoptedNodes, - 'content-unchanged', - recorder, - ) - } else { - targetChildren[targetIndex] = adoptVerbatim( - originalChildren[originalIndex]!, - targetChildren[targetIndex]!, - adoptedNodes, - 'content-unchanged', - recorder, - 'omit-filled-keys', - ) - } - } - - // The concatenation search is quadratic in the block's span count, - // so it shares the similarity tier's pair cap: past it, the merge - // search is skipped and the unmatched spans fall through to fresh - // keys rather than adopting at tens-of-seconds cost. - if ( - field === 'children' && - originalChildren.length * targetChildren.length <= MAX_SIMILARITY_PAIRS - ) { - adoptMergedSpans( - originalChildren, - canonicalChildren, - matchedOriginal, - targetChildren, - matchedTarget, - adoptedNodes, - recorder, - ) - } - - adoptResidualZip( - originalChildren, - canonicalChildren, - matchedOriginal, - targetChildren, - matchedTarget, - adoptedNodes, - recorder, - ) - } -} - -/** - * The container-level counterpart of `traceOrigins`'s guard: a - * child's canonical form is only trustworthy when the canonical - * container holds the same field as the same typed object array, - * equal in length and `_type` sequence to the original's, so pairing - * by index (original child `i` to canonical child `i`) means the same - * content on both sides. - */ -function canonicalChildArray( - canonicalCounterpart: Node | undefined, - field: string, - originalChildren: ReadonlyArray, -): Array | undefined { - if (!canonicalCounterpart) { - return undefined - } - const candidate = canonicalCounterpart[field] - if ( - !isTypedObjectArray(candidate) || - candidate.length !== originalChildren.length - ) { - return undefined - } - for (let index = 0; index < originalChildren.length; index++) { - if (originalChildren[index]!['_type'] !== candidate[index]!['_type']) { - return undefined - } - } - return candidate -} - -/** - * Restores fields the markdown dialect dropped or altered: present on - * the original, unchanged by the edit (the target still agrees with - * the original's own canonical round trip), and different on that - * round trip from the original (so the parse, left to itself, could - * never have produced the original's value). Absence counts as a - * value under both comparisons, which is what folds a wholly dropped - * field (no markdown form at all) and a coerced one (a custom style - * or decorator markdown silently maps to its closest built-in) into - * one rule. Structural child arrays (`children`, `markDefs`, and - * typed object arrays generally) are excluded: their elements adopt - * individually through the recursive per-child walk instead, except - * when the target has no such element to walk at all: a typed-object - * array field the dialect drops entirely leaves nothing on the target - * side for that walk to reconcile, so it falls through to the same - * absent-on-target, absent-on-canonical oracle as every scalar field, - * restored verbatim rather than left missing. Restored values are - * cloned, since the sibling-key-uniqueness pass may rewrite `_key`s - * inside a restored array of objects, and the original must stay - * untouched. - */ -function restoreFields( - original: Node, - canonicalCounterpart: Node | undefined, - target: Node, -): void { - if (!canonicalCounterpart) { - return - } - for (const field of Object.keys(original)) { - if ( - field === '_key' || - field === '_type' || - field === 'markDefs' || - field === 'children' - ) { - continue - } - if (isTypedObjectArray(original[field])) { - if ( - target[field] === undefined && - canonicalCounterpart[field] === undefined - ) { - target[field] = structuredClone(original[field]) - } - continue - } - const canonicalValue = canonicalCounterpart[field] - if (!valuesEqual(target[field], canonicalValue)) { - continue - } - if (valuesEqual(canonicalValue, original[field])) { - continue - } - target[field] = structuredClone(original[field]) - } -} - -/** - * Deep equality for restoration's before/after comparison: the - * `encodeNeutral` encoding without alias rewriting, so it compares - * `marks` arrays (and any other array or nested object) by value - * rather than by identity. Absent (`undefined`) encodes to the same - * string on both sides, so two absent fields count as equal. - */ -function valuesEqual(a: unknown, b: unknown): boolean { - return encodeNeutral(a, undefined) === encodeNeutral(b, undefined) -} - -/** - * A span in the edited output can be the merge of several stored - * spans: the parser merges adjacent same-mark spans, and an edit that - * removes formatting merges across the old mark boundary too. Matching - * is by text alone, and the first contributor's key survives, matching - * the editor's own span-merge normalization. - */ -function adoptMergedSpans( - originalChildren: ReadonlyArray, - canonicalChildren: ReadonlyArray | undefined, - matchedOriginal: Set, - targetChildren: ReadonlyArray, - matchedTarget: Set, - adoptedNodes: AdoptedNodes, - recorder: ReconciliationRecorder | undefined, -): void { - for ( - let targetIndex = 0; - targetIndex < targetChildren.length; - targetIndex++ - ) { - if (matchedTarget.has(targetIndex)) { - continue - } - const targetSpan = targetChildren[targetIndex]! - const targetText = targetSpan['text'] - if (typeof targetText !== 'string') { - continue - } - - for (let start = 0; start < originalChildren.length; start++) { - if (matchedOriginal.has(start)) { - continue - } - let concatenated = '' - const used: Array = [] - for (let index = start; index < originalChildren.length; index++) { - if (matchedOriginal.has(index)) { - break - } - const originalSpan = originalChildren[index]! - if (typeof originalSpan['text'] !== 'string') { - break - } - concatenated += originalSpan['text'] - used.push(index) - if (concatenated.length > targetText.length) { - break - } - if (concatenated === targetText && used.length > 1) { - matchedTarget.add(targetIndex) - for (const usedIndex of used) { - matchedOriginal.add(usedIndex) - } - adoptNode( - originalChildren[used[0]!]!, - canonicalChildren?.[used[0]!], - targetSpan, - adoptedNodes, - 'content-merged', - recorder, - ) - break - } - } - if (matchedTarget.has(targetIndex)) { - break - } - } - } -} - -/** - * The equal-count residual rule, mirroring `resolveGap`'s positional - * zip: elements left unmatched after neutral-form (and, for - * `children`, span-merge) matching are treated as in-place edits when - * both sides leave the same count, position being the same evidence - * the block-level zip already trusts, and the trade is the same too: - * a reorder-plus-edit with balanced counts mispairs. Unequal counts - * adopt nothing, since position no longer lines up. - */ -function adoptResidualZip( - originalChildren: ReadonlyArray, - canonicalChildren: ReadonlyArray | undefined, - matchedOriginal: ReadonlySet, - targetChildren: ReadonlyArray, - matchedTarget: ReadonlySet, - adoptedNodes: AdoptedNodes, - recorder: ReconciliationRecorder | undefined, -): void { - const originalRest = originalChildren - .map((node, index) => ({node, index})) - .filter(({index}) => !matchedOriginal.has(index)) - const targetRest = targetChildren.filter( - (_, index) => !matchedTarget.has(index), - ) - if (originalRest.length !== targetRest.length) { - return - } - for (let offset = 0; offset < originalRest.length; offset++) { - const originalNode = originalRest[offset]!.node - const targetNode = targetRest[offset]! - if (originalNode['_type'] === targetNode['_type']) { - adoptNode( - originalNode, - canonicalChildren?.[originalRest[offset]!.index], - targetNode, - adoptedNodes, - 'same-position', - recorder, - ) - } - } -} - -/** - * Matches `markDefs` by definition content, `_key` excluded. Returns - * the mapping from the target's fresh keys to the adopted stored - * keys, for rewriting `span.marks` references. - */ -function adoptMarkDefs( - original: Node, - canonicalCounterpart: Node | undefined, - target: Node, - recorder: ReconciliationRecorder | undefined, -): Map { - const keyMap = new Map() - const originalDefs = original['markDefs'] - const targetDefs = target['markDefs'] - if (!isTypedObjectArray(originalDefs) || !isTypedObjectArray(targetDefs)) { - return keyMap - } - const canonicalDefs = canonicalChildArray( - canonicalCounterpart, - 'markDefs', - originalDefs, - ) - - const matchedOriginal = new Set() - const matchedTarget = new Set() - // Stored definitions can carry fields the dialect drops (restored - // later from the stored side), which the parsed definitions never - // have; fingerprinting the stored side via its canonical twin keeps - // the two sides' fingerprints dialect-consistent. - const originalFingerprintSource = originalDefs.map( - (def, index) => canonicalDefs?.[index] ?? def, - ) - const originalGroups = groupByNeutralForm( - originalFingerprintSource, - matchedOriginal, - ) - const targetGroups = groupByNeutralForm(targetDefs, matchedTarget) - - const adoptDef = ( - originalDef: Node, - canonicalDef: Node | undefined, - targetDef: Node, - basis: PreservationBasis, - ): void => { - restoreFields(originalDef, canonicalDef, targetDef) - if ( - typeof originalDef['_key'] === 'string' && - typeof targetDef['_key'] === 'string' - ) { - const adoptedKey = originalDef['_key'] - const collidesWithSibling = targetDefs.some( - (def) => def !== targetDef && def['_key'] === adoptedKey, - ) - if (collidesWithSibling) { - recorder?.annotationKeyConflicts.push(targetDef) - return - } - keyMap.set(targetDef['_key'], adoptedKey) - targetDef['_key'] = adoptedKey - recorder?.preservation.set(targetDef, { - basis, - storedCounterpart: originalDef, - }) - } - } - - for (const [neutral, originalIndexes] of originalGroups) { - const targetIndexes = targetGroups.get(neutral) - if (!targetIndexes || targetIndexes.length !== originalIndexes.length) { - continue - } - // Equal-count identical definitions pair in order, the repeated-content - // policy: the definitions are content-equal, so no pairing can - // mis-resolve a reference. - for (let offset = 0; offset < originalIndexes.length; offset++) { - matchedOriginal.add(originalIndexes[offset]!) - matchedTarget.add(targetIndexes[offset]!) - adoptDef( - originalDefs[originalIndexes[offset]!]!, - canonicalDefs?.[originalIndexes[offset]!], - targetDefs[targetIndexes[offset]!]!, - originalIndexes.length === 1 ? 'content-unchanged' : 'same-position', - ) - } - } - - const originalRest = originalDefs - .map((node, index) => ({node, index})) - .filter(({index}) => !matchedOriginal.has(index)) - const targetRest = targetDefs.filter((_, index) => !matchedTarget.has(index)) - if ( - originalRest.length === 1 && - targetRest.length === 1 && - originalRest[0]!.node['_type'] === targetRest[0]!['_type'] - ) { - adoptDef( - originalRest[0]!.node, - canonicalDefs?.[originalRest[0]!.index], - targetRest[0]!, - 'same-position', - ) - } - - return keyMap -} - -function rewriteMarkReferences(block: Node, keyMap: Map): void { - if (keyMap.size === 0) { - return - } - const children = block['children'] - if (!isTypedObjectArray(children)) { - return - } - for (const child of children) { - const marks = child['marks'] - if (!Array.isArray(marks)) { - continue - } - child['marks'] = marks.map((mark) => - typeof mark === 'string' && keyMap.has(mark) ? keyMap.get(mark) : mark, - ) - } -} - -function groupByNeutralForm( - nodes: ReadonlyArray, - exclude: ReadonlySet, - aliasByKey?: Map, -): Map> { - return groupByForm(nodes, exclude, (node) => - aliasByKey ? encodeNeutral(node, aliasByKey) : neutralForm(node), - ) -} - -function groupByForm( - nodes: ReadonlyArray, - exclude: ReadonlySet, - formOf: (node: Node) => string, -): Map> { - const groups = new Map>() - for (let index = 0; index < nodes.length; index++) { - if (exclude.has(index)) { - continue - } - const form = formOf(nodes[index]!) - const group = groups.get(form) - if (group) { - group.push(index) - } else { - groups.set(form, [index]) - } - } - return groups -} - -/** - * A stored child whose adoption form equals the parse's can be - * returned verbatim. Only keys adoption reconciles are identity: the - * node's own, those of typed object array members reached through - * typed object arrays, and a definition's own `_key` (references - * compare through `marks` instead). `adoptMarkDefs` does not reconcile - * inside a definition, and nothing reconciles below any other field, so - * every `_key` there is content an edit can change. - */ -function adoptionForm(node: Node): string { - return encodeAdoption(node, 'identity', undefined) -} - -type KeyRole = 'identity' | 'definition' | 'content' - -function encodeAdoption( - value: unknown, - keyRole: KeyRole, - aliasByKey: Map | undefined, -): string { - if (Array.isArray(value)) { - return `[${value - .map((item) => encodeAdoption(item, keyRole, aliasByKey)) - .join(',')}]` - } - if (typeof value !== 'object' || value === null) { - return JSON.stringify(value) ?? 'undefined' - } - const node = value as Node - const ownAliasByKey = - keyRole === 'identity' - ? (buildAdoptionAliasMap(node) ?? aliasByKey) - : undefined - const entries = Object.entries(node) - .filter(([field]) => !(keyRole !== 'content' && field === '_key')) - .sort(([a], [b]) => (a < b ? -1 : a > b ? 1 : 0)) - .map(([field, fieldValue]) => { - if (field === 'marks' && ownAliasByKey && Array.isArray(fieldValue)) { - const tagged = fieldValue.map((mark) => - typeof mark === 'string' && ownAliasByKey.has(mark) - ? ownAliasByKey.get(mark) - : ['m', mark], - ) - return `${JSON.stringify(field)}:${JSON.stringify(tagged)}` - } - return `${JSON.stringify(field)}:${encodeAdoption( - fieldValue, - childKeyRole(keyRole, field, fieldValue), - ownAliasByKey, - )}` - }) - return `{${entries.join(',')}}` -} - -function childKeyRole( - parentRole: KeyRole, - field: string, - fieldValue: unknown, -): KeyRole { - if (parentRole !== 'identity' || !isTypedObjectArray(fieldValue)) { - return 'content' - } - return field === 'markDefs' ? 'definition' : 'identity' -} - -function buildAdoptionAliasMap( - node: Node, -): Map | undefined { - const markDefs = node['markDefs'] - if (!isTypedObjectArray(markDefs)) { - return undefined - } - const aliasByKey = new Map() - const occurrenceByForm = new Map() - for (const definition of markDefs) { - const key = definition['_key'] - if (typeof key !== 'string') { - continue - } - const form = encodeAdoption(definition, 'definition', undefined) - const occurrence = occurrenceByForm.get(form) ?? 0 - occurrenceByForm.set(form, occurrence + 1) - aliasByKey.set(key, ['a', form, occurrence]) - } - return aliasByKey -} - -/** - * A reference encodes as `['a', form, occurrence]` and a literal mark - * as `['m', mark]`, so no decorator string can spell a reference. - */ -type AdoptionMarkAlias = ['a', string, number] - -const INLINE_OBJECT_SENTINEL = '\uFFFC' - -/** - * The block's comparison text: concatenated span text with inline - * objects as sentinels. Marks are ignored, since a formatting-only - * edit does not change textual identity. - */ -function blockText(block: Node): string { - const children = block['children'] - if (!isTypedObjectArray(children)) { - return '' - } - return children.map(childText).join('') -} - -function childText(child: Node): string { - return typeof child['text'] === 'string' - ? child['text'] - : INLINE_OBJECT_SENTINEL -} - -/** - * Text similarity in `[0, 1]`, gated to `0` unless the block shells - * agree and the inline objects are compatible: however alike the - * prose, blocks that disagree on structure are not the same block. - * The length prescreen skips the diff when the size difference alone - * puts the score under `MIN_BLOCK_SIMILARITY`. - */ -function blockSimilarity(canonicalBlock: Node, resultBlock: Node): number { - if (!shellEquals(canonicalBlock, resultBlock)) { - return 0 - } - if (!inlineObjectsCompatible(canonicalBlock, resultBlock)) { - return 0 - } - const canonicalText = blockText(canonicalBlock) - const resultText = blockText(resultBlock) - if (canonicalText.length === 0 || resultText.length === 0) { - return 0 - } - const longer = Math.max(canonicalText.length, resultText.length) - const lengthBound = - 1 - Math.abs(canonicalText.length - resultText.length) / longer - if (lengthBound < MIN_BLOCK_SIMILARITY) { - return 0 - } - const diffs = cleanupEfficiency( - makeDiff(canonicalText, resultText, { - timeout: SIMILARITY_DIFF_TIMEOUT_SECONDS, - }), - ) - return 1 - levenshteinFromDiffs(diffs) / longer -} - -/** - * The distance derivation from diff runs: insertions and deletions - * between equality runs accumulate as the larger of the two, so a - * delete-plus-insert counts as one substitution. - */ -function levenshteinFromDiffs(diffs: ReadonlyArray<[number, string]>): number { - let distance = 0 - let insertions = 0 - let deletions = 0 - for (const [operation, text] of diffs) { - if (operation === 1) { - insertions += text.length - } else if (operation === -1) { - deletions += text.length - } else { - distance += Math.max(insertions, deletions) - insertions = 0 - deletions = 0 - } - } - return distance + Math.max(insertions, deletions) -} - -/** - * The block minus its content: `_type`, `style`, `listItem`, `level`, - * and any custom fields must agree before text similarity means - * anything. - */ -function shellEquals(a: Node, b: Node): boolean { - const shellOf = (node: Node): string => - encodeNeutral( - Object.fromEntries( - Object.entries(node).filter( - ([field]) => field !== 'children' && field !== 'markDefs', - ), - ), - undefined, - ) - return shellOf(a) === shellOf(b) -} - -/** - * Inline objects are opaque content: two blocks whose objects differ - * are different blocks no matter how similar their prose is. - */ -function inlineObjectsCompatible(a: Node, b: Node): boolean { - const objectsOf = (node: Node): Array => { - const children = node['children'] - if (!isTypedObjectArray(children)) { - return [] - } - return children.filter((child) => typeof child['text'] !== 'string') - } - const aObjects = objectsOf(a) - const bObjects = objectsOf(b) - if (aObjects.length !== bObjects.length) { - return false - } - return aObjects.every((aObject, index) => { - const bObject = bObjects[index]! - if (aObject['_type'] !== bObject['_type']) { - return false - } - if ( - typeof aObject['_key'] === 'string' && - aObject['_key'] === bObject['_key'] - ) { - return true - } - return neutralForm(aObject) === neutralForm(bObject) - }) -} - -/** - * The highest-scoring candidate, or `undefined` on a tie: a tie is - * ambiguity, and ambiguity refuses adoption rather than guessing. - */ -function uniqueBest( - candidates: ReadonlyArray, - scoreOf: (candidate: number) => number | undefined, -): number | undefined { - let best: number | undefined - let bestScore = 0 - let tied = false - for (const candidate of candidates) { - const score = scoreOf(candidate) - if (score === undefined) { - continue - } - if (score > bestScore) { - best = candidate - bestScore = score - tied = false - } else if (score === bestScore && best !== undefined) { - tied = true - } - } - return tied ? undefined : best -} - -/** - * Deliberately loose: any node with a `children` array reconciles like - * a text block, custom block types included. - */ -function isTextBlock(node: Node): boolean { - return Array.isArray(node['children']) -} - -/** - * A text block the round trip drops. Whether a whitespace-only block - * survives serialize→parse depends on the converters, not on - * structure: a heading renders `## ` and survives, a custom style - * falls back to a plain paragraph and vanishes, and a non-breaking - * space renders "blank-looking" output the parser still keeps (JS - * `trim` folds NBSP, CommonMark does not), so any string check here is - * a hand-written mirror of one converter or the other that drifts. The - * block's own render-then-reparse is the authority, the same round - * trip `canonicalizeStored` performs, so this predicate cannot - * disagree with the canonical node count. The JS-trim pre-check only - * keeps the per-block round trip off paths that cannot qualify: it - * over-admits candidates (NBSP text passes it), and the reparse then - * decides. - */ -function isEmptyTextBlock( - node: Node, - options: ApplyMarkdownEditOptions | undefined, -): boolean { - if (!(isTextBlock(node) && blockText(node).trim() === '')) { - return false - } - const rendered = portableTextToMarkdown( - [structuredClone(node)] as unknown as Array, - {...stripOnDegradation(options?.serialize), schema: options?.schema}, - ) - if (rendered === '') { - return true - } - const {onDegradation: _onDegradation, ...deserializeOptions} = - options?.deserialize ?? {} - let probeKeyCounter = 0 - return ( - markdownToPortableText(rendered, { - ...deserializeOptions, - schema: options?.schema, - keyGenerator: () => `empty-probe-${probeKeyCounter++}`, - }).length === 0 - ) -} - -/** - * Blank lines are markdown's block separator, so an empty text block - * has no serialized form: `canonicalizeStored`'s round trip drops it, - * the same way `markdownToPortableText` would if it were parsed back - * from `editedMarkdown`. Tracing origins and aligning blocks over this - * subsequence keeps both sides the same length; `reinsertEmptyRuns` - * restores the dropped blocks afterward. - */ -function nonEmptyBlocks( - stored: ReadonlyArray, - options: ApplyMarkdownEditOptions | undefined, -): Array { - return stored.filter( - (node) => !isEmptyTextBlock(node as unknown as Node, options), - ) -} - -type EmptyRun = { - anchorKey: string - insertAfter: boolean - blocks: Array -} - -/** - * Maximal runs of empty text blocks, each paired with the surviving - * neighbor its restoration hangs off: a run with a preceding block - * anchors to it (insert after); a run at the document's start, with - * none, anchors to the block that follows it (insert before). A run - * with neither (the whole document is empty blocks) has nothing to - * anchor to and is dropped. - */ -function findEmptyRuns( - stored: ReadonlyArray, - options: ApplyMarkdownEditOptions | undefined, -): Array { - const runs: Array = [] - let index = 0 - while (index < stored.length) { - if (!isEmptyTextBlock(stored[index] as unknown as Node, options)) { - index++ - continue - } - const runStart = index - while ( - index < stored.length && - isEmptyTextBlock(stored[index] as unknown as Node, options) - ) { - index++ - } - const precedingBlock = - runStart > 0 ? (stored[runStart - 1] as unknown as Node) : undefined - const followingBlock = - index < stored.length ? (stored[index] as unknown as Node) : undefined - const anchor = precedingBlock ?? followingBlock - if (anchor && typeof anchor['_key'] === 'string') { - runs.push({ - anchorKey: anchor['_key'], - insertAfter: precedingBlock !== undefined, - blocks: stored.slice(runStart, index) as unknown as Array, - }) - } - } - return runs -} - -/** - * Restores each empty run next to the result node that adopted its - * anchor's key, found by `_key` since positions have already shifted - * under insertion, deletion, and move. An anchor whose key did not - * survive into `result` (its region was rewritten or deleted) drops - * the run with it, consistent with rewrite semantics elsewhere in this - * module. Runs are cloned and marked adopted, the same authoritative - * status as every other restored key, so a collision resolves in - * their favor like `enforceSiblingKeyUniqueness` already does for - * `json:object` duplicates. - */ -function reinsertEmptyRuns( - stored: ReadonlyArray, - result: Array, - adoptedNodes: AdoptedNodes, - options: ApplyMarkdownEditOptions | undefined, - recorder: ReconciliationRecorder | undefined, -): void { - for (const run of findEmptyRuns(stored, options)) { - // A pasted `json:object` duplicate can wear the anchor's key at an - // earlier position until the uniqueness pass renames it, so an - // adopted bearer of the key outranks a verbatim duplicate. - const adoptedAnchorIndex = result.findIndex( - (node) => node['_key'] === run.anchorKey && adoptedNodes.has(node), - ) - const anchorIndex = - adoptedAnchorIndex !== -1 - ? adoptedAnchorIndex - : result.findIndex((node) => node['_key'] === run.anchorKey) - if (anchorIndex === -1) { - continue - } - const clones = run.blocks.map((block) => structuredClone(block)) - for (const clone of clones) { - adoptedNodes.add(clone) - if (recorder) { - tagSubtreePreserved(clone, recorder) - } - } - result.splice(run.insertAfter ? anchorIndex + 1 : anchorIndex, 0, ...clones) - } -} - -/** - * Every keyed node in a reinserted empty run is the stored value - * verbatim, at every depth, so key resolution reporting tags the - * whole subtree `content-unchanged` rather than only the run's top - * block. - */ -function tagSubtreePreserved( - node: Node, - recorder: ReconciliationRecorder, -): void { - tagSubtreePreservedAgainst(node, structuredClone(node), recorder) -} - -function tagSubtreePreservedAgainst( - node: Node, - counterpart: Node, - recorder: ReconciliationRecorder, - storedKeysOnly = false, -): void { - if ( - typeof node['_key'] === 'string' && - (!storedKeysOnly || typeof counterpart['_key'] === 'string') - ) { - recorder.preservation.set(node, { - basis: 'content-unchanged', - storedCounterpart: counterpart, - }) - } - for (const [field, value] of Object.entries(node)) { - const counterpartValue = counterpart[field] - if ( - Array.isArray(value) && - value.every((item) => typeof item === 'object' && item !== null) && - value.length > 0 - ) { - const counterpartChildren = counterpartValue as Array - value.forEach((child, index) => { - tagSubtreePreservedAgainst( - child as Node, - counterpartChildren[index]!, - recorder, - storedKeysOnly, - ) - }) - } else if (typeof value === 'object' && value !== null) { - tagSubtreePreservedAgainst( - value as Node, - counterpartValue as Node, - recorder, - storedKeysOnly, - ) - } - } -} - -/** - * `json:object` payloads transport their `_key` verbatim, so a - * copy-pasted fence puts the same key on two siblings, and everything - * downstream (patches, anchors, editor normalization) assumes sibling - * keys are unique. Adopted keys are authoritative, so a duplicate that - * was adopted from the stored value wins and the other occurrences are - * regenerated. - */ -function enforceSiblingKeyUniqueness( - nodes: Array, - keyGenerator: () => string, - adoptedNodes: AdoptedNodes, - recorder: ReconciliationRecorder | undefined, - onKeyRewritten?: (oldKey: string, newKey: string) => void, -): void { - const usedKeys = new Set() - // A fresh key must not take a key a later sibling keeps, or that - // sibling would be the one renamed when its turn comes. - const siblingKeys = new Set( - nodes - .map((node) => node['_key']) - .filter((key): key is string => typeof key === 'string'), - ) - const isTaken = (key: string) => usedKeys.has(key) || siblingKeys.has(key) - - const claim = (node: Node): void => { - const key = node['_key'] - if (typeof key !== 'string') { - return - } - if (usedKeys.has(key)) { - let freshKey: string | undefined - for (let attempt = 0; attempt < MAX_KEY_GENERATOR_ATTEMPTS; attempt++) { - const candidate = keyGenerator() - if (!isTaken(candidate)) { - freshKey = candidate - break - } - } - if (freshKey === undefined) { - let suffix = 1 - let candidate = `${key}_${suffix}` - while (isTaken(candidate)) { - suffix++ - candidate = `${key}_${suffix}` - } - freshKey = candidate - } - node['_key'] = freshKey - usedKeys.add(freshKey) - onKeyRewritten?.(key, freshKey) - recorder?.renamePreviousKey.set(node, key) - return - } - usedKeys.add(key) - } - - const adopted = nodes.filter((node) => adoptedNodes.has(node)) - const rest = nodes.filter((node) => !adoptedNodes.has(node)) - for (const node of [...adopted, ...rest]) { - claim(node) - } - - for (const node of nodes) { - enforceNestedSiblingKeyUniqueness( - node, - keyGenerator, - adoptedNodes, - recorder, - ) - } -} - -function enforceNestedSiblingKeyUniqueness( - node: Node, - keyGenerator: () => string, - adoptedNodes: AdoptedNodes, - recorder: ReconciliationRecorder | undefined, -): void { - for (const [field, value] of Object.entries(node)) { - if ( - Array.isArray(value) && - value.every((item) => typeof item === 'object' && item !== null) && - value.length > 0 - ) { - enforceSiblingKeyUniqueness( - value as Array, - keyGenerator, - adoptedNodes, - recorder, - field === 'markDefs' ? buildMarkDefKeyRewriter(node) : undefined, - ) - } else if (typeof value === 'object' && value !== null) { - enforceNestedSiblingKeyUniqueness( - value as Node, - keyGenerator, - adoptedNodes, - recorder, - ) - } - } -} - -/** - * A colliding `keyGenerator` can mint the identical string for two - * `markDefs` entries, which means the block's spans already reference - * that shared string ambiguously before any rewrite happens: a - * blanket find-and-replace of the old key would move every span's - * reference, including the one that was never renamed. The - * `markDefs` array and each span's `marks` are walked in the same - * fixed document order, so the Nth occurrence of a given key in one - * lines up with the Nth occurrence in the other; the first occurrence - * is always the survivor (`enforceSiblingKeyUniqueness` only renames - * on collision, never the first sighting of a key), so each rename - * event retargets the next occurrence in that shared order instead of - * every occurrence. - */ -function buildMarkDefKeyRewriter( - block: Node, -): (oldKey: string, newKey: string) => void { - const children = block['children'] - if (!isTypedObjectArray(children)) { - return () => {} - } - const occurrencesByKey = new Map< - string, - Array<{child: Node; markIndex: number}> - >() - for (const child of children) { - const marks = child['marks'] - if (!Array.isArray(marks)) { - continue - } - for (let markIndex = 0; markIndex < marks.length; markIndex++) { - const mark = marks[markIndex] - if (typeof mark !== 'string') { - continue - } - const occurrences = occurrencesByKey.get(mark) ?? [] - occurrences.push({child, markIndex}) - occurrencesByKey.set(mark, occurrences) - } - } - const consumedByKey = new Map() - return (oldKey: string, newKey: string): void => { - const occurrences = occurrencesByKey.get(oldKey) - // The first occurrence of `oldKey` is the survivor and is never - // passed here, so the first rename event targets the second - // occurrence. - const index = consumedByKey.get(oldKey) ?? 1 - consumedByKey.set(oldKey, index + 1) - const target = occurrences?.[index] - if (!target) { - return - } - const marks = target.child['marks'] - if (Array.isArray(marks)) { - marks[target.markIndex] = newKey - } - } -} - -/** - * Materializes the public report from the recorder's node-identity - * decisions, walking the settled result tree once so every `key` and - * `path` matches the returned value exactly: key resolution records - * decisions before the sibling-uniqueness pass can still rewrite a - * key, so keys and paths are only trustworthy read back from the - * final tree, not from the moment a decision was made. - */ -function buildReconciliationReport( - result: Array, - recorder: ReconciliationRecorder, - losses: Pick< - ReconciliationReport, - 'hiddenLosses' | 'markdownLosses' | 'undeclaredObjects' - >, -): ReconciliationReport { - const preservedKeys: Extract< - ReconciliationReport, - {keyMatching: 'performed'} - >['preservedKeys'] = [] - const renamedKeys: Extract< - ReconciliationReport, - {keyMatching: 'performed'} - >['renamedKeys'] = [] - const pathByNode = new WeakMap() - - const walk = (node: Node, path: ReconciliationKeyPath): void => { - pathByNode.set(node, path) - const key = node['_key'] - if (typeof key === 'string') { - const preservation = recorder.preservation.get(node) - const previousKey = recorder.renamePreviousKey.get(node) - if (preservation && previousKey === undefined) { - // A node that was adopted and then renamed (possible only when - // the stored value itself carries duplicate sibling keys) did - // not keep its stored key: the renamed-key entry carries that - // story, and a preserved-key entry would report a key that - // exists nowhere in the stored value. - preservedKeys.push({ - basis: preservation.basis, - key, - path, - valueChanged: !deepEqualNodes(preservation.storedCounterpart, node), - }) - } - if (previousKey !== undefined) { - renamedKeys.push({previousKey, key, path}) - } - } - for (const [field, value] of Object.entries(node)) { - if ( - Array.isArray(value) && - value.every((item) => typeof item === 'object' && item !== null) && - value.length > 0 - ) { - // `enforceNestedSiblingKeyUniqueness` recurses into every - // element of an all-object array whether or not the element - // itself carries a `_key` (a `json:object` payload can nest a - // keyless wrapper around keyed content); the walk enters the - // same nodes, or a renamed key or key fallback on a keyed - // descendant of a keyless wrapper never gets a path to report - // against. - value.forEach((child, index) => { - const childKey = (child as Node)['_key'] - walk(child as Node, [ - ...path, - field, - typeof childKey === 'string' ? {_key: childKey} : index, - ]) - }) - } else if (typeof value === 'object' && value !== null) { - walk(value as Node, [...path, field]) - } - } - } - - result.forEach((block, index) => { - const key = block['_key'] - // `enforceSiblingKeyUniqueness` enters every top-level node whether - // or not it carries a `_key` (a `json:object` fence payload needs - // only `_type`), so the walk must too, or a renamed key inside a - // keyless block never gets reported. A keyless root contributes - // its index as a number segment, like a keyless wrapper at any - // depth. - walk(block, [typeof key === 'string' ? {_key: key} : index]) - }) - - if (recorder.skip) { - return {keyMatching: 'skipped', ...recorder.skip, renamedKeys, ...losses} - } - - const keyFallbacks: Extract< - ReconciliationReport, - {keyMatching: 'performed'} - >['keyFallbacks'] = [] - for (const group of recorder.ambiguousRegionGroups) { - keyFallbacks.push({ - type: 'ambiguous-region-too-large', - keys: group - .map((node) => node['_key']) - .filter((key): key is string => typeof key === 'string'), - }) - } - for (const node of recorder.annotationKeyConflicts) { - // The walk above reaches every node in `result`, keyless - // intermediates included, so a key fallback's target `markDefs` - // entry always has a path by the time the report is built. - keyFallbacks.push({ - type: 'annotation-key-conflict', - path: pathByNode.get(node)!, - }) - } - - return { - keyMatching: 'performed', - preservedKeys, - keyFallbacks, - renamedKeys, - ...losses, - } -} - -/** - * Undeclared objects the returned value holds that the stored value did - * not. A field may already hold such objects from before a schema - * change. A result object counts as already held when a stored one in - * the same position has the same `_type` and `_key`. A stored - * object with no `_key` gets a fresh one from the round trip, so it - * counts as held by a result object of the same type and content - * instead. Each stored object is held by at most one result object. - */ -function findIntroducedUndeclaredObjects( - stored: ReadonlyArray, - result: Array, - schema: Schema, -): Array { - const held = new Map() - const count = (identity: string, delta: number) => - held.set(identity, (held.get(identity) ?? 0) + delta) - for (const object of collectUndeclaredObjects(stored, schema)) { - count(keyIdentity(object), 1) - } - const unheld = collectUndeclaredObjects(result, schema).filter((object) => { - const identity = keyIdentity(object) - if ((held.get(identity) ?? 0) > 0) { - count(identity, -1) - return false - } - return true - }) - return unheld - .filter((object) => { - const identity = contentIdentity(object) - if ((held.get(identity) ?? 0) > 0) { - count(identity, -1) - return false - } - return true - }) - .map(({scope: _scope, node: _node, ...object}) => object) -} - -/** - * A stored object with no `_key` gets the content identity instead, so - * only a result object of the same content can count as holding it. - */ -function keyIdentity(object: FoundUndeclaredObject): string { - return object._key === undefined - ? contentIdentity(object) - : JSON.stringify([object.scope, object.position, object._type, object._key]) -} - -function contentIdentity(object: FoundUndeclaredObject): string { - return JSON.stringify([ - object.scope, - object.position, - encodeNeutral(object.node, undefined), - ]) -} - -type FoundUndeclaredObject = UndeclaredObject & { - node: Node - /** - * Where the object sits, without keys: `[]` for a top-level block - * object, `['children']` for an inline object, since editing a - * paragraph can give its block a fresh key. - */ - scope: Array -} - -/** - * Collects the top-level block objects and the inline objects of - * top-level text blocks whose type the schema does not declare. The - * fields of a declared object are not searched: what a `json:object` - * payload holds inside its own fields is the payload's business, the - * same as for any other write of that object. - */ -function collectUndeclaredObjects( - blocks: ReadonlyArray, - schema: Schema, -): Array { - const found: Array = [] - const add = ( - node: Node, - position: UndeclaredObject['position'], - scope: Array, - path: ReconciliationKeyPath, - ) => - found.push({ - _type: String(node['_type']), - ...(typeof node['_key'] === 'string' && {_key: node['_key']}), - position, - path, - message: degradationMessage['object-type-undeclared']( - String(node['_type']), - position, - ), - scope, - node, - }) - - blocks.forEach((block, index) => { - if (!isObjectNode(block)) { - return - } - const blockPath = [pathSegment(block, index)] - if (block['_type'] !== schema.block.name) { - if (!schema.blockObjects.some(({name}) => name === block['_type'])) { - add(block, 'block', [], blockPath) - } - return - } - const children = Array.isArray(block['children']) ? block['children'] : [] - children.forEach((child: unknown, childIndex) => { - if ( - !isObjectNode(child) || - child['_type'] === schema.span.name || - schema.inlineObjects.some(({name}) => name === child['_type']) - ) { - return - } - add( - child, - 'inline', - ['children'], - [...blockPath, 'children', pathSegment(child, childIndex)], - ) - }) - }) - return found -} - -function isObjectNode(value: unknown): value is Node { - return typeof value === 'object' && value !== null -} - -/** - * Keeps the stored value's serialize degradations whose content the - * returned value no longer carries, so a degradation the edit's - * restoration repaired is not reported. Listed implies gone; the - * reverse only holds where a stored block has a key to correspond by. - * The indexes are built at most once per scope, so the check stays - * linear in the document however many degradations it has. - */ -function findHiddenLosses( - degradations: ReadonlyArray, - result: Array, - keyMatching: 'performed' | 'skipped', -): Array { - if (degradations.length === 0) { - return [] - } - const lookup: HiddenLossLookup = { - topLevelByKey: new Map(), - childrenByKey: new WeakMap(), - contentByScope: new WeakMap(), - contentByTextBlock: new WeakMap(), - } - for (const node of result) { - const key = node['_key'] - if (typeof key === 'string' && !lookup.topLevelByKey.has(key)) { - lookup.topLevelByKey.set(key, node) - } - } - return degradations - .filter((degradation) => - isHiddenLoss(degradation, result, keyMatching, lookup), - ) - .map(({type, message, path, snippet}) => - snippet === undefined - ? {type, message, path} - : {type, message, path, snippet}, - ) -} - -type HiddenLossLookup = { - topLevelByKey: Map - childrenByKey: WeakMap, Map> - contentByScope: WeakMap - contentByTextBlock: WeakMap -} - -/** - * Everything the text blocks inside one scope carry, in the shape - * `hasLostContent` looks degradations up by. - */ -type CarriedContent = { - annotations: Set - decorators: Set - styles: Set - listItems: Set -} - -function isHiddenLoss( - degradation: LostContentDegradation, - result: Array, - keyMatching: 'performed' | 'skipped', - lookup: HiddenLossLookup, -): boolean { - const [topSegment] = degradation.path - if ( - keyMatching === 'skipped' || - typeof topSegment !== 'object' || - topSegment === null - ) { - // Nothing corresponds by key: a keyless stored block's key is - // filled in fresh, and a skipped document's keys are all fresh. - return !hasLostContent(carriedContent(result, lookup), degradation) - } - const topLevelBlock = lookup.topLevelByKey.get(topSegment._key) - if (!topLevelBlock) { - return true - } - if (degradation.ownerPath === undefined) { - // A renderer rendered a copy of the stored text block, so which - // block held the content is unknown: search the whole top-level - // block rather than report a loss that cannot be confirmed. - return !hasLostContent(carriedContent(topLevelBlock, lookup), degradation) - } - const owner = resolveNestedPath( - topLevelBlock, - degradation.ownerPath.slice(1), - lookup, - ) - return !( - typeof owner === 'object' && - owner !== null && - isTextBlock(owner as Node) && - hasLostContent(textBlockContent(owner as Node, lookup), degradation) - ) -} - -function resolveNestedPath( - root: unknown, - path: ReadonlyArray, - lookup: HiddenLossLookup, -): unknown { - let current = root - for (const segment of path) { - if (typeof segment === 'string') { - current = - typeof current === 'object' && current !== null - ? (current as Node)[segment] - : undefined - } else if (typeof segment === 'number') { - current = Array.isArray(current) ? current[segment] : undefined - } else { - current = Array.isArray(current) - ? childrenByKey(current, lookup).get(segment._key) - : undefined - } - if (current === undefined) { - return undefined - } - } - return current -} - -function childrenByKey( - items: Array, - lookup: HiddenLossLookup, -): Map { - const cached = lookup.childrenByKey.get(items) - if (cached) { - return cached - } - const byKey = new Map() - for (const item of items) { - const key = - typeof item === 'object' && item !== null - ? (item as Node)['_key'] - : undefined - if (typeof key === 'string' && !byKey.has(key)) { - byKey.set(key, item) - } - } - lookup.childrenByKey.set(items, byKey) - return byKey -} - -/** - * Merges what `scope` and every text block nested inside it carry, - * built once per scope and reused by every degradation looked up in it. - */ -function carriedContent( - scope: object, - lookup: HiddenLossLookup, -): CarriedContent { - const cached = lookup.contentByScope.get(scope) - if (cached) { - return cached - } - const content: CarriedContent = { - annotations: new Set(), - decorators: new Set(), - styles: new Set(), - listItems: new Set(), - } - const visit = (value: unknown): void => { - if (Array.isArray(value)) { - value.forEach(visit) - return - } - if (typeof value !== 'object' || value === null) { - return - } - const node = value as Node - if (isTextBlock(node)) { - addTextBlockContent(node, content) - } - Object.values(node).forEach(visit) - } - visit(scope) - lookup.contentByScope.set(scope, content) - return content -} - -function textBlockContent( - block: Node, - lookup: HiddenLossLookup, -): CarriedContent { - const cached = lookup.contentByTextBlock.get(block) - if (cached) { - return cached - } - const content: CarriedContent = { - annotations: new Set(), - decorators: new Set(), - styles: new Set(), - listItems: new Set(), - } - addTextBlockContent(block, content) - lookup.contentByTextBlock.set(block, content) - return content -} - -/** - * Only real spans count as carrying a mark: an inline object's own - * `marks` field is arbitrary data, and a definition only it references - * still renders as nothing. - */ -function addTextBlockContent(block: Node, content: CarriedContent): void { - content.styles.add(block['style']) - content.listItems.add(block['listItem']) - const carriedMarks = new Set() - for (const child of block['children'] as Array) { - const marks = - typeof child === 'object' && - child !== null && - (child as Node)['_type'] === 'span' - ? (child as Node)['marks'] - : undefined - if (Array.isArray(marks)) { - for (const mark of marks) { - carriedMarks.add(mark) - content.decorators.add(mark) - } - } - } - const markDefs = block['markDefs'] - if (!Array.isArray(markDefs)) { - return - } - for (const markDef of markDefs) { - if (typeof markDef !== 'object' || markDef === null) { - continue - } - const {_key: key, ...definition} = markDef as Node - if (carriedMarks.has(key)) { - content.annotations.add(encodeDefinition(definition)) - } - } -} - -function hasLostContent( - content: CarriedContent, - degradation: LostContentDegradation, -): boolean { - switch (degradation.type) { - case 'annotation-dropped': { - const {_key: _storedKey, ...storedDefinition} = - degradation.markDefinition as Node - return content.annotations.has(encodeDefinition(storedDefinition)) - } - case 'decorator-dropped': - return content.decorators.has(degradation.decorator) - case 'style-fallback': - return content.styles.has(degradation.style) - case 'list-item-fallback': - return content.listItems.has(degradation.listItem) - } -} - -/** - * The same equality as `deepEqualNodes`, as a string to index by: - * field order is noise, array order and nested `_key`s are not. - */ -function encodeDefinition(value: unknown): string { - if (Array.isArray(value)) { - return `[${value.map(encodeDefinition).join(',')}]` - } - if (typeof value === 'object' && value !== null) { - const entries = Object.entries(value as Node) - .sort(([a], [b]) => (a < b ? -1 : a > b ? 1 : 0)) - .map( - ([field, fieldValue]) => - `${JSON.stringify(field)}:${encodeDefinition(fieldValue)}`, - ) - return `{${entries.join(',')}}` - } - return JSON.stringify(value) ?? 'undefined' -} - -/** - * Field order is noise here, matching the fingerprints this module - * compares on, while array order is part of the value. - */ -function deepEqualNodes(a: unknown, b: unknown): boolean { - if (a === b) { - return true - } - if (Array.isArray(a) || Array.isArray(b)) { - if (!Array.isArray(a) || !Array.isArray(b) || a.length !== b.length) { - return false - } - return a.every((item, index) => deepEqualNodes(item, b[index])) - } - if ( - typeof a === 'object' && - a !== null && - typeof b === 'object' && - b !== null - ) { - const aRecord = a as Record - const bRecord = b as Record - const aFields = Object.keys(aRecord) - const bFields = Object.keys(bRecord) - if (aFields.length !== bFields.length) { - return false - } - return aFields.every( - (field) => - Object.hasOwn(bRecord, field) && - deepEqualNodes(aRecord[field], bRecord[field]), - ) - } - return false -} diff --git a/packages/markdown/src/default-schema.ts b/packages/markdown/src/default-schema.ts deleted file mode 100644 index 0434eef33d..0000000000 --- a/packages/markdown/src/default-schema.ts +++ /dev/null @@ -1,225 +0,0 @@ -import { - compileSchema, - defineSchema, - type AnnotationDefinition, - type BlockObjectDefinition, - type DecoratorDefinition, - type ListDefinition, - type StyleDefinition, -} from '@portabletext/schema' - -/******************** - * Default style definitions - ********************/ - -export const normalStyleDefinition = { - name: 'normal', -} as const satisfies StyleDefinition - -export const h1StyleDefinition = { - name: 'h1', -} as const satisfies StyleDefinition - -export const h2StyleDefinition = { - name: 'h2', -} as const satisfies StyleDefinition - -export const h3StyleDefinition = { - name: 'h3', -} as const satisfies StyleDefinition - -export const h4StyleDefinition = { - name: 'h4', -} as const satisfies StyleDefinition - -export const h5StyleDefinition = { - name: 'h5', -} as const satisfies StyleDefinition - -export const h6StyleDefinition = { - name: 'h6', -} as const satisfies StyleDefinition - -export const blockquoteStyleDefinition = { - name: 'blockquote', -} as const satisfies StyleDefinition - -/******************** - * Default list definitions - ********************/ - -export const defaultOrderedListItemDefinition = { - name: 'number', -} as const satisfies ListDefinition - -export const defaultUnorderedListItemDefinition = { - name: 'bullet', -} as const satisfies ListDefinition - -export const defaultTaskListItemDefinition = { - name: 'task', -} as const satisfies ListDefinition - -/******************** - * Default decorator definitions - ********************/ - -export const defaultStrongDecoratorDefinition = { - name: 'strong', -} as const satisfies DecoratorDefinition - -export const defaultEmDecoratorDefinition = { - name: 'em', -} as const satisfies DecoratorDefinition - -export const defaultCodeDecoratorDefinition = { - name: 'code', -} as const satisfies DecoratorDefinition - -export const defaultStrikeThroughDecoratorDefinition = { - name: 'strike-through', -} as const satisfies DecoratorDefinition - -export const defaultUnderlineDecoratorDefinition = { - name: 'underline', -} as const satisfies DecoratorDefinition - -/******************** - * Default annotation definitions - ********************/ - -export const defaultLinkObjectDefinition = { - name: 'link', - fields: [ - {name: 'href', type: 'string'}, - {name: 'title', type: 'string'}, - ], -} as const satisfies AnnotationDefinition - -/******************** - * Default object definitions - ********************/ - -export const defaultCodeObjectDefinition = { - name: 'code', - fields: [ - {name: 'language', type: 'string'}, - {name: 'code', type: 'string'}, - ], -} as const satisfies BlockObjectDefinition - -export const defaultImageObjectDefinition = { - name: 'image', - fields: [ - {name: 'src', type: 'string'}, - {name: 'alt', type: 'string'}, - {name: 'title', type: 'string'}, - ], -} as const satisfies BlockObjectDefinition - -export const defaultHorizontalRuleObjectDefinition = { - name: 'horizontal-rule', -} as const satisfies BlockObjectDefinition - -export const defaultHtmlObjectDefinition = { - name: 'html', - fields: [{name: 'html', type: 'string'}], -} as const satisfies BlockObjectDefinition - -/** - * Mirrors the canonical shape `@portabletext/plugin-table` expects: `table` - * (`headerRows`, `rows`), `row` (`cells`), `cell` (`value`, text blocks or - * standalone `image` objects). `alignment` is a `@portabletext/markdown` - * extension field; `@portabletext/plugin-table` ignores it. Keep this in - * sync with `packages/plugin-table/src/table-config.ts`'s - * `defaultTableConfig`. - */ -export const defaultTableObjectDefinition = { - name: 'table', - fields: [ - {name: 'headerRows', type: 'number'}, - {name: 'alignment', type: 'array'}, - { - name: 'rows', - type: 'array', - of: [ - { - type: 'object', - name: 'row', - fields: [ - { - name: 'cells', - type: 'array', - of: [ - { - type: 'object', - name: 'cell', - fields: [ - { - name: 'value', - type: 'array', - of: [{type: 'block'}, {type: 'image'}], - }, - ], - }, - ], - }, - ], - }, - ], - }, - ], -} as const satisfies BlockObjectDefinition - -export const defaultCalloutObjectDefinition = { - name: 'callout', - fields: [ - {name: 'tone', type: 'string'}, - {name: 'content', type: 'array'}, - ], -} as const satisfies BlockObjectDefinition - -/** - * The default schema for converting markdown to Portable Text. - * - * @public - */ -export const defaultSchema = compileSchema( - defineSchema({ - block: { - fields: [{name: 'checked', type: 'boolean'}], - }, - styles: [ - normalStyleDefinition, - h1StyleDefinition, - h2StyleDefinition, - h3StyleDefinition, - h4StyleDefinition, - h5StyleDefinition, - h6StyleDefinition, - blockquoteStyleDefinition, - ], - lists: [ - defaultOrderedListItemDefinition, - defaultUnorderedListItemDefinition, - defaultTaskListItemDefinition, - ], - decorators: [ - defaultStrongDecoratorDefinition, - defaultEmDecoratorDefinition, - defaultCodeDecoratorDefinition, - defaultStrikeThroughDecoratorDefinition, - defaultUnderlineDecoratorDefinition, - ], - annotations: [defaultLinkObjectDefinition], - blockObjects: [ - defaultCalloutObjectDefinition, - defaultCodeObjectDefinition, - defaultHorizontalRuleObjectDefinition, - defaultHtmlObjectDefinition, - defaultImageObjectDefinition, - defaultTableObjectDefinition, - ], - inlineObjects: [defaultImageObjectDefinition], - }), -) diff --git a/packages/markdown/src/escape.test.ts b/packages/markdown/src/escape.test.ts deleted file mode 100644 index b027d083b5..0000000000 --- a/packages/markdown/src/escape.test.ts +++ /dev/null @@ -1,103 +0,0 @@ -import {describe, expect, test} from 'vitest' -import { - escapeImageAndLinkText, - escapeImageAndLinkTitle, - escapeTableCell, - unescapeImageAndLinkText, -} from './escape' - -describe(escapeImageAndLinkText.name, () => { - test('escapes brackets', () => { - expect(escapeImageAndLinkText('a[b]c')).toBe('a\\[b\\]c') - }) - - test('escapes backslash', () => { - expect(escapeImageAndLinkText('a\\b')).toBe('a\\\\b') - }) - - test('escapes backslash before bracket', () => { - expect(escapeImageAndLinkText('a\\]b')).toBe('a\\\\\\]b') - }) - - test('leaves other characters unchanged', () => { - expect(escapeImageAndLinkText('hello world!')).toBe('hello world!') - }) -}) - -describe(unescapeImageAndLinkText.name, () => { - test('unescapes brackets', () => { - expect(unescapeImageAndLinkText('a\\[b\\]c')).toBe('a[b]c') - }) - - test('unescapes backslash', () => { - expect(unescapeImageAndLinkText('a\\\\b')).toBe('a\\b') - }) - - test('unescapes all ASCII punctuation', () => { - expect(unescapeImageAndLinkText('\\!\\#\\*')).toBe('!#*') - }) - - test('leaves non-escaped characters unchanged', () => { - expect(unescapeImageAndLinkText('hello world')).toBe('hello world') - }) - - test('leaves backslash before non-punctuation unchanged', () => { - expect(unescapeImageAndLinkText('a\\bc')).toBe('a\\bc') - }) -}) - -describe(escapeImageAndLinkTitle.name, () => { - test('escapes double quotes', () => { - expect(escapeImageAndLinkTitle('My "Cool" Page')).toBe('My \\"Cool\\" Page') - }) - - test('escapes backslash', () => { - expect(escapeImageAndLinkTitle('path\\to\\file')).toBe('path\\\\to\\\\file') - }) - - test('leaves other characters unchanged', () => { - expect(escapeImageAndLinkTitle('Hello World!')).toBe('Hello World!') - }) -}) - -describe(escapeTableCell.name, () => { - test('escapes literal pipe', () => { - expect(escapeTableCell('a | b')).toBe('a \\| b') - }) - - test('escapes multiple pipes', () => { - expect(escapeTableCell('a | b | c')).toBe('a \\| b \\| c') - }) - - test('replaces newlines with
', () => { - expect(escapeTableCell('line 1\nline 2')).toBe('line 1
line 2') - }) - - test('leaves already-escaped pipes intact', () => { - expect(escapeTableCell('a \\| b')).toBe('a \\| b') - }) - - test('escapes a pipe behind an even number of backslashes', () => { - // Two backslashes cancel out to a literal backslash, leaving the pipe - // live, the shape leaf escaping produces from a literal `\|` in a span. - expect(escapeTableCell('a\\\\| b')).toBe('a\\\\\\| b') - }) - - test('leaves a pipe behind an odd number of backslashes intact', () => { - expect(escapeTableCell('a\\\\\\| b')).toBe('a\\\\\\| b') - }) - - test('leaves backslashes alone', () => { - expect(escapeTableCell('a\\b')).toBe('a\\b') - }) - - test('preserves link-text escapes alongside pipe escaping', () => { - expect( - escapeTableCell('[link \\[with brackets\\]](https://example.com)'), - ).toBe('[link \\[with brackets\\]](https://example.com)') - }) - - test('leaves plain text unchanged', () => { - expect(escapeTableCell('hello world')).toBe('hello world') - }) -}) diff --git a/packages/markdown/src/escape.ts b/packages/markdown/src/escape.ts deleted file mode 100644 index f30ec963eb..0000000000 --- a/packages/markdown/src/escape.ts +++ /dev/null @@ -1,43 +0,0 @@ -/** - * Escapes special characters in image alt texts and link texts. - */ -export function escapeImageAndLinkText(text: string): string { - return text.replace(/([[\]\\])/g, '\\$1') -} - -/** - * Unescapes special characters in image alt texts and link texts. - */ -export function unescapeImageAndLinkText(text: string): string { - return text.replace(/\\([!"#$%&'()*+,\-./:;<=>?@[\\\]^_`{|}~])/g, '$1') -} - -/** - * Escapes special characters in image/link titles (the part inside quotes). - */ -export function escapeImageAndLinkTitle(text: string): string { - return text.replace(/([\\"])/g, '\\$1') -} - -/** - * Escapes characters that have special meaning at the row level of a GFM - * table cell. - * - * A literal `|` ends the cell, so a pipe preceded by an even number of - * backslashes (including zero) gets one more: paired backslashes cancel - * out to a literal backslash and leave the pipe live, so parity, not mere - * presence, decides whether it is already escaped. Newlines end the row, - * so they are replaced with `
` to keep the visible line break inside - * the cell. - * - * Backslashes themselves are left alone here; only the parity check reads - * them, so escapes already in the rendered cell (such as `\[` and `\]` in - * link text) survive the pass untouched. - */ -export function escapeTableCell(text: string): string { - return text - .replace(/(\\*)\|/g, (match, backslashes: string) => - backslashes.length % 2 === 0 ? `${backslashes}\\|` : match, - ) - .replace(/\n/g, '
') -} diff --git a/packages/markdown/src/example-document.advanced.md b/packages/markdown/src/example-document.advanced.md deleted file mode 100644 index 511fe6743e..0000000000 --- a/packages/markdown/src/example-document.advanced.md +++ /dev/null @@ -1,156 +0,0 @@ -# An h1 header - -Paragraphs are separated by a blank line. - -2nd paragraph. _Italic_, **bold**, and `monospace`. Itemized lists -look like: - -- this one -- that one -- the other one - -Note that --- not considering the asterisk --- the actual text -content starts at 4-columns in. - -> Block quotes are -> written like so. -> -> They can span multiple paragraphs, -> if you like. - -Use 3 dashes for an em-dash. Use 2 dashes for ranges (ex., "it's all -in chapters 12--14"). Three dots ... will be converted to an ellipsis. -Unicode is supported. ☺ - -## An h2 header - -Here's a numbered list: - -1. first item -2. second item -3. third item - -Note again how the actual text starts at 4 columns in (4 characters -from the left side). Here's a code sample: - - # Let me re-iterate ... - for i in 1 .. 10 { do-something(i) } - -As you probably guessed, indented 4 spaces. By the way, instead of -indenting the block, you can use delimited blocks, if you like: - -``` -define foobar() { - print "Welcome to flavor country!"; -} -``` - -(which makes copying & pasting easier). You can optionally mark the -delimited block for Pandoc to syntax highlight it: - -```python -import time -# Quick, count to ten! -for i in range(10): - # (but not *too* quick) - time.sleep(0.5) - print i -``` - -### An h3 header - -Now a nested list: - -1. First, get these ingredients: - - carrots - - celery - - lentils - -2. Boil some water. - -3. Dump everything in the pot and follow - this algorithm: - - find wooden spoon - uncover pot - stir - cover pot - balance wooden spoon precariously on pot handle - wait 10 minutes - goto first step (or shut off burner when done) - - Do not bump wooden spoon or it will fall. - -Notice again how text always lines up on 4-space indents (including -that last line which continues item 3 above). - -Here's a link to [a website](http://foo.bar), to a [local -doc](local-doc.html), and to a [section heading in the current -doc](#an-h2-header). Here's a footnote [^1]. - -[^1]: Footnote text goes here. - -Tables can look like this: - -size material color - ---- - -9 leather brown -10 hemp canvas natural -11 glass transparent - -Table: Shoes, their sizes, and what they're made of - -(The above is the caption for the table.) Pandoc also supports -multi-line tables: - ---- - -keyword text - ---- - -red Sunsets, apples, and -other red or reddish -things. - -green Leaves, grass, frogs -and other things it's -not easy being. - ---- - -A horizontal rule follows. - ---- - -Here's a definition list: - -apples -: Good for making applesauce. -oranges -: Citrus! -tomatoes -: There's no "e" in tomatoe. - -Again, text is indented 4 spaces. (Put a blank line between each -term/definition pair to spread things out more.) - -Here's a "line block": - -| Line one -| Line too -| Line tree - -and images can be specified like so: - -![example image](example-image.jpg 'An exemplary image') - -Inline math equations go in like so: $\omega = d\phi / dt$. Display -math should get its own line and be put in in double-dollarsigns: - -$$I = \int \rho R^{2} dV$$ - -And note that you can backslash-escape any punctuation characters -which you wish to be displayed literally, ex.: \`foo\`, \*bar\*, etc. diff --git a/packages/markdown/src/example-document.advanced.out.md b/packages/markdown/src/example-document.advanced.out.md deleted file mode 100644 index cddea94f74..0000000000 --- a/packages/markdown/src/example-document.advanced.out.md +++ /dev/null @@ -1,128 +0,0 @@ -# An h1 header - -Paragraphs are separated by a blank line. - -2nd paragraph. _Italic_, **bold**, and `monospace`. Itemized lists look like: - -- this one -- that one -- the other one - -Note that --- not considering the asterisk --- the actual text content starts at 4-columns in. - -> Block quotes are written like so. -> -> They can span multiple paragraphs, if you like. - -Use 3 dashes for an em-dash. Use 2 dashes for ranges (ex., "it's all in chapters 12--14"). Three dots ... will be converted to an ellipsis. Unicode is supported. ☺ - -## An h2 header - -Here's a numbered list: - -1. first item -2. second item -3. third item - -Note again how the actual text starts at 4 columns in (4 characters from the left side). Here's a code sample: - -``` -# Let me re-iterate ... -for i in 1 .. 10 { do-something(i) } -``` - -As you probably guessed, indented 4 spaces. By the way, instead of indenting the block, you can use delimited blocks, if you like: - -``` -define foobar() { - print "Welcome to flavor country!"; -} -``` - -(which makes copying & pasting easier). You can optionally mark the delimited block for Pandoc to syntax highlight it: - -```python -import time -# Quick, count to ten! -for i in range(10): - # (but not *too* quick) - time.sleep(0.5) - print i -``` - -### An h3 header - -Now a nested list: - -1. First, get these ingredients: - - carrots - - celery - - lentils -2. Boil some water. -3. Dump everything in the pot and follow this algorithm: - -``` -find wooden spoon -uncover pot -stir -cover pot -balance wooden spoon precariously on pot handle -wait 10 minutes -goto first step (or shut off burner when done) -``` - -1. Do not bump wooden spoon or it will fall. - -Notice again how text always lines up on 4-space indents (including that last line which continues item 3 above). - -Here's a link to [a website](http://foo.bar), to a [local doc](local-doc.html), and to a [section heading in the current doc](#an-h2-header). Here's a footnote [^1]. - -\[^1]: Footnote text goes here. - -Tables can look like this: - -size material color - ---- - -9 leather brown 10 hemp canvas natural 11 glass transparent - -Table: Shoes, their sizes, and what they're made of - -(The above is the caption for the table.) Pandoc also supports multi-line tables: - ---- - -keyword text - ---- - -red Sunsets, apples, and other red or reddish things. - -green Leaves, grass, frogs and other things it's not easy being. - ---- - -A horizontal rule follows. - ---- - -Here's a definition list: - -apples : Good for making applesauce. oranges : Citrus! tomatoes : There's no "e" in tomatoe. - -Again, text is indented 4 spaces. (Put a blank line between each term/definition pair to spread things out more.) - -Here's a "line block": - -| Line one | Line too | Line tree - -and images can be specified like so: - -![example image](example-image.jpg "An exemplary image") - -Inline math equations go in like so: $\omega = d\phi / dt$. Display math should get its own line and be put in in double-dollarsigns: - -$$I = \int \rho R^{2} dV$$ - -And note that you can backslash-escape any punctuation characters which you wish to be displayed literally, ex.: \`foo\`, \*bar\*, etc. diff --git a/packages/markdown/src/example-document.advanced.terse-pt.json b/packages/markdown/src/example-document.advanced.terse-pt.json deleted file mode 100644 index 4aeed44827..0000000000 --- a/packages/markdown/src/example-document.advanced.terse-pt.json +++ /dev/null @@ -1,60 +0,0 @@ -[ - "h1:An h1 header", - "Paragraphs are separated by a blank line.", - "2nd paragraph. ,Italic,, ,bold,, and ,monospace,. Itemized lists look like:", - ">-:this one", - ">-:that one", - ">-:the other one", - "Note that --- not considering the asterisk --- the actual text content starts at 4-columns in.", - "q:Block quotes are written like so.", - "q:They can span multiple paragraphs, if you like.", - "Use 3 dashes for an em-dash. Use 2 dashes for ranges (ex., \"it's all in chapters 12--14\"). Three dots ... will be converted to an ellipsis. Unicode is supported. ☺", - "h2:An h2 header", - "Here's a numbered list:", - ">#:first item", - ">#:second item", - ">#:third item", - "Note again how the actual text starts at 4 columns in (4 characters from the left side). Here's a code sample:", - "{code}", - "As you probably guessed, indented 4 spaces. By the way, instead of indenting the block, you can use delimited blocks, if you like:", - "{code}", - "(which makes copying & pasting easier). You can optionally mark the delimited block for Pandoc to syntax highlight it:", - "{code}", - "h3:An h3 header", - "Now a nested list:", - ">#:First, get these ingredients:", - ">>-:carrots", - ">>-:celery", - ">>-:lentils", - ">#:Boil some water.", - ">#:Dump everything in the pot and follow this algorithm:", - "{code}", - ">#:Do not bump wooden spoon or it will fall.", - "Notice again how text always lines up on 4-space indents (including that last line which continues item 3 above).", - "Here's a link to ,a website,, to a ,local doc,, and to a ,section heading in the current doc,. Here's a footnote [^1].", - "[^1]: Footnote text goes here.", - "Tables can look like this:", - "size material color", - "{horizontal-rule}", - "9 leather brown 10 hemp canvas natural 11 glass transparent", - "Table: Shoes, their sizes, and what they're made of", - "(The above is the caption for the table.) Pandoc also supports multi-line tables:", - "{horizontal-rule}", - "keyword text", - "{horizontal-rule}", - "red Sunsets, apples, and other red or reddish things.", - "green Leaves, grass, frogs and other things it's not easy being.", - "{horizontal-rule}", - "A horizontal rule follows.", - "{horizontal-rule}", - "Here's a definition list:", - "apples : Good for making applesauce. oranges : Citrus! tomatoes : There's no \"e\" in tomatoe.", - "Again, text is indented 4 spaces. (Put a blank line between each term/definition pair to spread things out more.)", - "Here's a \"line block\":", - "| Line one | Line too | Line tree", - "and images can be specified like so:", - "{image}", - "Inline math equations go in like so: $\\omega = d\\phi / dt$. Display math should get its own line and be put in in double-dollarsigns:", - "$$I = \\int \\rho R^{2} dV$$", - "And note that you can backslash-escape any punctuation characters which you wish to be displayed literally, ex.: `foo`, *bar*, etc." -] diff --git a/packages/markdown/src/example-document.advanced.test.ts b/packages/markdown/src/example-document.advanced.test.ts deleted file mode 100644 index a79ed68f3b..0000000000 --- a/packages/markdown/src/example-document.advanced.test.ts +++ /dev/null @@ -1,49 +0,0 @@ -import fs from 'node:fs' -import path from 'node:path' -import {createTestKeyGenerator, getTersePt} from '@portabletext/test' -import {describe, expect, test} from 'vitest' -import {defaultSchema} from './default-schema' -import {portableTextToMarkdown} from './from-portable-text/portable-text-to-markdown' -import {markdownToPortableText} from './to-portable-text/markdown-to-portable-text' - -const exampleDocumentMarkdown = fs.readFileSync( - path.resolve(__dirname, 'example-document.advanced.md'), - 'utf-8', -) -const exampleDocumentMarkdownOut = fs.readFileSync( - path.resolve(__dirname, 'example-document.advanced.out.md'), - 'utf-8', -) -const exampleDocumentTersePt = JSON.parse( - fs.readFileSync( - path.resolve(__dirname, 'example-document.advanced.terse-pt.json'), - 'utf-8', - ), -) - -describe('example document (advanced)', () => { - test('markdown to portable text', () => { - const keyGenerator = createTestKeyGenerator() - const blocks = markdownToPortableText(exampleDocumentMarkdown, { - keyGenerator, - }) - const tersePt = getTersePt({schema: defaultSchema, value: blocks}) - - expect(tersePt).toEqual(exampleDocumentTersePt) - }) - - test('portable text to markdown', () => { - const keyGenerator = createTestKeyGenerator() - const blocks = markdownToPortableText(exampleDocumentMarkdown, { - keyGenerator, - html: { - inline: 'text', - }, - }) - const markdown = portableTextToMarkdown(blocks, { - hardBreak: () => '
\n', - }) - - expect(`${markdown}\n`).toBe(exampleDocumentMarkdownOut) - }) -}) diff --git a/packages/markdown/src/example-document.md b/packages/markdown/src/example-document.md deleted file mode 100644 index ce16ba5714..0000000000 --- a/packages/markdown/src/example-document.md +++ /dev/null @@ -1,237 +0,0 @@ -# Markdown to Portable Text: A Complete Guide - -Converting markdown to Portable Text is a **powerful** way to bridge the gap between _simple text formatting_ and **_structured content_**. - -## Why Portable Text? - -Portable Text is a **specification** for _rich text_ that is: - -- Platform **agnostic** -- **Structured** and _queryable_ -- Designed for **_modern content workflows_** - -Visit for more information, or check out the [official documentation](https://github.com/portabletext/portabletext 'Portable Text Spec'). - ---- - -## Supported Features - -### Text Formatting - -You can use **bold text**, _italic text_, `inline code`, and even ~~strikethrough~~ text. The parser handles **_nested formatting_** gracefully, including **_bold and italic_** combined. - -Here's some `code with **bold inside**` and **text with `code inside`** to test edge cases. - -### Links and Images - -Reference-style links work too! Check out this [example link][ref1] that uses references defined elsewhere. - -![A beautiful diagram](https://example.com/diagram.png 'Markdown to PT Flow') - -Here's an inline image in text: ![icon](https://example.com/icon.png) followed by more text. - -### Code Blocks - -Fenced code blocks with syntax highlighting: - -```javascript -function markdownToPortableText(markdown) { - const tokens = parseMarkdown(markdown) - return transformToPortableText(tokens) -} -``` - -Indented code blocks also work: - - const simple = "code block"; - console.log(simple); - -### Blockquotes - -> Markdown is a lightweight markup language for creating formatted text. -> -> It was created by John Gruber in 2004. - -Nested blockquotes are supported: - -> This is the outer quote -> -> > And this is nested deeper -> > -> > With multiple paragraphs - -### Lists - -#### Unordered Lists - -- **Bold item** in a list -- _Italic item_ with [a link](https://example.com) -- Item with `inline code` - - Nested item one - - Nested item two - - Even deeper nesting -- Back to top level - -#### Ordered Lists - -1. First step: Parse the markdown -2. Second step: Generate tokens -3. Third step: Transform to Portable Text - 1. Map block types - 2. Handle inline formatting - 3. Preserve structure - -#### Mixed Lists - -1. Ordered parent - - Unordered child - - Another unordered - 1. Back to ordered - 2. Still ordered -2. Continue ordered parent - -### Tables - -Here's a comparison of different content formats: - -| Format | Structured | Portable | Query-able | -| ----------------- | ---------- | -------- | ---------- | -| **Markdown** | ✗ | ✓ | ✗ | -| **HTML** | ~ | ✗ | ✗ | -| **Portable Text** | ✓ | ✓ | ✓ | -| Plain Text | ✗ | ✓ | ✗ | - -Feature support matrix: - -| Feature | Basic | Advanced | Notes | -| ---------- | ----- | -------- | ------------------- | -| Paragraphs | ✓ | ✓ | Full support | -| **Bold** | ✓ | ✓ | Including nested | -| _Italic_ | ✓ | ✓ | Works everywhere | -| `Code` | ✓ | ✓ | Inline and blocks | -| Links | ✓ | ✓ | All types supported | -| Images | ~ | ✓ | Block and inline | - -### Horizontal Rules - -You can separate sections with horizontal rules: - ---- - -They create visual breaks in the content. - ---- - -### All Heading Levels - -# Heading 1 - -## Heading 2 - -### Heading 3 - -#### Heading 4 - -##### Heading 5 - -###### Heading 6 - -## Advanced Examples - -### Overlapping Formatting - -This paragraph contains **bold with _italic inside_** and _italic with **bold inside**_ to test proper nesting. - -You can also have **bold with `code`** and _italic with `code`_ and even `code with **bold** and _italic_` (though the formatting inside code should be preserved as-is). - -### Links with Formatting - -Here's a [**bold link**](https://example.com) and an [_italic link_](https://example.com) and even a [`code link`](https://example.com). - -### Complex List Items - -- Item with **bold**, _italic_, and `code` -- Item with a [link to **bold** content](https://example.com 'Link Title') -- Item with an inline image ![small icon](https://example.com/icon.png) in the middle - - Nested **bold item** with _italic_ and `code` - - Another nested item - -### Line Breaks - -This is a line with a hard break\ -that continues on the next line but stays in the same paragraph. - -Another paragraph with a break\ -and more content. - -### Autolinks - -Check out these autolinks: and for quick linking. - -### HTML Passthrough - -
-

This is raw HTML that gets preserved

-
- -Inline HTML like highlighted text can be handled too. - -### Reference Links - -This is [reference link one][ref1] and this is [reference link two][ref2]. - -You can also use [implicit references] by just using the text as the reference. - -[ref1]: https://portabletext.org 'Portable Text Homepage' -[ref2]: https://github.com/portabletext/editor 'Portable Text Editor' -[implicit references]: https://example.com 'Implicit Reference Example' - -## Implementation Notes - -The transformation process involves several key steps: - -1. **Lexical Analysis**: Parse markdown into tokens -2. **Syntax Tree Building**: Construct an AST from tokens -3. **Transformation**: Map markdown AST to Portable Text structure -4. **Validation**: Ensure output conforms to schema - -### Edge Cases - -#### Empty Blocks - -Sometimes you have empty paragraphs: - -Or consecutive horizontal rules: - ---- - ---- - -#### Special Characters - -Text with special characters like `&`, `<`, and `>` should be handled correctly. - -#### URLs in Text - -Raw URLs like https://example.com (without angle brackets) may or may not become links depending on the parser configuration. - -## Conclusion - -This document demonstrates the **comprehensive** support for _markdown features_ in the `markdown-to-portable-text` converter. With proper handling of: - -- All basic formatting -- Complex nesting -- Various link types -- Images (block and inline) -- Code blocks -- Lists of all types -- Tables -- And much more! - -The result is **_robust, reliable_** content transformation that preserves both structure and semantics. - ---- - -**Happy converting!** 🎉 - -_Last updated: 2025_ diff --git a/packages/markdown/src/example-document.out.md b/packages/markdown/src/example-document.out.md deleted file mode 100644 index 95c09b0c0c..0000000000 --- a/packages/markdown/src/example-document.out.md +++ /dev/null @@ -1,235 +0,0 @@ -# Markdown to Portable Text: A Complete Guide - -Converting markdown to Portable Text is a **powerful** way to bridge the gap between _simple text formatting_ and **_structured content_**. - -## Why Portable Text? - -Portable Text is a **specification** for _rich text_ that is: - -- Platform **agnostic** -- **Structured** and _queryable_ -- Designed for **_modern content workflows_** - -Visit [https://portabletext.org](https://portabletext.org) for more information, or check out the [official documentation](https://github.com/portabletext/portabletext "Portable Text Spec"). - ---- - -## Supported Features - -### Text Formatting - -You can use **bold text**, _italic text_, `inline code`, and even ~~strikethrough~~ text. The parser handles **_nested formatting_** gracefully, including **_bold and italic_** combined. - -Here's some `code with **bold inside**` and **text with `code inside`** to test edge cases. - -### Links and Images - -Reference-style links work too! Check out this [example link](https://portabletext.org "Portable Text Homepage") that uses references defined elsewhere. - -![A beautiful diagram](https://example.com/diagram.png "Markdown to PT Flow") - -Here's an inline image in text: ![icon](https://example.com/icon.png) followed by more text. - -### Code Blocks - -Fenced code blocks with syntax highlighting: - -```javascript -function markdownToPortableText(markdown) { - const tokens = parseMarkdown(markdown) - return transformToPortableText(tokens) -} -``` - -Indented code blocks also work: - -``` -const simple = "code block"; -console.log(simple); -``` - -### Blockquotes - -> Markdown is a lightweight markup language for creating formatted text. -> -> It was created by John Gruber in 2004. - -Nested blockquotes are supported: - -> This is the outer quote -> -> And this is nested deeper -> -> With multiple paragraphs - -### Lists - -#### Unordered Lists - -- **Bold item** in a list -- _Italic item_ with [a link](https://example.com) -- Item with `inline code` - - Nested item one - - Nested item two - - Even deeper nesting -- Back to top level - -#### Ordered Lists - -1. First step: Parse the markdown -2. Second step: Generate tokens -3. Third step: Transform to Portable Text - 1. Map block types - 2. Handle inline formatting - 3. Preserve structure - -#### Mixed Lists - -1. Ordered parent - - Unordered child - - Another unordered - 1. Back to ordered - 2. Still ordered -2. Continue ordered parent - -### Tables - -Here's a comparison of different content formats: - -| Format | Structured | Portable | Query-able | -| --- | --- | --- | --- | -| **Markdown** | ✗ | ✓ | ✗ | -| **HTML** | ~ | ✗ | ✗ | -| **Portable Text** | ✓ | ✓ | ✓ | -| Plain Text | ✗ | ✓ | ✗ | - -Feature support matrix: - -| Feature | Basic | Advanced | Notes | -| --- | --- | --- | --- | -| Paragraphs | ✓ | ✓ | Full support | -| **Bold** | ✓ | ✓ | Including nested | -| _Italic_ | ✓ | ✓ | Works everywhere | -| `Code` | ✓ | ✓ | Inline and blocks | -| Links | ✓ | ✓ | All types supported | -| Images | ~ | ✓ | Block and inline | - -### Horizontal Rules - -You can separate sections with horizontal rules: - ---- - -They create visual breaks in the content. - ---- - -### All Heading Levels - -# Heading 1 - -## Heading 2 - -### Heading 3 - -#### Heading 4 - -##### Heading 5 - -###### Heading 6 - -## Advanced Examples - -### Overlapping Formatting - -This paragraph contains **bold with _italic inside_** and _italic with **bold inside**_ to test proper nesting. - -You can also have **bold with `code`** and _italic with `code`_ and even `code with **bold** and _italic_` (though the formatting inside code should be preserved as-is). - -### Links with Formatting - -Here's a [**bold link**](https://example.com) and an [_italic link_](https://example.com) and even a [`code link`](https://example.com). - -### Complex List Items - -- Item with **bold**, _italic_, and `code` -- Item with a [link to **bold** content](https://example.com "Link Title") -- Item with an inline image ![small icon](https://example.com/icon.png) in the middle - - Nested **bold item** with _italic_ and `code` - - Another nested item - -### Line Breaks - -This is a line with a hard break -that continues on the next line but stays in the same paragraph. - -Another paragraph with a break -and more content. - -### Autolinks - -Check out these autolinks: [https://example.com](https://example.com) and [mailto:hello@example.com](mailto:hello@example.com) for quick linking. - -### HTML Passthrough - -
-

This is raw HTML that gets preserved

-
- -Inline HTML like \highlighted text\ can be handled too. - -### Reference Links - -This is [reference link one](https://portabletext.org "Portable Text Homepage") and this is [reference link two](https://github.com/portabletext/editor "Portable Text Editor"). - -You can also use [implicit references](https://example.com "Implicit Reference Example") by just using the text as the reference. - -## Implementation Notes - -The transformation process involves several key steps: - -1. **Lexical Analysis**: Parse markdown into tokens -2. **Syntax Tree Building**: Construct an AST from tokens -3. **Transformation**: Map markdown AST to Portable Text structure -4. **Validation**: Ensure output conforms to schema - -### Edge Cases - -#### Empty Blocks - -Sometimes you have empty paragraphs: - -Or consecutive horizontal rules: - ---- - ---- - -#### Special Characters - -Text with special characters like `&`, `<`, and `>` should be handled correctly. - -#### URLs in Text - -Raw URLs like [https://example.com](https://example.com) (without angle brackets) may or may not become links depending on the parser configuration. - -## Conclusion - -This document demonstrates the **comprehensive** support for _markdown features_ in the `markdown-to-portable-text` converter. With proper handling of: - -- All basic formatting -- Complex nesting -- Various link types -- Images (block and inline) -- Code blocks -- Lists of all types -- Tables -- And much more! - -The result is **_robust, reliable_** content transformation that preserves both structure and semantics. - ---- - -**Happy converting!** 🎉 - -_Last updated: 2025_ diff --git a/packages/markdown/src/example-document.terse-pt.json b/packages/markdown/src/example-document.terse-pt.json deleted file mode 100644 index aec9c65e87..0000000000 --- a/packages/markdown/src/example-document.terse-pt.json +++ /dev/null @@ -1,124 +0,0 @@ -[ - "h1:Markdown to Portable Text: A Complete Guide", - "Converting markdown to Portable Text is a ,powerful, way to bridge the gap between ,simple text formatting, and ,structured content,.", - "h2:Why Portable Text?", - "Portable Text is a ,specification, for ,rich text, that is:", - ">-:Platform ,agnostic", - ">-:Structured, and ,queryable", - ">-:Designed for ,modern content workflows", - "Visit ,https://portabletext.org, for more information, or check out the ,official documentation,.", - "{horizontal-rule}", - "h2:Supported Features", - "h3:Text Formatting", - "You can use ,bold text,, ,italic text,, ,inline code,, and even ,strikethrough, text. The parser handles ,nested formatting, gracefully, including ,bold and italic, combined.", - "Here's some ,code with **bold inside**, and ,text with ,code inside, to test edge cases.", - "h3:Links and Images", - "Reference-style links work too! Check out this ,example link, that uses references defined elsewhere.", - "{image}", - "Here's an inline image in text: ,{image}, followed by more text.", - "h3:Code Blocks", - "Fenced code blocks with syntax highlighting:", - "{code}", - "Indented code blocks also work:", - "{code}", - "h3:Blockquotes", - "q:Markdown is a lightweight markup language for creating formatted text.", - "q:It was created by John Gruber in 2004.", - "Nested blockquotes are supported:", - "q:This is the outer quote", - "q:And this is nested deeper", - "q:With multiple paragraphs", - "h3:Lists", - "h4:Unordered Lists", - ">-:Bold item, in a list", - ">-:Italic item, with ,a link", - ">-:Item with ,inline code", - ">>-:Nested item one", - ">>-:Nested item two", - ">>>-:Even deeper nesting", - ">-:Back to top level", - "h4:Ordered Lists", - ">#:First step: Parse the markdown", - ">#:Second step: Generate tokens", - ">#:Third step: Transform to Portable Text", - ">>#:Map block types", - ">>#:Handle inline formatting", - ">>#:Preserve structure", - "h4:Mixed Lists", - ">#:Ordered parent", - ">>-:Unordered child", - ">>-:Another unordered", - ">>>#:Back to ordered", - ">>>#:Still ordered", - ">#:Continue ordered parent", - "h3:Tables", - "Here's a comparison of different content formats:", - "{table}", - "Feature support matrix:", - "{table}", - "h3:Horizontal Rules", - "You can separate sections with horizontal rules:", - "{horizontal-rule}", - "They create visual breaks in the content.", - "{horizontal-rule}", - "h3:All Heading Levels", - "h1:Heading 1", - "h2:Heading 2", - "h3:Heading 3", - "h4:Heading 4", - "h5:Heading 5", - "h6:Heading 6", - "h2:Advanced Examples", - "h3:Overlapping Formatting", - "This paragraph contains ,bold with ,italic inside, and ,italic with ,bold inside, to test proper nesting.", - "You can also have ,bold with ,code, and ,italic with ,code, and even ,code with **bold** and _italic_, (though the formatting inside code should be preserved as-is).", - "h3:Links with Formatting", - "Here's a ,bold link, and an ,italic link, and even a ,code link,.", - "h3:Complex List Items", - ">-:Item with ,bold,, ,italic,, and ,code", - ">-:Item with a ,link to ,bold, content", - ">-:Item with an inline image ,{image}, in the middle", - ">>-:Nested ,bold item, with ,italic, and ,code", - ">>-:Another nested item", - "h3:Line Breaks", - "This is a line with a hard break\nthat continues on the next line but stays in the same paragraph.", - "Another paragraph with a break\nand more content.", - "h3:Autolinks", - "Check out these autolinks: ,https://example.com, and ,mailto:hello@example.com, for quick linking.", - "h3:HTML Passthrough", - "{html}", - "Inline HTML like highlighted text can be handled too.", - "h3:Reference Links", - "This is ,reference link one, and this is ,reference link two,.", - "You can also use ,implicit references, by just using the text as the reference.", - "h2:Implementation Notes", - "The transformation process involves several key steps:", - ">#:Lexical Analysis,: Parse markdown into tokens", - ">#:Syntax Tree Building,: Construct an AST from tokens", - ">#:Transformation,: Map markdown AST to Portable Text structure", - ">#:Validation,: Ensure output conforms to schema", - "h3:Edge Cases", - "h4:Empty Blocks", - "Sometimes you have empty paragraphs:", - "Or consecutive horizontal rules:", - "{horizontal-rule}", - "{horizontal-rule}", - "h4:Special Characters", - "Text with special characters like ,&,, ,<,, and ,>, should be handled correctly.", - "h4:URLs in Text", - "Raw URLs like ,https://example.com, (without angle brackets) may or may not become links depending on the parser configuration.", - "h2:Conclusion", - "This document demonstrates the ,comprehensive, support for ,markdown features, in the ,markdown-to-portable-text, converter. With proper handling of:", - ">-:All basic formatting", - ">-:Complex nesting", - ">-:Various link types", - ">-:Images (block and inline)", - ">-:Code blocks", - ">-:Lists of all types", - ">-:Tables", - ">-:And much more!", - "The result is ,robust, reliable, content transformation that preserves both structure and semantics.", - "{horizontal-rule}", - "Happy converting!, 🎉", - "Last updated: 2025" -] diff --git a/packages/markdown/src/example-document.test.ts b/packages/markdown/src/example-document.test.ts deleted file mode 100644 index 48b606dbdb..0000000000 --- a/packages/markdown/src/example-document.test.ts +++ /dev/null @@ -1,60 +0,0 @@ -import fs from 'node:fs' -import path from 'node:path' -import {createTestKeyGenerator, getTersePt} from '@portabletext/test' -import {describe, expect, test} from 'vitest' -import {defaultSchema} from './default-schema' -import {portableTextToMarkdown} from './from-portable-text/portable-text-to-markdown' -import {markdownToPortableText} from './to-portable-text/markdown-to-portable-text' - -const exampleDocumentMarkdown = fs.readFileSync( - path.resolve(__dirname, 'example-document.md'), - 'utf-8', -) -const exampleDocumentMarkdownOut = fs.readFileSync( - path.resolve(__dirname, 'example-document.out.md'), - 'utf-8', -) -const exampleDocumentTersePt = JSON.parse( - fs.readFileSync( - path.resolve(__dirname, 'example-document.terse-pt.json'), - 'utf-8', - ), -) - -describe('example document', () => { - test('markdown to portable text', () => { - const keyGenerator = createTestKeyGenerator() - const blocks = markdownToPortableText(exampleDocumentMarkdown, { - keyGenerator, - }) - const tersePt = getTersePt({schema: defaultSchema, value: blocks}) - - expect(tersePt).toEqual(exampleDocumentTersePt) - }) - - test('portable text to markdown', () => { - const keyGenerator = createTestKeyGenerator() - const blocks = markdownToPortableText(exampleDocumentMarkdown, { - keyGenerator, - html: { - inline: 'text', - }, - }) - const markdown = portableTextToMarkdown(blocks) - - expect(`${markdown}\n`).toBe(exampleDocumentMarkdownOut) - }) - - test('round-trip closes: the normalized markdown is a fixpoint', () => { - const keyGenerator = createTestKeyGenerator() - const blocks = markdownToPortableText(exampleDocumentMarkdownOut, { - keyGenerator, - html: { - inline: 'text', - }, - }) - const markdown = portableTextToMarkdown(blocks) - - expect(`${markdown}\n`).toBe(exampleDocumentMarkdownOut) - }) -}) diff --git a/packages/markdown/src/from-portable-text/build-list-index-map.ts b/packages/markdown/src/from-portable-text/build-list-index-map.ts deleted file mode 100644 index 581039afe5..0000000000 --- a/packages/markdown/src/from-portable-text/build-list-index-map.ts +++ /dev/null @@ -1,159 +0,0 @@ -import type {PortableTextBlock} from '@portabletext/schema' -import {isPortableTextBlock} from '@portabletext/toolkit' -import type {ArbitraryTypedObject, TypedObject} from '@portabletext/types' - -/** - * Builds a map of list item `_key`s to their index, and a map of list item - * `_key`s to the depth they should be rendered at. - * - * The depth is not the same as the block's `level`. A list can start at a level - * deeper than 1, and can skip levels, but Markdown has no way to express either: - * indentation is relative to the list item above, and indenting a first item by - * four spaces or more makes it a code block rather than a list. So each jump to a - * deeper level counts as a single step of nesting, however many levels it spans. - * - * Blocks without a `_key` get no entry in either map. - */ -export function buildListIndexMap< - Block extends TypedObject = PortableTextBlock | ArbitraryTypedObject, ->( - blocks: Array, -): {listIndexMap: Map; listDepthMap: Map} { - const levelIndexMaps = new Map>() - const listIndexMap = new Map() - const listDepthMap = new Map() - - // Levels of the list items this one is nested inside, shallowest first - let levelStack: Array = [] - - function depthOf(level: number): number { - let deepest = levelStack.at(-1) - - while (deepest !== undefined && deepest > level) { - levelStack.pop() - deepest = levelStack.at(-1) - } - - if (deepest !== level) { - levelStack.push(level) - } - - return levelStack.length - 1 - } - - let previousListItem: - | { - listItem: string - depth: number - } - | undefined - - for (let blockIndex = 0; blockIndex < blocks.length; blockIndex++) { - const block = blocks.at(blockIndex) - - if (block === undefined) { - continue - } - - // Clear the state if we encounter a non-text block - if (!isPortableTextBlock(block)) { - levelIndexMaps.clear() - previousListItem = undefined - levelStack = [] - - continue - } - - // Clear the state if we encounter a non-list text block - if ( - !block._key || - block.listItem === undefined || - block.level === undefined - ) { - levelIndexMaps.clear() - previousListItem = undefined - levelStack = [] - - continue - } - - const depth = depthOf(block.level) - listDepthMap.set(block._key, depth) - - // If we encounter a new list item, we set the initial index to 1 for the - // list type on that level. - if (!previousListItem) { - const listIndex = 1 - const levelIndexMap = - levelIndexMaps.get(block.listItem) ?? new Map() - levelIndexMap.set(depth, listIndex) - levelIndexMaps.set(block.listItem, levelIndexMap) - - listIndexMap.set(block._key, listIndex) - - previousListItem = { - listItem: block.listItem, - depth, - } - - continue - } - - // If the previous list item is of the same type but on a lower level, we - // need to reset the level index map for that type. - if ( - previousListItem.listItem === block.listItem && - previousListItem.depth < depth - ) { - const listIndex = 1 - const levelIndexMap = - levelIndexMaps.get(block.listItem) ?? new Map() - levelIndexMap.set(depth, listIndex) - levelIndexMaps.set(block.listItem, levelIndexMap) - - listIndexMap.set(block._key, listIndex) - - previousListItem = { - listItem: block.listItem, - depth, - } - - continue - } - - // Reset other list types at current depth and deeper - levelIndexMaps.forEach((levelIndexMap, listItem) => { - if (listItem === block.listItem) { - return - } - - // Reset all levels that are >= current level - const depthsToDelete: number[] = [] - - levelIndexMap.forEach((_, existingDepth) => { - if (existingDepth >= depth) { - depthsToDelete.push(existingDepth) - } - }) - - depthsToDelete.forEach((depthToDelete) => { - levelIndexMap.delete(depthToDelete) - }) - }) - - const levelIndexMap = - levelIndexMaps.get(block.listItem) ?? new Map() - const levelCounter = levelIndexMap.get(depth) ?? 0 - levelIndexMap.set(depth, levelCounter + 1) - levelIndexMaps.set(block.listItem, levelIndexMap) - - listIndexMap.set(block._key, levelCounter + 1) - - previousListItem = { - listItem: block.listItem, - depth, - } - } - - return {listIndexMap, listDepthMap} -} diff --git a/packages/markdown/src/from-portable-text/degradation-messages.ts b/packages/markdown/src/from-portable-text/degradation-messages.ts deleted file mode 100644 index eb1f171ebf..0000000000 --- a/packages/markdown/src/from-portable-text/degradation-messages.ts +++ /dev/null @@ -1,25 +0,0 @@ -import type {SerializeDegradationType} from './degradation-report' - -/** - * The full catalog of `SerializeDegradation['message']` prose, one entry - * per `SerializeDegradationType`. Kept apart from the conversion's - * emission sites (`render-node.ts`) so the catalog reads as one list - * instead of being scattered across the walk that triggers it. - * - * Not exported from the package entry: `message` is unstable by contract - * (see `SerializeDegradation['message']`'s doc comment), so the catalog - * producing it stays internal too. - */ -export const serializeDegradationMessage = { - 'annotation-dropped': (markType: string): string => - `Removed the \`${markType}\` annotation, kept its text: no \`${markType}\` mark renderer`, - - 'decorator-dropped': (markType: string): string => - `Removed the \`${markType}\` decorator, kept the text: no \`${markType}\` mark renderer`, - - 'style-fallback': (style: string): string => - `Dropped the \`${style}\` style, kept the text: no \`${style}\` block renderer`, - - 'list-item-fallback': (listItem: string): string => - `Rendered the \`${listItem}\` list item as a plain bullet, kept the text: no \`${listItem}\` list-item renderer`, -} satisfies Record string> diff --git a/packages/markdown/src/from-portable-text/degradation-report.test.ts b/packages/markdown/src/from-portable-text/degradation-report.test.ts deleted file mode 100644 index 7f6c2dbce7..0000000000 --- a/packages/markdown/src/from-portable-text/degradation-report.test.ts +++ /dev/null @@ -1,93 +0,0 @@ -import {describe, expect, test} from 'vitest' -import { - buildSerializeDegradationMessage, - truncateSnippet, -} from './degradation-report' - -describe(truncateSnippet.name, () => { - test('backs off from a cut landing mid-surrogate-pair, keeping the result well-formed', () => { - const text = `${'a'.repeat(39)}\u{1f600}bcdef` - const result = truncateSnippet(text) - - expect(result).toEqual(`${'a'.repeat(39)}...`) - expect(result?.isWellFormed()).toEqual(true) - }) -}) - -describe(buildSerializeDegradationMessage.name, () => { - test('a group past the per-group cap lists the first five snippets and tallies the rest', () => { - const degradations = ['a', 'b', 'c', 'd', 'e', 'f', 'g'].map( - (snippet, index) => ({ - type: 'decorator-dropped' as const, - message: - 'Removed the `highlight` decorator, kept the text: no `highlight` mark renderer', - path: [{_key: `b${index}`}, 'children', {_key: `s${index}`}], - topIndex: index, - snippet, - }), - ) - - expect(buildSerializeDegradationMessage(degradations)).toEqual( - [ - 'Portable Text could not be serialized to Markdown without loss:', - '- Removed the `highlight` decorator, kept the text: no `highlight` mark renderer (7\u00d7: "a", "b", "c", "d", "e", and 2 more)', - ].join('\n'), - ) - }) - - test('a group where some entries have no snippet reports the blocks it spans instead of a snippet list', () => { - const message = buildSerializeDegradationMessage([ - { - type: 'decorator-dropped', - message: - 'Removed the `highlight` decorator, kept the text: no `highlight` mark renderer', - path: [{_key: 'b0'}, 'children', {_key: 's0'}], - topIndex: 0, - snippet: 'a', - }, - { - type: 'decorator-dropped', - message: - 'Removed the `highlight` decorator, kept the text: no `highlight` mark renderer', - path: [{_key: 'b1'}, 'children', {_key: 's1'}], - topIndex: 1, - }, - ]) - - expect(message).toEqual( - [ - 'Portable Text could not be serialized to Markdown without loss:', - '- Removed the `highlight` decorator, kept the text: no `highlight` mark renderer (2\u00d7: blocks 0, 1)', - ].join('\n'), - ) - }) - - test('two different groups sort by their earliest `topIndex`, even when encountered in reverse order', () => { - const message = buildSerializeDegradationMessage([ - { - type: 'style-fallback', - message: - 'Dropped the `fancy` style, kept the text: no `fancy` block renderer', - path: [{_key: 'b5'}], - topIndex: 5, - snippet: 'z', - }, - { - type: 'decorator-dropped', - message: - 'Removed the `highlight` decorator, kept the text: no `highlight` mark renderer', - path: [{_key: 'b1'}, 'children', {_key: 's1'}], - topIndex: 1, - snippet: 'a', - }, - ]) - - expect(message).toEqual( - [ - 'Portable Text could not be serialized to Markdown without loss:', - '- block 1: Removed the `highlight` decorator, kept the text: no `highlight` mark renderer ("a")', - '- block 5: Dropped the `fancy` style, kept the text: no `fancy` block renderer ("z")', - ].join('\n'), - ) - }) -}) diff --git a/packages/markdown/src/from-portable-text/degradation-report.ts b/packages/markdown/src/from-portable-text/degradation-report.ts deleted file mode 100644 index e13ce8df5c..0000000000 --- a/packages/markdown/src/from-portable-text/degradation-report.ts +++ /dev/null @@ -1,369 +0,0 @@ -/** - * The classification of a lossy conversion encountered while converting - * Portable Text to Markdown: an annotation or decorator with no mark - * renderer, a block style or list-item kind with no renderer, always - * because a consumer's `renderers` option doesn't cover it (the built-in - * renderers cover every Portable Text construct they define). - * `list-item-fallback` never fires under the default configuration: the - * default `listItem` renderer is a single function covering every kind, - * so the event only fires behind a consumer-supplied partial `listItem` - * map. - * - * @public - */ -export type SerializeDegradationType = - | 'annotation-dropped' - | 'decorator-dropped' - | 'style-fallback' - | 'list-item-fallback' - -/** - * Reports a single lossy conversion, in encounter order: as the walk - * renders the blocks array, top to bottom. `path` addresses the node the - * loss occurred on, read from the top-level `blocks` array down: an array - * position is `{_key: node._key}` when the node at that position has a - * `_key`, the numeric index otherwise, and an object property is its - * field name. A table cell's degradation carries the full path down to - * the offending node (`[{_key: 't1'}, 'rows', {_key: 'r1'}, 'cells', - * {_key: 'c1'}, 'value', {_key: 'b1'}, 'children', {_key: 's1'}]` for a - * span in a cell), not just the table's own position, and a keyless - * top-level block reports a numeric first segment rather than the key the - * conversion generates for it internally (`[0]`, never a made-up key). - * `path` is best-effort: it names the deepest node the conversion can - * re-find from its enclosing top-level block, falling back to just the - * top-level segment when it can't. That happens when a custom renderer - * clones a nested node under a different identity before rendering it, - * and it also happens for a keyless nested span, since the conversion - * rebuilds every marked span into a new wrapper object with no stable - * reference back to the original and nothing to search by once the - * `_key` is missing too. An annotation covering several spans reports - * the first covered span's path. - * `snippet` is the offending text, truncated to 40 characters with an - * ellipsis, present whenever there's a specific piece of text to quote and - * absent when there is none (an empty span, say). `message` is - * human-readable and may change between releases. Match on `type`, not - * `message`. The set of `type` values grows in minor releases as new - * degradation sites report, so compare against the values you handle - * rather than switching exhaustively. - * - * @public - */ -export type SerializeDegradation = { - type: SerializeDegradationType - message: string - path: Array - snippet?: string -} - -/** - * What a degradation left out of the markdown. Not part of the public - * payload. - */ -export type SerializeDegradationLoss = - | {type: 'annotation-dropped'; markDefinition: object} - | {type: 'decorator-dropped'; decorator: string} - | {type: 'style-fallback'; style: string} - | {type: 'list-item-fallback'; listItem: string} - -/** - * A degradation's loss and the text block that held it, for - * `applyMarkdownEdit` to look for in the value an edit returns. Not - * part of the public payload. - */ -export type SerializeDegradationContent = SerializeDegradationLoss & { - /** - * The text block holding the lost content, found by reference, so - * unlike the public `path` it never lands on a sibling that reuses a - * `_key`. `undefined` when a renderer rendered a copy of the block. - */ - ownerPath: Array | undefined -} - -type PathSegment = SerializeDegradation['path'][number] - -function isPlainObject(value: unknown): value is Record { - return typeof value === 'object' && value !== null && !Array.isArray(value) -} - -function nodesMatch(candidate: unknown, target: object): boolean { - return candidate === target -} - -/** - * `buildMarksTree` (`@portabletext/toolkit`) rebuilds every marked span - * into a synthetic `@span` wrapper: it copies the original span's `_key` - * onto the wrapper, but the wrapper's own `children` hold freshly built - * `@text` leaves (the span's text re-split on newlines), never the - * original span object, so a wrapper's identity search can't find its - * source span by reference. This is the one place a `_key` match stands - * in for a broken reference, and it's deliberately narrow: `target` must - * be such a wrapper (`_type === '@span'`), `candidate` must be a real - * Portable Text span (`_type === 'span'`), and `candidate` must sit - * directly inside a `children` array, since that's the only array a span - * can ever occupy. Without that last constraint, a row, cell, or block - * that happens to reuse the wrapped span's `_key` (legal: `_key` - * uniqueness only holds among siblings) would match first. - */ -function spanKeyMatches( - candidate: unknown, - target: object, - containerKey: string | undefined, -): boolean { - if (containerKey !== 'children') { - return false - } - if (!isPlainObject(candidate) || candidate['_type'] !== 'span') { - return false - } - const targetRecord = target as Record - if (targetRecord['_type'] !== '@span') { - return false - } - const candidateKey = candidate['_key'] - const targetKey = targetRecord['_key'] - return ( - typeof candidateKey === 'string' && - typeof targetKey === 'string' && - candidateKey === targetKey - ) -} - -function findDescendantPath( - container: unknown, - target: object, - containerKey: string | undefined, -): Array | undefined { - if (Array.isArray(container)) { - for (let index = 0; index < container.length; index++) { - const child: unknown = container[index] - const segment: PathSegment = - isPlainObject(child) && typeof child['_key'] === 'string' - ? {_key: child['_key']} - : index - if ( - nodesMatch(child, target) || - spanKeyMatches(child, target, containerKey) - ) { - return [segment] - } - if (isPlainObject(child) || Array.isArray(child)) { - const nested = findDescendantPath(child, target, undefined) - if (nested !== undefined) { - return [segment, ...nested] - } - } - } - return undefined - } - - if (isPlainObject(container)) { - for (const key of Object.keys(container)) { - const child = container[key] - if (nodesMatch(child, target)) { - return [key] - } - if (isPlainObject(child) || Array.isArray(child)) { - const nested = findDescendantPath(child, target, key) - if (nested !== undefined) { - return [key, ...nested] - } - } - } - return undefined - } - - return undefined -} - -/** - * Reconstructs a degradation's `path` by identity search from its - * enclosing top-level block: the walk renders the same node references it - * was given, so the offending node is still reachable by searching the - * top-level block's own structure for it, except for a span rebuilt into - * a marks tree, whose wrapper is a new object (see `spanKeyMatches`) and - * is instead found by matching a real span carrying the wrapper's `_key`. - * `path` is best-effort: it names the deepest node the search manages to - * re-find, and falls back to just `topSegment` when it can't, which - * happens when a custom renderer clones a node under a different identity - * before rendering it, or when the node in question (a nested span with - * no `_key` of its own) has neither a stable reference nor a `_key` to - * search by. A `_key` search also can't tell apart two spans that share a - * `_key` across separate `children` arrays of the same top-level block - * (rows, table cells): whichever one the search reaches first wins, even - * if it isn't the one that degraded. - */ -/** - * Every object reachable from `topLevelBlock` mapped to its path, by - * reference alone: built once per top-level block, so looking up many - * nodes in it stays linear. Unlike `buildSerializeDegradationPath` it - * never falls back to a `_key` match, so it cannot land on a sibling - * that reuses the key; a copy a renderer made is simply absent. - */ -export function buildNodePathIndex( - topLevelBlock: object, - topSegment: PathSegment, -): Map> { - const index = new Map>() - const visit = (value: unknown, path: Array): void => { - if (Array.isArray(value)) { - value.forEach((child: unknown, position) => { - visit(child, [ - ...path, - isPlainObject(child) && typeof child['_key'] === 'string' - ? {_key: child['_key']} - : position, - ]) - }) - return - } - if (!isPlainObject(value) || index.has(value)) { - return - } - index.set(value, path) - for (const key of Object.keys(value)) { - visit(value[key], [...path, key]) - } - } - visit(topLevelBlock, [topSegment]) - return index -} - -export function buildSerializeDegradationPath( - topLevelBlock: object, - topSegment: PathSegment, - target: object, -): Array { - if (nodesMatch(topLevelBlock, target)) { - return [topSegment] - } - const nested = findDescendantPath(topLevelBlock, target, undefined) - return nested === undefined ? [topSegment] : [topSegment, ...nested] -} - -/** - * Truncates a degradation message's snippet to keep the reported message - * readable, backing off by one unit when the cut would land on a lead - * surrogate so a snippet ending mid-emoji doesn't produce an unpaired - * surrogate. Undefined for empty input, so a construct with nothing to - * quote omits `snippet` entirely instead of reporting `""`. - * - * Keep in sync with `to-portable-text/markdown-to-portable-text.ts`'s own - * `truncateSnippet`: duplicated rather than imported, since the parse - * side's version isn't exported and the two sides have no other coupling. - */ -export function truncateSnippet( - text: string, - maxLength = 40, -): string | undefined { - if (text.length === 0) { - return undefined - } - - if (text.length <= maxLength) { - return text.replace(/\n/g, '\\n') - } - - let cut = maxLength - const codeUnit = text.charCodeAt(cut - 1) - if (codeUnit >= 0xd800 && codeUnit <= 0xdbff) { - cut -= 1 - } - - return `${text.slice(0, cut).replace(/\n/g, '\\n')}...` -} - -// Keep in sync with `to-portable-text/markdown-to-portable-text.ts`'s own -// `MAX_LISTED_PER_GROUP`. -const MAX_LISTED_PER_GROUP = 5 - -function capList(values: ReadonlyArray): string { - if (values.length <= MAX_LISTED_PER_GROUP) { - return values.join(', ') - } - const shown = values.slice(0, MAX_LISTED_PER_GROUP) - const more = values.length - MAX_LISTED_PER_GROUP - return `${shown.join(', ')}, and ${more} more` -} - -/** - * A `SerializeDegradation` paired with the 0-based index of its enclosing - * top-level block, used only to render and sort the grouped message: - * `path`'s first segment can be a `_key`, which sorts by encounter order, - * not by document order, so the message needs the plain index alongside - * it. Not part of the public payload. - */ -type SerializeDegradationWithTopIndex = SerializeDegradation & { - topIndex: number -} - -/** - * Builds the canonical grouped message reported alongside a non-empty - * `degradations` array: identical (`type`, `message`) pairs collapse into - * one line, so a document with the same missing decorator on three spans - * doesn't repeat the same sentence three times. Groups sort by their - * earliest `topIndex` across all their entries, not by which entry - * happened to be encountered first, so the message reads top-to-bottom - * regardless of walk order. - * - * Mirrors `to-portable-text/markdown-to-portable-text.ts`'s - * `buildDegradationMessage`, adapted to `topIndex` (always present, no - * "no line" fallback needed). - */ -export function buildSerializeDegradationMessage( - degradations: ReadonlyArray, -): string { - const groups: Array<{ - base: string - entries: Array - }> = [] - const groupIndexByKey = new Map() - - for (const degradation of degradations) { - const key = `${degradation.type}\u0000${degradation.message}` - let groupIndex = groupIndexByKey.get(key) - if (groupIndex === undefined) { - groupIndex = groups.length - groupIndexByKey.set(key, groupIndex) - groups.push({base: degradation.message, entries: []}) - } - groups[groupIndex]!.entries.push(degradation) - } - - const minTopIndex = ( - entries: ReadonlyArray, - ): number => - entries.reduce( - (lowest, entry) => Math.min(lowest, entry.topIndex), - Number.POSITIVE_INFINITY, - ) - - const sortedGroups = [...groups].sort( - (a, b) => minTopIndex(a.entries) - minTopIndex(b.entries), - ) - - const lines = sortedGroups.map((group) => { - if (group.entries.length === 1) { - const event = group.entries[0]! - const snippetPart = - event.snippet === undefined ? '' : ` ("${event.snippet}")` - return `- block ${event.topIndex}: ${event.message}${snippetPart}` - } - - const snippets = group.entries - .map((entry) => entry.snippet) - .filter((snippet): snippet is string => snippet !== undefined) - - const count = group.entries.length - const suffix = - snippets.length === count - ? `(${count}\u00d7: ${capList(snippets.map((snippet) => `"${snippet}"`))})` - : `(${count}\u00d7: blocks ${capList([...new Set(group.entries.map((entry) => entry.topIndex))].sort((a, b) => a - b).map(String))})` - - return `- ${group.base} ${suffix}` - }) - - return [ - 'Portable Text could not be serialized to Markdown without loss:', - ...lines, - ].join('\n') -} diff --git a/packages/markdown/src/from-portable-text/escape-plain-text.ts b/packages/markdown/src/from-portable-text/escape-plain-text.ts deleted file mode 100644 index db9d28bb33..0000000000 --- a/packages/markdown/src/from-portable-text/escape-plain-text.ts +++ /dev/null @@ -1,748 +0,0 @@ -import {isPortableTextSpan} from '@portabletext/toolkit' -import type { - ArbitraryTypedObject, - PortableTextMarkDefinition, - PortableTextSpan, -} from '@portabletext/types' -import {LinkifyIt} from 'linkify-it' - -/** - * The CommonMark ASCII punctuation set. Only these characters can be - * backslash-escaped into a literal without changing the parsed text. - */ -const ASCII_PUNCTUATION = /[!-/:-@[-`{-~]/ - -const ENTITY_REFERENCE = /&(?:[a-zA-Z][a-zA-Z0-9]*|#[0-9]+|#[xX][0-9a-fA-F]+);/g -const BACKSLASH_BEFORE_PUNCTUATION = new RegExp( - `\\\\(?=${ASCII_PUNCTUATION.source})`, - 'g', -) -const TILDE_RUN = /~{2,}/g -const HTML_LIKE_ANGLE_BRACKET = /<(?=[a-zA-Z/!?])/g -const BRACKET_BEFORE_LINK_OPEN = /\](?=[([])/g - -/** - * A GFM alert marker alone on a line (`[!NOTE]`, any case, trailing - * whitespace allowed). As the first line of a quote it opens a callout on - * import and the marker text is consumed, so it is escaped wherever it - * starts a block. - */ -const GFM_ALERT_MARKER = /^\[!(?:note|tip|important|warning|caution)\][ \t]*$/i - -/** - * markdown-it's own emphasis-flanking rule classifies a delimiter's - * neighbor as punctuation using Unicode's `P` (Punctuation) and `S` - * (Symbol) categories, not just ASCII: an emoji, an em dash, or a CJK - * character flanks a `*`/`_` run exactly like an ASCII punctuation - * character does, so testing ASCII alone would fabricate emphasis next to - * non-ASCII text the real reparse wouldn't create. Matched against one - * full code point at a time (a surrogate pair for an astral character - * like an emoji), never a lone UTF-16 code unit, which the category - * classes never match. - */ -const UNICODE_PUNCTUATION_OR_SYMBOL = /^(?:\p{P}|\p{S})$/u - -// Mirrors `md.linkify` in `markdown-to-portable-text.ts`: same default -// schemas (http/https/ftp/'//'/mailto), the same built-in TLD list, and the -// same `fuzzyLink`/`urlAuth` overrides, so a range this reports as a link is -// a range the real reparse will claim too. -const linkify = new LinkifyIt({fuzzyLink: true, urlAuth: true}) - -type LeafPiece = - | {kind: 'text'; raw: string; isLinkLabel: boolean; markSignature: string} - | {kind: 'hardBreak'} - | {kind: 'opaque'} - -/** - * Stands in for one opaque child (an inline object, or a leaf a custom - * renderer will replace) in a joined line's text. It can never match a - * hazard's trigger character, so it safely walls off an in-progress - * construct (an ordered-list marker, a ref-def label) without needing a - * dedicated flag, while still counting as real, non-whitespace content for - * emphasis-flanking purposes. - */ -const OPAQUE_CHAR = '\0' - -/** Which leaf (by index into the flat `pieces` list) a line character came - * from, and at what offset into that leaf's *prepared* text (raw text, with - * a link-label leaf's brackets/backslashes already doubled). `null` marks - * an opaque character: it has no leaf-local home, so no edit can target it. */ -interface LineChar { - pieceIndex: number - offset: number -} - -/** A single-position splice against a joined line's raw text: remove - * `deleteCount` characters at `at` and put `insert` in their place. A pure - * insertion (a backslash escape) has `deleteCount: 0`. */ -interface Edit { - at: number - deleteCount: number - insert: string - /** - * Set for an entity-reference or backtick escape: both change what - * markdown-it's inline parser hands to its linkify pass (an entity - * decodes first, a backtick can open a code span first), so the linkify - * mask - built from this line's raw, undecoded text - can never be - * trusted to have already accounted for them. Kept regardless of any - * linkify claim overlapping it. - */ - bypassLinkifyMask?: boolean -} - -/** - * Plans the escaped replacement for every plain-text leaf a block's children - * will produce, in the exact left-to-right order `renderText` visits them - * (mirroring `buildMarksTree`'s own `text.split('\n')` leaf splitting). - * - * Escaping runs ahead of rendering, over the flat span sequence: some - * hazards only exist across a leaf boundary (an ordered-list marker, a - * ref-def label, an emphasis run) because an annotation or decorator mark - * that introduces no markup of its own splices its children in seamlessly. - * The plan works line by line (a block's children joined into text, split - * at hard breaks) rather than leaf by leaf: each line's leaves are joined - * into one string first, opaque children (inline objects, or leaves a - * custom renderer will replace) masked with a sentinel that can't match any - * hazard, and every hazard - inline and line-start alike - is detected once - * against that real, complete line, with true left/right context on both - * sides. Detected hazards become position-tracked edits against the line's - * raw text, which are then split back into each contributing leaf's own - * escaped text; only that composition step is leaf-scoped. - * - * A joined line's text that markdown-it's own linkify pass (bundled as - * `linkify-it`) would claim as a bare URL or email is masked from most - * edits: the linkify carve-out promises that substring round-trips - * byte-identical, gaining only a link mark, so escaping inside it would - * corrupt text linkify is about to claim as a link's visible text. An - * entity-reference or backtick escape is never masked (see `computeLinkifyMask` - * for why), and a claim spliced across a decorator boundary is never masked - * in the first place. - * - * `isHeading` is set for ATX headings: only the first joined line sits - * inside the `# ` prefix an ATX heading can never be reparsed as a block - * construct within, so line-leading hazards are skipped there; a hard - * break's later lines are ordinary markdown lines and get the full - * line-start battery. That first line carries a line-*end* hazard of its - * own instead: a trailing `#`-run reads back as the heading's own optional - * closing sequence. - * - * `isListItem` is set when the block renders as list-item content: a - * `[ ] `/`[x] `/`[X] ` at the very start of the first joined line reads - * back as a GFM task-list checkbox, regardless of the list's own item type. - * - * `hardBreakOutputHasNewline` says whether the renderer's actual hard-break - * output contains a newline. A custom `hardBreak` can render to something - * with no newline of its own (eg `() => '
'`), in which case the - * leaves on either side of it land on the same rendered line, not two: a - * hard break like that can't be planned as a line boundary, so it's walled - * off as an opaque segment instead, the same protection an inline object's - * unknown rendered text already gets. - */ -export function planLeafEscaping( - children: ReadonlyArray, - markDefs: ReadonlyArray, - options: { - isHeading: boolean - isListItem: boolean - hardBreakOutputHasNewline: boolean - }, -): Array { - const linkMarkKeys = new Set( - markDefs.filter((def) => def._type === 'link').map((def) => def._key), - ) - // Every markDef key a span's marks can reference (any annotation, not - // just link): what's left after removing those from a span's `marks` is - // its decorator set, the only marks `buildMarksTree` reliably renders as - // delimiters by default (an unregistered annotation type falls back to - // passing its children through unchanged, same as an unknown decorator). - const markDefKeys = new Set(markDefs.map((def) => def._key)) - const pieces: Array = [] - - for (const child of children) { - if (isPortableTextSpan(child)) { - const isLinkLabel = (child.marks ?? []).some((mark) => - linkMarkKeys.has(mark), - ) - // Sorted so two spans carrying the same decorators in a different - // order still compare equal: `buildMarksTree` nests by decorator - // identity, not by a span's own array order, so it produces the same - // markup boundaries either way. - const markSignature = (child.marks ?? []) - .filter((mark) => !markDefKeys.has(mark)) - .sort() - .join(',') - const lines = child.text.split('\n') - lines.forEach((line, index) => { - if (index > 0) { - pieces.push( - options.hardBreakOutputHasNewline - ? {kind: 'hardBreak'} - : {kind: 'opaque'}, - ) - } - pieces.push({kind: 'text', raw: line, isLinkLabel, markSignature}) - }) - } else { - pieces.push({kind: 'opaque'}) - } - } - - const pieceOutputs: Array = pieces.map(() => '') - - let lineIndex = 0 - let lineText = '' - let lineChars: Array = [] - let lineIsLinkLabelChar: Array = [] - let lineMarkSignature: Array = [] - - const flushLine = () => { - processLine({ - text: lineText, - chars: lineChars, - isLinkLabelChar: lineIsLinkLabelChar, - markSignature: lineMarkSignature, - lineIndex, - isHeading: options.isHeading, - isListItem: options.isListItem, - pieceOutputs, - }) - lineIndex++ - lineText = '' - lineChars = [] - lineIsLinkLabelChar = [] - lineMarkSignature = [] - } - - for (let pieceIndex = 0; pieceIndex < pieces.length; pieceIndex++) { - const piece = pieces[pieceIndex] - - if (!piece || piece.kind === 'hardBreak') { - flushLine() - continue - } - - if (piece.kind === 'opaque') { - lineText += OPAQUE_CHAR - lineChars.push(null) - lineIsLinkLabelChar.push(false) - lineMarkSignature.push('') - continue - } - - // A link label's brackets and backslashes are escaped unconditionally, - // up front: a label must stay bracket-balanced regardless of context, - // so this doesn't depend on anything the line-level hazard scan below - // discovers. - const prepared = piece.isLinkLabel - ? escapeLinkLabelBrackets(piece.raw) - : piece.raw - - for (let offset = 0; offset < prepared.length; offset++) { - lineText += prepared[offset] - lineChars.push({pieceIndex, offset}) - lineIsLinkLabelChar.push(piece.isLinkLabel) - lineMarkSignature.push(piece.markSignature) - } - } - flushLine() - - const escaped: Array = [] - pieces.forEach((piece, index) => { - if (piece.kind === 'text') { - escaped.push(pieceOutputs[index] ?? '') - } - }) - return escaped -} - -function processLine(args: { - text: string - chars: Array - isLinkLabelChar: Array - markSignature: Array - lineIndex: number - isHeading: boolean - isListItem: boolean - pieceOutputs: Array -}): void { - const {text, chars, isLinkLabelChar, markSignature, pieceOutputs} = args - - const linkifyMask = computeLinkifyMask( - text, - chars, - isLinkLabelChar, - markSignature, - ) - const edits = [ - ...collectInlineEdits(text, isLinkLabelChar), - ...collectLineStartEdits(text, args), - ].filter((edit) => edit.bypassLinkifyMask || !isMasked(edit, linkifyMask)) - - applyEdits(text, chars, edits, pieceOutputs) -} - -/** - * Marks every character of this line that markdown-it's linkify pass would - * claim as part of a bare URL or email. Link-label and opaque characters - * are blanked out first: a link label's visible text sits inside `[...]` - * markup real linkify never reconsiders, and an opaque child's rendered - * text is unknown at plan time, so neither should join or seed a match. - * - * The probe only sees this line's raw, undecoded text, one hazard pass - * ahead of markdown-it's own pipeline: it runs linkify against inline - * tokenization and entity decoding, not before them. A claim survives only - * if it lies entirely inside one run of identical decorator marks: a - * decorator boundary crossing it splices that decorator's delimiters - * (`**`, `` ` ``, ...) into the middle of the range real linkify would see, - * which breaks the very claim being trusted. An annotation-only boundary - * (a link's own label text is already excluded above; any other - * annotation type falls back to rendering with no delimiters at all, - * same as an unregistered decorator) never splices, so it can't invalidate - * a claim either. - */ -function computeLinkifyMask( - text: string, - chars: Array, - isLinkLabelChar: Array, - markSignature: Array, -): Array { - const mask = new Array(text.length).fill(false) - - // Every schema `linkify-it`'s default config recognizes - `http(s):`, - // `ftp:`, `//`, `www.`, and a `user@host` email - needs a `.`, `:`, or - // `@` somewhere in the line; skipping the match call on a line with - // none of those is the cheap majority-case exit, not a heuristic that - // could miss a real claim. - if (!/[.:@]/.test(text)) { - return mask - } - - let probe = '' - for (let index = 0; index < text.length; index++) { - probe += chars[index] === null || isLinkLabelChar[index] ? ' ' : text[index] - } - - const matches = linkify.match(probe) ?? [] - for (const match of matches) { - if (match.schema === '') { - // A fuzzy claim (`www.`, bare domain): markdown-it's emphasis pass - // beats these on reparse, so paired `*`/`_`/`~~` inside one is - // consumed as markup and the characters vanish. Only explicit-scheme - // claims (`http:`, `mailto:`, ...) win whole against inline - // constructs; fuzzy forms take normal escaping instead, trading - // their link mark for text fidelity. - continue - } - const signature = markSignature[match.index] - let staysWithinOneMarkRun = true - for (let index = match.index; index < match.lastIndex; index++) { - if (markSignature[index] !== signature) { - staysWithinOneMarkRun = false - break - } - } - if (!staysWithinOneMarkRun) { - continue - } - for (let index = match.index; index < match.lastIndex; index++) { - mask[index] = true - } - } - return mask -} - -function isMasked(edit: Edit, mask: ReadonlyArray): boolean { - const end = edit.at + Math.max(edit.deleteCount, 1) - for (let index = edit.at; index < end; index++) { - if (mask[index]) { - return true - } - } - return false -} - -/** Rewrites a line's raw text into each contributing leaf's escaped text by - * walking it once, left to right, applying at most one edit per position. - * Every hazard is keyed off its own trigger character - a backslash, a - * tilde, a backtick, an `&`, a `<`, a `*`/`_`, a `]`, or (line-start only, - * one hazard per line) a `#`, `>`, `[`, `-`/`+`/`*`, the `.`/`)` after an - * ordered-list marker's digits, `=`, 4 spaces, or a tab - and no two of - * those characters coincide at one position, so two edits can never target - * the same position. */ -function applyEdits( - text: string, - chars: ReadonlyArray, - edits: ReadonlyArray, - pieceOutputs: Array, -): void { - const editsByPosition = new Map() - for (const edit of edits) { - if (editsByPosition.has(edit.at)) { - throw new Error( - `Two hazard edits targeted the same position (${edit.at}); ` + - 'hazard trigger characters are assumed disjoint by construction.', - ) - } - editsByPosition.set(edit.at, edit) - } - - let index = 0 - while (index < text.length) { - const edit = editsByPosition.get(index) - const owner = chars[index] - - if (edit) { - if (owner) { - pieceOutputs[owner.pieceIndex] = - (pieceOutputs[owner.pieceIndex] ?? '') + edit.insert - } - if (edit.deleteCount > 0) { - index += edit.deleteCount - continue - } - } - - if (owner) { - pieceOutputs[owner.pieceIndex] = - (pieceOutputs[owner.pieceIndex] ?? '') + (text[index] ?? '') - } - index++ - } -} - -/** - * Hazards that can appear anywhere on a line: emphasis/strikethrough runs, - * a backtick, an entity reference, an HTML/autolink-shaped `<`, a literal - * backslash before punctuation, and a `]` immediately before `(`/`[` - * (which would otherwise read back as a link/image open). - */ -function collectInlineEdits( - text: string, - isLinkLabelChar: ReadonlyArray, -): Array { - const edits: Array = [] - - for (const match of text.matchAll(BACKSLASH_BEFORE_PUNCTUATION)) { - const at = match.index ?? 0 - // A link label's backslashes were already doubled unconditionally - // while preparing its text; doubling them again here would flip their - // parity back to unescaped. - if (!isLinkLabelChar[at]) { - edits.push({at, deleteCount: 0, insert: '\\'}) - } - } - - for (const match of text.matchAll(TILDE_RUN)) { - const start = match.index ?? 0 - for (let index = start; index < start + match[0].length; index++) { - edits.push({at: index, deleteCount: 0, insert: '\\'}) - } - } - - for (let index = 0; index < text.length; index++) { - if (text[index] === '`') { - // Every backtick is escaped outright: even a lone one can pair with - // another lone backtick elsewhere to open a code span, which the - // linkify mask can't see coming - a code span forms during inline - // tokenization, before linkify ever runs - so this bypasses it. - edits.push({ - at: index, - deleteCount: 0, - insert: '\\', - bypassLinkifyMask: true, - }) - } - } - - for (const match of text.matchAll(ENTITY_REFERENCE)) { - // An entity reference decodes before linkify runs, so a masked range - // built from this line's raw text can't already account for it. - edits.push({ - at: match.index ?? 0, - deleteCount: 0, - insert: '\\', - bypassLinkifyMask: true, - }) - } - - for (const match of text.matchAll(HTML_LIKE_ANGLE_BRACKET)) { - edits.push({at: match.index ?? 0, deleteCount: 0, insert: '\\'}) - } - - edits.push(...collectEmphasisEdits(text)) - - for (const match of text.matchAll(BRACKET_BEFORE_LINK_OPEN)) { - const at = match.index ?? 0 - // A link label's `]` was already escaped unconditionally while - // preparing its text (`escapeLinkLabelBrackets`); escaping it again - // here would double the backslash and reopen the label early on - // reparse, same reasoning as the backslash rule above. - if (!isLinkLabelChar[at]) { - edits.push({at, deleteCount: 0, insert: '\\'}) - } - } - - return edits -} - -function isWhitespace(char: string | undefined): boolean { - return char === undefined || /\s/.test(char) -} - -function isPunctuation(char: string | undefined): boolean { - return char !== undefined && UNICODE_PUNCTUATION_OR_SYMBOL.test(char) -} - -/** - * The full code point sitting immediately before `index`: two UTF-16 code - * units for an astral character (eg an emoji) whose low surrogate lands at - * `index - 1`, one otherwise. - */ -function codePointBefore(text: string, index: number): string | undefined { - if (index <= 0) { - return undefined - } - if ( - index >= 2 && - isLowSurrogate(text[index - 1]) && - isHighSurrogate(text[index - 2]) - ) { - return text.slice(index - 2, index) - } - return text[index - 1] -} - -/** - * The full code point sitting immediately at `index`: two UTF-16 code units - * for an astral character whose high surrogate lands at `index`, one - * otherwise. - */ -function codePointAt(text: string, index: number): string | undefined { - if (index >= text.length) { - return undefined - } - if (isHighSurrogate(text[index]) && isLowSurrogate(text[index + 1])) { - return text.slice(index, index + 2) - } - return text[index] -} - -function isHighSurrogate(char: string | undefined): boolean { - if (char === undefined) { - return false - } - const code = char.charCodeAt(0) - return code >= 0xd800 && code <= 0xdbff -} - -function isLowSurrogate(char: string | undefined): boolean { - if (char === undefined) { - return false - } - const code = char.charCodeAt(0) - return code >= 0xdc00 && code <= 0xdfff -} - -function isLeftFlanking( - before: string | undefined, - after: string | undefined, -): boolean { - if (isWhitespace(after)) { - return false - } - return !isPunctuation(after) || isWhitespace(before) || isPunctuation(before) -} - -function isRightFlanking( - before: string | undefined, - after: string | undefined, -): boolean { - if (isWhitespace(before)) { - return false - } - return !isPunctuation(before) || isWhitespace(after) || isPunctuation(after) -} - -/** - * Finds `*`/`_` runs CommonMark would treat as flanking delimiters, using - * each run's true neighbors on the joined line (the start/end of the line - * itself counts as whitespace, matching the spec's treatment of line - * boundaries). - */ -function collectEmphasisEdits(text: string): Array { - const edits: Array = [] - let index = 0 - - while (index < text.length) { - const char = text[index] - - if (char !== '*' && char !== '_') { - index++ - continue - } - - let end = index - while (end < text.length && text[end] === char) { - end++ - } - - const before = codePointBefore(text, index) - const after = codePointAt(text, end) - const leftFlanking = isLeftFlanking(before, after) - const rightFlanking = isRightFlanking(before, after) - - const canOpen = - char === '_' - ? leftFlanking && (!rightFlanking || isPunctuation(before)) - : leftFlanking - const canClose = - char === '_' - ? rightFlanking && (!leftFlanking || isPunctuation(after)) - : rightFlanking - - if (canOpen || canClose) { - for (let position = index; position < end; position++) { - edits.push({at: position, deleteCount: 0, insert: '\\'}) - } - } - - index = end - } - - return edits -} - -/** - * Hazards that only matter at the start (or, for a handful of whole-line - * constructs, the start *and* end) of a line: headings, blockquotes, list - * markers, ref-defs, setext underlines, thematic breaks, indented code, and - * a list item's own GFM task-checkbox prefix. A fence needs no branch of - * its own here: the inline backtick/tilde escaping every line already - * neutralizes the run a fence needs, so it can never open one on reparse. - * The remaining branches are mutually exclusive by construction - * (each targets a disjoint leading character) and return as soon as one - * matches, mirroring how CommonMark itself commits to one block-start - * interpretation per line; the checkbox branch above is the one exception, - * since a list item's checkbox prefix and, say, its heading marker are two - * independent hazards that can both apply to the same first line. - */ -function collectLineStartEdits( - text: string, - context: {isHeading: boolean; isListItem: boolean; lineIndex: number}, -): Array { - const edits: Array = [] - const isFirstLine = context.lineIndex === 0 - - // CommonMark allows up to 3 leading spaces before a block marker without - // affecting how it's parsed, so every hazard below (including the GFM - // task-checkbox the parser's own pre-pass looks for, after its own - // leading-whitespace trim) checks what follows them; the escape itself - // still has to land right before the marker, not at the front of those - // spaces (a backslash-space isn't an escape). - const leadingSpaces = /^ {0,3}/.exec(text)?.[0].length ?? 0 - const rest = text.slice(leadingSpaces) - - if (context.isListItem && isFirstLine && /^\[[ xX]\] /.test(rest)) { - edits.push({at: leadingSpaces, deleteCount: 0, insert: '\\'}) - } - - if (context.isHeading && isFirstLine) { - // A closing sequence must be preceded by a space, unless it's *all* - // the heading has: then the space ATX headings require after their - // opening `#`s stands in for it. - const closingSequence = /^(?:(.*[ \t]))?(#+[ \t]*)$/.exec(text) - if (closingSequence) { - edits.push({ - at: closingSequence[1]?.length ?? 0, - deleteCount: 0, - insert: '\\', - }) - } - return edits - } - - if (isFirstLine && GFM_ALERT_MARKER.test(rest)) { - edits.push({at: leadingSpaces, deleteCount: 0, insert: '\\'}) - return edits - } - - const orderedListMarker = /^ {0,3}(\d{1,9})([.)])(?=[ \t]|$)/.exec(text) - if (orderedListMarker) { - edits.push({ - at: orderedListMarker[0].length - 1, - deleteCount: 0, - insert: '\\', - }) - return edits - } - - if (/^#{1,6}(?:[ \t]|$)/.test(rest)) { - edits.push({at: leadingSpaces, deleteCount: 0, insert: '\\'}) - return edits - } - - if (rest.startsWith('>')) { - edits.push({at: leadingSpaces, deleteCount: 0, insert: '\\'}) - return edits - } - - if (/^\[[^\]\n]*\]:/.test(rest)) { - edits.push({at: leadingSpaces, deleteCount: 0, insert: '\\'}) - return edits - } - - if (/^[-+*](?:[ \t]|$)/.test(rest)) { - edits.push({at: leadingSpaces, deleteCount: 0, insert: '\\'}) - return edits - } - - // CommonMark 4.1 allows interior spaces/tabs between a thematic break's - // delimiter characters. - if (/^ {0,3}([-*_])(?:[ \t]*\1){2,}[ \t]*$/.test(text)) { - edits.push({at: leadingSpaces, deleteCount: 0, insert: '\\'}) - return edits - } - - if (/^ {0,3}=+[ \t]*$/.test(text)) { - edits.push({at: leadingSpaces, deleteCount: 0, insert: '\\'}) - return edits - } - - if (/^ {0,3}-+[ \t]*$/.test(text)) { - edits.push({at: leadingSpaces, deleteCount: 0, insert: '\\'}) - return edits - } - - if (/^ {4}/.test(text)) { - // A numeric character reference decodes to the same literal character - // during inline parsing, after block structure (and its indented-code - // -block rule, 4 columns of leading whitespace) has already been - // decided. - edits.push({at: 0, deleteCount: 1, insert: ' '}) - return edits - } - - const tabIndent = /^ {0,3}\t/.exec(text) - if (tabIndent) { - // A tab advances to the next multiple of 4 columns, so even 0-3 - // leading spaces before one reaches the indented-code-block - // threshold; encoding the tab itself (not the spaces before it) is - // enough to break that count. - edits.push({at: tabIndent[0].length - 1, deleteCount: 1, insert: ' '}) - return edits - } - - return edits -} - -/** - * Text rendered inside a link label needs every `[`, `]` and `\` escaped - * unconditionally, on top of the general-purpose hazard escaping every - * line gets: a link label must stay bracket-balanced, and any literal - * backslash in it needs protecting regardless of what follows (unlike - * plain text, where only a backslash immediately before punctuation is a - * hazard). - */ -function escapeLinkLabelBrackets(text: string): string { - return text.replace(/[[\]\\]/g, (char) => `\\${char}`) -} diff --git a/packages/markdown/src/from-portable-text/list-item-first-block.ts b/packages/markdown/src/from-portable-text/list-item-first-block.ts deleted file mode 100644 index 7dffb16be7..0000000000 --- a/packages/markdown/src/from-portable-text/list-item-first-block.ts +++ /dev/null @@ -1,26 +0,0 @@ -import type {PortableTextBlock} from '@portabletext/types' - -/** - * Blocks currently known to be a list item's first content block: it shares - * its first line with the list marker (and, for a task item, its GFM - * checkbox), which changes how `renderBlock` plans line-start hazard - * escaping. Internal to this package so the signal never reaches the - * public `Serializable`/`RenderNode` types a custom renderer's `.d.ts` - * would otherwise expose it through. - * - * A block is marked right before rendering it; the `renderNode` call that - * dispatches to `renderBlock` consumes the membership on the way past so a - * later, unrelated render of the same object (still possible - `renderNode` - * accepts any `TypedObject`) doesn't inherit a stale claim. - */ -const listItemFirstBlocks = new WeakSet() - -export function markListItemFirstBlock(block: PortableTextBlock): void { - listItemFirstBlocks.add(block) -} - -export function consumeListItemFirstBlock(block: PortableTextBlock): boolean { - const isListItemFirstBlock = listItemFirstBlocks.has(block) - listItemFirstBlocks.delete(block) - return isListItemFirstBlock -} diff --git a/packages/markdown/src/from-portable-text/portable-text-to-markdown.ts b/packages/markdown/src/from-portable-text/portable-text-to-markdown.ts deleted file mode 100644 index e329dd3981..0000000000 --- a/packages/markdown/src/from-portable-text/portable-text-to-markdown.ts +++ /dev/null @@ -1,549 +0,0 @@ -import type {Schema} from '@portabletext/schema' -import {isPortableTextBlock, toPlainText} from '@portabletext/toolkit' -import type { - ArbitraryTypedObject, - PortableTextBlock, - TypedObject, -} from '@portabletext/types' -import {defaultSchema} from '../default-schema' -import {defaultKeyGenerator} from '../key-generator' -import {neutralForm, type Node} from '../neutral-form' -import {markdownToPortableTextWithBlockLines} from '../to-portable-text/markdown-to-portable-text' -import {buildListIndexMap} from './build-list-index-map' -import type { - SerializeDegradation, - SerializeDegradationContent, - SerializeDegradationLoss, -} from './degradation-report' -import { - buildSerializeDegradationMessage, - buildNodePathIndex, - buildSerializeDegradationPath, -} from './degradation-report' -import {createRenderNode} from './render-node' -import { - DefaultBlockSpacingRenderer, - type BlockSpacingRenderer, -} from './renderers/block-spacing' -import {DefaultHardBreakRenderer} from './renderers/hard-break' -import { - DefaultListItemRenderer, - DefaultUnknownListItemRenderer, -} from './renderers/list-item' -import { - DefaultCodeRenderer, - DefaultEmRenderer, - DefaultLinkRenderer, - DefaultStrikeThroughRenderer, - DefaultStrongRenderer, - DefaultUnderlineRenderer, - DefaultUnknownMarkRenderer, -} from './renderers/marks' -import { - DefaultBlockquoteRenderer, - DefaultH1Renderer, - DefaultH2Renderer, - DefaultH3Renderer, - DefaultH4Renderer, - DefaultH5Renderer, - DefaultH6Renderer, - DefaultNormalRenderer, - DefaultUnknownStyleRenderer, -} from './renderers/style' -import { - DefaultCalloutRenderer, - DefaultCodeBlockRenderer, - DefaultHorizontalRuleRenderer, - DefaultHtmlRenderer, - DefaultImageRenderer, - DefaultTableRenderer, - DefaultUnknownTypeRenderer, -} from './renderers/type' -import type {PortableTextRenderers} from './types' - -const defaultRenderers: PortableTextRenderers = { - types: { - 'callout': DefaultCalloutRenderer, - 'code': DefaultCodeBlockRenderer, - 'horizontal-rule': DefaultHorizontalRuleRenderer, - 'html': DefaultHtmlRenderer, - 'image': DefaultImageRenderer, - 'table': DefaultTableRenderer, - }, - - block: { - normal: DefaultNormalRenderer, - blockquote: DefaultBlockquoteRenderer, - h1: DefaultH1Renderer, - h2: DefaultH2Renderer, - h3: DefaultH3Renderer, - h4: DefaultH4Renderer, - h5: DefaultH5Renderer, - h6: DefaultH6Renderer, - }, - marks: { - 'em': DefaultEmRenderer, - 'strong': DefaultStrongRenderer, - 'code': DefaultCodeRenderer, - 'underline': DefaultUnderlineRenderer, - 'strike-through': DefaultStrikeThroughRenderer, - 'link': DefaultLinkRenderer, - }, - listItem: DefaultListItemRenderer, - hardBreak: DefaultHardBreakRenderer, - - unknownType: DefaultUnknownTypeRenderer, - unknownMark: DefaultUnknownMarkRenderer, - unknownListItem: DefaultUnknownListItemRenderer, - unknownBlockStyle: DefaultUnknownStyleRenderer, -} - -const SETEXT_STYLES = new Set(['h1', 'h2']) - -type Options = Partial & { - blockSpacing?: BlockSpacingRenderer - - /** - * Compiled schema that gates the built-in type renderers; it never - * validates the value. A default renderer runs only when the schema - * declares its type (`blockObjects` for block position, `inlineObjects` - * for inline); undeclared types render through `unknownType`, whose - * default output reparses back to the same value. The gate checks the - * type name only, so declare the type's fields too: - * `markdownToPortableText` cannot rebuild a value from a fieldless - * declaration. Renderers passed in `types` are never gated. Pass the - * same schema to `markdownToPortableText` to keep the round trip - * consistent. Omitted, all default renderers stay active. - */ - schema?: Schema - - /** - * Called at most once, after the conversion has walked every block, - * only when at least one construct degraded to a lossier rendering (an - * annotation or decorator with no mark renderer, a block style or - * list-item kind with no renderer). Left unset, the conversion stays - * silent and returns the lossiest representation it can build. Passed - * a function, observe every degradation via `report.degradations`, in - * encounter order, and the same degradations grouped and sorted by - * block via `report.message`. Enforce against lossy output by throwing - * your own error from inside the callback. The throw propagates out of - * `portableTextToMarkdown`. - * - * ```ts - * portableTextToMarkdown(blocks, {onDegradation: ({message}) => { throw new Error(message) }}) - * ``` - * - * `report` is a single object, not positional parameters, so it can - * gain fields later without a breaking change. - */ - onDegradation?: (report: { - degradations: Array - message: string - }) => void -} - -/** - * @public - */ -export function portableTextToMarkdown< - Block extends TypedObject = PortableTextBlock | ArbitraryTypedObject, ->(blocks: Array, options: Options = {}): string { - return renderMarkdown(blocks, options, undefined) -} - -/** - * `portableTextToMarkdown`, plus each degradation paired with the - * content it left out of the markdown, for `applyMarkdownEdit` to look - * for in the value an edit returns. - */ -export function portableTextToMarkdownWithLostContent( - blocks: Array, - options: Omit, -): { - markdown: string - degradations: Array -} { - const degradations: Array< - SerializeDegradation & SerializeDegradationContent - > = [] - const markdown = renderMarkdown(blocks, options, (degradation) => { - degradations.push(degradation) - }) - return {markdown, degradations} -} - -function renderMarkdown( - inputBlocks: Array, - options: Options, - onLostContent: - | (( - degradation: SerializeDegradation & SerializeDegradationContent, - ) => void) - | undefined, -): string { - const renderers = { - block: - typeof options.block === 'function' - ? options.block - : { - ...defaultRenderers.block, - ...options.block, - }, - listItem: options.listItem ?? defaultRenderers.listItem, - marks: { - ...defaultRenderers.marks, - ...options.marks, - }, - types: { - ...gateDefaultTypeRenderers( - defaultRenderers.types, - options.schema, - options.unknownType ?? defaultRenderers.unknownType, - ), - ...options.types, - }, - hardBreak: options.hardBreak ?? defaultRenderers.hardBreak, - unknownType: options.unknownType ?? defaultRenderers.unknownType, - unknownBlockStyle: - options.unknownBlockStyle ?? defaultRenderers.unknownBlockStyle, - unknownListItem: - options.unknownListItem ?? defaultRenderers.unknownListItem, - unknownMark: options.unknownMark ?? defaultRenderers.unknownMark, - } - const renderBlockSpacing = options.blockSpacing ?? DefaultBlockSpacingRenderer - - const tracksDegradations = Boolean(options.onDegradation || onLostContent) - const originalBlockKeys = tracksDegradations - ? inputBlocks.map((node) => node._key) - : undefined - - // List numbering is looked up by `_key`, so keyless blocks get one, on a - // copy so the caller's blocks are left as they were passed in. - const blocks = inputBlocks.map((block) => - block._key ? block : {...block, _key: defaultKeyGenerator()}, - ) - - const {listIndexMap, listDepthMap} = buildListIndexMap(blocks) - - type PathSegment = SerializeDegradation['path'][number] - - const internalDegradations: Array = - [] - let currentBlock: {block: TypedObject; index: number; topSegment: PathSegment} - let nodePathIndex: - | {block: TypedObject; paths: Map>} - | undefined - const findOwnerPath = (owner: object) => { - if (nodePathIndex?.block !== currentBlock.block) { - nodePathIndex = { - block: currentBlock.block, - paths: buildNodePathIndex(currentBlock.block, currentBlock.topSegment), - } - } - return nodePathIndex.paths.get(owner) - } - const reportDegradation = tracksDegradations - ? ( - entry: SerializeDegradationLoss & { - message: string - snippet: string | undefined - node: object - owner: object - }, - ) => { - const degradation: SerializeDegradation = { - type: entry.type, - message: entry.message, - path: buildSerializeDegradationPath( - currentBlock.block, - currentBlock.topSegment, - entry.node, - ), - } - if (entry.snippet !== undefined) { - degradation.snippet = entry.snippet - } - internalDegradations.push({ - ...degradation, - topIndex: currentBlock.index, - }) - if (onLostContent) { - onLostContent({ - ...degradation, - ...lostContent(entry), - ownerPath: findOwnerPath(entry.owner), - }) - } - } - : undefined - - const renderNode = createRenderNode( - renderers, - listIndexMap, - listDepthMap, - reportDegradation, - ) - - // Blocks rendering to '' are dropped before spacing is computed, so - // `blockSpacing` only ever sees blocks that survive into the output. - const renderedBlocks = blocks - .map((node, index) => { - if (originalBlockKeys !== undefined) { - const key = originalBlockKeys[index] - currentBlock = { - block: node, - index, - topSegment: key !== undefined ? {_key: key} : index, - } - } - return { - node, - rendered: renderNode({node, index, isInline: false, renderNode}), - index, - } - }) - .filter(({rendered}) => rendered !== '') - - const renderedOutputs = renderedBlocks.map(({rendered}) => rendered) - const spacings = renderedBlocks.slice(1).map( - (nextBlock, position) => - renderBlockSpacing({ - current: renderedBlocks[position]!.node, - next: nextBlock.node, - }) ?? '\n\n', - ) - - if (options.hardBreak === undefined && options.blockSpacing === undefined) { - const candidates = new Map() - renderedBlocks.forEach(({node, rendered, index}, position) => { - const candidate = renderCandidate(node, index) - if (candidate !== undefined && candidate !== rendered) { - candidates.set(position, candidate) - } - }) - if (candidates.size > 0) { - for (const [position, candidate] of verifyCandidates( - renderedBlocks.map(({node}) => node), - renderedOutputs, - spacings, - candidates, - options.schema, - )) { - renderedOutputs[position] = candidate - } - } - } - - const markdown = joinRenderedBlocks(renderedOutputs, spacings) - - function renderCandidate( - node: TypedObject, - index: number, - ): string | undefined { - if (!isPortableTextBlock(node)) { - return undefined - } - const text = toPlainText(node) - if ( - !text.includes('\n\n') && - !(SETEXT_STYLES.has(node.style ?? 'normal') && text.includes('\n')) - ) { - return undefined - } - const renderCandidateNode = createRenderNode( - renderers, - listIndexMap, - listDepthMap, - undefined, - true, - ) - return renderCandidateNode({ - node, - index, - isInline: false, - renderNode: renderCandidateNode, - }) - } - - if (options.onDegradation && internalDegradations.length > 0) { - options.onDegradation({ - degradations: internalDegradations.map( - ({topIndex, ...degradation}) => degradation, - ), - message: buildSerializeDegradationMessage(internalDegradations), - }) - } - - return markdown -} - -// Dropping a failed candidate restores a whitespace-only line, which can -// change how later blocks parse (the next list item becomes a lazy -// continuation), so the survivors need verifying again, and a failure then -// drops every candidate rather than starting another round. The deserialize -// options a caller pairs with this call are not visible here, so the parse -// uses the default parser settings plus `schema`. -function verifyCandidates( - nodes: ReadonlyArray, - renderedOutputs: ReadonlyArray, - spacings: ReadonlyArray, - candidates: ReadonlyMap, - schema: Schema | undefined, -): ReadonlyMap { - const expectedByBlock = parseByBlock(renderedOutputs, spacings, schema) - const firstRound = failingCandidates( - nodes, - renderedOutputs, - spacings, - candidates, - expectedByBlock, - schema, - ) - if (firstRound.size === 0) { - return candidates - } - const survivors = new Map( - [...candidates].filter(([position]) => !firstRound.has(position)), - ) - if (survivors.size === 0) { - return survivors - } - const secondRound = failingCandidates( - nodes, - renderedOutputs, - spacings, - survivors, - expectedByBlock, - schema, - ) - return secondRound.size === 0 ? survivors : new Map() -} - -function failingCandidates( - nodes: ReadonlyArray, - renderedOutputs: ReadonlyArray, - spacings: ReadonlyArray, - candidates: ReadonlyMap, - expectedByBlock: ReadonlyArray>, - schema: Schema | undefined, -): Set { - const parsedByBlock = parseByBlock( - renderedOutputs.map( - (rendered, position) => candidates.get(position) ?? rendered, - ), - spacings, - schema, - ) - const parsedTextBlockName = (schema ?? defaultSchema).block.name - const failing = new Set() - // A node holds the bytes from the line it starts in onward, so a block - // without a candidate that parses differently was changed by a candidate - // its nodes ran into: one after it, before the next block without a - // candidate that parses as expected. - let blamingLaterCandidates = false - parsedByBlock.forEach((parsed, position) => { - const expected = candidates.has(position) - ? [neutralForm({...nodes[position], _type: parsedTextBlockName})] - : expectedByBlock[position]! - const matches = - parsed.length === expected.length && - parsed.every((form, formIndex) => form === expected[formIndex]) - if (candidates.has(position)) { - if (!matches || blamingLaterCandidates) { - failing.add(position) - } - } else { - blamingLaterCandidates = !matches - } - }) - return failing -} - -function parseByBlock( - renderedOutputs: ReadonlyArray, - spacings: ReadonlyArray, - schema: Schema | undefined, -): Array> { - const blockStartLines: Array = [] - let line = 0 - renderedOutputs.forEach((rendered, position) => { - blockStartLines.push(line) - line += lineCount(rendered) + lineCount(spacings[position] ?? '') - }) - const parsed = markdownToPortableTextWithBlockLines( - joinRenderedBlocks(renderedOutputs, spacings), - {schema}, - ) - const formsByBlock: Array> = renderedOutputs.map(() => []) - let position = 0 - parsed.portableText.forEach((node, nodeIndex) => { - const nodeLine = parsed.blockStartLines[nodeIndex]! - while ( - position + 1 < blockStartLines.length && - blockStartLines[position + 1]! <= nodeLine - ) { - position++ - } - formsByBlock[position]!.push(neutralForm(node as unknown as Node)) - }) - return formsByBlock -} - -function lineCount(text: string): number { - let count = 0 - for (const character of text) { - if (character === '\n') { - count++ - } - } - return count -} - -function joinRenderedBlocks( - renderedOutputs: ReadonlyArray, - spacings: ReadonlyArray, -): string { - return renderedOutputs - .map((rendered, position) => `${rendered}${spacings[position] ?? ''}`) - .join('') -} - -function gateDefaultTypeRenderers( - defaultTypeRenderers: PortableTextRenderers['types'], - schema: Schema | undefined, - resolvedUnknownType: PortableTextRenderers['unknownType'], -): PortableTextRenderers['types'] { - if (!schema) { - return defaultTypeRenderers - } - - return Object.fromEntries( - Object.entries(defaultTypeRenderers).map(([typeName, renderer]) => [ - typeName, - (rendererOptions: Parameters[0]) => { - const declared = ( - rendererOptions.isInline ? schema.inlineObjects : schema.blockObjects - ).some((item) => item.name === typeName) - - return declared && renderer - ? renderer(rendererOptions) - : resolvedUnknownType(rendererOptions) - }, - ]), - ) -} - -function lostContent( - entry: SerializeDegradationLoss, -): SerializeDegradationLoss { - switch (entry.type) { - case 'annotation-dropped': - return {type: entry.type, markDefinition: entry.markDefinition} - case 'decorator-dropped': - return {type: entry.type, decorator: entry.decorator} - case 'style-fallback': - return {type: entry.type, style: entry.style} - case 'list-item-fallback': - return {type: entry.type, listItem: entry.listItem} - } -} diff --git a/packages/markdown/src/from-portable-text/render-node.ts b/packages/markdown/src/from-portable-text/render-node.ts deleted file mode 100644 index 56d81669e8..0000000000 --- a/packages/markdown/src/from-portable-text/render-node.ts +++ /dev/null @@ -1,366 +0,0 @@ -import { - buildMarksTree, - isPortableTextBlock, - isPortableTextListItemBlock, - isPortableTextToolkitSpan, - isPortableTextToolkitTextNode, - spanToPlainText, - toPlainText, - type ToolkitNestedPortableTextSpan, - type ToolkitTextNode, -} from '@portabletext/toolkit' -import type { - PortableTextBlock, - PortableTextListItemBlock, - PortableTextMarkDefinition, - PortableTextSpan, - TypedObject, -} from '@portabletext/types' -import {serializeDegradationMessage} from './degradation-messages' -import { - truncateSnippet, - type SerializeDegradationLoss, - type SerializeDegradationType, -} from './degradation-report' -import {planLeafEscaping} from './escape-plain-text' -import { - consumeListItemFirstBlock, - markListItemFirstBlock, -} from './list-item-first-block' -import {DefaultUnknownListItemRenderer} from './renderers/list-item' -import {DefaultUnknownMarkRenderer} from './renderers/marks' -import { - DefaultH1Renderer, - DefaultH2Renderer, - DefaultUnknownStyleRenderer, -} from './renderers/style' -import type {PortableTextRenderers, RenderNode, Serializable} from './types' - -type ReportDegradation = ( - entry: SerializeDegradationLoss & { - message: string - snippet: string | undefined - node: object - owner: object - }, -) => void - -/** - * ATX headings are single-line, inline-only leaf blocks: an ATX heading's - * first line sits inside its `# ` prefix and can never be reparsed as a - * block construct, so line-leading hazards never apply there. A hard - * break's later lines are ordinary markdown lines outside that prefix and - * get the full line-start battery, same as any other block's continuation. - */ -const HEADING_STYLES = new Set(['h1', 'h2', 'h3', 'h4', 'h5', 'h6']) - -const SETEXT_UNDERLINES = new Map([ - [DefaultH1Renderer, '==='], - [DefaultH2Renderer, '---'], -]) - -export const createRenderNode = ( - renderers: PortableTextRenderers, - listIndexMap: Map, - listDepthMap: Map, - reportDegradation: ReportDegradation | undefined, - candidateForms = false, -): RenderNode => { - // Keyed by the actual `@text` node objects `buildMarksTree` produces, not - // by render order: a custom type/mark renderer can call `renderNode` with - // a synthetic node of its own mid-block (eg to render a placeholder), and - // that call must not shift which escaped string a later real leaf gets. - // A synthetic node was never planned, so it's absent from the map and - // renders its own raw text. - const escapedTextByNode = new WeakMap() - - // A `' \n'` break right after an empty line leaves a whitespace-only - // line, which Markdown reads as a blank line ending the paragraph. A - // backslash break keeps the line non-blank. - const backslashBreakNodes = new WeakSet() - - // `renderListItem` renders a styled item through a copy of the block - // (with `listItem` stripped, to avoid recursing back into itself), so a - // degradation reported on that copy could not be re-found in the input - // tree by reference. The alias maps the copy back to the input node. - const reportedNodeAliases = new WeakMap() - - // Computed once per document render: a custom `hardBreak` can render to - // something with no newline of its own (eg `() => '
'`), in which - // case a `\n` inside a span's text is not a real line boundary in the - // rendered output, and `planLeafEscaping` needs to know that up front. - const hardBreakOutputHasNewline = renderers.hardBreak().includes('\n') - - // The text block whose children are being rendered, so a mark - // degradation can name the block that held the span: a marks-tree - // `@span` wrapper keeps only the span's `_key`, which sibling table - // cells may reuse. - let currentTextBlock: TypedObject | undefined - - // Shared by `renderBlock` and `renderListItem`, whatever the block's - // style: both flatten `node.children` into the same text/mark tree and - // need it escaped and keyed before rendering it. - function renderBlockChildren( - node: PortableTextBlock | PortableTextListItemBlock, - isHeading: boolean, - isListItem = false, - ): string { - const chunks = planLeafEscaping(node.children ?? [], node.markDefs ?? [], { - isHeading, - isListItem, - hardBreakOutputHasNewline, - }) - const tree = buildMarksTree(node) - assignEscapedText(tree, chunks) - - const enclosingTextBlock = currentTextBlock - currentTextBlock = reportedNodeAliases.get(node) ?? node - const rendered = tree - .map((child, i) => - renderNode({node: child, isInline: true, index: i, renderNode}), - ) - .join('') - currentTextBlock = enclosingTextBlock - return rendered - } - - // Walks the tree in the same left-to-right, opaque-object-skipping order - // `planLeafEscaping` used to produce `chunks`, so each `@text` leaf gets - // keyed to the chunk planned for it. - function assignEscapedText( - nodes: ReadonlyArray< - ToolkitNestedPortableTextSpan | ToolkitTextNode | TypedObject - >, - chunks: ReadonlyArray, - ): void { - let pointer = 0 - let isFirstLine = true - let lineIsEmpty = true - - const visit = ( - node: ToolkitNestedPortableTextSpan | ToolkitTextNode | TypedObject, - ) => { - if (isPortableTextToolkitTextNode(node)) { - if (node.text === '\n') { - if (candidateForms && lineIsEmpty && !isFirstLine) { - backslashBreakNodes.add(node) - } - isFirstLine = false - lineIsEmpty = true - return - } - const escaped = chunks[pointer] - if (escaped !== undefined) { - escapedTextByNode.set(node, escaped) - } - pointer++ - if (node.text !== '') { - lineIsEmpty = false - } - return - } - - if (isPortableTextToolkitSpan(node)) { - node.children.forEach(visit) - return - } - - lineIsEmpty = false - } - - nodes.forEach(visit) - } - - function renderNode(options: Serializable): string { - const {node, index, isInline} = options - - if (isPortableTextListItemBlock(node)) { - return renderListItem(node, index) - } - - if (isPortableTextToolkitSpan(node)) { - return renderSpan(node) - } - - if (isPortableTextBlock(node)) { - return renderBlock(node, index, isInline, consumeListItemFirstBlock(node)) - } - - if (isPortableTextToolkitTextNode(node)) { - return renderText(node) - } - - return renderCustomBlock(node, index, isInline) - } - - function renderListItem( - node: PortableTextListItemBlock< - PortableTextMarkDefinition, - PortableTextSpan - >, - index: number, - ): string { - const renderer = renderers.listItem - const handler = - typeof renderer === 'function' ? renderer : renderer[node.listItem] - const itemHandler = handler || renderers.unknownListItem - - if ( - !handler && - renderers.unknownListItem === DefaultUnknownListItemRenderer - ) { - reportDegradation?.({ - type: 'list-item-fallback', - listItem: node.listItem, - message: serializeDegradationMessage['list-item-fallback']( - node.listItem, - ), - snippet: truncateSnippet(toPlainText(node)), - node, - owner: node, - }) - } - - let children: string - - if (node.style && node.style !== 'normal') { - // Wrap any other style in whatever the block component says to use. - // `renderNode` would recurse straight back into `renderListItem` if - // `blockNode` still carried `listItem`, so it's stripped from the - // copy; `markListItemFirstBlock` restores the list-item context - // (line-start hazard escaping, the GFM checkbox prefix) onto that - // same copy so `renderBlock` picks it up via `consumeListItemFirstBlock`. - const {listItem: _listItem, ...blockNode} = node - reportedNodeAliases.set(blockNode, node) - markListItemFirstBlock(blockNode) - children = renderNode({ - node: blockNode, - index, - isInline: false, - renderNode, - }) - // Strip trailing newlines from block styles - list item component handles spacing - children = children.replace(/\n+$/, '') - } else { - children = renderBlockChildren(node, false, true) - } - - return itemHandler({ - value: node, - index, - listIndex: node._key ? listIndexMap.get(node._key) : undefined, - listDepth: node._key ? listDepthMap.get(node._key) : undefined, - isInline: false, - renderNode, - children, - }) - } - - function renderSpan(node: ToolkitNestedPortableTextSpan): string { - const {markDef, markType, markKey} = node - const resolvedMark = renderers.marks[markType] - const span = resolvedMark || renderers.unknownMark - - if (!resolvedMark && renderers.unknownMark === DefaultUnknownMarkRenderer) { - const type: SerializeDegradationType = markDef - ? 'annotation-dropped' - : 'decorator-dropped' - reportDegradation?.({ - ...(markDef - ? {type: 'annotation-dropped', markDefinition: markDef} - : {type: 'decorator-dropped', decorator: markType}), - message: serializeDegradationMessage[type](markType), - snippet: truncateSnippet(spanToPlainText(node)), - node, - owner: currentTextBlock ?? node, - }) - } - - const children = node.children.map((child, childIndex) => - renderNode({node: child, index: childIndex, isInline: true, renderNode}), - ) - - return span({ - text: spanToPlainText(node), - value: markDef, - markType, - markKey, - renderNode, - children: children.join(''), - }) - } - - function renderBlock( - node: PortableTextBlock, - index: number, - isInline: boolean, - isListItem: boolean, - ): string { - const style = node.style || 'normal' - const handler = - typeof renderers.block === 'function' - ? renderers.block - : renderers.block[style] - // A setext heading's lines are ordinary paragraph lines, so every line, - // the first included, gets the full line-start escaping. - const setextUnderline = - candidateForms && - !isInline && - !isListItem && - handler !== undefined && - toPlainText(node).includes('\n') - ? SETEXT_UNDERLINES.get(handler) - : undefined - const children = renderBlockChildren( - node, - HEADING_STYLES.has(style) && setextUnderline === undefined, - isListItem, - ) - const block = handler || renderers.unknownBlockStyle - - if ( - !handler && - renderers.unknownBlockStyle === DefaultUnknownStyleRenderer - ) { - reportDegradation?.({ - type: 'style-fallback', - style, - message: serializeDegradationMessage['style-fallback'](style), - snippet: truncateSnippet(toPlainText(node)), - node: reportedNodeAliases.get(node) ?? node, - owner: reportedNodeAliases.get(node) ?? node, - }) - } - - if (setextUnderline !== undefined) { - return `${children}\n${setextUnderline}` - } - - return block({index, isInline, children, value: node, renderNode}) - } - - function renderText(node: ToolkitTextNode): string { - if (node.text === '\n') { - return backslashBreakNodes.has(node) ? '\\\n' : renderers.hardBreak() - } - - return escapedTextByNode.get(node) ?? node.text - } - - function renderCustomBlock( - value: TypedObject, - index: number, - isInline: boolean, - ): string { - const component = renderers.types[value._type] ?? renderers.unknownType - - return component({ - value, - isInline, - index, - renderNode, - }) - } - - return renderNode -} diff --git a/packages/markdown/src/from-portable-text/renderers/block-spacing.ts b/packages/markdown/src/from-portable-text/renderers/block-spacing.ts deleted file mode 100644 index 6420f5d6ab..0000000000 --- a/packages/markdown/src/from-portable-text/renderers/block-spacing.ts +++ /dev/null @@ -1,39 +0,0 @@ -import { - isPortableTextBlock, - isPortableTextListItemBlock, -} from '@portabletext/toolkit' -import type {TypedObject} from '@portabletext/types' - -/** - * @public - */ -export type BlockSpacingRenderer = (options: { - current: TypedObject - next: TypedObject -}) => string | undefined - -/** - * @public - */ -export const DefaultBlockSpacingRenderer: BlockSpacingRenderer = ({ - current, - next, -}) => { - if ( - isPortableTextListItemBlock(current) && - isPortableTextListItemBlock(next) - ) { - return '\n' - } - - if ( - isPortableTextBlock(current) && - isPortableTextBlock(next) && - current.style === 'blockquote' && - next.style === 'blockquote' - ) { - return '\n>\n' - } - - return '\n\n' -} diff --git a/packages/markdown/src/from-portable-text/renderers/hard-break.ts b/packages/markdown/src/from-portable-text/renderers/hard-break.ts deleted file mode 100644 index 14f7c1b835..0000000000 --- a/packages/markdown/src/from-portable-text/renderers/hard-break.ts +++ /dev/null @@ -1,4 +0,0 @@ -/** - * @public - */ -export const DefaultHardBreakRenderer = (): string => ' \n' diff --git a/packages/markdown/src/from-portable-text/renderers/list-item.ts b/packages/markdown/src/from-portable-text/renderers/list-item.ts deleted file mode 100644 index 87eb74b7a4..0000000000 --- a/packages/markdown/src/from-portable-text/renderers/list-item.ts +++ /dev/null @@ -1,39 +0,0 @@ -import type {PortableTextListItemRenderer} from '../types' - -/** - * @public - */ -export const DefaultListItemRenderer: PortableTextListItemRenderer = ({ - children, - value, - listIndex, - listDepth, -}) => { - const listStyle = value.listItem || 'bullet' - const depth = listDepth ?? (value.level || 1) - 1 - const indent = ' '.repeat(depth) - - if (listStyle === 'number') { - return `${indent}${listIndex ?? 1}. ${children}` - } - - if (listStyle === 'task') { - const checked = - 'checked' in value && typeof value.checked === 'boolean' - ? value.checked - : false - const marker = checked ? '[x]' : '[ ]' - return `${indent}- ${marker} ${children}` - } - - return `${indent}- ${children}` -} - -/** - * @public - */ -export const DefaultUnknownListItemRenderer: PortableTextListItemRenderer = ({ - children, -}) => { - return `- ${children}\n` -} diff --git a/packages/markdown/src/from-portable-text/renderers/marks.ts b/packages/markdown/src/from-portable-text/renderers/marks.ts deleted file mode 100644 index 162d3087ae..0000000000 --- a/packages/markdown/src/from-portable-text/renderers/marks.ts +++ /dev/null @@ -1,145 +0,0 @@ -import type {TypedObject} from '@portabletext/types' -import {escapeImageAndLinkTitle} from '../../escape' -import type {PortableTextMarkRenderer} from '../types' - -/** - * @public - */ -export const DefaultEmRenderer: PortableTextMarkRenderer = ({children}) => - `_${children}_` - -/** - * @public - */ -export const DefaultStrongRenderer: PortableTextMarkRenderer = ({children}) => - `**${children}**` - -/** - * Renders a `code` decorator from the raw span text, bypassing the escaped - * `children`: code content is verbatim, never markdown syntax. The - * backtick fence is widened past the longest run of backticks already in - * the text (CommonMark: the fence must be longer than any run it encloses). - * - * A space is padded on each side when the content starts or ends with a - * backtick, so the fence and the content's own backtick never merge into - * one run, and also when the content starts and ends with a space and - * isn't all whitespace (`text.trim() !== ''`; an all-space code span has - * nothing else for CommonMark's strip rule to leave behind, so padding it - * would only add visible spaces), because CommonMark itself would - * otherwise strip one such space per side on reparse; the padding - * pre-compensates for that strip. - * - * @public - */ -export const DefaultCodeRenderer: PortableTextMarkRenderer = ({text}) => - wrapInCodeSpan(text) - -export function wrapInCodeSpan(text: string): string { - const fence = '`'.repeat(longestBacktickRun(text) + 1) - const touchesBacktick = text.startsWith('`') || text.endsWith('`') - const wouldBeStripped = - text.startsWith(' ') && text.endsWith(' ') && text.trim() !== '' - const padding = touchesBacktick || wouldBeStripped ? ' ' : '' - return `${fence}${padding}${text}${padding}${fence}` -} - -function longestBacktickRun(text: string): number { - let longest = 0 - for (const run of text.match(/`+/g) ?? []) { - longest = Math.max(longest, run.length) - } - return longest -} - -/** - * @public - */ -export const DefaultUnderlineRenderer: PortableTextMarkRenderer = ({ - children, -}) => `${children}` - -/** - * @public - */ -export const DefaultStrikeThroughRenderer: PortableTextMarkRenderer = ({ - children, -}) => `~~${children}~~` - -interface DefaultLink extends TypedObject { - _type: 'link' - href: string - title: string | undefined -} - -/** - * @public - */ -export const DefaultLinkRenderer: PortableTextMarkRenderer = ({ - children, - value, -}) => { - const href = value?.href || '' - const title = value?.title || '' - const looksSafe = uriLooksSafe(href) - - if (looksSafe) { - // Check if the URL looks like an HTML injection attempt - // If it has quotes AND angle brackets, or other suspicious patterns, encode more aggressively - const looksLikeInjection = /["'][^"']*[<>]|[<>][^<>]*["']/.test(href) - - if (looksLikeInjection) { - // Encode all special characters that could be used for injection - const encodedHref = href.replace(/["<>() ]/g, (char) => { - return `%${char.charCodeAt(0).toString(16).toUpperCase()}` - }) - return `[${children}](${encodedHref})` - } - - // For normal URLs, don't encode parentheses - Markdown handles balanced parens fine - return `[${children}](${href}${title ? ` "${escapeImageAndLinkTitle(title)}"` : ''})` - } - - // Return children without link when URL is unsafe - return children -} - -function uriLooksSafe(uri: string): boolean { - const url = (uri || '').trim() - const first = url.charAt(0) - - if (first === '#' || first === '/') { - return true - } - - const colonIndex = url.indexOf(':') - if (colonIndex === -1) { - return true - } - - const allowedProtocols = ['http', 'https', 'mailto', 'tel'] - const proto = url.slice(0, colonIndex).toLowerCase() - if (allowedProtocols.indexOf(proto) !== -1) { - return true - } - - const queryIndex = url.indexOf('?') - if (queryIndex !== -1 && colonIndex > queryIndex) { - return true - } - - const hashIndex = url.indexOf('#') - if (hashIndex !== -1 && colonIndex > hashIndex) { - return true - } - - return false -} - -/** - * @public - */ -export const DefaultUnknownMarkRenderer: PortableTextMarkRenderer = ({ - children, -}) => { - return children -} diff --git a/packages/markdown/src/from-portable-text/renderers/style.ts b/packages/markdown/src/from-portable-text/renderers/style.ts deleted file mode 100644 index 0569ee333b..0000000000 --- a/packages/markdown/src/from-portable-text/renderers/style.ts +++ /dev/null @@ -1,81 +0,0 @@ -import type {PortableTextBlock} from '@portabletext/types' -import type {PortableTextRenderer} from '../types' - -type PortableTextBlockRenderer = PortableTextRenderer - -/** - * @public - */ -export const DefaultNormalRenderer: PortableTextBlockRenderer = ({ - children, -}) => { - // Empty blocks should not add extra spacing - if (!children || children.trim() === '') { - return '' - } - - return children -} - -/** - * @public - */ -export const DefaultBlockquoteRenderer: PortableTextBlockRenderer = ({ - children, -}) => { - // Prefix each line with "> " for proper blockquote formatting - // This handles multi-line content and preserves empty lines - if (!children) { - return '>' - } - - return children - .split('\n') - .map((line) => `> ${line}`) - .join('\n') -} - -/** - * @public - */ -export const DefaultH1Renderer: PortableTextBlockRenderer = ({children}) => - `# ${children}` - -/** - * @public - */ -export const DefaultH2Renderer: PortableTextBlockRenderer = ({children}) => - `## ${children}` - -/** - * @public - */ -export const DefaultH3Renderer: PortableTextBlockRenderer = ({children}) => - `### ${children}` - -/** - * @public - */ -export const DefaultH4Renderer: PortableTextBlockRenderer = ({children}) => - `#### ${children}` - -/** - * @public - */ -export const DefaultH5Renderer: PortableTextBlockRenderer = ({children}) => - `##### ${children}` - -/** - * @public - */ -export const DefaultH6Renderer: PortableTextBlockRenderer = ({children}) => - `###### ${children}` - -/** - * @public - */ -export const DefaultUnknownStyleRenderer: PortableTextBlockRenderer = ({ - children, -}) => { - return children ?? '' -} diff --git a/packages/markdown/src/from-portable-text/renderers/type.ts b/packages/markdown/src/from-portable-text/renderers/type.ts deleted file mode 100644 index 316ed776e9..0000000000 --- a/packages/markdown/src/from-portable-text/renderers/type.ts +++ /dev/null @@ -1,561 +0,0 @@ -import {isTypedObject} from '@portabletext/schema' -import {isPortableTextBlock} from '@portabletext/toolkit' -import type {PortableTextBlock, TypedObject} from '@portabletext/types' -import { - escapeImageAndLinkText, - escapeImageAndLinkTitle, - escapeTableCell, -} from '../../escape' -import {markListItemFirstBlock} from '../list-item-first-block' -import type {PortableTextTypeRenderer} from '../types' -import {validateLink} from '../validate-link' -import {wrapInCodeSpan} from './marks' - -/** - * @public - */ -export const DefaultCodeBlockRenderer: PortableTextTypeRenderer<{ - _type: 'code' - code: string - language: string | undefined -}> = (options) => { - if (!isCodeShaped(options.value)) { - return DefaultUnknownTypeRenderer(options) - } - const fence = '`'.repeat( - Math.max(3, longestClosingFenceRun(options.value.code) + 1), - ) - return `${fence}${normalizeLanguage(options.value.language)}\n${options.value.code}\n${fence}` -} - -/** - * A code line closes the fence early when it is nothing but a backtick run - * at least as long as the opening fence (CommonMark allows up to 3 spaces - * of indent and trailing spaces or tabs). Backtick runs anywhere else in - * the code can't close it, so they don't widen the fence. Lines split on - * CommonMark's line endings only: a regex `m` flag would also split on - * U+2028 and U+2029, which Markdown reads as ordinary characters. - */ -function longestClosingFenceRun(code: string): number { - let longest = 0 - for (const line of code.split(/\r\n|\r|\n/)) { - const run = /^ {0,3}(`+)[ \t]*$/.exec(line)?.[1] - longest = Math.max(longest, run?.length ?? 0) - } - return longest -} - -function isCodeShaped(value: unknown): value is {code: string} { - return typeof (value as {code?: unknown} | null)?.code === 'string' -} - -/** - * A fence info string is everything after the opening fence on the same - * line, so a real `language` can never contain a newline, and the parser - * only ever produces a string. Junk in this optional field should not send - * an otherwise valid code block to the fenced-JSON path, so it is treated - * as absent instead of guarded. - */ -function normalizeLanguage(language: unknown): string { - if (typeof language !== 'string' || language.includes('\n')) { - return '' - } - if (language === 'json:object') { - // `json:object` is reserved as the object carrier: a code block - // emitting it as its info string would re-parse as the embedded - // object whenever its content happens to be typed JSON, destroying - // the code block. The language degrades to absent instead. - return '' - } - return language -} - -/** - * @public - */ -export const DefaultHorizontalRuleRenderer: PortableTextTypeRenderer = () => { - return '---' -} - -/** - * @public - */ -export const DefaultHtmlRenderer: PortableTextTypeRenderer<{ - _type: 'html' - html: string -}> = (options) => { - if (!isHtmlShaped(options.value)) { - return DefaultUnknownTypeRenderer(options) - } - return options.value.html -} - -function isHtmlShaped(value: unknown): value is {html: string} { - return typeof (value as {html?: unknown} | null)?.html === 'string' -} - -/** - * @public - */ -export const DefaultImageRenderer: PortableTextTypeRenderer<{ - _type: 'image' - src: string - alt: string | undefined - title: string | undefined -}> = (options) => { - if (!isImageShaped(options.value) || !validateLink(options.value.src)) { - // A `src` the parse side rejects would reparse as literal text instead - // of an image, so it is guarded the same way a malformed shape is - return DefaultUnknownTypeRenderer(options) - } - const alt = escapeImageAndLinkText(options.value.alt ?? '') - const title = options.value.title - ? ` "${escapeImageAndLinkTitle(options.value.title)}"` - : '' - return `![${alt}](${options.value.src}${title})` -} - -function isImageShaped(value: unknown): value is { - src: string - alt: string | null | undefined - title: string | null | undefined -} { - const image = value as {src?: unknown; alt?: unknown; title?: unknown} | null - return ( - typeof image?.src === 'string' && - // CMS payloads commonly store cleared optional strings as `null`; the - // render body treats `null` like absent, so the guard must too - (image.alt == null || typeof image.alt === 'string') && - (image.title == null || typeof image.title === 'string') - ) -} - -/** - * A table is table-shaped when everything the renderer dereferences is - * there: `rows` an array of typed objects with a `cells` array, every cell - * a typed object whose `value` array holds typed objects (`renderNode`'s - * input contract). The predicate narrows to exactly what `renderTable` - * consumes, so the renderer needs no casts. A malformed `table` value - * (e.g. a consumer's differently-shaped `table` type) falls back to the - * fenced-JSON path instead of throwing. - */ -function isTableShaped(value: unknown): value is TableShaped { - const rows = (value as {rows?: unknown} | null)?.rows - return ( - Array.isArray(rows) && - rows.every( - (row) => - isTypedObject(row) && - Array.isArray(row['cells']) && - row['cells'].every( - (cell) => - isTypedObject(cell) && - Array.isArray(cell['value']) && - cell['value'].every(isTypedObject), - ), - ) - ) -} - -type TableShaped = { - headerRows?: unknown - alignment?: unknown - rows: Array<{cells: Array<{value: Array}>}> -} - -/** - * Renders a Portable Text table block-object back to Markdown. - * - * The PT `headerRows` field decides the header. Missing `headerRows` and - * `headerRows === 0` both render headerless: GFM has no headerless form, so - * an empty header row is emitted and every row goes in the body (that empty - * header reads back as `headerRows: 0` via `markdownToPortableText`). - * `headerRows >= 1` promotes `rows[0]` to the header. GFM allows exactly one - * header row, so header rows beyond the first flatten into the body, lossy, - * but the extra rows stay on the Portable Text side. - * - * Asymmetric tables (rows of varying cell counts) are widened to match - * the row with the most cells. Narrower rows are padded with empty cells - * so a GFM parser doesn't silently drop the extra cells in wider rows. - * - * @public - */ -export const DefaultTableRenderer: PortableTextTypeRenderer<{ - _type: 'table' - headerRows: number | undefined - alignment: Array<'left' | 'center' | 'right' | null> | undefined - rows: Array<{ - _key: string - cells: Array<{ - _key: string - value: Array - }> - }> -}> = (options) => { - const {value, renderNode} = options - - if (!isTableShaped(value)) { - return DefaultUnknownTypeRenderer(options) - } - - return renderTable(value, renderNode) -} - -function renderTable( - value: TableShaped, - renderNode: Parameters[0]['renderNode'], -): string { - const rows = value.rows - // `alignment` is an extension field, not part of the table shape: junk - // here should not send an otherwise valid table to the fenced-JSON path, - // so it is normalized away instead of guarded (`{}.at` would throw below). - const alignment: ReadonlyArray | undefined = Array.isArray( - value.alignment, - ) - ? value.alignment - : undefined - - const headerRow = rows.at(0) - - if (!headerRow) { - return '' - } - - // Helper to extract text from cell blocks - const getCellText = (cellBlocks: Array): string => { - return cellBlocks - .map((block, index) => { - const rendered = renderNode({ - node: block, - index, - isInline: false, - renderNode, - }) - const rendererOptions = { - value: block, - isInline: false, - index, - renderNode, - } - if (rendered === DefaultUnknownTypeRenderer(rendererOptions)) { - // A GFM cell is one line, so the multi-line fence carrier - // would squash into `
` soup that reparses as plain text; - // the inline carrier is single-line and reparses to the same - // value. Exact-matched against the default carrier's output, - // so declared markdown forms and custom `unknownType` output - // pass through untouched. - return DefaultUnknownTypeRenderer({ - ...rendererOptions, - isInline: true, - }) - } - return rendered - }) - .join(' ') - .trim() - } - - const lines: string[] = [] - - // GFM requires every row to have the same number of cells as the header row - // and the delimiter row. Parsers silently drop excess cells from body rows - // that are wider than the header, so we widen the table to the widest row - // and pad narrower rows with empty cells to keep all data visible. - const columnCount = rows.reduce( - (max, row) => Math.max(max, row.cells.length), - 0, - ) - - const renderCells = (texts: Array): string => { - const padded = [...texts] - while (padded.length < columnCount) { - padded.push('') - } - return `| ${padded.join(' | ')} |` - } - - const renderRow = (cells: typeof headerRow.cells): string => - renderCells(cells.map((cell) => escapeTableCell(getCellText(cell.value)))) - - // Delimiter row, sized to the column count. Each cell's colons encode the - // column's alignment as defined by `value.alignment[columnIndex]`. - const separators = Array.from({length: columnCount}, (_, index) => { - const align = alignment?.at(index) - if (align === 'left') { - return ' :--- ' - } - if (align === 'center') { - return ' :---: ' - } - if (align === 'right') { - return ' ---: ' - } - return ' --- ' - }) - const delimiter = `|${separators.join('|')}|` - - const hasHeader = (Number(value.headerRows) || 0) >= 1 - - if (!hasHeader) { - // Headerless table: emit an empty header row and keep every row in the - // body. - lines.push(renderCells([])) - lines.push(delimiter) - for (const row of rows) { - lines.push(renderRow(row.cells)) - } - } else { - // `rows[0]` is the header. Header rows beyond the first flatten into the - // body (GFM has a single header row). - lines.push(renderRow(headerRow.cells)) - lines.push(delimiter) - for (let i = 1; i < rows.length; i++) { - const row = rows.at(i) - if (row) { - lines.push(renderRow(row.cells)) - } - } - } - - return lines.join('\n') -} - -/** - * @public - */ -export const DefaultCalloutRenderer: PortableTextTypeRenderer<{ - _type: 'callout' - tone: string - content: Array -}> = (options) => { - if (!isCalloutShaped(options.value)) { - return DefaultUnknownTypeRenderer(options) - } - const {renderNode} = options - const renderedContent = options.value.content - .map((block, index) => - renderNode({ - node: isPortableTextBlock(block) ? {...block, style: 'normal'} : block, - index, - isInline: false, - renderNode, - }), - ) - .filter((rendered) => rendered !== '') - .join('\n\n') - - const prefixed = renderedContent - .split('\n') - .map((line) => (line === '' ? '>' : `> ${line}`)) - .join('\n') - - return `> [!${options.value.tone.toUpperCase()}]\n${prefixed}` -} - -function isCalloutShaped( - value: unknown, -): value is {tone: string; content: Array} { - const callout = value as {tone?: unknown; content?: unknown} | null - return ( - typeof callout?.tone === 'string' && - Array.isArray(callout.content) && - callout.content.every(isTypedObject) - ) -} - -/** - * Renders a structural blockquote block-object (the `types.blockquote` shape - * produced by `markdownToPortableText` when a `types.blockquote` matcher is - * provided) back to Markdown. Each content block is rendered via the - * recursive renderer pipeline, joined with blank lines, and every line is - * prefixed with `> ` to form a Markdown blockquote. - * - * Distinct from `DefaultBlockquoteRenderer`, which renders flat-path text - * blocks with `style: 'blockquote'`. - * - * @public - */ -export const DefaultBlockquoteObjectRenderer: PortableTextTypeRenderer<{ - _type: 'blockquote' - content: Array -}> = ({value, renderNode}) => { - const renderedContent = value.content - .map((block, index) => - renderNode({ - node: isPortableTextBlock(block) ? {...block, style: 'normal'} : block, - index, - isInline: false, - renderNode, - }), - ) - .filter((rendered) => rendered !== '') - .join('\n\n') - - return renderedContent - .split('\n') - .map((line) => (line === '' ? '>' : `> ${line}`)) - .join('\n') -} - -/** - * Renders a structural list block-object (the `types.list` shape produced by - * `markdownToPortableText` when a `types.list` matcher is provided) back to - * Markdown. Items render as `- ` for `kind: 'bullet'`, `1. `/`2. ` for `'number'`, - * and `- [x] ` / `- [ ] ` for `'task'`. Items can hold any blocks - text blocks, - * code blocks, callouts, images, and nested lists - and content other than the - * leading text block is indented to keep it inside the item. - * - * @public - */ -export const DefaultListRenderer: PortableTextTypeRenderer<{ - _type: 'list' - kind: 'bullet' | 'number' | 'task' - items: Array<{ - _type: 'list-item' - _key: string - checked?: boolean - content: Array - }> -}> = ({value, renderNode}) => { - const renderedItems = value.items.map((item) => { - // The marker-line mark changes how `renderBlock` plans line-start - // hazard escaping, so it must be placed before rendering, but which - // block ends up on the marker line is only knowable after (blocks - // rendering to '' are dropped at join time). So every text block gets - // the mark until something renders output; a mark on a dropped empty - // block is harmless, consumed by its own render. Whether the first - // surviving block actually takes the marker line is decided at - // assembly below. - let markerLineSettled = false - - return item.content.map((block, blockIndex) => { - const isNestedList = (block as TypedObject)._type === 'list' - const isTextBlock = !isNestedList && isPortableTextBlock(block) - if (!markerLineSettled && isTextBlock) { - markListItemFirstBlock(block) - } - const text = renderNode({ - node: block as TypedObject, - index: blockIndex, - isInline: false, - renderNode, - }) - if (text !== '') { - markerLineSettled = true - } - return {isNestedList, isTextBlock, text} - }) - }) - - // A list is "loose" when any item carries multiple non-list-block - // content entries (a continuation paragraph, a code block, etc). - // CommonMark uses blank lines between items in loose lists; tight lists - // pack items together with single newlines. A nested list as a second - // child of an item does NOT make the list loose, and neither does a - // block that rendered to nothing, so we ignore both when counting. - const isLoose = renderedItems.some((renderedBlocks) => { - const nonNestedBlocks = renderedBlocks.filter( - (rendered) => !rendered.isNestedList && rendered.text !== '', - ) - return nonNestedBlocks.length > 1 - }) - const itemSeparator = isLoose ? '\n\n' : '\n' - - const lines = value.items.map((item, itemIndex) => { - const marker = getListMarker(value.kind, itemIndex, item.checked) - // Continuation indent matches the marker's width so that subsequent - // blocks attach to this item under CommonMark's lazy-continuation rule. - // Bullet `- ` indents to 2; ordered `1. ` indents to 3, `10. ` to 4. - // Task `- [x] ` is conceptually `- ` + a `[x] ` content prefix at the - // markdown-it level, so its continuation indent stays at 2. - const indentWidth = value.kind === 'task' ? 2 : marker.length - const indent = ' '.repeat(indentWidth) - const indentLines = (text: string) => - text - .split('\n') - .map((line) => (line === '' ? '' : `${indent}${line}`)) - .join('\n') - - const nonEmptyBlocks = (renderedItems[itemIndex] ?? []).filter( - (rendered) => rendered.text !== '', - ) - // A nested list never shares the marker line: fusing the markers into - // `- - sub` reparses the same for bullet and number kinds but reads as - // one doubled marker, and after a task checkbox the nested marker is - // literal text, destroying the sublist. A task item's marker line only - // takes a text block for the same reason: everything after `- [x] ` is - // inline text, so a promoted fence or table would reparse as words. - const markerLineCandidate = nonEmptyBlocks[0] - const promoted = - markerLineCandidate && - !markerLineCandidate.isNestedList && - (value.kind !== 'task' || markerLineCandidate.isTextBlock) - ? markerLineCandidate - : undefined - const rest = promoted ? nonEmptyBlocks.slice(1) : nonEmptyBlocks - // Only the promoted block's first line shares the marker line; its - // later lines (a code fence's body, a table's rows) are ordinary - // continuation lines that must sit under the item indent, or - // CommonMark ends the list at the first column-0 line. - const [promotedFirstLine = '', ...promotedRestLines] = ( - promoted?.text ?? '' - ).split('\n') - // Trailing whitespace is trimmed from the joined head, reaching only - // its last line: an empty item's `- ` becomes `-`, while a hard - // break's trailing spaces on an earlier line survive. - const head = [ - `${marker}${promotedFirstLine}`, - ...(promotedRestLines.length > 0 - ? [indentLines(promotedRestLines.join('\n'))] - : []), - ] - .join('\n') - .trimEnd() - if (rest.length === 0) { - return head - } - - const tail = rest - .map((rendered) => { - const indented = indentLines(rendered.text) - // Nested lists hug the previous block (tight list); other content - // gets a blank line separator (paragraph break). - return rendered.isNestedList ? `\n${indented}` : `\n\n${indented}` - }) - .join('') - - return `${head}${tail}` - }) - - return lines.join(itemSeparator) -} - -function getListMarker( - kind: 'bullet' | 'number' | 'task', - itemIndex: number, - checked: boolean | undefined, -): string { - if (kind === 'number') { - return `${itemIndex + 1}. ` - } - if (kind === 'task') { - return checked ? '- [x] ' : '- [ ] ' - } - return '- ' -} - -/** - * @public - */ -export const DefaultUnknownTypeRenderer: PortableTextTypeRenderer = ({ - value, - isInline, -}) => { - if (isInline) { - // Single-line JSON: code spans turn newlines into spaces on - // reparse, so a pretty-printed payload would still reconstruct but - // would not survive byte-identically, breaking the fixpoint. - return `json:object${wrapInCodeSpan(JSON.stringify(value))}` - } - return `\`\`\`json:object\n${JSON.stringify(value, null, 2)}\n\`\`\`` -} diff --git a/packages/markdown/src/from-portable-text/types.ts b/packages/markdown/src/from-portable-text/types.ts deleted file mode 100644 index 5a0407fbef..0000000000 --- a/packages/markdown/src/from-portable-text/types.ts +++ /dev/null @@ -1,267 +0,0 @@ -import type { - ArbitraryTypedObject, - PortableTextBlock, - PortableTextBlockStyle, - PortableTextListItemBlock, - PortableTextListItemType, - TypedObject, -} from '@portabletext/types' - -type LooseRecord = Record & { - [P in K]?: V // autocompleted keys -} - -/** - * Generic type for portable text renderers that takes blocks/inline blocks - * - * @public - */ -export type PortableTextRenderer = ( - options: PortableTextRendererOptions, -) => string - -/** - * Renderer function type for rendering portable text blocks (paragraphs, headings, blockquotes etc) - * - * @public - */ -export type PortableTextBlockRenderer = PortableTextRenderer - -/** - * Renderer function type for rendering portable text list items - * - * @public - */ -export type PortableTextListItemRenderer = - PortableTextRenderer - -/** - * Renderer function type for rendering portable text marks and/or decorators - * - * @public - */ -export type PortableTextMarkRenderer = ( - options: PortableTextMarkRendererOptions, -) => string - -/** - * @public - */ -export type PortableTextTypeRenderer = ( - options: PortableTextTypeRendererOptions, -) => string - -/** - * Object defining the different renderer functions to use for rendering various aspects - * of Portable Text and user-provided types to Markdown. - * - * @public - */ -export interface PortableTextRenderers { - /** - * Object of renderer functions for different types of objects that might appear - * both as part of the blocks array, or as inline objects _inside_ of a block, - * alongside text spans. - * - * Use the `isInline` property to check whether or not this is an inline object or a block. - * - * The object has the shape `{typeName: RendererFn}`, where `typeName` is the value set - * in individual `_type` attributes. - */ - types: Record - - /** - * Object of renderer functions for different types of marks that might appear in spans. - * - * The object has the shape `{markName: RendererFn}`, where `markName` is the value set - * in individual `_type` attributes, values being stored in the parent blocks `markDefs`. - */ - marks: Record - - /** - * Object of renderer functions for blocks with different `style` properties. - * - * The object has the shape `{styleName: RendererFn}`, where `styleName` is the value set - * in individual `style` attributes on blocks. - * - * Can also be set to a single renderer function, which would handle block styles of _any_ type. - */ - block: - | LooseRecord - | PortableTextBlockRenderer - - /** - * Object of renderer functions used to render different list item styles. - * - * The object has the shape `{listItemType: RendererFn}`, where `listItemType` is the value - * set in individual `listItem` attributes on blocks. - * - * Can also be set to a single renderer function, which would handle list items of _any_ type. - */ - listItem: - | LooseRecord< - PortableTextListItemType, - PortableTextListItemRenderer | undefined - > - | PortableTextListItemRenderer - - /** - * Renderer for "hard breaks", eg `\n` inside of text spans. - * By default renders as Markdown hard break (` \n` - two trailing spaces). - */ - hardBreak: () => string - - /** - * Renderer function used when encountering a mark type there is no registered renderer for - * in the `marks` option. - */ - unknownMark: PortableTextMarkRenderer - - /** - * Renderer function used when encountering an object type there is no registered renderer for - * in the `types` option. - */ - unknownType: PortableTextRenderer - - /** - * Renderer function used when encountering a block style there is no registered renderer for - * in the `block` option. Only used if `block` is an object. - */ - unknownBlockStyle: PortableTextRenderer - - /** - * Renderer function used when encountering a list item style there is no registered renderer for - * in the `listItem` option. Only used if `listItem` is an object. - */ - unknownListItem: PortableTextRenderer -} - -/** - * Options received by most Portable Text renderers - * - * @public - */ -export interface PortableTextRendererOptions { - /** - * Data associated with this portable text node, eg the raw JSON value of a block/type - */ - value: T - - /** - * Index within its parent - */ - index: number - - /** - * Index of a list item - */ - listIndex?: number | undefined - - /** - * How deeply the list item should be indented, starting at 0. - * - * This is not the same as the block's `level`. A list can start deeper than - * level 1, and can skip levels, neither of which Markdown can express, so each - * jump to a deeper level counts as a single step of nesting. - */ - listDepth?: number | undefined - - /** - * Whether or not this node is "inline" - ie as a child of a text block, - * alongside text spans, or a block in and of itself. - */ - isInline: boolean - - /** - * Serialized Markdown of child nodes of this block/type: nested marks - * rendered, markdown-significant punctuation in plain text - * backslash-escaped. Safe to embed in markdown output as-is. - */ - children?: string - - /** - * Function used to render any node that might appear in a portable text array or block, - * including virtual "toolkit"-nodes like lists and nested spans. You will rarely need - * to use this. - */ - renderNode: RenderNode -} - -/** - * Options received by any user-defined type renderer in the input array that is not a text block - * - * @public - */ -export type PortableTextTypeRendererOptions = Omit< - PortableTextRendererOptions, - 'children' -> - -/** - * Options received by Portable Text mark renderers - * - * @public - */ -export interface PortableTextMarkRendererOptions< - M extends TypedObject = ArbitraryTypedObject, -> { - /** - * Mark definition, eg the actual data of the annotation. If the mark is a simple decorator, this will be `undefined` - */ - value?: M - - /** - * The raw text content of this mark: the span text exactly as stored in - * the Portable Text, unescaped, with no nested mark rendering. Read this - * when your renderer emits its own delimiters or non-markdown output - * (code fences, HTML tags), where backslash escapes would appear - * literally. The built-in `code` decorator renderer works from this - * field. - */ - text: string - - /** - * Key for this mark. The same key can be used amongst multiple text spans within the same block, so don't rely on this to be unique. - */ - markKey: string | undefined - - /** - * Type of mark - ie value of `_type` in the case of annotations, or the name of the decorator otherwise - eg `em`, `italic`. - */ - markType: string - - /** - * Serialized Markdown of this mark's child nodes: nested marks are - * rendered and markdown-significant punctuation in plain text is - * backslash-escaped. Safe to embed in markdown output as-is; wrapping - * renderers (`**${children}**`) should always use this. For the raw, - * unescaped text, read `text` instead. - */ - children: string - - /** - * Function used to render any node that might appear in a portable text array or block, - * including virtual "toolkit"-nodes like lists and nested spans. You will rarely need - * to use this. - */ - renderNode: RenderNode -} - -/** - * Any node type that we can't identify - eg it has an `_type`, - * but we don't know anything about its other properties - */ -export type UnknownNodeType = - | {[key: string]: unknown; _type: string} - | TypedObject - -export type RenderNode = ( - options: Serializable, -) => string - -export interface Serializable { - node: T - index: number - isInline: boolean - renderNode: RenderNode -} diff --git a/packages/markdown/src/from-portable-text/validate-link.test.ts b/packages/markdown/src/from-portable-text/validate-link.test.ts deleted file mode 100644 index 50e6461d5e..0000000000 --- a/packages/markdown/src/from-portable-text/validate-link.test.ts +++ /dev/null @@ -1,57 +0,0 @@ -import markdownit from 'markdown-it' -import {describe, expect, test} from 'vitest' -import {validateLink} from './validate-link' - -describe(validateLink.name, () => { - const md = markdownit() - - test.each([ - ['http://example.com/foo.png', true], - ['https://example.com/foo.png', true], - ['HTTPS://EXAMPLE.COM/FOO.PNG', true], - ['mailto:foo@example.com', true], - ['/foo/bar.png', true], - ['./foo.png', true], - ['../foo.png', true], - ['foo.png', true], - ['#foo', true], - ['?foo=bar', true], - ['//example.com/foo.png', true], - ['', true], - ['ftp://example.com/foo.png', true], - ['javascript:alert(1)', false], - ['JavaScript:alert(1)', false], - ['JAVASCRIPT:alert(1)', false], - [' javascript:alert(1)', false], - ['\tjavascript:alert(1)', false], - ['\njavascript:alert(1)', false], - [' javascript:alert(1)', false], - ['javascript:alert(1)', false], - ['javascript:alert(1) ', false], - ['javascript :alert(1)', true], - ['java\tscript:alert(1)', true], - ['javascript:alert(1)', true], - ['javascript:alert(1)', true], - ['javascript%3Aalert(1)', true], - ['vbscript:msgbox(1)', false], - ['VBScript:msgbox(1)', false], - ['file:///etc/passwd', false], - ['FILE:///etc/passwd', false], - ['data:image/png;base64,Zm9v', true], - ['data:image/gif;base64,Zm9v', true], - ['data:image/jpeg;base64,Zm9v', true], - ['data:image/webp;base64,Zm9v', true], - ['DATA:IMAGE/PNG;BASE64,Zm9v', true], - [' data:image/png;base64,Zm9v', true], - ['data:image/svg+xml;base64,Zm9v', false], - ['data:image/jpg;base64,Zm9v', false], - ['data:image/png,Zm9v', false], - ['data:image/pngx;base64,Zm9v', false], - ['data:text/html;base64,Zm9v', false], - ['data:,foo', false], - ['xdata:image/svg+xml;base64,Zm9v', true], - ])('%j is %s', (url, expected) => { - expect(validateLink(url)).toBe(expected) - expect(md.validateLink(url)).toBe(expected) - }) -}) diff --git a/packages/markdown/src/from-portable-text/validate-link.ts b/packages/markdown/src/from-portable-text/validate-link.ts deleted file mode 100644 index f5e3650eaf..0000000000 --- a/packages/markdown/src/from-portable-text/validate-link.ts +++ /dev/null @@ -1,18 +0,0 @@ -const BAD_PROTOCOL = /^(vbscript|javascript|file|data):/ -const GOOD_DATA_URI = /^data:image\/(gif|png|jpeg|webp);/ - -/** - * The markdown-it instance the parse side builds - * (`to-portable-text/markdown-to-portable-text.ts`) never overrides - * `validateLink`, so markdown-it's default validator is the one that runs on - * reparse and this must stay identical to it: it rejects - * `javascript:`/`vbscript:`/`file:` and all `data:` URIs except - * png/gif/jpeg/webp. A copy instead of a `markdown-it` instance keeps - * markdown-it out of bundles that only serialize. - */ -export function validateLink(url: string): boolean { - const normalizedUrl = url.trim().toLowerCase() - return BAD_PROTOCOL.test(normalizedUrl) - ? GOOD_DATA_URI.test(normalizedUrl) - : true -} diff --git a/packages/markdown/src/index.ts b/packages/markdown/src/index.ts deleted file mode 100644 index e40f5a5e79..0000000000 --- a/packages/markdown/src/index.ts +++ /dev/null @@ -1,63 +0,0 @@ -export { - applyMarkdownEdit, - type ApplyMarkdownEditOptions, - type ReconciliationKeyPath, - type ReconciliationReport, - type UndeclaredObject, -} from './apply-markdown-edit' -export {portableTextToMarkdown} from './from-portable-text/portable-text-to-markdown' -export type {SerializeDegradation} from './from-portable-text/degradation-report' -export { - DefaultBlockSpacingRenderer, - type BlockSpacingRenderer, -} from './from-portable-text/renderers/block-spacing' -export {DefaultHardBreakRenderer} from './from-portable-text/renderers/hard-break' -export {DefaultListItemRenderer} from './from-portable-text/renderers/list-item' -export { - DefaultBlockquoteRenderer, - DefaultH1Renderer, - DefaultH2Renderer, - DefaultH3Renderer, - DefaultH4Renderer, - DefaultH5Renderer, - DefaultH6Renderer, - DefaultNormalRenderer, -} from './from-portable-text/renderers/style' -export { - DefaultCodeRenderer, - DefaultEmRenderer, - DefaultLinkRenderer, - DefaultStrikeThroughRenderer, - DefaultStrongRenderer, - DefaultUnderlineRenderer, -} from './from-portable-text/renderers/marks' -export { - DefaultBlockquoteObjectRenderer, - DefaultCalloutRenderer, - DefaultCodeBlockRenderer, - DefaultHorizontalRuleRenderer, - DefaultHtmlRenderer, - DefaultImageRenderer, - DefaultListRenderer, - DefaultTableRenderer, -} from './from-portable-text/renderers/type' -export type { - PortableTextBlockRenderer, - PortableTextListItemRenderer, - PortableTextMarkRenderer, - PortableTextMarkRendererOptions, - PortableTextRenderer, - PortableTextRendererOptions, - PortableTextRenderers, - PortableTextTypeRenderer, - PortableTextTypeRendererOptions, -} from './from-portable-text/types' -export {markdownToPortableText} from './to-portable-text/markdown-to-portable-text' -export type {Degradation} from './to-portable-text/markdown-to-portable-text' -export type { - AnnotationMatcher, - DecoratorMatcher, - ListItemMatcher, - ObjectMatcher, - StyleMatcher, -} from './to-portable-text/matchers' diff --git a/packages/markdown/src/key-generator.ts b/packages/markdown/src/key-generator.ts deleted file mode 100644 index 01a2b63a30..0000000000 --- a/packages/markdown/src/key-generator.ts +++ /dev/null @@ -1,73 +0,0 @@ -export function defaultKeyGenerator() { - return randomKey(12) -} - -/** - * Wraps a caller-supplied key generator so one conversion can never - * mint the same key twice. Keys attach content to its identity at - * creation time (a span's `marks` entry points at the mark definition - * minted with the same key), so a generator that repeats a key does - * not just violate sibling uniqueness, it makes ownership ambiguous in - * a way no later pass can repair: two definitions sharing a key leave - * every referencing span attributable to either. Bounded retries, then - * deterministic suffixing, mirroring how sibling-uniqueness repair - * treats a generator that keeps returning claimed keys. `reservedKeys` - * holds keys the input itself carries. Keys only need to be unique - * among siblings, but a caller may not know the final siblings before - * it mints, so a generated key never repeats a reserved one anywhere - * in the conversion. It is read on every call, so the caller can fill - * it after creating the generator but before the first key is minted. - */ -export function uniqueKeyGenerator( - generator: () => string, - reservedKeys: ReadonlySet = new Set(), -): () => string { - const mintedKeys = new Set() - const isClaimed = (key: string) => - mintedKeys.has(key) || reservedKeys.has(key) - return () => { - let candidate = generator() - for (let attempt = 0; attempt < 3 && isClaimed(candidate); attempt++) { - candidate = generator() - } - if (isClaimed(candidate)) { - const base = candidate - let suffix = 2 - while (isClaimed(`${base}-${suffix}`)) { - suffix++ - } - candidate = `${base}-${suffix}` - } - mintedKeys.add(candidate) - return candidate - } -} - -const getByteHexTable = (() => { - let table: any[] - return () => { - if (table) { - return table - } - - table = [] - for (let i = 0; i < 256; ++i) { - table[i] = (i + 0x100).toString(16).slice(1) - } - return table - } -})() - -// WHATWG crypto RNG - https://w3c.github.io/webcrypto/Overview.html -function whatwgRNG(length = 16) { - const rnds8 = new Uint8Array(length) - crypto.getRandomValues(rnds8) - return rnds8 -} - -function randomKey(length?: number): string { - const table = getByteHexTable() - return whatwgRNG(length) - .reduce((str, n) => str + table[n], '') - .slice(0, length) -} diff --git a/packages/markdown/src/markdown-to-portable-text.test.ts b/packages/markdown/src/markdown-to-portable-text.test.ts deleted file mode 100644 index ac6f5cbef3..0000000000 --- a/packages/markdown/src/markdown-to-portable-text.test.ts +++ /dev/null @@ -1,11133 +0,0 @@ -import { - compileSchema, - defineSchema, - type BlockObjectDefinition, -} from '@portabletext/schema' -import {createTestKeyGenerator, getTersePt} from '@portabletext/test' -import {describe, expect, test, vi} from 'vitest' -import { - defaultCalloutObjectDefinition, - defaultSchema, - defaultTableObjectDefinition, -} from './default-schema' -import {portableTextToMarkdown} from './from-portable-text/portable-text-to-markdown' -import { - markdownToPortableText, - type Degradation, -} from './to-portable-text/markdown-to-portable-text' -import {buildObjectMatcher} from './to-portable-text/matchers' - -/** - * Strips `_key` from a Portable Text tree before comparing two conversions - * that use independent key generators (a structural decline against the - * flat path it's supposed to mirror, say): the keys are never equal, only - * the structure needs to be. - */ -function omitKeys(value: T): T { - return JSON.parse( - JSON.stringify(value, (key, val) => (key === '_key' ? undefined : val)), - ) -} - -describe(markdownToPortableText.name, () => { - test('empty string', () => { - const keyGenerator = createTestKeyGenerator() - expect(markdownToPortableText('', {keyGenerator})).toEqual([]) - }) - - describe('custom block name', () => { - const schema = compileSchema( - defineSchema({ - ...defaultSchema, - block: {...defaultSchema.block, name: 'myBlock'}, - }), - ) - - test('marked paragraph', () => { - expect( - markdownToPortableText('foo **bar**', { - schema, - keyGenerator: createTestKeyGenerator(), - }), - ).toEqual([ - { - _type: 'myBlock', - _key: 'k0', - style: 'normal', - markDefs: [], - children: [ - {_type: 'span', _key: 'k1', text: 'foo ', marks: []}, - {_type: 'span', _key: 'k2', text: 'bar', marks: ['strong']}, - ], - }, - ]) - }) - - test('heading and nested list', () => { - expect( - markdownToPortableText('## foo\n\n- bar\n - baz', { - schema, - keyGenerator: createTestKeyGenerator(), - }), - ).toEqual([ - { - _type: 'myBlock', - _key: 'k0', - style: 'h2', - markDefs: [], - children: [{_type: 'span', _key: 'k1', text: 'foo', marks: []}], - }, - { - _type: 'myBlock', - _key: 'k2', - style: 'normal', - markDefs: [], - listItem: 'bullet', - level: 1, - children: [{_type: 'span', _key: 'k3', text: 'bar', marks: []}], - }, - { - _type: 'myBlock', - _key: 'k4', - style: 'normal', - markDefs: [], - listItem: 'bullet', - level: 2, - children: [{_type: 'span', _key: 'k5', text: 'baz', marks: []}], - }, - ]) - }) - - test('declined blockquote restyles paragraphs', () => { - expect( - markdownToPortableText('> foo', { - schema: { - ...schema, - blockObjects: [ - ...schema.blockObjects, - { - name: 'blockquote', - fields: [{name: 'content', type: 'array'}], - }, - ], - }, - keyGenerator: createTestKeyGenerator(), - types: {blockquote: () => undefined}, - }), - ).toEqual([ - { - _type: 'myBlock', - _key: 'k0', - style: 'blockquote', - markDefs: [], - children: [{_type: 'span', _key: 'k1', text: 'foo', marks: []}], - }, - ]) - }) - - test('declined structural list merges and stamps paragraphs', () => { - expect( - markdownToPortableText('- foo\n\n bar', { - schema: { - ...schema, - blockObjects: [ - ...schema.blockObjects, - { - name: 'list', - fields: [{name: 'items', type: 'array'}], - }, - ], - }, - keyGenerator: createTestKeyGenerator(), - types: {list: () => undefined}, - }), - ).toEqual([ - { - _type: 'myBlock', - _key: 'k1', - style: 'normal', - markDefs: [], - listItem: 'bullet', - level: 1, - children: [ - {_type: 'span', _key: 'k2', text: 'foo', marks: []}, - {_type: 'span', _key: 'k4', text: 'bar', marks: []}, - ], - }, - ]) - }) - - test('table cells lift images without renaming the image object', () => { - expect( - markdownToPortableText( - '| foo |\n| --- |\n| ![bar](https://example.com/bar.png) |', - { - schema, - keyGenerator: createTestKeyGenerator(), - }, - ), - ).toEqual([ - { - _type: 'table', - _key: 'k8', - headerRows: 1, - rows: [ - { - _type: 'row', - _key: 'k3', - cells: [ - { - _type: 'cell', - _key: 'k2', - value: [ - { - _type: 'myBlock', - _key: 'k0', - style: 'normal', - markDefs: [], - children: [ - {_type: 'span', _key: 'k1', text: 'foo', marks: []}, - ], - }, - ], - }, - ], - }, - { - _type: 'row', - _key: 'k7', - cells: [ - { - _type: 'cell', - _key: 'k6', - value: [ - { - _type: 'image', - _key: 'k5', - src: 'https://example.com/bar.png', - alt: 'bar', - }, - ], - }, - ], - }, - ], - }, - ]) - }) - - test('table cells report block images demoted beside text', () => { - const onDegradation = vi.fn() - - expect( - markdownToPortableText( - '| foo |\n| --- |\n| bar ![baz](https://example.com/baz.png) |', - { - schema: compileSchema( - defineSchema({ - ...schema, - inlineObjects: schema.inlineObjects.filter( - (inlineObject) => inlineObject.name !== 'image', - ), - }), - ), - keyGenerator: createTestKeyGenerator(), - onDegradation, - }, - ), - ).toEqual([ - { - _type: 'table', - _key: 'k9', - headerRows: 1, - rows: [ - { - _type: 'row', - _key: 'k3', - cells: [ - { - _type: 'cell', - _key: 'k2', - value: [ - { - _type: 'myBlock', - _key: 'k0', - style: 'normal', - markDefs: [], - children: [ - {_type: 'span', _key: 'k1', text: 'foo', marks: []}, - ], - }, - ], - }, - ], - }, - { - _type: 'row', - _key: 'k8', - cells: [ - { - _type: 'cell', - _key: 'k7', - value: [ - { - _type: 'myBlock', - _key: 'k4', - style: 'normal', - markDefs: [], - children: [ - {_type: 'span', _key: 'k5', text: 'bar ', marks: []}, - { - _type: 'image', - _key: 'k6', - src: 'https://example.com/baz.png', - alt: 'baz', - }, - ], - }, - ], - }, - ], - }, - ], - }, - ]) - expect(onDegradation.mock.calls).toEqual([ - [ - { - degradations: [ - { - type: 'image-block-to-inline', - message: - "The image became inline: a table cell can't hold a block-level `image`", - line: 1, - snippet: 'baz', - }, - ], - message: - 'Markdown could not be converted without loss:\n- line 1: The image became inline: a table cell can\'t hold a block-level `image` ("baz")', - }, - ], - ]) - }) - - test('table cells retain text and create an empty text block', () => { - expect( - markdownToPortableText('| foo | bar |\n| --- | --- |\n| baz | |', { - schema, - keyGenerator: createTestKeyGenerator(), - }), - ).toEqual([ - { - _type: 'table', - _key: 'k14', - headerRows: 1, - rows: [ - { - _type: 'row', - _key: 'k6', - cells: [ - { - _type: 'cell', - _key: 'k2', - value: [ - { - _type: 'myBlock', - _key: 'k0', - style: 'normal', - markDefs: [], - children: [ - {_type: 'span', _key: 'k1', text: 'foo', marks: []}, - ], - }, - ], - }, - { - _type: 'cell', - _key: 'k5', - value: [ - { - _type: 'myBlock', - _key: 'k3', - style: 'normal', - markDefs: [], - children: [ - {_type: 'span', _key: 'k4', text: 'bar', marks: []}, - ], - }, - ], - }, - ], - }, - { - _type: 'row', - _key: 'k13', - cells: [ - { - _type: 'cell', - _key: 'k9', - value: [ - { - _type: 'myBlock', - _key: 'k7', - style: 'normal', - markDefs: [], - children: [ - {_type: 'span', _key: 'k8', text: 'baz', marks: []}, - ], - }, - ], - }, - { - _type: 'cell', - _key: 'k12', - value: [ - { - _type: 'myBlock', - _key: 'k10', - style: 'normal', - markDefs: [], - children: [ - {_type: 'span', _key: 'k11', text: '', marks: []}, - ], - }, - ], - }, - ], - }, - ], - }, - ]) - }) - }) - - describe('style', () => { - describe('normal', () => { - test('default definition', () => { - const keyGenerator = createTestKeyGenerator() - expect(markdownToPortableText('foo', {keyGenerator})).toEqual([ - { - _type: 'block', - _key: 'k0', - children: [{_type: 'span', _key: 'k1', text: 'foo', marks: []}], - markDefs: [], - style: 'normal', - }, - ]) - }) - - test('custom definition', () => { - const keyGenerator = createTestKeyGenerator() - expect( - markdownToPortableText('foo', { - keyGenerator, - schema: compileSchema( - defineSchema({styles: [{name: 'paragraph'}]}), - ), - block: { - normal: ({context}) => { - return context.schema.styles.find( - (style) => style.name === 'paragraph', - )?.name - }, - }, - }), - ).toEqual([ - { - _type: 'block', - _key: 'k0', - children: [{_type: 'span', _key: 'k1', text: 'foo', marks: []}], - markDefs: [], - style: 'paragraph', - }, - ]) - }) - - test('no definition', () => { - const keyGenerator = createTestKeyGenerator() - expect( - markdownToPortableText('foo', { - keyGenerator, - schema: compileSchema(defineSchema({})), - }), - ).toEqual([ - { - _type: 'block', - _key: 'k0', - children: [{_type: 'span', _key: 'k1', text: 'foo', marks: []}], - markDefs: [], - style: 'normal', - }, - ]) - }) - }) - - describe('blockquote', () => { - test('default definition', () => { - const keyGenerator = createTestKeyGenerator() - expect(markdownToPortableText('> foo', {keyGenerator})).toEqual([ - { - _type: 'block', - _key: 'k0', - children: [{_type: 'span', _key: 'k1', text: 'foo', marks: []}], - markDefs: [], - style: 'blockquote', - }, - ]) - }) - - test('custom definition', () => { - const keyGenerator = createTestKeyGenerator() - expect( - markdownToPortableText('> foo', { - keyGenerator, - schema: compileSchema( - defineSchema({ - styles: [{name: 'normal'}, {name: 'quote'}], - }), - ), - block: { - blockquote: ({context}) => - context.schema.styles.find((style) => style.name === 'quote') - ?.name, - }, - }), - ).toEqual([ - { - _type: 'block', - _key: 'k0', - children: [{_type: 'span', _key: 'k1', text: 'foo', marks: []}], - markDefs: [], - style: 'quote', - }, - ]) - }) - - test('no definition', () => { - const keyGenerator = createTestKeyGenerator() - expect( - markdownToPortableText('> foo', { - keyGenerator, - schema: compileSchema(defineSchema({})), - }), - ).toEqual([ - { - _type: 'block', - _key: 'k0', - children: [{_type: 'span', _key: 'k1', text: 'foo', marks: []}], - markDefs: [], - style: 'normal', - }, - ]) - }) - - describe('multiple lines', () => { - const markdown = '> foo\n>\n> bar\n> baz' - - test('default definition', () => { - const keyGenerator = createTestKeyGenerator() - expect(markdownToPortableText(markdown, {keyGenerator})).toEqual([ - { - _type: 'block', - _key: 'k0', - children: [{_type: 'span', _key: 'k1', text: 'foo', marks: []}], - markDefs: [], - style: 'blockquote', - }, - { - _type: 'block', - _key: 'k2', - children: [ - {_type: 'span', _key: 'k3', text: 'bar baz', marks: []}, - ], - markDefs: [], - style: 'blockquote', - }, - ]) - }) - - test('custom definition', () => { - const keyGenerator = createTestKeyGenerator() - expect( - markdownToPortableText(markdown, { - keyGenerator, - schema: compileSchema(defineSchema({styles: [{name: 'quote'}]})), - block: { - blockquote: () => 'quote', - }, - }), - ).toEqual([ - { - _type: 'block', - _key: 'k0', - children: [{_type: 'span', _key: 'k1', text: 'foo', marks: []}], - markDefs: [], - style: 'quote', - }, - { - _type: 'block', - _key: 'k2', - children: [ - {_type: 'span', _key: 'k3', text: 'bar baz', marks: []}, - ], - markDefs: [], - style: 'quote', - }, - ]) - }) - - test('no definition', () => { - const keyGenerator = createTestKeyGenerator() - expect( - markdownToPortableText(markdown, { - keyGenerator, - schema: compileSchema(defineSchema({})), - }), - ).toEqual([ - { - _type: 'block', - _key: 'k0', - children: [{_type: 'span', _key: 'k1', text: 'foo', marks: []}], - markDefs: [], - style: 'normal', - }, - { - _type: 'block', - _key: 'k2', - children: [ - {_type: 'span', _key: 'k3', text: 'bar baz', marks: []}, - ], - markDefs: [], - style: 'normal', - }, - ]) - }) - }) - - test('nested blockquotes', () => { - const keyGenerator = createTestKeyGenerator() - const markdown = ['> outer', '> > inner'].join('\n') - expect(markdownToPortableText(markdown, {keyGenerator})).toEqual([ - { - _type: 'block', - _key: 'k0', - children: [{_type: 'span', _key: 'k1', text: 'outer', marks: []}], - markDefs: [], - style: 'blockquote', - }, - { - _type: 'block', - _key: 'k2', - children: [{_type: 'span', _key: 'k3', text: 'inner', marks: []}], - markDefs: [], - style: 'blockquote', - }, - ]) - }) - - describe('with list items', () => { - const markdown = ['> - foo', '> - bar'].join('\n') - - test('default definition', () => { - const keyGenerator = createTestKeyGenerator() - expect(markdownToPortableText(markdown, {keyGenerator})).toEqual([ - { - _type: 'block', - _key: 'k0', - children: [{_type: 'span', _key: 'k1', text: 'foo', marks: []}], - markDefs: [], - style: 'blockquote', - listItem: 'bullet', - level: 1, - }, - { - _type: 'block', - _key: 'k2', - children: [{_type: 'span', _key: 'k3', text: 'bar', marks: []}], - markDefs: [], - style: 'blockquote', - listItem: 'bullet', - level: 1, - }, - ]) - }) - - test('only list definition', () => { - const keyGenerator = createTestKeyGenerator() - expect( - markdownToPortableText(markdown, { - keyGenerator, - schema: compileSchema(defineSchema({lists: [{name: 'bullet'}]})), - }), - ).toEqual([ - { - _type: 'block', - _key: 'k0', - children: [{_type: 'span', _key: 'k1', text: 'foo', marks: []}], - markDefs: [], - style: 'normal', - listItem: 'bullet', - level: 1, - }, - { - _type: 'block', - _key: 'k2', - children: [{_type: 'span', _key: 'k3', text: 'bar', marks: []}], - markDefs: [], - style: 'normal', - listItem: 'bullet', - level: 1, - }, - ]) - }) - }) - }) - - describe('h1', () => { - test('default definition', () => { - const keyGenerator = createTestKeyGenerator() - expect(markdownToPortableText('# foo', {keyGenerator})).toEqual([ - { - _type: 'block', - _key: 'k0', - children: [{_type: 'span', _key: 'k1', text: 'foo', marks: []}], - markDefs: [], - style: 'h1', - }, - ]) - }) - - test('custom definition', () => { - const keyGenerator = createTestKeyGenerator() - expect( - markdownToPortableText('# foo', { - keyGenerator, - schema: compileSchema( - defineSchema({styles: [{name: 'heading 1'}]}), - ), - block: { - h1: ({context}) => { - return context.schema.styles.find( - (style) => style.name === 'heading 1', - )?.name - }, - }, - }), - ).toEqual([ - { - _type: 'block', - _key: 'k0', - children: [{_type: 'span', _key: 'k1', text: 'foo', marks: []}], - markDefs: [], - style: 'heading 1', - }, - ]) - }) - - test('no definition', () => { - const keyGenerator = createTestKeyGenerator() - expect( - markdownToPortableText('# foo', { - keyGenerator, - schema: compileSchema(defineSchema({})), - }), - ).toEqual([ - { - _type: 'block', - _key: 'k0', - children: [{_type: 'span', _key: 'k1', text: 'foo', marks: []}], - markDefs: [], - style: 'normal', - }, - ]) - }) - }) - }) - - describe('hard breaks', () => { - test('backslash syntax', () => { - const keyGenerator = createTestKeyGenerator() - expect(markdownToPortableText('foo\\\nbar', {keyGenerator})).toEqual([ - { - _type: 'block', - _key: 'k0', - children: [{_type: 'span', _key: 'k1', text: 'foo\nbar', marks: []}], - markDefs: [], - style: 'normal', - }, - ]) - }) - - test('two-space syntax', () => { - const keyGenerator = createTestKeyGenerator() - expect(markdownToPortableText('foo \nbar', {keyGenerator})).toEqual([ - { - _type: 'block', - _key: 'k0', - children: [{_type: 'span', _key: 'k1', text: 'foo\nbar', marks: []}], - markDefs: [], - style: 'normal', - }, - ]) - }) - - test('multiple hard breaks in same paragraph', () => { - const keyGenerator = createTestKeyGenerator() - expect( - markdownToPortableText('foo \nbar \nbaz', {keyGenerator}), - ).toEqual([ - { - _type: 'block', - _key: 'k0', - children: [ - {_type: 'span', _key: 'k1', text: 'foo\nbar\nbaz', marks: []}, - ], - markDefs: [], - style: 'normal', - }, - ]) - }) - - test('hard break in list item', () => { - const keyGenerator = createTestKeyGenerator() - expect(markdownToPortableText('- foo \n bar', {keyGenerator})).toEqual([ - { - _type: 'block', - _key: 'k0', - children: [{_type: 'span', _key: 'k1', text: 'foo\nbar', marks: []}], - markDefs: [], - style: 'normal', - listItem: 'bullet', - level: 1, - }, - ]) - }) - }) - - describe('soft breaks', () => { - test('produces a single space', () => { - const keyGenerator = createTestKeyGenerator() - expect(markdownToPortableText('foo\nbar', {keyGenerator})).toEqual([ - { - _type: 'block', - _key: 'k0', - children: [{_type: 'span', _key: 'k1', text: 'foo bar', marks: []}], - markDefs: [], - style: 'normal', - }, - ]) - }) - - test('a single trailing space before the newline does not produce a double space', () => { - const keyGenerator = createTestKeyGenerator() - expect(markdownToPortableText('foo \nbar', {keyGenerator})).toEqual([ - { - _type: 'block', - _key: 'k0', - children: [{_type: 'span', _key: 'k1', text: 'foo bar', marks: []}], - markDefs: [], - style: 'normal', - }, - ]) - }) - - test('an indented continuation line collapses to a single space', () => { - const keyGenerator = createTestKeyGenerator() - expect(markdownToPortableText('foo\n bar', {keyGenerator})).toEqual([ - { - _type: 'block', - _key: 'k0', - children: [{_type: 'span', _key: 'k1', text: 'foo bar', marks: []}], - markDefs: [], - style: 'normal', - }, - ]) - }) - - test('soft break inside a blockquote', () => { - const keyGenerator = createTestKeyGenerator() - const markdown = ['> foo', '> bar'].join('\n') - expect(markdownToPortableText(markdown, {keyGenerator})).toEqual([ - { - _type: 'block', - _key: 'k0', - children: [{_type: 'span', _key: 'k1', text: 'foo bar', marks: []}], - markDefs: [], - style: 'blockquote', - }, - ]) - }) - - test('soft break inside a list item', () => { - const keyGenerator = createTestKeyGenerator() - expect(markdownToPortableText('- foo\n bar', {keyGenerator})).toEqual([ - { - _type: 'block', - _key: 'k0', - children: [{_type: 'span', _key: 'k1', text: 'foo bar', marks: []}], - markDefs: [], - style: 'normal', - listItem: 'bullet', - level: 1, - }, - ]) - }) - - test('a soft break inside marked text keeps the mark across the joined span', () => { - const keyGenerator = createTestKeyGenerator() - expect(markdownToPortableText('*foo\nbar*', {keyGenerator})).toEqual([ - { - _type: 'block', - _key: 'k0', - style: 'normal', - children: [ - {_type: 'span', _key: 'k1', text: 'foo bar', marks: ['em']}, - ], - markDefs: [], - }, - ]) - }) - - test('a two-space hard break still produces a literal newline, not a space', () => { - const keyGenerator = createTestKeyGenerator() - expect(markdownToPortableText('foo \nbar', {keyGenerator})).toEqual([ - { - _type: 'block', - _key: 'k0', - children: [{_type: 'span', _key: 'k1', text: 'foo\nbar', marks: []}], - markDefs: [], - style: 'normal', - }, - ]) - }) - - test('a backslash hard break still produces a literal newline, not a space', () => { - const keyGenerator = createTestKeyGenerator() - expect(markdownToPortableText('foo\\\nbar', {keyGenerator})).toEqual([ - { - _type: 'block', - _key: 'k0', - children: [{_type: 'span', _key: 'k1', text: 'foo\nbar', marks: []}], - markDefs: [], - style: 'normal', - }, - ]) - }) - - test('round-trips through portableTextToMarkdown as a fixpoint', () => { - const blocks = markdownToPortableText('foo\nbar', { - keyGenerator: createTestKeyGenerator(), - }) - const markdown = portableTextToMarkdown(blocks) - - expect(markdown).toBe('foo bar') - - const blocksAgain = markdownToPortableText(markdown, { - keyGenerator: createTestKeyGenerator(), - }) - expect(portableTextToMarkdown(blocksAgain)).toBe(markdown) - }) - }) - - describe('decorators', () => { - describe('strong', () => { - test('default definition', () => { - const keyGenerator = createTestKeyGenerator() - expect( - markdownToPortableText('foo **bar** baz', {keyGenerator}), - ).toEqual([ - { - _type: 'block', - _key: 'k0', - children: [ - {_type: 'span', _key: 'k1', text: 'foo ', marks: []}, - {_type: 'span', _key: 'k2', text: 'bar', marks: ['strong']}, - {_type: 'span', _key: 'k3', text: ' baz', marks: []}, - ], - markDefs: [], - style: 'normal', - }, - ]) - }) - - test('custom definition', () => { - const keyGenerator = createTestKeyGenerator() - expect( - markdownToPortableText('foo **bar** baz', { - keyGenerator, - schema: compileSchema(defineSchema({decorators: [{name: 'bold'}]})), - marks: { - strong: () => 'bold', - }, - }), - ).toEqual([ - { - _type: 'block', - _key: 'k0', - children: [ - {_type: 'span', _key: 'k1', text: 'foo ', marks: []}, - {_type: 'span', _key: 'k2', text: 'bar', marks: ['bold']}, - {_type: 'span', _key: 'k3', text: ' baz', marks: []}, - ], - markDefs: [], - style: 'normal', - }, - ]) - }) - - test('no definition', () => { - const keyGenerator = createTestKeyGenerator() - expect( - markdownToPortableText('foo **bar** baz', { - keyGenerator, - schema: compileSchema(defineSchema({})), - }), - ).toEqual([ - { - _type: 'block', - _key: 'k0', - children: [ - {_type: 'span', _key: 'k1', text: 'foo bar baz', marks: []}, - ], - markDefs: [], - style: 'normal', - }, - ]) - }) - }) - - describe('emphasis', () => { - test('default definition', () => { - const keyGenerator = createTestKeyGenerator() - expect(markdownToPortableText('foo _bar_ baz', {keyGenerator})).toEqual( - [ - { - _type: 'block', - _key: 'k0', - children: [ - {_type: 'span', _key: 'k1', text: 'foo ', marks: []}, - {_type: 'span', _key: 'k2', text: 'bar', marks: ['em']}, - {_type: 'span', _key: 'k3', text: ' baz', marks: []}, - ], - markDefs: [], - style: 'normal', - }, - ], - ) - }) - - test('custom definition', () => { - const keyGenerator = createTestKeyGenerator() - expect( - markdownToPortableText('foo _bar_ baz', { - keyGenerator, - schema: compileSchema( - defineSchema({decorators: [{name: 'italic'}]}), - ), - marks: { - em: () => 'italic', - }, - }), - ).toEqual([ - { - _type: 'block', - _key: 'k0', - children: [ - {_type: 'span', _key: 'k1', text: 'foo ', marks: []}, - {_type: 'span', _key: 'k2', text: 'bar', marks: ['italic']}, - {_type: 'span', _key: 'k3', text: ' baz', marks: []}, - ], - markDefs: [], - style: 'normal', - }, - ]) - }) - - test('no definition', () => { - const keyGenerator = createTestKeyGenerator() - expect( - markdownToPortableText('foo _bar_ baz', { - keyGenerator, - schema: compileSchema(defineSchema({})), - }), - ).toEqual([ - { - _type: 'block', - _key: 'k0', - children: [ - {_type: 'span', _key: 'k1', text: 'foo bar baz', marks: []}, - ], - markDefs: [], - style: 'normal', - }, - ]) - }) - }) - - describe('code', () => { - test('default definition', () => { - const keyGenerator = createTestKeyGenerator() - expect(markdownToPortableText('foo `bar` baz', {keyGenerator})).toEqual( - [ - { - _type: 'block', - _key: 'k0', - children: [ - {_type: 'span', _key: 'k1', text: 'foo ', marks: []}, - {_type: 'span', _key: 'k2', text: 'bar', marks: ['code']}, - {_type: 'span', _key: 'k3', text: ' baz', marks: []}, - ], - markDefs: [], - style: 'normal', - }, - ], - ) - }) - - test('custom definition', () => { - const keyGenerator = createTestKeyGenerator() - expect( - markdownToPortableText('foo `bar` baz', { - keyGenerator, - schema: compileSchema( - defineSchema({decorators: [{name: 'monospace'}]}), - ), - marks: { - code: () => 'monospace', - }, - }), - ).toEqual([ - { - _type: 'block', - _key: 'k0', - children: [ - {_type: 'span', _key: 'k1', text: 'foo ', marks: []}, - {_type: 'span', _key: 'k2', text: 'bar', marks: ['monospace']}, - {_type: 'span', _key: 'k3', text: ' baz', marks: []}, - ], - markDefs: [], - style: 'normal', - }, - ]) - }) - - test('no definition', () => { - const keyGenerator = createTestKeyGenerator() - expect( - markdownToPortableText('foo `bar` baz', { - keyGenerator, - schema: compileSchema(defineSchema({})), - }), - ).toEqual([ - { - _type: 'block', - _key: 'k0', - children: [ - {_type: 'span', _key: 'k1', text: 'foo bar baz', marks: []}, - ], - markDefs: [], - style: 'normal', - }, - ]) - }) - }) - - describe('strike-through', () => { - test('default definition', () => { - const keyGenerator = createTestKeyGenerator() - expect( - markdownToPortableText('foo ~~bar~~ baz', {keyGenerator}), - ).toEqual([ - { - _type: 'block', - _key: 'k0', - children: [ - {_type: 'span', _key: 'k1', text: 'foo ', marks: []}, - { - _type: 'span', - _key: 'k2', - text: 'bar', - marks: ['strike-through'], - }, - {_type: 'span', _key: 'k3', text: ' baz', marks: []}, - ], - markDefs: [], - style: 'normal', - }, - ]) - }) - - test('custom definition', () => { - const keyGenerator = createTestKeyGenerator() - expect( - markdownToPortableText('foo ~~bar~~ baz', { - keyGenerator, - schema: compileSchema( - defineSchema({decorators: [{name: 'strikethrough'}]}), - ), - marks: { - strikeThrough: () => 'strikethrough', - }, - }), - ).toEqual([ - { - _type: 'block', - _key: 'k0', - children: [ - {_type: 'span', _key: 'k1', text: 'foo ', marks: []}, - { - _type: 'span', - _key: 'k2', - text: 'bar', - marks: ['strikethrough'], - }, - {_type: 'span', _key: 'k3', text: ' baz', marks: []}, - ], - markDefs: [], - style: 'normal', - }, - ]) - }) - - test('no definition', () => { - const keyGenerator = createTestKeyGenerator() - expect( - markdownToPortableText('foo ~~bar~~ baz', { - keyGenerator, - schema: compileSchema(defineSchema({})), - }), - ).toEqual([ - { - _type: 'block', - _key: 'k0', - children: [ - {_type: 'span', _key: 'k1', text: 'foo bar baz', marks: []}, - ], - markDefs: [], - style: 'normal', - }, - ]) - }) - }) - - describe('underline', () => { - test('default definition', () => { - const keyGenerator = createTestKeyGenerator() - const onDegradation = vi.fn() - expect( - markdownToPortableText('foo bar baz', { - keyGenerator, - onDegradation, - }), - ).toEqual([ - { - _type: 'block', - _key: 'k0', - children: [ - {_type: 'span', _key: 'k1', text: 'foo ', marks: []}, - {_type: 'span', _key: 'k2', text: 'bar', marks: ['underline']}, - {_type: 'span', _key: 'k3', text: ' baz', marks: []}, - ], - markDefs: [], - style: 'normal', - }, - ]) - expect(onDegradation).not.toHaveBeenCalled() - }) - - test('uppercase tags', () => { - const keyGenerator = createTestKeyGenerator() - expect( - markdownToPortableText('foo bar baz', {keyGenerator}), - ).toEqual([ - { - _type: 'block', - _key: 'k0', - children: [ - {_type: 'span', _key: 'k1', text: 'foo ', marks: []}, - {_type: 'span', _key: 'k2', text: 'bar', marks: ['underline']}, - {_type: 'span', _key: 'k3', text: ' baz', marks: []}, - ], - markDefs: [], - style: 'normal', - }, - ]) - }) - - test('inside strong', () => { - const keyGenerator = createTestKeyGenerator() - expect( - markdownToPortableText('**foo bar** baz', {keyGenerator}), - ).toEqual([ - { - _type: 'block', - _key: 'k0', - children: [ - {_type: 'span', _key: 'k1', text: 'foo ', marks: ['strong']}, - { - _type: 'span', - _key: 'k2', - text: 'bar', - marks: ['strong', 'underline'], - }, - {_type: 'span', _key: 'k3', text: ' baz', marks: []}, - ], - markDefs: [], - style: 'normal', - }, - ]) - }) - - test('around strong', () => { - const keyGenerator = createTestKeyGenerator() - expect( - markdownToPortableText('foo **bar** baz', {keyGenerator}), - ).toEqual([ - { - _type: 'block', - _key: 'k0', - children: [ - {_type: 'span', _key: 'k1', text: 'foo ', marks: ['underline']}, - { - _type: 'span', - _key: 'k2', - text: 'bar', - marks: ['underline', 'strong'], - }, - {_type: 'span', _key: 'k3', text: ' baz', marks: []}, - ], - markDefs: [], - style: 'normal', - }, - ]) - }) - - test('`html.inline` set to `text`', () => { - const keyGenerator = createTestKeyGenerator() - expect( - markdownToPortableText('foo bar baz', { - keyGenerator, - html: {inline: 'text'}, - }), - ).toEqual([ - { - _type: 'block', - _key: 'k0', - children: [ - {_type: 'span', _key: 'k1', text: 'foo ', marks: []}, - {_type: 'span', _key: 'k2', text: 'bar', marks: ['underline']}, - { - _type: 'span', - _key: 'k3', - text: ' baz', - marks: [], - }, - ], - markDefs: [], - style: 'normal', - }, - ]) - }) - - test('custom definition', () => { - const keyGenerator = createTestKeyGenerator() - expect( - markdownToPortableText('foo bar baz', { - keyGenerator, - schema: compileSchema(defineSchema({decorators: [{name: 'u'}]})), - marks: { - underline: () => 'u', - }, - }), - ).toEqual([ - { - _type: 'block', - _key: 'k0', - children: [ - {_type: 'span', _key: 'k1', text: 'foo ', marks: []}, - {_type: 'span', _key: 'k2', text: 'bar', marks: ['u']}, - {_type: 'span', _key: 'k3', text: ' baz', marks: []}, - ], - markDefs: [], - style: 'normal', - }, - ]) - }) - - test('no definition', () => { - const keyGenerator = createTestKeyGenerator() - const onDegradation = vi.fn() - expect( - markdownToPortableText('foo bar baz', { - keyGenerator, - schema: compileSchema(defineSchema({})), - onDegradation, - }), - ).toEqual([ - { - _type: 'block', - _key: 'k0', - children: [ - {_type: 'span', _key: 'k1', text: 'foo bar baz', marks: []}, - ], - markDefs: [], - style: 'normal', - }, - ]) - expect(onDegradation.mock.calls).toEqual([ - [ - { - degradations: [ - { - type: 'decorator-dropped', - message: - 'Removed underline formatting, kept the text: the schema has no `underline` decorator', - line: 1, - snippet: 'bar', - }, - ], - message: - 'Markdown could not be converted without loss:\n- line 1: Removed underline formatting, kept the text: the schema has no `underline` decorator ("bar")', - }, - ], - ]) - }) - - test('a tag with attributes stays dropped inline HTML', () => { - const keyGenerator = createTestKeyGenerator() - const onDegradation = vi.fn() - expect( - markdownToPortableText('foo bar baz', { - keyGenerator, - onDegradation, - }), - ).toEqual([ - { - _type: 'block', - _key: 'k0', - children: [ - {_type: 'span', _key: 'k1', text: 'foo bar baz', marks: []}, - ], - markDefs: [], - style: 'normal', - }, - ]) - expect( - onDegradation.mock.calls.map(([report]) => report.degradations), - ).toEqual([ - [ - { - type: 'inline-html-dropped', - message: - 'Removed inline HTML tags, kept nothing: `html.inline` is `skip` (the default)', - line: 1, - snippet: '', - }, - { - type: 'inline-html-dropped', - message: - 'Removed inline HTML tags, kept nothing: `html.inline` is `skip` (the default)', - line: 1, - snippet: '', - }, - ], - ]) - }) - - test('an unclosed open tag underlines nothing, in its block or the next', () => { - const keyGenerator = createTestKeyGenerator() - const onDegradation = vi.fn() - expect( - markdownToPortableText('foo bar\n\nbaz', { - keyGenerator, - onDegradation, - }), - ).toEqual([ - { - _type: 'block', - _key: 'k0', - children: [{_type: 'span', _key: 'k1', text: 'foo bar', marks: []}], - markDefs: [], - style: 'normal', - }, - { - _type: 'block', - _key: 'k2', - children: [{_type: 'span', _key: 'k3', text: 'baz', marks: []}], - markDefs: [], - style: 'normal', - }, - ]) - expect( - onDegradation.mock.calls.map(([report]) => report.degradations), - ).toEqual([ - [ - { - type: 'inline-html-dropped', - message: - 'Removed inline HTML tags, kept nothing: `html.inline` is `skip` (the default)', - line: 1, - snippet: '', - }, - ], - ]) - }) - - test('an unmatched close tag stays dropped inline HTML', () => { - const keyGenerator = createTestKeyGenerator() - const onDegradation = vi.fn() - expect( - markdownToPortableText('**foo bar**', { - keyGenerator, - onDegradation, - }), - ).toEqual([ - { - _type: 'block', - _key: 'k0', - children: [ - {_type: 'span', _key: 'k1', text: 'foo bar', marks: ['strong']}, - ], - markDefs: [], - style: 'normal', - }, - ]) - expect( - onDegradation.mock.calls.map(([report]) => report.degradations), - ).toEqual([ - [ - { - type: 'inline-html-dropped', - message: - 'Removed inline HTML tags, kept nothing: `html.inline` is `skip` (the default)', - line: 1, - snippet: '', - }, - ], - ]) - }) - }) - }) - - describe('link', () => { - test('bold that wraps a link and continues after it', () => { - const keyGenerator = createTestKeyGenerator() - expect( - markdownToPortableText('**[x](https://example.com) y** z', { - keyGenerator, - }), - ).toEqual([ - { - _type: 'block', - _key: 'k0', - children: [ - { - _type: 'span', - _key: 'k2', - text: 'x', - marks: ['strong', 'k1'], - }, - { - _type: 'span', - _key: 'k3', - text: ' y', - marks: ['strong'], - }, - { - _type: 'span', - _key: 'k4', - text: ' z', - marks: [], - }, - ], - markDefs: [{_type: 'link', _key: 'k1', href: 'https://example.com'}], - style: 'normal', - }, - ]) - }) - - test('default definition', () => { - const keyGenerator = createTestKeyGenerator() - expect( - markdownToPortableText('foo [bar](https://example.com) baz', { - keyGenerator, - }), - ).toEqual([ - { - _type: 'block', - _key: 'k0', - children: [ - {_type: 'span', _key: 'k1', text: 'foo ', marks: []}, - {_type: 'span', _key: 'k3', text: 'bar', marks: ['k2']}, - {_type: 'span', _key: 'k4', text: ' baz', marks: []}, - ], - markDefs: [ - { - _key: 'k2', - _type: 'link', - href: 'https://example.com', - }, - ], - style: 'normal', - }, - ]) - }) - - test('no href field', () => { - const keyGenerator = createTestKeyGenerator() - expect( - markdownToPortableText('foo [bar](https://example.com) baz', { - keyGenerator, - schema: compileSchema( - defineSchema({annotations: [{name: 'link', fields: []}]}), - ), - }), - ).toEqual([ - { - _type: 'block', - _key: 'k0', - children: [ - {_type: 'span', _key: 'k1', text: 'foo ', marks: []}, - {_type: 'span', _key: 'k3', text: 'bar', marks: ['k2']}, - {_type: 'span', _key: 'k4', text: ' baz', marks: []}, - ], - markDefs: [ - { - _key: 'k2', - _type: 'link', - }, - ], - style: 'normal', - }, - ]) - }) - - test('custom definition', () => { - const keyGenerator = createTestKeyGenerator() - expect( - markdownToPortableText('foo [bar](https://example.com) baz', { - keyGenerator, - schema: compileSchema( - defineSchema({ - annotations: [ - { - name: 'internal link', - fields: [{name: 'url', type: 'string'}], - }, - ], - }), - ), - marks: { - link: ({context, value}) => { - return { - _type: 'internal link', - _key: context.keyGenerator(), - url: value.href, - } - }, - }, - }), - ).toEqual([ - { - _type: 'block', - _key: 'k0', - children: [ - {_type: 'span', _key: 'k1', text: 'foo ', marks: []}, - {_type: 'span', _key: 'k3', text: 'bar', marks: ['k2']}, - {_type: 'span', _key: 'k4', text: ' baz', marks: []}, - ], - markDefs: [ - { - _key: 'k2', - _type: 'internal link', - url: 'https://example.com', - }, - ], - style: 'normal', - }, - ]) - }) - - test('no definition', () => { - const keyGenerator = createTestKeyGenerator() - expect( - markdownToPortableText('foo [bar](https://example.com) baz', { - keyGenerator, - schema: compileSchema(defineSchema({})), - }), - ).toEqual([ - { - _type: 'block', - _key: 'k0', - children: [ - {_type: 'span', _key: 'k1', text: 'foo bar baz', marks: []}, - ], - markDefs: [], - style: 'normal', - }, - ]) - }) - - test('with title', () => { - const keyGenerator = createTestKeyGenerator() - expect( - markdownToPortableText( - 'foo [bar](https://example.com "Link Title") baz', - { - keyGenerator, - }, - ), - ).toEqual([ - { - _type: 'block', - _key: 'k0', - children: [ - {_type: 'span', _key: 'k1', text: 'foo ', marks: []}, - {_type: 'span', _key: 'k3', text: 'bar', marks: ['k2']}, - {_type: 'span', _key: 'k4', text: ' baz', marks: []}, - ], - markDefs: [ - { - _key: 'k2', - _type: 'link', - href: 'https://example.com', - title: 'Link Title', - }, - ], - style: 'normal', - }, - ]) - }) - - test('autolink', () => { - const keyGenerator = createTestKeyGenerator() - expect( - markdownToPortableText('Visit for more', { - keyGenerator, - }), - ).toEqual([ - { - _type: 'block', - _key: 'k0', - children: [ - {_type: 'span', _key: 'k1', text: 'Visit ', marks: []}, - { - _type: 'span', - _key: 'k3', - text: 'https://example.com', - marks: ['k2'], - }, - {_type: 'span', _key: 'k4', text: ' for more', marks: []}, - ], - markDefs: [ - { - _key: 'k2', - _type: 'link', - href: 'https://example.com', - }, - ], - style: 'normal', - }, - ]) - }) - - test('reference-style link', () => { - const keyGenerator = createTestKeyGenerator() - const markdown = [ - 'See [my site][ref] for details', - '', - '[ref]: https://example.com "My Site"', - ].join('\n') - expect( - markdownToPortableText(markdown, { - keyGenerator, - }), - ).toEqual([ - { - _type: 'block', - _key: 'k0', - children: [ - {_type: 'span', _key: 'k1', text: 'See ', marks: []}, - {_type: 'span', _key: 'k3', text: 'my site', marks: ['k2']}, - {_type: 'span', _key: 'k4', text: ' for details', marks: []}, - ], - markDefs: [ - { - _key: 'k2', - _type: 'link', - href: 'https://example.com', - title: 'My Site', - }, - ], - style: 'normal', - }, - ]) - }) - - test('link with escaped bracket in text', () => { - const keyGenerator = createTestKeyGenerator() - expect( - markdownToPortableText('foo [b\\[ar](https://example.com) baz', { - keyGenerator, - }), - ).toEqual([ - { - _type: 'block', - _key: 'k0', - children: [ - { - _type: 'span', - _key: 'k1', - text: 'foo ', - marks: [], - }, - { - _type: 'span', - _key: 'k3', - text: 'b[ar', - marks: ['k2'], - }, - { - _type: 'span', - _key: 'k4', - text: ' baz', - marks: [], - }, - ], - markDefs: [ - { - _key: 'k2', - _type: 'link', - href: 'https://example.com', - }, - ], - style: 'normal', - }, - ]) - }) - }) - - test('link with escaped backslash in text', () => { - const keyGenerator = createTestKeyGenerator() - expect( - markdownToPortableText('foo [b\\\\ar](https://example.com) baz', { - keyGenerator, - }), - ).toEqual([ - { - _type: 'block', - _key: 'k0', - children: [ - { - _type: 'span', - _key: 'k1', - text: 'foo ', - marks: [], - }, - { - _type: 'span', - _key: 'k3', - text: 'b\\ar', - marks: ['k2'], - }, - { - _type: 'span', - _key: 'k4', - text: ' baz', - marks: [], - }, - ], - markDefs: [ - { - _key: 'k2', - _type: 'link', - href: 'https://example.com', - }, - ], - style: 'normal', - }, - ]) - }) - - describe('lists', () => { - describe('unordered', () => { - test('default unordered list', () => { - const keyGenerator = createTestKeyGenerator() - expect(markdownToPortableText('- foo', {keyGenerator})).toEqual([ - { - _type: 'block', - _key: 'k0', - children: [{_type: 'span', _key: 'k1', text: 'foo', marks: []}], - markDefs: [], - style: 'normal', - listItem: 'bullet', - level: 1, - }, - ]) - }) - - test('custom unordered list', () => { - const keyGenerator = createTestKeyGenerator() - expect( - markdownToPortableText('- foo', { - keyGenerator, - schema: compileSchema(defineSchema({lists: [{name: 'dot'}]})), - listItem: { - bullet: ({context}) => { - return context.schema.lists.find((list) => list.name === 'dot') - ?.name - }, - }, - }), - ).toEqual([ - { - _type: 'block', - _key: 'k0', - children: [{_type: 'span', _key: 'k1', text: 'foo', marks: []}], - markDefs: [], - style: 'normal', - listItem: 'dot', - level: 1, - }, - ]) - }) - - test('no unordered list', () => { - const keyGenerator = createTestKeyGenerator() - expect( - markdownToPortableText('- foo', { - keyGenerator, - schema: compileSchema(defineSchema({})), - }), - ).toEqual([ - { - _type: 'block', - _key: 'k0', - children: [{_type: 'span', _key: 'k1', text: 'foo', marks: []}], - markDefs: [], - style: 'normal', - }, - ]) - }) - }) - - describe('ordered', () => { - test('default ordered list', () => { - const keyGenerator = createTestKeyGenerator() - expect(markdownToPortableText('1. foo', {keyGenerator})).toEqual([ - { - _type: 'block', - _key: 'k0', - children: [{_type: 'span', _key: 'k1', text: 'foo', marks: []}], - markDefs: [], - style: 'normal', - listItem: 'number', - level: 1, - }, - ]) - }) - - test('custom ordered list', () => { - const keyGenerator = createTestKeyGenerator() - expect( - markdownToPortableText('1. foo', { - keyGenerator, - schema: compileSchema(defineSchema({lists: [{name: 'alpha'}]})), - listItem: { - number: ({context}) => { - return context.schema.lists.find( - (list) => list.name === 'alpha', - )?.name - }, - }, - }), - ).toEqual([ - { - _type: 'block', - _key: 'k0', - children: [{_type: 'span', _key: 'k1', text: 'foo', marks: []}], - markDefs: [], - style: 'normal', - listItem: 'alpha', - level: 1, - }, - ]) - }) - - test('no ordered list', () => { - const keyGenerator = createTestKeyGenerator() - expect( - markdownToPortableText('1. foo', { - keyGenerator, - schema: compileSchema(defineSchema({})), - }), - ).toEqual([ - { - _type: 'block', - _key: 'k0', - children: [{_type: 'span', _key: 'k1', text: 'foo', marks: []}], - markDefs: [], - style: 'normal', - }, - ]) - }) - }) - - describe('nested', () => { - test('unordered nested lists', () => { - const keyGenerator = createTestKeyGenerator() - const markdown = [ - '- Level 1 item 1', - ' - Level 2 item 1', - ' - Level 2 item 2', - ' - Level 3 item 1', - '- Level 1 item 2', - ].join('\n') - expect(markdownToPortableText(markdown, {keyGenerator})).toEqual([ - { - _type: 'block', - _key: 'k0', - children: [ - {_type: 'span', _key: 'k1', text: 'Level 1 item 1', marks: []}, - ], - markDefs: [], - style: 'normal', - listItem: 'bullet', - level: 1, - }, - { - _type: 'block', - _key: 'k2', - children: [ - {_type: 'span', _key: 'k3', text: 'Level 2 item 1', marks: []}, - ], - markDefs: [], - style: 'normal', - listItem: 'bullet', - level: 2, - }, - { - _type: 'block', - _key: 'k4', - children: [ - {_type: 'span', _key: 'k5', text: 'Level 2 item 2', marks: []}, - ], - markDefs: [], - style: 'normal', - listItem: 'bullet', - level: 2, - }, - { - _type: 'block', - _key: 'k6', - children: [ - {_type: 'span', _key: 'k7', text: 'Level 3 item 1', marks: []}, - ], - markDefs: [], - style: 'normal', - listItem: 'bullet', - level: 3, - }, - { - _type: 'block', - _key: 'k8', - children: [ - {_type: 'span', _key: 'k9', text: 'Level 1 item 2', marks: []}, - ], - markDefs: [], - style: 'normal', - listItem: 'bullet', - level: 1, - }, - ]) - }) - - test('ordered nested lists', () => { - const keyGenerator = createTestKeyGenerator() - const markdown = [ - '1. Level 1 item 1', - ' 1. Level 2 item 1', - ' 2. Level 2 item 2', - '2. Level 1 item 2', - ].join('\n') - expect(markdownToPortableText(markdown, {keyGenerator})).toEqual([ - { - _type: 'block', - _key: 'k0', - children: [ - {_type: 'span', _key: 'k1', text: 'Level 1 item 1', marks: []}, - ], - markDefs: [], - style: 'normal', - listItem: 'number', - level: 1, - }, - { - _type: 'block', - _key: 'k2', - children: [ - {_type: 'span', _key: 'k3', text: 'Level 2 item 1', marks: []}, - ], - markDefs: [], - style: 'normal', - listItem: 'number', - level: 2, - }, - { - _type: 'block', - _key: 'k4', - children: [ - {_type: 'span', _key: 'k5', text: 'Level 2 item 2', marks: []}, - ], - markDefs: [], - style: 'normal', - listItem: 'number', - level: 2, - }, - { - _type: 'block', - _key: 'k6', - children: [ - {_type: 'span', _key: 'k7', text: 'Level 1 item 2', marks: []}, - ], - markDefs: [], - style: 'normal', - listItem: 'number', - level: 1, - }, - ]) - }) - - test('mixed nested lists', () => { - const keyGenerator = createTestKeyGenerator() - const markdown = [ - '1. Ordered item', - ' - Unordered nested', - ' - Another unordered', - '2. Back to ordered', - ].join('\n') - expect(markdownToPortableText(markdown, {keyGenerator})).toEqual([ - { - _type: 'block', - _key: 'k0', - children: [ - {_type: 'span', _key: 'k1', text: 'Ordered item', marks: []}, - ], - markDefs: [], - style: 'normal', - listItem: 'number', - level: 1, - }, - { - _type: 'block', - _key: 'k2', - children: [ - {_type: 'span', _key: 'k3', text: 'Unordered nested', marks: []}, - ], - markDefs: [], - style: 'normal', - listItem: 'bullet', - level: 2, - }, - { - _type: 'block', - _key: 'k4', - children: [ - { - _type: 'span', - _key: 'k5', - text: 'Another unordered', - marks: [], - }, - ], - markDefs: [], - style: 'normal', - listItem: 'bullet', - level: 2, - }, - { - _type: 'block', - _key: 'k6', - children: [ - {_type: 'span', _key: 'k7', text: 'Back to ordered', marks: []}, - ], - markDefs: [], - style: 'normal', - listItem: 'number', - level: 1, - }, - ]) - }) - - test('mixed, deeply nested lists', () => { - const keyGenerator = createTestKeyGenerator() - const markdown = [ - '1. Ordered parent', - ' - Unordered child', - ' - Another unordered', - ' 1. Back to ordered', - ' 2. Still ordered', - '2. Continue ordered parent', - ].join('\n') - const portableText = markdownToPortableText(markdown, {keyGenerator}) - - expect( - getTersePt({ - schema: defaultSchema, - value: portableText, - }), - ).toEqual([ - '>#:Ordered parent', - '>>-:Unordered child', - '>>-:Another unordered', - '>>>#:Back to ordered', - '>>>#:Still ordered', - '>#:Continue ordered parent', - ]) - }) - }) - - describe('task', () => { - test('default unchecked task', () => { - const keyGenerator = createTestKeyGenerator() - expect(markdownToPortableText('- [ ] foo', {keyGenerator})).toEqual([ - { - _type: 'block', - _key: 'k0', - children: [{_type: 'span', _key: 'k1', text: 'foo', marks: []}], - markDefs: [], - style: 'normal', - listItem: 'task', - level: 1, - checked: false, - }, - ]) - }) - - test('default checked task', () => { - const keyGenerator = createTestKeyGenerator() - expect(markdownToPortableText('- [x] foo', {keyGenerator})).toEqual([ - { - _type: 'block', - _key: 'k0', - children: [{_type: 'span', _key: 'k1', text: 'foo', marks: []}], - markDefs: [], - style: 'normal', - listItem: 'task', - level: 1, - checked: true, - }, - ]) - }) - - test('uppercase X is also checked', () => { - const keyGenerator = createTestKeyGenerator() - expect(markdownToPortableText('- [X] foo', {keyGenerator})).toEqual([ - { - _type: 'block', - _key: 'k0', - children: [{_type: 'span', _key: 'k1', text: 'foo', marks: []}], - markDefs: [], - style: 'normal', - listItem: 'task', - level: 1, - checked: true, - }, - ]) - }) - - test('custom task list type', () => { - const keyGenerator = createTestKeyGenerator() - expect( - markdownToPortableText('- [x] foo', { - keyGenerator, - schema: compileSchema(defineSchema({lists: [{name: 'todo'}]})), - listItem: { - task: ({context}) => { - return context.schema.lists.find((list) => list.name === 'todo') - ?.name - }, - }, - }), - ).toEqual([ - { - _type: 'block', - _key: 'k0', - children: [{_type: 'span', _key: 'k1', text: 'foo', marks: []}], - markDefs: [], - style: 'normal', - listItem: 'todo', - level: 1, - checked: true, - }, - ]) - }) - - test('falls back to bullet when schema does not declare a task list', () => { - const keyGenerator = createTestKeyGenerator() - expect( - markdownToPortableText('- [x] foo', { - keyGenerator, - schema: compileSchema(defineSchema({lists: [{name: 'bullet'}]})), - }), - ).toEqual([ - { - _type: 'block', - _key: 'k0', - children: [{_type: 'span', _key: 'k1', text: 'foo', marks: []}], - markDefs: [], - style: 'normal', - listItem: 'bullet', - level: 1, - }, - ]) - }) - - test('mixed task and plain bullet items', () => { - const keyGenerator = createTestKeyGenerator() - const markdown = ['- [ ] todo', '- done', '- [x] also done'].join('\n') - expect(markdownToPortableText(markdown, {keyGenerator})).toEqual([ - { - _type: 'block', - _key: 'k0', - children: [{_type: 'span', _key: 'k1', text: 'todo', marks: []}], - markDefs: [], - style: 'normal', - listItem: 'task', - level: 1, - checked: false, - }, - { - _type: 'block', - _key: 'k2', - children: [{_type: 'span', _key: 'k3', text: 'done', marks: []}], - markDefs: [], - style: 'normal', - listItem: 'bullet', - level: 1, - }, - { - _type: 'block', - _key: 'k4', - children: [ - {_type: 'span', _key: 'k5', text: 'also done', marks: []}, - ], - markDefs: [], - style: 'normal', - listItem: 'task', - level: 1, - checked: true, - }, - ]) - }) - - test('task nested under bullet', () => { - const keyGenerator = createTestKeyGenerator() - const markdown = ['- foo', ' - [x] bar'].join('\n') - expect(markdownToPortableText(markdown, {keyGenerator})).toEqual([ - { - _type: 'block', - _key: 'k0', - children: [{_type: 'span', _key: 'k1', text: 'foo', marks: []}], - markDefs: [], - style: 'normal', - listItem: 'bullet', - level: 1, - }, - { - _type: 'block', - _key: 'k2', - children: [{_type: 'span', _key: 'k3', text: 'bar', marks: []}], - markDefs: [], - style: 'normal', - listItem: 'task', - level: 2, - checked: true, - }, - ]) - }) - }) - - describe('with code block', () => { - const markdown = [ - '1. foo', - '', - ' const foo = "bar"', - '', - ' bar', - ].join('\n') - - test('default definition', () => { - const keyGenerator = createTestKeyGenerator() - expect(markdownToPortableText(markdown, {keyGenerator})).toEqual([ - { - _key: 'k0', - _type: 'block', - children: [ - { - _key: 'k1', - _type: 'span', - text: 'foo', - marks: [], - }, - ], - markDefs: [], - style: 'normal', - listItem: 'number', - level: 1, - }, - { - _key: 'k2', - _type: 'code', - code: 'const foo = "bar"', - }, - { - _key: 'k3', - _type: 'block', - children: [{_type: 'span', _key: 'k4', text: 'bar', marks: []}], - markDefs: [], - style: 'normal', - listItem: 'number', - level: 1, - }, - ]) - }) - - test('no code block definition', () => { - const keyGenerator = createTestKeyGenerator() - expect( - markdownToPortableText(markdown, { - keyGenerator, - schema: compileSchema(defineSchema({lists: [{name: 'number'}]})), - }), - ).toEqual([ - { - _key: 'k0', - _type: 'block', - children: [ - { - _key: 'k1', - _type: 'span', - text: 'foo', - marks: [], - }, - ], - markDefs: [], - style: 'normal', - listItem: 'number', - level: 1, - }, - { - _key: 'k2', - _type: 'block', - children: [ - { - _key: 'k3', - _type: 'span', - text: 'const foo = "bar"', - marks: [], - }, - ], - markDefs: [], - style: 'normal', - }, - { - _key: 'k4', - _type: 'block', - children: [{_type: 'span', _key: 'k5', text: 'bar', marks: []}], - markDefs: [], - style: 'normal', - listItem: 'number', - level: 1, - }, - ]) - }) - }) - - describe('with multiline code block', () => { - const markdown = [ - '3. Dump everything in the pot and follow', - ' this algorithm:', - '', - ' find wooden spoon', - ' uncover pot', - ' stir', - ' cover pot', - ' balance wooden spoon precariously on pot handle', - ' wait 10 minutes', - ' goto first step (or shut off burner when done)', - '', - ' Do not bump wooden spoon or it will fall.', - ].join('\n') - - test('default definition', () => { - const keyGenerator = createTestKeyGenerator() - expect(markdownToPortableText(markdown, {keyGenerator})).toEqual([ - { - _key: 'k0', - _type: 'block', - children: [ - { - _key: 'k1', - _type: 'span', - text: 'Dump everything in the pot and follow this algorithm:', - marks: [], - }, - ], - markDefs: [], - style: 'normal', - listItem: 'number', - level: 1, - }, - { - _key: 'k2', - _type: 'code', - code: 'find wooden spoon\nuncover pot\nstir\ncover pot\nbalance wooden spoon precariously on pot handle\nwait 10 minutes\ngoto first step (or shut off burner when done)', - }, - { - _key: 'k3', - _type: 'block', - children: [ - { - _key: 'k4', - _type: 'span', - text: 'Do not bump wooden spoon or it will fall.', - marks: [], - }, - ], - markDefs: [], - style: 'normal', - listItem: 'number', - level: 1, - }, - ]) - }) - - test('no code block definition', () => { - const keyGenerator = createTestKeyGenerator() - expect( - markdownToPortableText(markdown, { - keyGenerator, - schema: compileSchema(defineSchema({lists: [{name: 'number'}]})), - }), - ).toEqual([ - { - _key: 'k0', - _type: 'block', - children: [ - { - _key: 'k1', - _type: 'span', - text: 'Dump everything in the pot and follow this algorithm:', - marks: [], - }, - ], - markDefs: [], - style: 'normal', - listItem: 'number', - level: 1, - }, - { - _key: 'k2', - _type: 'block', - children: [ - { - _key: 'k3', - _type: 'span', - text: 'find wooden spoon\nuncover pot\nstir\ncover pot\nbalance wooden spoon precariously on pot handle\nwait 10 minutes\ngoto first step (or shut off burner when done)', - marks: [], - }, - ], - markDefs: [], - style: 'normal', - }, - { - _key: 'k4', - _type: 'block', - children: [ - { - _key: 'k5', - _type: 'span', - text: 'Do not bump wooden spoon or it will fall.', - marks: [], - }, - ], - markDefs: [], - style: 'normal', - listItem: 'number', - level: 1, - }, - ]) - }) - }) - - describe('with fenced code block', () => { - const markdown = [ - '1. foo', - '', - ' ```js', - ' const foo = "bar"', - ' ```', - '', - ' bar', - ].join('\n') - - test('default definition', () => { - const keyGenerator = createTestKeyGenerator() - expect(markdownToPortableText(markdown, {keyGenerator})).toEqual([ - { - _key: 'k0', - _type: 'block', - children: [{_key: 'k1', _type: 'span', text: 'foo', marks: []}], - markDefs: [], - style: 'normal', - listItem: 'number', - level: 1, - }, - { - _key: 'k2', - _type: 'code', - code: 'const foo = "bar"', - language: 'js', - }, - { - _key: 'k3', - _type: 'block', - children: [{_type: 'span', _key: 'k4', text: 'bar', marks: []}], - markDefs: [], - style: 'normal', - listItem: 'number', - level: 1, - }, - ]) - }) - - test('no code block definition', () => { - const keyGenerator = createTestKeyGenerator() - expect( - markdownToPortableText(markdown, { - keyGenerator, - schema: compileSchema(defineSchema({lists: [{name: 'number'}]})), - }), - ).toEqual([ - { - _key: 'k0', - _type: 'block', - children: [{_key: 'k1', _type: 'span', text: 'foo', marks: []}], - markDefs: [], - style: 'normal', - listItem: 'number', - level: 1, - }, - { - _key: 'k2', - _type: 'block', - children: [ - {_key: 'k3', _type: 'span', text: 'const foo = "bar"', marks: []}, - ], - markDefs: [], - style: 'normal', - }, - { - _key: 'k4', - _type: 'block', - children: [{_type: 'span', _key: 'k5', text: 'bar', marks: []}], - markDefs: [], - style: 'normal', - listItem: 'number', - level: 1, - }, - ]) - }) - }) - - describe('with multiline fenced code block', () => { - const markdown = [ - '1. foo', - '', - ' ```js', - ' line1', - ' line2', - ' line3', - ' ```', - '', - ' bar', - ].join('\n') - - test('default definition', () => { - const keyGenerator = createTestKeyGenerator() - expect(markdownToPortableText(markdown, {keyGenerator})).toEqual([ - { - _key: 'k0', - _type: 'block', - children: [{_key: 'k1', _type: 'span', text: 'foo', marks: []}], - markDefs: [], - style: 'normal', - listItem: 'number', - level: 1, - }, - { - _key: 'k2', - _type: 'code', - code: 'line1\nline2\nline3', - language: 'js', - }, - { - _key: 'k3', - _type: 'block', - children: [{_type: 'span', _key: 'k4', text: 'bar', marks: []}], - markDefs: [], - style: 'normal', - listItem: 'number', - level: 1, - }, - ]) - }) - - test('no code block definition', () => { - const keyGenerator = createTestKeyGenerator() - expect( - markdownToPortableText(markdown, { - keyGenerator, - schema: compileSchema(defineSchema({lists: [{name: 'number'}]})), - }), - ).toEqual([ - { - _key: 'k0', - _type: 'block', - children: [{_key: 'k1', _type: 'span', text: 'foo', marks: []}], - markDefs: [], - style: 'normal', - listItem: 'number', - level: 1, - }, - { - _key: 'k2', - _type: 'block', - children: [ - { - _key: 'k3', - _type: 'span', - text: 'line1\nline2\nline3', - marks: [], - }, - ], - markDefs: [], - style: 'normal', - }, - { - _key: 'k4', - _type: 'block', - children: [{_type: 'span', _key: 'k5', text: 'bar', marks: []}], - markDefs: [], - style: 'normal', - listItem: 'number', - level: 1, - }, - ]) - }) - }) - - describe('with block image', () => { - const markdown = [ - '1. foo', - '', - ' ![alt](https://example.com/image.png)', - '', - ' bar', - ].join('\n') - - test('default definition', () => { - const keyGenerator = createTestKeyGenerator() - expect(markdownToPortableText(markdown, {keyGenerator})).toEqual([ - { - _key: 'k0', - _type: 'block', - children: [{_key: 'k1', _type: 'span', text: 'foo', marks: []}], - markDefs: [], - style: 'normal', - listItem: 'number', - level: 1, - }, - { - _key: 'k2', - _type: 'image', - src: 'https://example.com/image.png', - alt: 'alt', - }, - { - _key: 'k3', - _type: 'block', - children: [{_type: 'span', _key: 'k4', text: 'bar', marks: []}], - markDefs: [], - style: 'normal', - listItem: 'number', - level: 1, - }, - ]) - }) - - test('only inline image definition', () => { - const keyGenerator = createTestKeyGenerator() - expect( - markdownToPortableText(markdown, { - schema: compileSchema( - defineSchema({ - inlineObjects: [ - { - name: 'image', - fields: [ - {name: 'src', type: 'string'}, - {name: 'alt', type: 'string'}, - ], - }, - ], - lists: [{name: 'number'}], - }), - ), - keyGenerator, - }), - ).toEqual([ - { - _key: 'k0', - _type: 'block', - children: [ - {_key: 'k1', _type: 'span', text: 'foo', marks: []}, - { - _key: 'k2', - _type: 'image', - src: 'https://example.com/image.png', - alt: 'alt', - }, - {_key: 'k3', _type: 'span', text: 'bar', marks: []}, - ], - markDefs: [], - style: 'normal', - listItem: 'number', - level: 1, - }, - ]) - }) - - test('only inline image definition and no list definition', () => { - const keyGenerator = createTestKeyGenerator() - expect( - markdownToPortableText(markdown, { - schema: compileSchema( - defineSchema({ - inlineObjects: [ - { - name: 'image', - fields: [ - {name: 'src', type: 'string'}, - {name: 'alt', type: 'string'}, - ], - }, - ], - }), - ), - keyGenerator, - }), - ).toEqual([ - { - _key: 'k0', - _type: 'block', - children: [ - {_key: 'k1', _type: 'span', text: 'foo', marks: []}, - { - _key: 'k2', - _type: 'image', - src: 'https://example.com/image.png', - alt: 'alt', - }, - {_key: 'k3', _type: 'span', text: 'bar', marks: []}, - ], - markDefs: [], - style: 'normal', - }, - ]) - }) - - test('no image definition', () => { - const keyGenerator = createTestKeyGenerator() - expect( - markdownToPortableText(markdown, { - schema: compileSchema( - defineSchema({ - lists: [{name: 'number'}], - }), - ), - keyGenerator, - }), - ).toEqual([ - { - _key: 'k0', - _type: 'block', - children: [ - { - _key: 'k1', - _type: 'span', - text: 'foo![alt](https://example.com/image.png)bar', - marks: [], - }, - ], - markDefs: [], - style: 'normal', - listItem: 'number', - level: 1, - }, - ]) - }) - }) - - describe('with horizontal rule', () => { - const markdown = ['1. foo', '', ' ---', '', ' bar'].join('\n') - - test('default definition', () => { - const keyGenerator = createTestKeyGenerator() - expect(markdownToPortableText(markdown, {keyGenerator})).toEqual([ - { - _key: 'k0', - _type: 'block', - children: [{_key: 'k1', _type: 'span', text: 'foo', marks: []}], - markDefs: [], - style: 'normal', - listItem: 'number', - level: 1, - }, - { - _key: 'k2', - _type: 'horizontal-rule', - }, - { - _key: 'k3', - _type: 'block', - children: [{_type: 'span', _key: 'k4', text: 'bar', marks: []}], - markDefs: [], - style: 'normal', - listItem: 'number', - level: 1, - }, - ]) - }) - - test('no horizontal rule definition', () => { - const keyGenerator = createTestKeyGenerator() - expect( - markdownToPortableText(markdown, { - keyGenerator, - schema: compileSchema( - defineSchema({ - lists: [{name: 'number'}], - }), - ), - }), - ).toEqual([ - { - _key: 'k0', - _type: 'block', - children: [{_key: 'k1', _type: 'span', text: 'foo', marks: []}], - markDefs: [], - style: 'normal', - listItem: 'number', - level: 1, - }, - { - _key: 'k2', - _type: 'block', - children: [{_type: 'span', _key: 'k3', text: '---', marks: []}], - markDefs: [], - style: 'normal', - }, - { - _key: 'k4', - _type: 'block', - children: [{_type: 'span', _key: 'k5', text: 'bar', marks: []}], - markDefs: [], - style: 'normal', - listItem: 'number', - level: 1, - }, - ]) - }) - - test('no horizontal rule definition and no list definition', () => { - const keyGenerator = createTestKeyGenerator() - expect( - markdownToPortableText(markdown, { - keyGenerator, - schema: compileSchema(defineSchema({})), - }), - ).toEqual([ - { - _key: 'k0', - _type: 'block', - children: [{_key: 'k1', _type: 'span', text: 'foo', marks: []}], - markDefs: [], - style: 'normal', - }, - { - _key: 'k2', - _type: 'block', - children: [{_type: 'span', _key: 'k3', text: '---', marks: []}], - markDefs: [], - style: 'normal', - }, - { - _key: 'k4', - _type: 'block', - children: [{_type: 'span', _key: 'k5', text: 'bar', marks: []}], - markDefs: [], - style: 'normal', - }, - ]) - }) - }) - - test('with multiple block elements', () => { - const keyGenerator = createTestKeyGenerator() - const markdown = [ - '1. foo', - '', - ' ```js', - ' const x = 1', - ' ```', - '', - ' ![alt](https://example.com/image.png)', - '', - ' bar', - ].join('\n') - - expect(markdownToPortableText(markdown, {keyGenerator})).toEqual([ - { - _key: 'k0', - _type: 'block', - children: [{_key: 'k1', _type: 'span', text: 'foo', marks: []}], - markDefs: [], - style: 'normal', - listItem: 'number', - level: 1, - }, - { - _key: 'k2', - _type: 'code', - code: 'const x = 1', - language: 'js', - }, - { - _key: 'k4', - _type: 'image', - src: 'https://example.com/image.png', - alt: 'alt', - }, - { - _key: 'k5', - _type: 'block', - children: [{_type: 'span', _key: 'k6', text: 'bar', marks: []}], - markDefs: [], - style: 'normal', - listItem: 'number', - level: 1, - }, - ]) - }) - - test('nested list with block element', () => { - const keyGenerator = createTestKeyGenerator() - const markdown = [ - '1. parent', - ' - nested', - '', - ' ```js', - ' const x = 1', - ' ```', - '', - ' after code', - ].join('\n') - - expect(markdownToPortableText(markdown, {keyGenerator})).toEqual([ - { - _key: 'k0', - _type: 'block', - children: [{_key: 'k1', _type: 'span', text: 'parent', marks: []}], - markDefs: [], - style: 'normal', - listItem: 'number', - level: 1, - }, - { - _key: 'k2', - _type: 'block', - children: [{_key: 'k3', _type: 'span', text: 'nested', marks: []}], - markDefs: [], - style: 'normal', - listItem: 'bullet', - level: 2, - }, - { - _key: 'k4', - _type: 'code', - code: 'const x = 1', - language: 'js', - }, - { - _key: 'k5', - _type: 'block', - children: [ - {_type: 'span', _key: 'k6', text: 'after code', marks: []}, - ], - markDefs: [], - style: 'normal', - listItem: 'bullet', - level: 2, - }, - ]) - }) - - describe('with html block', () => { - const markdown = [ - '1. foo', - '', - '
html content
', - '', - ' bar', - ].join('\n') - - test('default definition', () => { - const keyGenerator = createTestKeyGenerator() - - expect(markdownToPortableText(markdown, {keyGenerator})).toEqual([ - { - _key: 'k0', - _type: 'block', - children: [{_key: 'k1', _type: 'span', text: 'foo', marks: []}], - markDefs: [], - style: 'normal', - listItem: 'number', - level: 1, - }, - { - _key: 'k2', - _type: 'html', - html: '
html content
', - }, - { - _key: 'k3', - _type: 'block', - children: [{_type: 'span', _key: 'k4', text: 'bar', marks: []}], - markDefs: [], - style: 'normal', - listItem: 'number', - level: 1, - }, - ]) - }) - - test('no html block definition', () => { - const keyGenerator = createTestKeyGenerator() - expect( - markdownToPortableText(markdown, { - keyGenerator, - schema: compileSchema( - defineSchema({ - lists: [{name: 'number'}], - }), - ), - }), - ).toEqual([ - { - _key: 'k0', - _type: 'block', - children: [ - { - _key: 'k1', - _type: 'span', - text: 'foo', - marks: [], - }, - ], - markDefs: [], - style: 'normal', - listItem: 'number', - level: 1, - }, - { - _key: 'k2', - _type: 'block', - children: [ - { - _type: 'span', - _key: 'k3', - text: '
html content
', - marks: [], - }, - ], - markDefs: [], - style: 'normal', - }, - { - _key: 'k4', - _type: 'block', - children: [{_type: 'span', _key: 'k5', text: 'bar', marks: []}], - markDefs: [], - style: 'normal', - listItem: 'number', - level: 1, - }, - ]) - }) - }) - - test('block element at start of list item', () => { - const keyGenerator = createTestKeyGenerator() - const markdown = [ - '1. x', - '', - ' ```js', - ' const foo = "bar"', - ' ```', - '', - ' after', - '', - '2. second', - ].join('\n') - - expect(markdownToPortableText(markdown, {keyGenerator})).toEqual([ - { - _key: 'k0', - _type: 'block', - children: [{_key: 'k1', _type: 'span', text: 'x', marks: []}], - markDefs: [], - style: 'normal', - listItem: 'number', - level: 1, - }, - { - _key: 'k2', - _type: 'code', - code: 'const foo = "bar"', - language: 'js', - }, - { - _key: 'k3', - _type: 'block', - children: [{_type: 'span', _key: 'k4', text: 'after', marks: []}], - markDefs: [], - style: 'normal', - listItem: 'number', - level: 1, - }, - { - _key: 'k5', - _type: 'block', - children: [{_type: 'span', _key: 'k6', text: 'second', marks: []}], - markDefs: [], - style: 'normal', - listItem: 'number', - level: 1, - }, - ]) - }) - - test('block element at end of list item', () => { - const keyGenerator = createTestKeyGenerator() - const markdown = [ - '1. before', - '', - ' ```js', - ' const foo = "bar"', - ' ```', - ].join('\n') - - expect(markdownToPortableText(markdown, {keyGenerator})).toEqual([ - { - _key: 'k0', - _type: 'block', - children: [{_key: 'k1', _type: 'span', text: 'before', marks: []}], - markDefs: [], - style: 'normal', - listItem: 'number', - level: 1, - }, - { - _key: 'k2', - _type: 'code', - code: 'const foo = "bar"', - language: 'js', - }, - ]) - }) - - test('with blockquote', () => { - const keyGenerator = createTestKeyGenerator() - const markdown = ['1. foo', '', ' > quoted text', '', ' bar'].join( - '\n', - ) - - expect(markdownToPortableText(markdown, {keyGenerator})).toEqual([ - { - _key: 'k0', - _type: 'block', - children: [{_key: 'k1', _type: 'span', text: 'foo', marks: []}], - markDefs: [], - style: 'normal', - listItem: 'number', - level: 1, - }, - { - _key: 'k2', - _type: 'block', - children: [ - {_type: 'span', _key: 'k3', text: 'quoted text', marks: []}, - ], - markDefs: [], - style: 'blockquote', - listItem: 'number', - level: 1, - }, - { - _key: 'k4', - _type: 'block', - children: [{_type: 'span', _key: 'k5', text: 'bar', marks: []}], - markDefs: [], - style: 'normal', - listItem: 'number', - level: 1, - }, - ]) - }) - - test('blockquote followed by code block', () => { - const keyGenerator = createTestKeyGenerator() - const markdown = [ - '1. foo', - '', - ' > quoted', - '', - ' ```js', - ' const x = 1', - ' ```', - '', - ' bar', - ].join('\n') - - expect(markdownToPortableText(markdown, {keyGenerator})).toEqual([ - { - _key: 'k0', - _type: 'block', - children: [{_key: 'k1', _type: 'span', text: 'foo', marks: []}], - markDefs: [], - style: 'normal', - listItem: 'number', - level: 1, - }, - { - _key: 'k2', - _type: 'block', - children: [{_type: 'span', _key: 'k3', text: 'quoted', marks: []}], - markDefs: [], - style: 'blockquote', - listItem: 'number', - level: 1, - }, - { - _key: 'k4', - _type: 'code', - code: 'const x = 1', - language: 'js', - }, - { - _key: 'k5', - _type: 'block', - children: [{_type: 'span', _key: 'k6', text: 'bar', marks: []}], - markDefs: [], - style: 'normal', - listItem: 'number', - level: 1, - }, - ]) - }) - - test('consecutive blockquotes', () => { - const keyGenerator = createTestKeyGenerator() - const markdown = [ - '1. foo', - '', - ' > quote 1', - '', - ' > quote 2', - '', - ' bar', - ].join('\n') - - expect(markdownToPortableText(markdown, {keyGenerator})).toEqual([ - { - _key: 'k0', - _type: 'block', - children: [{_key: 'k1', _type: 'span', text: 'foo', marks: []}], - markDefs: [], - style: 'normal', - listItem: 'number', - level: 1, - }, - { - _key: 'k2', - _type: 'block', - children: [{_type: 'span', _key: 'k3', text: 'quote 1', marks: []}], - markDefs: [], - style: 'blockquote', - listItem: 'number', - level: 1, - }, - { - _key: 'k4', - _type: 'block', - children: [{_type: 'span', _key: 'k5', text: 'quote 2', marks: []}], - markDefs: [], - style: 'blockquote', - listItem: 'number', - level: 1, - }, - { - _key: 'k6', - _type: 'block', - children: [{_type: 'span', _key: 'k7', text: 'bar', marks: []}], - markDefs: [], - style: 'normal', - listItem: 'number', - level: 1, - }, - ]) - }) - }) - - /********************* - * Objects - *********************/ - - describe('block image', () => { - test('default definition', () => { - const keyGenerator = createTestKeyGenerator() - expect( - markdownToPortableText('![alt text](https://example.com/image.png)', { - keyGenerator, - }), - ).toEqual([ - { - _type: 'image', - _key: 'k1', - src: 'https://example.com/image.png', - alt: 'alt text', - }, - ]) - }) - - test('custom definition and matcher', () => { - const keyGenerator = createTestKeyGenerator() - expect( - markdownToPortableText('![alt](https://example.com/pic.jpg)', { - keyGenerator, - schema: compileSchema( - defineSchema({ - blockObjects: [ - { - name: 'photo', - fields: [ - {name: 'url', type: 'string'}, - {name: 'description', type: 'string'}, - ], - }, - ], - }), - ), - types: { - image: ({context, value}) => ({ - _key: context.keyGenerator(), - _type: 'photo', - url: value.src, - description: value.alt, - }), - }, - }), - ).toEqual([ - { - _type: 'photo', - _key: 'k1', - url: 'https://example.com/pic.jpg', - description: 'alt', - }, - ]) - }) - - test('no definition', () => { - const keyGenerator = createTestKeyGenerator() - expect( - markdownToPortableText('![alt](https://example.com/image.png)', { - keyGenerator, - schema: compileSchema(defineSchema({})), - }), - ).toEqual([ - { - _type: 'block', - _key: 'k0', - children: [ - { - _type: 'span', - _key: 'k1', - text: '![alt](https://example.com/image.png)', - marks: [], - }, - ], - markDefs: [], - style: 'normal', - }, - ]) - }) - - test('no `src` field', () => { - const keyGenerator = createTestKeyGenerator() - expect( - markdownToPortableText('![alt](https://example.com/image.png)', { - keyGenerator, - schema: compileSchema( - defineSchema({ - blockObjects: [ - { - name: 'image', - fields: [ - {name: 'asset', type: 'object'}, - {name: 'alt', type: 'string'}, - ], - }, - ], - }), - ), - }), - ).toEqual([ - { - _type: 'block', - _key: 'k0', - children: [ - { - _type: 'span', - _key: 'k2', - text: '![alt](https://example.com/image.png)', - marks: [], - }, - ], - markDefs: [], - style: 'normal', - }, - ]) - }) - - test('no matching fields', () => { - const keyGenerator = createTestKeyGenerator() - expect( - markdownToPortableText('![alt](https://example.com/image.png)', { - keyGenerator, - schema: compileSchema( - defineSchema({ - blockObjects: [ - {name: 'image', fields: [{name: 'asset', type: 'object'}]}, - ], - }), - ), - }), - ).toEqual([ - { - _type: 'block', - _key: 'k0', - children: [ - { - _type: 'span', - _key: 'k2', - text: '![alt](https://example.com/image.png)', - marks: [], - }, - ], - markDefs: [], - style: 'normal', - }, - ]) - }) - - test('both block and inline images', () => { - const keyGenerator = createTestKeyGenerator() - const markdown = [ - '![block image](https://example.com/block.png)', - '', - 'Text with ![inline image](https://example.com/inline.png) in it', - ].join('\n') - - expect( - markdownToPortableText(markdown, { - keyGenerator, - }), - ).toEqual([ - { - _type: 'image', - _key: 'k1', - src: 'https://example.com/block.png', - alt: 'block image', - }, - { - _type: 'block', - _key: 'k2', - children: [ - {_type: 'span', _key: 'k3', text: 'Text with ', marks: []}, - { - _type: 'image', - _key: 'k4', - src: 'https://example.com/inline.png', - alt: 'inline image', - }, - {_type: 'span', _key: 'k5', text: ' in it', marks: []}, - ], - markDefs: [], - style: 'normal', - }, - ]) - }) - }) - - describe('block image with title', () => { - test('default definition', () => { - const keyGenerator = createTestKeyGenerator() - expect( - markdownToPortableText( - '![alt text](https://example.com/image.png "Image Title")', - {keyGenerator}, - ), - ).toEqual([ - { - _type: 'image', - _key: 'k1', - src: 'https://example.com/image.png', - alt: 'alt text', - title: 'Image Title', - }, - ]) - }) - }) - - describe('inline image', () => { - test('default definition', () => { - const keyGenerator = createTestKeyGenerator() - expect( - markdownToPortableText( - 'foo ![alt text](https://example.com/image.png) baz', - {keyGenerator}, - ), - ).toEqual([ - { - _type: 'block', - _key: 'k0', - children: [ - {_type: 'span', _key: 'k1', text: 'foo ', marks: []}, - { - _type: 'image', - _key: 'k2', - src: 'https://example.com/image.png', - alt: 'alt text', - }, - {_type: 'span', _key: 'k3', text: ' baz', marks: []}, - ], - markDefs: [], - style: 'normal', - }, - ]) - }) - - test('custom definition and matcher', () => { - const keyGenerator = createTestKeyGenerator() - expect( - markdownToPortableText('foo ![alt](https://example.com/pic.jpg) baz', { - keyGenerator, - schema: compileSchema( - defineSchema({ - inlineObjects: [ - { - name: 'photo', - fields: [ - {name: 'url', type: 'string'}, - {name: 'description', type: 'string'}, - ], - }, - ], - }), - ), - types: { - image: ({context, value}) => ({ - _key: context.keyGenerator(), - _type: 'photo', - url: value.src, - description: value.alt, - }), - }, - }), - ).toEqual([ - { - _type: 'block', - _key: 'k0', - children: [ - {_type: 'span', _key: 'k1', text: 'foo ', marks: []}, - { - _type: 'photo', - _key: 'k2', - url: 'https://example.com/pic.jpg', - description: 'alt', - }, - {_type: 'span', _key: 'k3', text: ' baz', marks: []}, - ], - markDefs: [], - style: 'normal', - }, - ]) - }) - - test('no definition', () => { - const keyGenerator = createTestKeyGenerator() - expect( - markdownToPortableText( - 'foo ![alt](https://example.com/image.png) baz', - { - keyGenerator, - schema: compileSchema(defineSchema({})), - }, - ), - ).toEqual([ - { - _type: 'block', - _key: 'k0', - children: [ - { - _type: 'span', - _key: 'k1', - text: 'foo ![alt](https://example.com/image.png) baz', - marks: [], - }, - ], - markDefs: [], - style: 'normal', - }, - ]) - }) - - test('only image block object definition', () => { - const keyGenerator = createTestKeyGenerator() - expect( - markdownToPortableText( - 'foo ![alt](https://example.com/image.png) baz', - { - keyGenerator, - schema: compileSchema( - defineSchema({ - blockObjects: [ - { - name: 'image', - fields: [ - {name: 'src', type: 'string'}, - {name: 'alt', type: 'string'}, - ], - }, - ], - }), - ), - }, - ), - ).toEqual([ - { - _type: 'block', - _key: 'k0', - children: [{_type: 'span', _key: 'k1', text: 'foo ', marks: []}], - markDefs: [], - style: 'normal', - }, - { - _type: 'image', - _key: 'k2', - src: 'https://example.com/image.png', - alt: 'alt', - }, - { - _type: 'block', - _key: 'k3', - children: [{_type: 'span', _key: 'k4', text: ' baz', marks: []}], - markDefs: [], - style: 'normal', - }, - ]) - }) - - test('image with escaped bracket in alt text', () => { - const keyGenerator = createTestKeyGenerator() - expect( - markdownToPortableText('![b\\[ar](https://example.com/image.png)', { - keyGenerator, - }), - ).toEqual([ - { - _type: 'image', - _key: 'k1', - src: 'https://example.com/image.png', - alt: 'b[ar', - }, - ]) - }) - }) - - describe('horizontal rule', () => { - test('default definition', () => { - const keyGenerator = createTestKeyGenerator() - expect(markdownToPortableText('---', {keyGenerator})).toEqual([ - { - _type: 'horizontal-rule', - _key: 'k0', - }, - ]) - }) - - test('custom definition', () => { - const keyGenerator = createTestKeyGenerator() - expect( - markdownToPortableText('---', { - keyGenerator, - schema: compileSchema( - defineSchema({ - blockObjects: [ - { - name: 'divider', - fields: [{name: 'orientation', type: 'string'}], - }, - ], - }), - ), - types: { - horizontalRule: ({context}) => ({ - _key: context.keyGenerator(), - _type: 'divider', - orientation: 'horizontal', - }), - }, - }), - ).toEqual([ - { - _type: 'divider', - _key: 'k0', - orientation: 'horizontal', - }, - ]) - }) - - test('no definition', () => { - const keyGenerator = createTestKeyGenerator() - expect( - markdownToPortableText('---', { - keyGenerator, - schema: compileSchema(defineSchema({})), - }), - ).toEqual([ - { - _key: 'k0', - _type: 'block', - children: [ - { - _key: 'k1', - _type: 'span', - text: '---', - marks: [], - }, - ], - markDefs: [], - style: 'normal', - }, - ]) - }) - }) - - describe('code block', () => { - describe('default definition', () => { - test('one line', () => { - const keyGenerator = createTestKeyGenerator() - const markdown = ['```js', `const foo = 'bar'`, '```'].join('\n') - expect(markdownToPortableText(markdown, {keyGenerator})).toEqual([ - { - _type: 'code', - _key: 'k0', - code: `const foo = 'bar'`, - language: 'js', - }, - ]) - }) - - test('multiple lines', () => { - const keyGenerator = createTestKeyGenerator() - const markdown = [ - '```js', - `const foo = 'bar'`, - `const bar = 'baz'`, - '```', - ].join('\n') - expect(markdownToPortableText(markdown, {keyGenerator})).toEqual([ - { - _type: 'code', - _key: 'k0', - code: `const foo = 'bar'\nconst bar = 'baz'`, - language: 'js', - }, - ]) - }) - }) - - test('custom definition', () => { - const keyGenerator = createTestKeyGenerator() - const markdown = ['```js', `const foo = 'bar'`, '```'].join('\n') - expect( - markdownToPortableText(markdown, { - keyGenerator, - schema: compileSchema( - defineSchema({ - blockObjects: [ - { - name: 'kode', - fields: [ - { - name: 'sprog', - type: 'string', - }, - { - name: 'indhold', - type: 'string', - }, - ], - }, - ], - }), - ), - types: { - code: ({context, value}) => { - const schemaDefinition = context.schema.blockObjects.find( - (object) => object.name === 'kode', - ) - - if (!schemaDefinition) { - return undefined - } - - return { - _type: schemaDefinition.name, - _key: context.keyGenerator(), - indhold: value.code, - ...(value.language ? {sprog: value.language} : {}), - } - }, - }, - }), - ).toEqual([ - { - _type: 'kode', - _key: 'k0', - sprog: 'js', - indhold: "const foo = 'bar'", - }, - ]) - }) - - describe('no definition', () => { - test('one line', () => { - const keyGenerator = createTestKeyGenerator() - const markdown = ['```js', `const foo = 'bar'`, '```'].join('\n') - expect( - markdownToPortableText(markdown, { - keyGenerator, - schema: compileSchema(defineSchema({})), - }), - ).toEqual([ - { - _type: 'block', - _key: 'k0', - children: [ - { - _type: 'span', - _key: 'k1', - text: "const foo = 'bar'", - marks: [], - }, - ], - markDefs: [], - style: 'normal', - }, - ]) - }) - - test('multiple lines', () => { - const keyGenerator = createTestKeyGenerator() - const markdown = [ - '```js', - `const foo = 'bar'`, - `const bar = 'baz'`, - '```', - ].join('\n') - expect( - markdownToPortableText(markdown, { - keyGenerator, - schema: compileSchema(defineSchema({})), - }), - ).toEqual([ - { - _key: 'k0', - _type: 'block', - children: [ - { - _key: 'k1', - _type: 'span', - text: `const foo = 'bar'\nconst bar = 'baz'`, - marks: [], - }, - ], - markDefs: [], - style: 'normal', - }, - ]) - }) - }) - - test('some matching fields', () => { - const keyGenerator = createTestKeyGenerator() - const markdown = ['```js', `const foo = 'bar'`, '```'].join('\n') - expect( - markdownToPortableText(markdown, { - keyGenerator, - schema: compileSchema( - defineSchema({ - blockObjects: [ - {name: 'code', fields: [{name: 'code', type: 'string'}]}, - ], - }), - ), - }), - ).toEqual([ - { - _type: 'code', - _key: 'k0', - code: `const foo = 'bar'`, - }, - ]) - }) - - test('no matching fields', () => { - const markdown = ['```js', `const foo = 'bar'`, '```'].join('\n') - const keyGenerator = createTestKeyGenerator() - expect( - markdownToPortableText(markdown, { - keyGenerator, - schema: compileSchema( - defineSchema({ - blockObjects: [{name: 'code'}], - }), - ), - }), - ).toEqual([ - { - _type: 'block', - _key: 'k1', - children: [ - { - _type: 'span', - _key: 'k2', - text: "const foo = 'bar'", - marks: [], - }, - ], - markDefs: [], - style: 'normal', - }, - ]) - }) - - test('indented code block', () => { - const keyGenerator = createTestKeyGenerator() - const markdown = ' const foo = "bar"' - expect(markdownToPortableText(markdown, {keyGenerator})).toEqual([ - { - _type: 'code', - _key: 'k0', - code: 'const foo = "bar"', - }, - ]) - }) - }) - - describe('HTML', () => { - describe('block HTML', () => { - test('default definition', () => { - const keyGenerator = createTestKeyGenerator() - expect( - markdownToPortableText('
Content
', { - keyGenerator, - }), - ).toEqual([ - { - _type: 'html', - _key: 'k0', - html: '
Content
', - }, - ]) - }) - - test('custom definition', () => { - const keyGenerator = createTestKeyGenerator() - expect( - markdownToPortableText('
Content
', { - keyGenerator, - schema: compileSchema( - defineSchema({ - blockObjects: [ - {name: 'rawHtml', fields: [{name: 'code', type: 'string'}]}, - ], - }), - ), - types: { - html: ({context, value}) => ({ - _key: context.keyGenerator(), - _type: 'rawHtml', - code: value.html, - }), - }, - }), - ).toEqual([ - { - _type: 'rawHtml', - _key: 'k0', - code: '
Content
', - }, - ]) - }) - - test('no definition', () => { - const keyGenerator = createTestKeyGenerator() - expect( - markdownToPortableText('
Content
', { - keyGenerator, - schema: compileSchema(defineSchema({})), - }), - ).toEqual([ - { - _key: 'k0', - _type: 'block', - children: [ - { - _key: 'k1', - _type: 'span', - text: '
Content
', - marks: [], - }, - ], - markDefs: [], - style: 'normal', - }, - ]) - }) - }) - - describe('inline HTML', () => { - test('skip by default', () => { - const keyGenerator = createTestKeyGenerator() - expect( - markdownToPortableText('foo bar baz', {keyGenerator}), - ).toEqual([ - { - _type: 'block', - _key: 'k0', - children: [ - {_type: 'span', _key: 'k1', text: 'foo bar baz', marks: []}, - ], - markDefs: [], - style: 'normal', - }, - ]) - }) - - test('convert to text when configured', () => { - const keyGenerator = createTestKeyGenerator() - expect( - markdownToPortableText('foo bar baz', { - keyGenerator, - html: {inline: 'text'}, - }), - ).toEqual([ - { - _type: 'block', - _key: 'k0', - children: [ - { - _type: 'span', - _key: 'k1', - text: 'foo bar baz', - marks: [], - }, - ], - markDefs: [], - style: 'normal', - }, - ]) - }) - }) - }) - - describe('tables', () => { - const tableObjectDefinition = { - name: 'table', - fields: [ - {name: 'headerRows', type: 'number'}, - {name: 'alignment', type: 'array'}, - {name: 'rows', type: 'array'}, - ], - } as const satisfies BlockObjectDefinition - - const schemaWithTable = compileSchema( - defineSchema({ - ...defaultSchema, - blockObjects: [...defaultSchema.blockObjects, tableObjectDefinition], - }), - ) - - // Helper to get table options for tests - const getTableTestOptions = (keyGenerator: () => string) => ({ - keyGenerator, - schema: schemaWithTable, - types: { - table: buildObjectMatcher(tableObjectDefinition), - }, - }) - - test('empty header row reads back as headerRows 0 with only the body rows', () => { - const keyGenerator = createTestKeyGenerator() - const markdown = [ - '| | |', - '| --- | --- |', - '| Cell 1 | Cell 2 |', - '| Cell 3 | Cell 4 |', - ].join('\n') - expect( - markdownToPortableText(markdown, getTableTestOptions(keyGenerator)), - ).toEqual([ - { - _key: 'k20', - _type: 'table', - headerRows: 0, - rows: [ - { - _key: 'k12', - _type: 'row', - cells: [ - { - _type: 'cell', - _key: 'k8', - value: [ - { - _type: 'block', - _key: 'k6', - style: 'normal', - children: [ - {_type: 'span', _key: 'k7', text: 'Cell 1', marks: []}, - ], - markDefs: [], - }, - ], - }, - { - _type: 'cell', - _key: 'k11', - value: [ - { - _type: 'block', - _key: 'k9', - style: 'normal', - children: [ - {_type: 'span', _key: 'k10', text: 'Cell 2', marks: []}, - ], - markDefs: [], - }, - ], - }, - ], - }, - { - _key: 'k19', - _type: 'row', - cells: [ - { - _type: 'cell', - _key: 'k15', - value: [ - { - _type: 'block', - _key: 'k13', - style: 'normal', - children: [ - {_type: 'span', _key: 'k14', text: 'Cell 3', marks: []}, - ], - markDefs: [], - }, - ], - }, - { - _type: 'cell', - _key: 'k18', - value: [ - { - _type: 'block', - _key: 'k16', - style: 'normal', - children: [ - {_type: 'span', _key: 'k17', text: 'Cell 4', marks: []}, - ], - markDefs: [], - }, - ], - }, - ], - }, - ], - }, - ]) - }) - - test('simple table', () => { - const keyGenerator = createTestKeyGenerator() - const markdown = [ - '| Header 1 | Header 2 |', - '|----------|----------|', - '| Cell 1 | Cell 2 |', - '| Cell 3 | Cell 4 |', - ].join('\n') - expect( - markdownToPortableText(markdown, getTableTestOptions(keyGenerator)), - ).toEqual([ - { - _type: 'table', - _key: 'k21', - headerRows: 1, - rows: [ - { - _key: 'k6', - _type: 'row', - cells: [ - { - _type: 'cell', - _key: 'k2', - value: [ - { - _type: 'block', - _key: 'k0', - style: 'normal', - children: [ - { - _type: 'span', - _key: 'k1', - text: 'Header 1', - marks: [], - }, - ], - markDefs: [], - }, - ], - }, - { - _type: 'cell', - _key: 'k5', - value: [ - { - _type: 'block', - _key: 'k3', - style: 'normal', - children: [ - { - _type: 'span', - _key: 'k4', - text: 'Header 2', - marks: [], - }, - ], - markDefs: [], - }, - ], - }, - ], - }, - { - _key: 'k13', - _type: 'row', - cells: [ - { - _type: 'cell', - _key: 'k9', - value: [ - { - _type: 'block', - _key: 'k7', - style: 'normal', - children: [ - { - _type: 'span', - _key: 'k8', - text: 'Cell 1', - marks: [], - }, - ], - markDefs: [], - }, - ], - }, - { - _type: 'cell', - _key: 'k12', - value: [ - { - _type: 'block', - _key: 'k10', - style: 'normal', - children: [ - { - _type: 'span', - _key: 'k11', - text: 'Cell 2', - marks: [], - }, - ], - markDefs: [], - }, - ], - }, - ], - }, - { - _key: 'k20', - _type: 'row', - cells: [ - { - _type: 'cell', - _key: 'k16', - value: [ - { - _type: 'block', - _key: 'k14', - style: 'normal', - children: [ - { - _type: 'span', - _key: 'k15', - text: 'Cell 3', - marks: [], - }, - ], - markDefs: [], - }, - ], - }, - { - _type: 'cell', - _key: 'k19', - value: [ - { - _type: 'block', - _key: 'k17', - style: 'normal', - children: [ - { - _type: 'span', - _key: 'k18', - text: 'Cell 4', - marks: [], - }, - ], - markDefs: [], - }, - ], - }, - ], - }, - ], - }, - ]) - }) - - test('table with formatting', () => { - const keyGenerator = createTestKeyGenerator() - const markdown = [ - '| **Header 1** | **Header 2** |', - '|--------------|--------------|', - '| *Cell 1* | *Cell 2* |', - '| *Cell 3* | *Cell 4* |', - ].join('\n') - expect( - markdownToPortableText(markdown, getTableTestOptions(keyGenerator)), - ).toEqual([ - { - _type: 'table', - _key: 'k21', - headerRows: 1, - rows: [ - { - _key: 'k6', - _type: 'row', - cells: [ - { - _type: 'cell', - _key: 'k2', - value: [ - { - _type: 'block', - _key: 'k0', - style: 'normal', - children: [ - { - _type: 'span', - _key: 'k1', - text: 'Header 1', - marks: ['strong'], - }, - ], - markDefs: [], - }, - ], - }, - { - _type: 'cell', - _key: 'k5', - value: [ - { - _type: 'block', - _key: 'k3', - style: 'normal', - children: [ - { - _type: 'span', - _key: 'k4', - text: 'Header 2', - marks: ['strong'], - }, - ], - markDefs: [], - }, - ], - }, - ], - }, - { - _key: 'k13', - _type: 'row', - cells: [ - { - _type: 'cell', - _key: 'k9', - value: [ - { - _type: 'block', - _key: 'k7', - style: 'normal', - children: [ - { - _type: 'span', - _key: 'k8', - text: 'Cell 1', - marks: ['em'], - }, - ], - markDefs: [], - }, - ], - }, - { - _type: 'cell', - _key: 'k12', - value: [ - { - _type: 'block', - _key: 'k10', - style: 'normal', - children: [ - { - _type: 'span', - _key: 'k11', - text: 'Cell 2', - marks: ['em'], - }, - ], - markDefs: [], - }, - ], - }, - ], - }, - { - _key: 'k20', - _type: 'row', - cells: [ - { - _type: 'cell', - _key: 'k16', - value: [ - { - _type: 'block', - _key: 'k14', - style: 'normal', - children: [ - { - _type: 'span', - _key: 'k15', - text: 'Cell 3', - marks: ['em'], - }, - ], - markDefs: [], - }, - ], - }, - { - _type: 'cell', - _key: 'k19', - value: [ - { - _type: 'block', - _key: 'k17', - style: 'normal', - children: [ - { - _type: 'span', - _key: 'k18', - text: 'Cell 4', - marks: ['em'], - }, - ], - markDefs: [], - }, - ], - }, - ], - }, - ], - }, - ]) - }) - - test('table with image', () => { - const keyGenerator = createTestKeyGenerator() - const markdown = [ - '| Block image | Inline image |', - '| --- | --- |', - '| ![Block image](https://example.com/block.png) | foo ![Inline image](https://example.com/inline.png) bar |', - ].join('\n') - expect( - markdownToPortableText(markdown, getTableTestOptions(keyGenerator)), - ).toEqual([ - { - _type: 'table', - _key: 'k16', - headerRows: 1, - rows: [ - { - _key: 'k6', - _type: 'row', - cells: [ - { - _type: 'cell', - _key: 'k2', - value: [ - { - _type: 'block', - _key: 'k0', - children: [ - { - _type: 'span', - _key: 'k1', - text: 'Block image', - marks: [], - }, - ], - style: 'normal', - markDefs: [], - }, - ], - }, - { - _type: 'cell', - _key: 'k5', - value: [ - { - _type: 'block', - _key: 'k3', - children: [ - { - _type: 'span', - _key: 'k4', - text: 'Inline image', - marks: [], - }, - ], - style: 'normal', - markDefs: [], - }, - ], - }, - ], - }, - { - _key: 'k15', - _type: 'row', - cells: [ - { - _type: 'cell', - _key: 'k9', - value: [ - { - _type: 'image', - _key: 'k8', - src: 'https://example.com/block.png', - alt: 'Block image', - }, - ], - }, - { - _type: 'cell', - _key: 'k14', - value: [ - { - _type: 'block', - _key: 'k10', - children: [ - { - _key: 'k11', - _type: 'span', - text: 'foo ', - marks: [], - }, - { - _key: 'k12', - _type: 'image', - src: 'https://example.com/inline.png', - alt: 'Inline image', - }, - { - _key: 'k13', - _type: 'span', - text: ' bar', - marks: [], - }, - ], - style: 'normal', - markDefs: [], - }, - ], - }, - ], - }, - ], - }, - ]) - }) - - test('table with images without inline image support', () => { - const keyGenerator = createTestKeyGenerator() - const markdown = [ - '| Block image | Inline image |', - '| --- | --- |', - '| ![Block image](https://example.com/block.png) | foo ![Inline image](https://example.com/inline.png) bar |', - ].join('\n') - - const schema = compileSchema( - defineSchema({ - blockObjects: [ - { - name: 'image', - fields: [ - {name: 'src', type: 'string'}, - {name: 'alt', type: 'string'}, - ], - }, - tableObjectDefinition, - ], - }), - ) - - expect( - markdownToPortableText(markdown, { - keyGenerator, - schema, - types: { - table: buildObjectMatcher(tableObjectDefinition), - }, - }), - ).toEqual([ - { - _type: 'table', - _key: 'k16', - headerRows: 1, - rows: [ - { - _key: 'k6', - _type: 'row', - cells: [ - { - _type: 'cell', - _key: 'k2', - value: [ - { - _type: 'block', - _key: 'k0', - children: [ - { - _type: 'span', - _key: 'k1', - text: 'Block image', - marks: [], - }, - ], - style: 'normal', - markDefs: [], - }, - ], - }, - { - _type: 'cell', - _key: 'k5', - value: [ - { - _type: 'block', - _key: 'k3', - children: [ - { - _type: 'span', - _key: 'k4', - text: 'Inline image', - marks: [], - }, - ], - style: 'normal', - markDefs: [], - }, - ], - }, - ], - }, - { - _key: 'k15', - _type: 'row', - cells: [ - { - _type: 'cell', - _key: 'k9', - value: [ - { - _type: 'image', - _key: 'k8', - src: 'https://example.com/block.png', - alt: 'Block image', - }, - ], - }, - { - _type: 'cell', - _key: 'k14', - value: [ - { - _type: 'block', - _key: 'k10', - children: [ - { - _key: 'k11', - _type: 'span', - text: 'foo ', - marks: [], - }, - { - _key: 'k12', - _type: 'image', - src: 'https://example.com/inline.png', - alt: 'Inline image', - }, - { - _key: 'k13', - _type: 'span', - text: ' bar', - marks: [], - }, - ], - style: 'normal', - markDefs: [], - }, - ], - }, - ], - }, - ], - }, - ]) - }) - - test('table with empty cells', () => { - const keyGenerator = createTestKeyGenerator() - const markdown = ['| A | | C |', '|---|---|---|', '| 1 | | 3 |'].join( - '\n', - ) - expect( - markdownToPortableText(markdown, getTableTestOptions(keyGenerator)), - ).toEqual([ - { - _type: 'table', - _key: 'k20', - headerRows: 1, - rows: [ - { - _key: 'k9', - _type: 'row', - cells: [ - { - _type: 'cell', - _key: 'k2', - value: [ - { - _type: 'block', - _key: 'k0', - style: 'normal', - children: [ - { - _type: 'span', - _key: 'k1', - text: 'A', - marks: [], - }, - ], - markDefs: [], - }, - ], - }, - { - _type: 'cell', - _key: 'k5', - value: [ - { - _type: 'block', - _key: 'k3', - style: 'normal', - children: [ - { - _type: 'span', - _key: 'k4', - text: '', - marks: [], - }, - ], - markDefs: [], - }, - ], - }, - { - _type: 'cell', - _key: 'k8', - value: [ - { - _type: 'block', - _key: 'k6', - style: 'normal', - children: [ - { - _type: 'span', - _key: 'k7', - text: 'C', - marks: [], - }, - ], - markDefs: [], - }, - ], - }, - ], - }, - { - _key: 'k19', - _type: 'row', - cells: [ - { - _type: 'cell', - _key: 'k12', - value: [ - { - _type: 'block', - _key: 'k10', - style: 'normal', - children: [ - { - _type: 'span', - _key: 'k11', - text: '1', - marks: [], - }, - ], - markDefs: [], - }, - ], - }, - { - _type: 'cell', - _key: 'k15', - value: [ - { - _type: 'block', - _key: 'k13', - style: 'normal', - children: [ - { - _type: 'span', - _key: 'k14', - text: '', - marks: [], - }, - ], - markDefs: [], - }, - ], - }, - { - _type: 'cell', - _key: 'k18', - value: [ - { - _type: 'block', - _key: 'k16', - style: 'normal', - children: [ - { - _type: 'span', - _key: 'k17', - text: '3', - marks: [], - }, - ], - markDefs: [], - }, - ], - }, - ], - }, - ], - }, - ]) - }) - - test('table with links', () => { - const keyGenerator = createTestKeyGenerator() - const markdown = [ - '| Link |', - '| --- |', - '| [Example](https://example.com) |', - ].join('\n') - expect( - markdownToPortableText(markdown, getTableTestOptions(keyGenerator)), - ).toEqual([ - { - _type: 'table', - _key: 'k9', - headerRows: 1, - rows: [ - { - _key: 'k3', - _type: 'row', - cells: [ - { - _type: 'cell', - _key: 'k2', - value: [ - { - _type: 'block', - _key: 'k0', - style: 'normal', - children: [ - { - _type: 'span', - _key: 'k1', - text: 'Link', - marks: [], - }, - ], - markDefs: [], - }, - ], - }, - ], - }, - { - _key: 'k8', - _type: 'row', - cells: [ - { - _type: 'cell', - _key: 'k7', - value: [ - { - _type: 'block', - _key: 'k4', - style: 'normal', - children: [ - { - _type: 'span', - _key: 'k6', - text: 'Example', - marks: ['k5'], - }, - ], - markDefs: [ - { - _type: 'link', - _key: 'k5', - href: 'https://example.com', - }, - ], - }, - ], - }, - ], - }, - ], - }, - ]) - }) - - test('table with inline code', () => { - const keyGenerator = createTestKeyGenerator() - const markdown = ['| Code |', '| --- |', '| `const x = 1` |'].join('\n') - expect( - markdownToPortableText(markdown, getTableTestOptions(keyGenerator)), - ).toEqual([ - { - _type: 'table', - _key: 'k8', - headerRows: 1, - rows: [ - { - _key: 'k3', - _type: 'row', - cells: [ - { - _type: 'cell', - _key: 'k2', - value: [ - { - _type: 'block', - _key: 'k0', - style: 'normal', - children: [ - { - _type: 'span', - _key: 'k1', - text: 'Code', - marks: [], - }, - ], - markDefs: [], - }, - ], - }, - ], - }, - { - _key: 'k7', - _type: 'row', - cells: [ - { - _type: 'cell', - _key: 'k6', - value: [ - { - _type: 'block', - _key: 'k4', - style: 'normal', - children: [ - { - _type: 'span', - _key: 'k5', - text: 'const x = 1', - marks: ['code'], - }, - ], - markDefs: [], - }, - ], - }, - ], - }, - ], - }, - ]) - }) - - test('table with mixed formatting', () => { - const keyGenerator = createTestKeyGenerator() - const markdown = [ - '| Mixed |', - '| --- |', - '| **bold** and *italic* and [link](https://example.com) |', - ].join('\n') - expect( - markdownToPortableText(markdown, getTableTestOptions(keyGenerator)), - ).toEqual([ - { - _type: 'table', - _key: 'k13', - headerRows: 1, - rows: [ - { - _key: 'k3', - _type: 'row', - cells: [ - { - _type: 'cell', - _key: 'k2', - value: [ - { - _type: 'block', - _key: 'k0', - style: 'normal', - children: [ - { - _type: 'span', - _key: 'k1', - text: 'Mixed', - marks: [], - }, - ], - markDefs: [], - }, - ], - }, - ], - }, - { - _key: 'k12', - _type: 'row', - cells: [ - { - _type: 'cell', - _key: 'k11', - value: [ - { - _type: 'block', - _key: 'k4', - style: 'normal', - children: [ - { - _type: 'span', - _key: 'k5', - text: 'bold', - marks: ['strong'], - }, - { - _type: 'span', - _key: 'k6', - text: ' and ', - marks: [], - }, - { - _type: 'span', - _key: 'k7', - text: 'italic', - marks: ['em'], - }, - { - _type: 'span', - _key: 'k8', - text: ' and ', - marks: [], - }, - { - _type: 'span', - _key: 'k10', - text: 'link', - marks: ['k9'], - }, - ], - markDefs: [ - { - _type: 'link', - _key: 'k9', - href: 'https://example.com', - }, - ], - }, - ], - }, - ], - }, - ], - }, - ]) - }) - - test('custom table definition', () => { - const keyGenerator = createTestKeyGenerator() - const markdown = ['| A | B |', '|---|---|', '| 1 | 2 |'].join('\n') - expect( - markdownToPortableText(markdown, { - keyGenerator, - schema: compileSchema( - defineSchema({ - blockObjects: [ - {name: 'dataTable', fields: [{name: 'data', type: 'array'}]}, - ], - }), - ), - types: { - table: ({context, value}) => ({ - _key: context.keyGenerator(), - _type: 'dataTable', - data: value.rows, - }), - }, - }), - ).toEqual([ - { - _type: 'dataTable', - _key: 'k14', - data: [ - { - _key: 'k6', - _type: 'row', - cells: [ - { - _type: 'cell', - _key: 'k2', - value: [ - { - _type: 'block', - _key: 'k0', - style: 'normal', - children: [ - { - _type: 'span', - _key: 'k1', - text: 'A', - marks: [], - }, - ], - markDefs: [], - }, - ], - }, - { - _type: 'cell', - _key: 'k5', - value: [ - { - _type: 'block', - _key: 'k3', - style: 'normal', - children: [ - { - _type: 'span', - _key: 'k4', - text: 'B', - marks: [], - }, - ], - markDefs: [], - }, - ], - }, - ], - }, - { - _key: 'k13', - _type: 'row', - cells: [ - { - _type: 'cell', - _key: 'k9', - value: [ - { - _type: 'block', - _key: 'k7', - style: 'normal', - children: [ - { - _type: 'span', - _key: 'k8', - text: '1', - marks: [], - }, - ], - markDefs: [], - }, - ], - }, - { - _type: 'cell', - _key: 'k12', - value: [ - { - _type: 'block', - _key: 'k10', - style: 'normal', - children: [ - { - _type: 'span', - _key: 'k11', - text: '2', - marks: [], - }, - ], - markDefs: [], - }, - ], - }, - ], - }, - ], - }, - ]) - }) - - test('schema without `table` flattens', () => { - const keyGenerator = createTestKeyGenerator() - const markdown = ['| A | B |', '|---|---|', '| 1 | 2 |'].join('\n') - expect( - markdownToPortableText(markdown, { - keyGenerator, - schema: compileSchema(defineSchema({})), - }), - ).toEqual([ - { - _type: 'block', - _key: 'k0', - style: 'normal', - children: [ - { - _type: 'span', - _key: 'k1', - text: 'A', - marks: [], - }, - ], - markDefs: [], - }, - { - _type: 'block', - _key: 'k3', - style: 'normal', - children: [ - { - _type: 'span', - _key: 'k4', - text: 'B', - marks: [], - }, - ], - markDefs: [], - }, - { - _type: 'block', - _key: 'k7', - style: 'normal', - children: [ - { - _type: 'span', - _key: 'k8', - text: '1', - marks: [], - }, - ], - markDefs: [], - }, - { - _type: 'block', - _key: 'k10', - style: 'normal', - children: [ - { - _type: 'span', - _key: 'k11', - text: '2', - marks: [], - }, - ], - markDefs: [], - }, - ]) - }) - - test('no table definition with formatting', () => { - const keyGenerator = createTestKeyGenerator() - const markdown = [ - '| **Bold** | _Italic_ |', - '| -------- | -------- |', - '| `Code` | Normal |', - ].join('\n') - expect( - markdownToPortableText(markdown, { - keyGenerator, - schema: compileSchema( - defineSchema({ - decorators: [{name: 'strong'}, {name: 'em'}, {name: 'code'}], - }), - ), - }), - ).toEqual([ - { - _type: 'block', - _key: 'k0', - style: 'normal', - children: [ - { - _type: 'span', - _key: 'k1', - text: 'Bold', - marks: ['strong'], - }, - ], - markDefs: [], - }, - { - _type: 'block', - _key: 'k3', - style: 'normal', - children: [ - { - _type: 'span', - _key: 'k4', - text: 'Italic', - marks: ['em'], - }, - ], - markDefs: [], - }, - { - _type: 'block', - _key: 'k7', - style: 'normal', - children: [ - { - _type: 'span', - _key: 'k8', - text: 'Code', - marks: ['code'], - }, - ], - markDefs: [], - }, - { - _type: 'block', - _key: 'k10', - style: 'normal', - children: [ - { - _type: 'span', - _key: 'k11', - text: 'Normal', - marks: [], - }, - ], - markDefs: [], - }, - ]) - }) - - test('table with column alignment', () => { - const keyGenerator = createTestKeyGenerator() - const markdown = [ - '| L | C | R | D |', - '| :--- | :---: | ---: | --- |', - '| 1 | 2 | 3 | 4 |', - ].join('\n') - - const result = markdownToPortableText( - markdown, - getTableTestOptions(keyGenerator), - ) - expect((result.at(0) as {alignment?: unknown}).alignment).toEqual([ - 'left', - 'center', - 'right', - null, - ]) - }) - - test('table with one aligned column among unaligned columns', () => { - const keyGenerator = createTestKeyGenerator() - const markdown = [ - '| A | B | C |', - '| --- | --- | ---: |', - '| 1 | 2 | 3 |', - ].join('\n') - - const result = markdownToPortableText( - markdown, - getTableTestOptions(keyGenerator), - ) - expect((result.at(0) as {alignment?: unknown}).alignment).toEqual([ - null, - null, - 'right', - ]) - }) - - test('table with no alignment omits the alignment field', () => { - const keyGenerator = createTestKeyGenerator() - const markdown = ['| A | B |', '| --- | --- |', '| 1 | 2 |'].join('\n') - - const result = markdownToPortableText( - markdown, - getTableTestOptions(keyGenerator), - ) - expect(result.at(0) as Record).not.toHaveProperty( - 'alignment', - ) - }) - - describe('zero-config (default schema)', () => { - test('GFM table with alignment converts to the canonical table object', () => { - const keyGenerator = createTestKeyGenerator() - const markdown = ['| L | R |', '| :--- | ---: |', '| foo | bar |'].join( - '\n', - ) - - expect(markdownToPortableText(markdown, {keyGenerator})).toEqual([ - { - _key: 'k14', - _type: 'table', - headerRows: 1, - alignment: ['left', 'right'], - rows: [ - { - _key: 'k6', - _type: 'row', - cells: [ - { - _type: 'cell', - _key: 'k2', - value: [ - { - _type: 'block', - _key: 'k0', - style: 'normal', - children: [ - {_type: 'span', _key: 'k1', text: 'L', marks: []}, - ], - markDefs: [], - }, - ], - }, - { - _type: 'cell', - _key: 'k5', - value: [ - { - _type: 'block', - _key: 'k3', - style: 'normal', - children: [ - {_type: 'span', _key: 'k4', text: 'R', marks: []}, - ], - markDefs: [], - }, - ], - }, - ], - }, - { - _key: 'k13', - _type: 'row', - cells: [ - { - _type: 'cell', - _key: 'k9', - value: [ - { - _type: 'block', - _key: 'k7', - style: 'normal', - children: [ - {_type: 'span', _key: 'k8', text: 'foo', marks: []}, - ], - markDefs: [], - }, - ], - }, - { - _type: 'cell', - _key: 'k12', - value: [ - { - _type: 'block', - _key: 'k10', - style: 'normal', - children: [ - {_type: 'span', _key: 'k11', text: 'bar', marks: []}, - ], - markDefs: [], - }, - ], - }, - ], - }, - ], - }, - ]) - }) - - test('headerless GFM table (empty header row) converts to headerRows: 0', () => { - const keyGenerator = createTestKeyGenerator() - const markdown = ['| | |', '| --- | --- |', '| foo | bar |'].join( - '\n', - ) - - expect(markdownToPortableText(markdown, {keyGenerator})).toEqual([ - { - _key: 'k13', - _type: 'table', - headerRows: 0, - rows: [ - { - _key: 'k12', - _type: 'row', - cells: [ - { - _type: 'cell', - _key: 'k8', - value: [ - { - _type: 'block', - style: 'normal', - children: [ - {_type: 'span', _key: 'k7', text: 'foo', marks: []}, - ], - _key: 'k6', - markDefs: [], - }, - ], - }, - { - _type: 'cell', - _key: 'k11', - value: [ - { - _type: 'block', - style: 'normal', - children: [ - {_type: 'span', _key: 'k10', text: 'bar', marks: []}, - ], - _key: 'k9', - markDefs: [], - }, - ], - }, - ], - }, - ], - }, - ]) - }) - - test('`types: {table: undefined}` opts out and flattens', () => { - const keyGenerator = createTestKeyGenerator() - const markdown = ['| A | B |', '| --- | --- |', '| 1 | 2 |'].join('\n') - - expect( - markdownToPortableText(markdown, { - keyGenerator, - types: {table: undefined}, - }), - ).toEqual([ - { - _type: 'block', - _key: 'k0', - style: 'normal', - children: [{_type: 'span', _key: 'k1', text: 'A', marks: []}], - markDefs: [], - }, - { - _type: 'block', - _key: 'k3', - style: 'normal', - children: [{_type: 'span', _key: 'k4', text: 'B', marks: []}], - markDefs: [], - }, - { - _type: 'block', - _key: 'k7', - style: 'normal', - children: [{_type: 'span', _key: 'k8', text: '1', marks: []}], - markDefs: [], - }, - { - _type: 'block', - _key: 'k10', - style: 'normal', - children: [{_type: 'span', _key: 'k11', text: '2', marks: []}], - markDefs: [], - }, - ]) - }) - - test('a differently-shaped schema `table` (no `rows` field) flattens instead of losing content', () => { - const keyGenerator = createTestKeyGenerator() - const schema = compileSchema( - defineSchema({ - ...defaultSchema, - blockObjects: [ - ...defaultSchema.blockObjects.filter( - (blockObject) => blockObject.name !== 'table', - ), - {name: 'table', fields: [{name: 'data', type: 'string'}]}, - ], - }), - ) - const markdown = ['| A | B |', '| --- | --- |', '| 1 | 2 |'].join('\n') - - expect( - markdownToPortableText(markdown, {keyGenerator, schema}), - ).toEqual([ - { - _type: 'block', - _key: 'k0', - style: 'normal', - children: [{_type: 'span', _key: 'k1', text: 'A', marks: []}], - markDefs: [], - }, - { - _type: 'block', - _key: 'k3', - style: 'normal', - children: [{_type: 'span', _key: 'k4', text: 'B', marks: []}], - markDefs: [], - }, - { - _type: 'block', - _key: 'k7', - style: 'normal', - children: [{_type: 'span', _key: 'k8', text: '1', marks: []}], - markDefs: [], - }, - { - _type: 'block', - _key: 'k10', - style: 'normal', - children: [{_type: 'span', _key: 'k11', text: '2', marks: []}], - markDefs: [], - }, - ]) - }) - }) - }) - - describe('callout', () => { - test('basic note callout', () => { - const keyGenerator = createTestKeyGenerator() - expect( - markdownToPortableText('> [!NOTE]\n> This is a note', { - keyGenerator, - }), - ).toEqual([ - { - _type: 'callout', - _key: 'k2', - tone: 'note', - content: [ - { - _type: 'block', - _key: 'k0', - style: 'blockquote', - children: [ - { - _type: 'span', - _key: 'k1', - text: 'This is a note', - marks: [], - }, - ], - markDefs: [], - }, - ], - }, - ]) - }) - - test('warning callout', () => { - const keyGenerator = createTestKeyGenerator() - expect( - markdownToPortableText('> [!WARNING]\n> Be careful here', { - keyGenerator, - }), - ).toEqual([ - { - _type: 'callout', - _key: 'k2', - tone: 'warning', - content: [ - { - _type: 'block', - _key: 'k0', - style: 'blockquote', - children: [ - { - _type: 'span', - _key: 'k1', - text: 'Be careful here', - marks: [], - }, - ], - markDefs: [], - }, - ], - }, - ]) - }) - - test('callout with formatting', () => { - const keyGenerator = createTestKeyGenerator() - expect( - markdownToPortableText('> [!TIP]\n> This is **bold** and *italic*', { - keyGenerator, - }), - ).toEqual([ - { - _type: 'callout', - _key: 'k5', - tone: 'tip', - content: [ - { - _type: 'block', - _key: 'k0', - style: 'blockquote', - children: [ - { - _type: 'span', - _key: 'k1', - text: 'This is ', - marks: [], - }, - { - _type: 'span', - _key: 'k2', - text: 'bold', - marks: ['strong'], - }, - { - _type: 'span', - _key: 'k3', - text: ' and ', - marks: [], - }, - { - _type: 'span', - _key: 'k4', - text: 'italic', - marks: ['em'], - }, - ], - markDefs: [], - }, - ], - }, - ]) - }) - - test('callout with multiple paragraphs', () => { - const keyGenerator = createTestKeyGenerator() - expect( - markdownToPortableText( - '> [!IMPORTANT]\n> First paragraph\n>\n> Second paragraph', - {keyGenerator}, - ), - ).toEqual([ - { - _type: 'callout', - _key: 'k4', - tone: 'important', - content: [ - { - _type: 'block', - _key: 'k0', - style: 'blockquote', - children: [ - { - _type: 'span', - _key: 'k1', - text: 'First paragraph', - marks: [], - }, - ], - markDefs: [], - }, - { - _type: 'block', - _key: 'k2', - style: 'blockquote', - children: [ - { - _type: 'span', - _key: 'k3', - text: 'Second paragraph', - marks: [], - }, - ], - markDefs: [], - }, - ], - }, - ]) - }) - - test('callout not in schema falls back to `blockquote` blocks', () => { - const keyGenerator = createTestKeyGenerator() - expect( - markdownToPortableText('> [!NOTE]\n> This is a note', { - keyGenerator, - schema: compileSchema( - defineSchema({ - styles: [{name: 'normal'}, {name: 'blockquote'}], - }), - ), - }), - ).toEqual([ - { - _type: 'block', - _key: 'k0', - style: 'blockquote', - children: [ - { - _type: 'span', - _key: 'k1', - text: 'This is a note', - marks: [], - }, - ], - markDefs: [], - }, - ]) - }) - - test('callout not in schema and no `blockquote` style falls back to `normal` text blocks', () => { - const keyGenerator = createTestKeyGenerator() - expect( - markdownToPortableText('> [!NOTE]\n> This is a note', { - keyGenerator, - schema: compileSchema(defineSchema({})), - }), - ).toEqual([ - { - _type: 'block', - _key: 'k0', - style: 'normal', - children: [ - { - _type: 'span', - _key: 'k1', - text: 'This is a note', - marks: [], - }, - ], - markDefs: [], - }, - ]) - }) - - test('all supported callout types', () => { - const types = ['NOTE', 'TIP', 'WARNING', 'CAUTION', 'IMPORTANT'] - - for (const type of types) { - const keyGenerator = createTestKeyGenerator() - expect( - markdownToPortableText(`> [!${type}]\n> Content`, { - keyGenerator, - }), - ).toEqual([ - { - _type: 'callout', - _key: 'k2', - tone: type.toLowerCase(), - content: [ - { - _type: 'block', - _key: 'k0', - style: 'blockquote', - children: [ - { - _type: 'span', - _key: 'k1', - text: 'Content', - marks: [], - }, - ], - markDefs: [], - }, - ], - }, - ]) - } - }) - }) - - describe('list as container (`types.list`)', () => { - const listItemDefinition = { - name: 'list-item', - fields: [ - {name: 'checked', type: 'boolean'}, - {name: 'content', type: 'array'}, - ], - } as const satisfies BlockObjectDefinition - - const listObjectDefinition = { - name: 'list', - fields: [ - {name: 'kind', type: 'string'}, - {name: 'items', type: 'array'}, - ], - } as const satisfies BlockObjectDefinition - - const tableObjectDefinition = { - name: 'table', - fields: [ - {name: 'headerRows', type: 'number'}, - {name: 'rows', type: 'array'}, - ], - } as const satisfies BlockObjectDefinition - - const schemaWithList = compileSchema( - defineSchema({ - ...defaultSchema, - blockObjects: [ - ...defaultSchema.blockObjects, - listObjectDefinition, - listItemDefinition, - ], - }), - ) - - const schemaWithListAndTable = compileSchema( - defineSchema({ - ...defaultSchema, - blockObjects: [ - ...defaultSchema.blockObjects, - listObjectDefinition, - listItemDefinition, - tableObjectDefinition, - ], - }), - ) - - const getListTestOptions = (keyGenerator: () => string) => ({ - keyGenerator, - schema: schemaWithList, - types: { - list: buildObjectMatcher(listObjectDefinition), - }, - }) - - test('simple bullet list', () => { - const keyGenerator = createTestKeyGenerator() - expect( - markdownToPortableText( - ['- one', '- two'].join('\n'), - getListTestOptions(keyGenerator), - ), - ).toEqual([ - { - _key: 'k6', - _type: 'list', - items: [ - { - _key: 'k0', - _type: 'list-item', - content: [ - { - _key: 'k1', - _type: 'block', - children: [ - { - _key: 'k2', - _type: 'span', - marks: [], - text: 'one', - }, - ], - markDefs: [], - style: 'normal', - }, - ], - }, - { - _key: 'k3', - _type: 'list-item', - content: [ - { - _key: 'k4', - _type: 'block', - children: [ - { - _key: 'k5', - _type: 'span', - marks: [], - text: 'two', - }, - ], - markDefs: [], - style: 'normal', - }, - ], - }, - ], - kind: 'bullet', - }, - ]) - }) - - test('ordered list', () => { - const keyGenerator = createTestKeyGenerator() - expect( - markdownToPortableText( - ['1. one', '2. two'].join('\n'), - getListTestOptions(keyGenerator), - ), - ).toEqual([ - { - _key: 'k6', - _type: 'list', - items: [ - { - _key: 'k0', - _type: 'list-item', - content: [ - { - _key: 'k1', - _type: 'block', - children: [ - { - _key: 'k2', - _type: 'span', - marks: [], - text: 'one', - }, - ], - markDefs: [], - style: 'normal', - }, - ], - }, - { - _key: 'k3', - _type: 'list-item', - content: [ - { - _key: 'k4', - _type: 'block', - children: [ - { - _key: 'k5', - _type: 'span', - marks: [], - text: 'two', - }, - ], - markDefs: [], - style: 'normal', - }, - ], - }, - ], - kind: 'number', - }, - ]) - }) - - test('task list with checked state', () => { - const keyGenerator = createTestKeyGenerator() - expect( - markdownToPortableText( - ['- [ ] todo', '- [x] done'].join('\n'), - getListTestOptions(keyGenerator), - ), - ).toEqual([ - { - _key: 'k6', - _type: 'list', - items: [ - { - _key: 'k0', - _type: 'list-item', - checked: false, - content: [ - { - _key: 'k1', - _type: 'block', - children: [ - { - _key: 'k2', - _type: 'span', - marks: [], - text: 'todo', - }, - ], - markDefs: [], - style: 'normal', - }, - ], - }, - { - _key: 'k3', - _type: 'list-item', - checked: true, - content: [ - { - _key: 'k4', - _type: 'block', - children: [ - { - _key: 'k5', - _type: 'span', - marks: [], - text: 'done', - }, - ], - markDefs: [], - style: 'normal', - }, - ], - }, - ], - kind: 'task', - }, - ]) - }) - - test('list item with code block', () => { - const keyGenerator = createTestKeyGenerator() - expect( - markdownToPortableText( - [ - '- one', - '', - ' ```ts', - ' const x = 1', - ' ```', - '', - '- two', - ].join('\n'), - getListTestOptions(keyGenerator), - ), - ).toEqual([ - { - _key: 'k7', - _type: 'list', - items: [ - { - _key: 'k0', - _type: 'list-item', - content: [ - { - _key: 'k1', - _type: 'block', - children: [ - { - _key: 'k2', - _type: 'span', - marks: [], - text: 'one', - }, - ], - markDefs: [], - style: 'normal', - }, - { - _key: 'k3', - _type: 'code', - code: 'const x = 1', - language: 'ts', - }, - ], - }, - { - _key: 'k4', - _type: 'list-item', - content: [ - { - _key: 'k5', - _type: 'block', - children: [ - { - _key: 'k6', - _type: 'span', - marks: [], - text: 'two', - }, - ], - markDefs: [], - style: 'normal', - }, - ], - }, - ], - kind: 'bullet', - }, - ]) - }) - - test('nested bullet list', () => { - const keyGenerator = createTestKeyGenerator() - expect( - markdownToPortableText( - ['- one', ' - nested', '- two'].join('\n'), - getListTestOptions(keyGenerator), - ), - ).toEqual([ - { - _key: 'k10', - _type: 'list', - items: [ - { - _key: 'k0', - _type: 'list-item', - content: [ - { - _key: 'k1', - _type: 'block', - children: [ - { - _key: 'k2', - _type: 'span', - marks: [], - text: 'one', - }, - ], - markDefs: [], - style: 'normal', - }, - { - _key: 'k6', - _type: 'list', - items: [ - { - _key: 'k3', - _type: 'list-item', - content: [ - { - _key: 'k4', - _type: 'block', - children: [ - { - _key: 'k5', - _type: 'span', - marks: [], - text: 'nested', - }, - ], - markDefs: [], - style: 'normal', - }, - ], - }, - ], - kind: 'bullet', - }, - ], - }, - { - _key: 'k7', - _type: 'list-item', - content: [ - { - _key: 'k8', - _type: 'block', - children: [ - { - _key: 'k9', - _type: 'span', - marks: [], - text: 'two', - }, - ], - markDefs: [], - style: 'normal', - }, - ], - }, - ], - kind: 'bullet', - }, - ]) - }) - - test('mixed nested kinds (bullet outside, ordered inside)', () => { - const keyGenerator = createTestKeyGenerator() - expect( - markdownToPortableText( - ['- one', ' 1. nested', '- two'].join('\n'), - getListTestOptions(keyGenerator), - ), - ).toEqual([ - { - _key: 'k10', - _type: 'list', - items: [ - { - _key: 'k0', - _type: 'list-item', - content: [ - { - _key: 'k1', - _type: 'block', - children: [ - { - _key: 'k2', - _type: 'span', - marks: [], - text: 'one', - }, - ], - markDefs: [], - style: 'normal', - }, - { - _key: 'k6', - _type: 'list', - items: [ - { - _key: 'k3', - _type: 'list-item', - content: [ - { - _key: 'k4', - _type: 'block', - children: [ - { - _key: 'k5', - _type: 'span', - marks: [], - text: 'nested', - }, - ], - markDefs: [], - style: 'normal', - }, - ], - }, - ], - kind: 'number', - }, - ], - }, - { - _key: 'k7', - _type: 'list-item', - content: [ - { - _key: 'k8', - _type: 'block', - children: [ - { - _key: 'k9', - _type: 'span', - marks: [], - text: 'two', - }, - ], - markDefs: [], - style: 'normal', - }, - ], - }, - ], - kind: 'bullet', - }, - ]) - }) - - test('matcher returning undefined falls back to flat list parsing', () => { - const keyGenerator = createTestKeyGenerator() - expect( - markdownToPortableText(['- one', '- two'].join('\n'), { - keyGenerator, - schema: schemaWithList, - types: { - list: () => undefined, - }, - }), - ).toEqual([ - { - _key: 'k1', - _type: 'block', - children: [ - { - _key: 'k2', - _type: 'span', - marks: [], - text: 'one', - }, - ], - level: 1, - listItem: 'bullet', - markDefs: [], - style: 'normal', - }, - { - _key: 'k4', - _type: 'block', - children: [ - { - _key: 'k5', - _type: 'span', - marks: [], - text: 'two', - }, - ], - level: 1, - listItem: 'bullet', - markDefs: [], - style: 'normal', - }, - ]) - }) - - test('without `types.list`, lists fall back to flat blocks', () => { - const keyGenerator = createTestKeyGenerator() - expect( - markdownToPortableText(['- one', '- two'].join('\n'), {keyGenerator}), - ).toEqual([ - { - _key: 'k0', - _type: 'block', - children: [ - { - _key: 'k1', - _type: 'span', - marks: [], - text: 'one', - }, - ], - level: 1, - listItem: 'bullet', - markDefs: [], - style: 'normal', - }, - { - _key: 'k2', - _type: 'block', - children: [ - { - _key: 'k3', - _type: 'span', - marks: [], - text: 'two', - }, - ], - level: 1, - listItem: 'bullet', - markDefs: [], - style: 'normal', - }, - ]) - }) - - test('list inside a callout', () => { - const keyGenerator = createTestKeyGenerator() - expect( - markdownToPortableText( - ['> [!NOTE]', '> - one', '> - two'].join('\n'), - getListTestOptions(keyGenerator), - ), - ).toEqual([ - { - _key: 'k7', - _type: 'callout', - content: [ - { - _key: 'k6', - _type: 'list', - items: [ - { - _key: 'k0', - _type: 'list-item', - content: [ - { - _key: 'k1', - _type: 'block', - children: [ - { - _key: 'k2', - _type: 'span', - marks: [], - text: 'one', - }, - ], - markDefs: [], - style: 'blockquote', - }, - ], - }, - { - _key: 'k3', - _type: 'list-item', - content: [ - { - _key: 'k4', - _type: 'block', - children: [ - { - _key: 'k5', - _type: 'span', - marks: [], - text: 'two', - }, - ], - markDefs: [], - style: 'blockquote', - }, - ], - }, - ], - kind: 'bullet', - }, - ], - tone: 'note', - }, - ]) - }) - - test('list inside a table cell', () => { - // GFM pipe tables only allow inline content in cells, so `- one` - // inside a cell stays as plain text. - const keyGenerator = createTestKeyGenerator() - expect( - markdownToPortableText( - ['| Header |', '|--------|', '| - one |', '| - two |'].join('\n'), - { - keyGenerator, - schema: schemaWithListAndTable, - types: { - table: buildObjectMatcher(tableObjectDefinition), - list: buildObjectMatcher(listObjectDefinition), - }, - }, - ), - ).toEqual([ - { - _key: 'k12', - _type: 'table', - headerRows: 1, - rows: [ - { - _key: 'k3', - _type: 'row', - cells: [ - { - _key: 'k2', - _type: 'cell', - value: [ - { - _key: 'k0', - _type: 'block', - children: [ - { - _key: 'k1', - _type: 'span', - marks: [], - text: 'Header', - }, - ], - markDefs: [], - style: 'normal', - }, - ], - }, - ], - }, - { - _key: 'k7', - _type: 'row', - cells: [ - { - _key: 'k6', - _type: 'cell', - value: [ - { - _key: 'k4', - _type: 'block', - children: [ - { - _key: 'k5', - _type: 'span', - marks: [], - text: '- one', - }, - ], - markDefs: [], - style: 'normal', - }, - ], - }, - ], - }, - { - _key: 'k11', - _type: 'row', - cells: [ - { - _key: 'k10', - _type: 'cell', - value: [ - { - _key: 'k8', - _type: 'block', - children: [ - { - _key: 'k9', - _type: 'span', - marks: [], - text: '- two', - }, - ], - markDefs: [], - style: 'normal', - }, - ], - }, - ], - }, - ], - }, - ]) - }) - - test('blockquote-styled block inside a list item', () => { - // The GFM alert plugin does not recognize `[!NOTE]` inside a list - // item, but regular blockquotes ARE recognized and land in the - // item's content with `style: 'blockquote'`. - const keyGenerator = createTestKeyGenerator() - expect( - markdownToPortableText( - ['- one', '', ' > a quote', '', '- two'].join('\n'), - getListTestOptions(keyGenerator), - ), - ).toEqual([ - { - _key: 'k8', - _type: 'list', - items: [ - { - _key: 'k0', - _type: 'list-item', - content: [ - { - _key: 'k1', - _type: 'block', - children: [ - { - _key: 'k2', - _type: 'span', - marks: [], - text: 'one', - }, - ], - markDefs: [], - style: 'normal', - }, - { - _key: 'k3', - _type: 'block', - children: [ - { - _key: 'k4', - _type: 'span', - marks: [], - text: 'a quote', - }, - ], - markDefs: [], - style: 'blockquote', - }, - ], - }, - { - _key: 'k5', - _type: 'list-item', - content: [ - { - _key: 'k6', - _type: 'block', - children: [ - { - _key: 'k7', - _type: 'span', - marks: [], - text: 'two', - }, - ], - markDefs: [], - style: 'normal', - }, - ], - }, - ], - kind: 'bullet', - }, - ]) - }) - }) - - describe('blockquote as container (`types.blockquote`)', () => { - const blockquoteObjectDefinition = { - name: 'blockquote', - fields: [{name: 'content', type: 'array'}], - } as const satisfies BlockObjectDefinition - - const schemaWithBlockquote = compileSchema( - defineSchema({ - ...defaultSchema, - blockObjects: [ - ...defaultSchema.blockObjects, - blockquoteObjectDefinition, - ], - }), - ) - - const getBlockquoteTestOptions = (keyGenerator: () => string) => ({ - keyGenerator, - schema: schemaWithBlockquote, - types: { - blockquote: buildObjectMatcher(blockquoteObjectDefinition), - }, - }) - - test('simple blockquote', () => { - const keyGenerator = createTestKeyGenerator() - expect( - markdownToPortableText('> one', getBlockquoteTestOptions(keyGenerator)), - ).toEqual([ - { - _key: 'k2', - _type: 'blockquote', - content: [ - { - _type: 'block', - style: 'normal', - children: [ - { - _type: 'span', - _key: 'k1', - text: 'one', - marks: [], - }, - ], - _key: 'k0', - markDefs: [], - }, - ], - }, - ]) - }) - - test('multi-paragraph blockquote', () => { - const keyGenerator = createTestKeyGenerator() - expect( - markdownToPortableText( - ['> one', '>', '> two'].join('\n'), - getBlockquoteTestOptions(keyGenerator), - ), - ).toEqual([ - { - _key: 'k4', - _type: 'blockquote', - content: [ - { - _type: 'block', - style: 'normal', - children: [ - { - _type: 'span', - _key: 'k1', - text: 'one', - marks: [], - }, - ], - _key: 'k0', - markDefs: [], - }, - { - _type: 'block', - style: 'normal', - children: [ - { - _type: 'span', - _key: 'k3', - text: 'two', - marks: [], - }, - ], - _key: 'k2', - markDefs: [], - }, - ], - }, - ]) - }) - - test('nested blockquote', () => { - const keyGenerator = createTestKeyGenerator() - expect( - markdownToPortableText( - ['> outer', '>', '> > inner'].join('\n'), - getBlockquoteTestOptions(keyGenerator), - ), - ).toEqual([ - { - _key: 'k5', - _type: 'blockquote', - content: [ - { - _type: 'block', - style: 'normal', - children: [ - { - _type: 'span', - _key: 'k1', - text: 'outer', - marks: [], - }, - ], - _key: 'k0', - markDefs: [], - }, - { - _key: 'k4', - _type: 'blockquote', - content: [ - { - _type: 'block', - style: 'normal', - children: [ - { - _type: 'span', - _key: 'k3', - text: 'inner', - marks: [], - }, - ], - _key: 'k2', - markDefs: [], - }, - ], - }, - ], - }, - ]) - }) - - test('blockquote with code block', () => { - const keyGenerator = createTestKeyGenerator() - expect( - markdownToPortableText( - ['> intro', '>', '> ```js', "> console.log('hi')", '> ```'].join( - '\n', - ), - getBlockquoteTestOptions(keyGenerator), - ), - ).toEqual([ - { - _key: 'k3', - _type: 'blockquote', - content: [ - { - _type: 'block', - style: 'normal', - children: [ - { - _type: 'span', - _key: 'k1', - text: 'intro', - marks: [], - }, - ], - _key: 'k0', - markDefs: [], - }, - { - _key: 'k2', - _type: 'code', - language: 'js', - code: "console.log('hi')", - }, - ], - }, - ]) - }) - - test('GFM alert is still a callout, not a blockquote', () => { - // When both `types.callout` and `types.blockquote` are registered, GFM - // alerts (`> [!NOTE]`) hit the alert_open/close tokens and produce - // callouts; only plain blockquotes hit blockquote_open/close. - const keyGenerator = createTestKeyGenerator() - expect( - markdownToPortableText( - ['> [!NOTE]', '> heads up'].join('\n'), - getBlockquoteTestOptions(keyGenerator), - ), - ).toEqual([ - { - _key: 'k2', - _type: 'callout', - tone: 'note', - content: [ - { - _type: 'block', - style: 'blockquote', - children: [ - { - _type: 'span', - _key: 'k1', - text: 'heads up', - marks: [], - }, - ], - _key: 'k0', - markDefs: [], - }, - ], - }, - ]) - }) - - test('matcher returning undefined falls back to flat blockquote-styled blocks', () => { - const keyGenerator = createTestKeyGenerator() - expect( - markdownToPortableText(['> one', '>', '> two'].join('\n'), { - keyGenerator, - schema: schemaWithBlockquote, - types: { - blockquote: () => undefined, - }, - }), - ).toEqual([ - { - _type: 'block', - style: 'blockquote', - children: [ - { - _type: 'span', - _key: 'k1', - text: 'one', - marks: [], - }, - ], - _key: 'k0', - markDefs: [], - }, - { - _type: 'block', - style: 'blockquote', - children: [ - { - _type: 'span', - _key: 'k3', - text: 'two', - marks: [], - }, - ], - _key: 'k2', - markDefs: [], - }, - ]) - }) - - test("without `types.blockquote`, blockquotes fall back to flat `style: 'blockquote'` blocks", () => { - const keyGenerator = createTestKeyGenerator() - expect( - markdownToPortableText(['> one', '>', '> two'].join('\n'), { - keyGenerator, - schema: schemaWithBlockquote, - }), - ).toEqual([ - { - _type: 'block', - style: 'blockquote', - children: [ - { - _type: 'span', - _key: 'k1', - text: 'one', - marks: [], - }, - ], - _key: 'k0', - markDefs: [], - }, - { - _type: 'block', - style: 'blockquote', - children: [ - { - _type: 'span', - _key: 'k3', - text: 'two', - marks: [], - }, - ], - _key: 'k2', - markDefs: [], - }, - ]) - }) - }) - describe('json:object fence', () => { - test('reconstructs the object, `_key` included', () => { - const keyGenerator = createTestKeyGenerator() - const markdown = [ - '```json:object', - '{"_type": "product", "_key": "product-key", "sku": "abc-123"}', - '```', - ].join('\n') - expect(markdownToPortableText(markdown, {keyGenerator})).toEqual([ - { - _type: 'product', - _key: 'product-key', - sku: 'abc-123', - }, - ]) - }) - - test('reconstructs regardless of the schema', () => { - const keyGenerator = createTestKeyGenerator() - const markdown = [ - '```json:object', - '{"_type": "product", "sku": "abc-123"}', - '```', - ].join('\n') - expect( - markdownToPortableText(markdown, { - keyGenerator, - schema: compileSchema(defineSchema({})), - }), - ).toEqual([ - { - _type: 'product', - _key: 'k0', - sku: 'abc-123', - }, - ]) - }) - - test('a payload without a `_key` gets a generated key', () => { - const keyGenerator = createTestKeyGenerator() - const markdown = [ - '```json:object', - '{"_type": "product", "sku": "abc-123"}', - '```', - ].join('\n') - expect(markdownToPortableText(markdown, {keyGenerator})).toEqual([ - { - _type: 'product', - _key: 'k0', - sku: 'abc-123', - }, - ]) - }) - - test('a payload with an empty `_key` gets a generated key', () => { - const keyGenerator = createTestKeyGenerator() - const markdown = [ - '```json:object', - '{"_type": "product", "_key": "", "sku": "abc-123"}', - '```', - ].join('\n') - expect(markdownToPortableText(markdown, {keyGenerator})).toEqual([ - { - _type: 'product', - _key: 'k0', - sku: 'abc-123', - }, - ]) - }) - - test('a payload with a non-string `_key` gets a generated key', () => { - const keyGenerator = createTestKeyGenerator() - const markdown = [ - '```json:object', - '{"_type": "product", "_key": 7, "sku": "abc-123"}', - '```', - ].join('\n') - expect(markdownToPortableText(markdown, {keyGenerator})).toEqual([ - { - _type: 'product', - _key: 'k0', - sku: 'abc-123', - }, - ]) - }) - - test('a generated payload key never repeats a key the conversion already minted', () => { - const markdown = [ - 'foo', - '', - '```json:object', - '{"_type": "product", "sku": "abc-123"}', - '```', - ].join('\n') - expect( - markdownToPortableText(markdown, {keyGenerator: () => 'constant'}), - ).toEqual([ - { - _type: 'block', - _key: 'constant', - style: 'normal', - markDefs: [], - children: [ - {_type: 'span', _key: 'constant-2', text: 'foo', marks: []}, - ], - }, - { - _type: 'product', - _key: 'constant-3', - sku: 'abc-123', - }, - ]) - }) - - test('a generated key never equals a key a later fence carries', () => { - const keyGenerator = createTestKeyGenerator() - const markdown = [ - '```json:object', - '{"_type": "bar"}', - '```', - '', - '```json:object', - '{"_type": "foo", "_key": "k0"}', - '```', - ].join('\n') - expect(markdownToPortableText(markdown, {keyGenerator})).toEqual([ - {_type: 'bar', _key: 'k1'}, - {_type: 'foo', _key: 'k0'}, - ]) - }) - - test('a generated key never equals a key an earlier fence carries', () => { - const keyGenerator = createTestKeyGenerator() - const markdown = [ - '```json:object', - '{"_type": "foo", "_key": "k0"}', - '```', - '', - '```json:object', - '{"_type": "bar"}', - '```', - ].join('\n') - expect(markdownToPortableText(markdown, {keyGenerator})).toEqual([ - {_type: 'foo', _key: 'k0'}, - {_type: 'bar', _key: 'k1'}, - ]) - }) - - test('a generated key never equals a key a later inline sibling carries', () => { - const keyGenerator = createTestKeyGenerator() - const markdown = - 'json:object`{"_type":"bar"}` json:object`{"_type":"foo","_key":"k1"}`' - expect(markdownToPortableText(markdown, {keyGenerator})).toEqual([ - { - _type: 'block', - _key: 'k0', - style: 'normal', - markDefs: [], - children: [ - {_type: 'bar', _key: 'k2'}, - {_type: 'span', _key: 'k3', text: ' ', marks: []}, - {_type: 'foo', _key: 'k1'}, - ], - }, - ]) - }) - - test('a generated key never equals a key an earlier inline sibling carries', () => { - const keyGenerator = createTestKeyGenerator() - const markdown = - 'json:object`{"_type":"foo","_key":"k1"}` json:object`{"_type":"bar"}`' - expect(markdownToPortableText(markdown, {keyGenerator})).toEqual([ - { - _type: 'block', - _key: 'k0', - style: 'normal', - markDefs: [], - children: [ - {_type: 'foo', _key: 'k1'}, - {_type: 'span', _key: 'k2', text: ' ', marks: []}, - {_type: 'bar', _key: 'k3'}, - ], - }, - ]) - }) - - test('a text block never gets the key a later fence carries', () => { - const keyGenerator = createTestKeyGenerator() - const markdown = [ - 'foo', - '', - '```json:object', - '{"_type": "product", "_key": "k0"}', - '```', - ].join('\n') - expect(markdownToPortableText(markdown, {keyGenerator})).toEqual([ - { - _type: 'block', - _key: 'k1', - style: 'normal', - markDefs: [], - children: [{_type: 'span', _key: 'k2', text: 'foo', marks: []}], - }, - {_type: 'product', _key: 'k0'}, - ]) - }) - - test('a text block never gets the key an earlier fence carries', () => { - const keyGenerator = createTestKeyGenerator() - const markdown = [ - '```json:object', - '{"_type": "product", "_key": "k0"}', - '```', - '', - 'foo', - ].join('\n') - expect(markdownToPortableText(markdown, {keyGenerator})).toEqual([ - {_type: 'product', _key: 'k0'}, - { - _type: 'block', - _key: 'k1', - style: 'normal', - markDefs: [], - children: [{_type: 'span', _key: 'k2', text: 'foo', marks: []}], - }, - ]) - }) - - test('a span never gets the key a later fence carries', () => { - const keyGenerator = createTestKeyGenerator() - const markdown = [ - 'foo', - '', - '```json:object', - '{"_type": "product", "_key": "k1"}', - '```', - ].join('\n') - expect(markdownToPortableText(markdown, {keyGenerator})).toEqual([ - { - _type: 'block', - _key: 'k0', - style: 'normal', - markDefs: [], - children: [{_type: 'span', _key: 'k2', text: 'foo', marks: []}], - }, - {_type: 'product', _key: 'k1'}, - ]) - }) - - test('a span never gets the key an earlier inline sibling carries', () => { - const keyGenerator = createTestKeyGenerator() - const markdown = 'json:object`{"_type":"foo","_key":"k1"}` bar' - expect(markdownToPortableText(markdown, {keyGenerator})).toEqual([ - { - _type: 'block', - _key: 'k0', - style: 'normal', - markDefs: [], - children: [ - {_type: 'foo', _key: 'k1'}, - {_type: 'span', _key: 'k2', text: ' bar', marks: []}, - ], - }, - ]) - }) - - test('a tagged payload with no open text block is kept as an inline object', () => { - const keyGenerator = createTestKeyGenerator() - const onDegradation = - vi.fn<(report: {degradations: Array}) => void>() - const markdown = [ - '- foo', - '', - ' ```json:object', - ' {"_type": "product", "_key": "p1"}', - ' ```', - '', - ' json:object`{"_type":"stockTicker","_key":"k0"}` bar', - ].join('\n') - - expect( - markdownToPortableText(markdown, { - keyGenerator, - schema: compileSchema(defineSchema({})), - onDegradation, - }), - ).toEqual([ - { - _type: 'block', - _key: 'k1', - style: 'normal', - markDefs: [], - children: [{_type: 'span', _key: 'k2', text: 'foo', marks: []}], - }, - {_type: 'product', _key: 'p1'}, - { - _type: 'block', - _key: 'k3', - style: 'normal', - markDefs: [], - children: [ - {_type: 'stockTicker', _key: 'k0'}, - {_type: 'span', _key: 'k4', text: ' bar', marks: []}, - ], - }, - ]) - - expect(onDegradation).toHaveBeenCalledTimes(1) - expect(onDegradation.mock.calls[0]![0]!.degradations).toEqual([ - { - type: 'list-flattened', - message: - 'Bullet list became plain paragraphs: the schema has no `bullet` list', - line: 1, - }, - { - type: 'object-type-undeclared', - message: - 'Kept the block-level `product` object as written: the schema has no block-level `product` object', - line: 3, - snippet: '{"_type": "product", "_key": "p1"}', - }, - { - type: 'object-type-undeclared', - message: - 'Kept the inline `stockTicker` object as written: the schema has no inline `stockTicker` object', - line: 7, - snippet: '{"_type":"stockTicker","_key":"k0"}', - }, - ]) - }) - - test('a keyless tagged payload with no open text block is keyed after the block it opens', () => { - const keyGenerator = createTestKeyGenerator() - const markdown = [ - '- foo', - '', - ' ```json:object', - ' {"_type": "product", "_key": "p1"}', - ' ```', - '', - ' json:object`{"_type":"stockTicker"}` bar', - ].join('\n') - - expect( - markdownToPortableText(markdown, { - keyGenerator, - schema: compileSchema(defineSchema({})), - }), - ).toEqual([ - { - _type: 'block', - _key: 'k0', - style: 'normal', - markDefs: [], - children: [{_type: 'span', _key: 'k1', text: 'foo', marks: []}], - }, - {_type: 'product', _key: 'p1'}, - { - _type: 'block', - _key: 'k2', - style: 'normal', - markDefs: [], - children: [ - {_type: 'stockTicker', _key: 'k3'}, - {_type: 'span', _key: 'k4', text: ' bar', marks: []}, - ], - }, - ]) - }) - - test('text before a tagged payload with no open text block stays a span', () => { - const keyGenerator = createTestKeyGenerator() - const markdown = [ - '- foo', - '', - ' ```json:object', - ' {"_type": "product", "_key": "p1"}', - ' ```', - '', - ' baz json:object`{"_type":"stockTicker","_key":"k0"}` bar', - ].join('\n') - - expect( - markdownToPortableText(markdown, { - keyGenerator, - schema: compileSchema(defineSchema({})), - }), - ).toEqual([ - { - _type: 'block', - _key: 'k1', - style: 'normal', - markDefs: [], - children: [{_type: 'span', _key: 'k2', text: 'foo', marks: []}], - }, - {_type: 'product', _key: 'p1'}, - { - _type: 'block', - _key: 'k3', - style: 'normal', - markDefs: [], - children: [ - {_type: 'span', _key: 'k4', text: 'baz ', marks: []}, - {_type: 'stockTicker', _key: 'k0'}, - {_type: 'span', _key: 'k5', text: ' bar', marks: []}, - ], - }, - ]) - }) - - test('a suffixed key from a repeating generator skips a carried key', () => { - const markdown = [ - 'foo', - '', - '```json:object', - '{"_type": "product", "_key": "constant-2"}', - '```', - ].join('\n') - expect( - markdownToPortableText(markdown, {keyGenerator: () => 'constant'}), - ).toEqual([ - { - _type: 'block', - _key: 'constant', - style: 'normal', - markDefs: [], - children: [ - {_type: 'span', _key: 'constant-3', text: 'foo', marks: []}, - ], - }, - {_type: 'product', _key: 'constant-2'}, - ]) - }) - - test('malformed JSON degrades to a code block', () => { - const keyGenerator = createTestKeyGenerator() - const markdown = ['```json:object', '{"_type": "product",', '```'].join( - '\n', - ) - expect(markdownToPortableText(markdown, {keyGenerator})).toEqual([ - { - _type: 'code', - _key: 'k0', - code: '{"_type": "product",', - language: 'json:object', - }, - ]) - }) - - test('JSON without a string `_type` degrades to a code block', () => { - const keyGenerator = createTestKeyGenerator() - const markdown = ['```json:object', '{"sku": "abc-123"}', '```'].join( - '\n', - ) - expect(markdownToPortableText(markdown, {keyGenerator})).toEqual([ - { - _type: 'code', - _key: 'k0', - code: '{"sku": "abc-123"}', - language: 'json:object', - }, - ]) - }) - - test('a tagged inline code span reconstructs an inline object in place', () => { - const keyGenerator = createTestKeyGenerator() - const markdown = - 'AAPL is at json:object`{"_type":"stockTicker","_key":"t1","symbol":"AAPL"}` right now.' - expect(markdownToPortableText(markdown, {keyGenerator})).toEqual([ - { - _type: 'block', - _key: 'k0', - style: 'normal', - markDefs: [], - children: [ - {_type: 'span', _key: 'k1', text: 'AAPL is at ', marks: []}, - {_type: 'stockTicker', _key: 't1', symbol: 'AAPL'}, - {_type: 'span', _key: 'k2', text: ' right now.', marks: []}, - ], - }, - ]) - }) - - test('an inline payload without a `_key` gets a generated key', () => { - const keyGenerator = createTestKeyGenerator() - const markdown = 'json:object`{"_type":"stockTicker","symbol":"AAPL"}`' - expect(markdownToPortableText(markdown, {keyGenerator})).toEqual([ - { - _type: 'block', - _key: 'k0', - style: 'normal', - markDefs: [], - children: [{_type: 'stockTicker', _key: 'k1', symbol: 'AAPL'}], - }, - ]) - }) - - test('an inline payload with an empty `_key` gets a generated key', () => { - const keyGenerator = createTestKeyGenerator() - const markdown = - 'json:object`{"_type":"stockTicker","_key":"","symbol":"AAPL"}`' - expect(markdownToPortableText(markdown, {keyGenerator})).toEqual([ - { - _type: 'block', - _key: 'k0', - style: 'normal', - markDefs: [], - children: [{_type: 'stockTicker', _key: 'k1', symbol: 'AAPL'}], - }, - ]) - }) - - test('a space between the tag and the code span prevents binding', () => { - const keyGenerator = createTestKeyGenerator() - const markdown = 'json:object `{"_type":"stockTicker"}`' - expect(markdownToPortableText(markdown, {keyGenerator})).toEqual([ - { - _type: 'block', - _key: 'k0', - style: 'normal', - markDefs: [], - children: [ - {_type: 'span', _key: 'k1', text: 'json:object ', marks: []}, - { - _type: 'span', - _key: 'k2', - text: '{"_type":"stockTicker"}', - marks: ['code'], - }, - ], - }, - ]) - }) - - test('a tagged code span without a string `_type` stays a code span', () => { - const keyGenerator = createTestKeyGenerator() - const markdown = 'json:object`{"symbol":"AAPL"}`' - expect(markdownToPortableText(markdown, {keyGenerator})).toEqual([ - { - _type: 'block', - _key: 'k0', - style: 'normal', - markDefs: [], - children: [ - {_type: 'span', _key: 'k1', text: 'json:object', marks: []}, - { - _type: 'span', - _key: 'k2', - text: '{"symbol":"AAPL"}', - marks: ['code'], - }, - ], - }, - ]) - }) - - test('a pretty-printed payload in a tagged span still reconstructs', () => { - const keyGenerator = createTestKeyGenerator() - const markdown = - 'json:object`{\n "_type": "stockTicker",\n "_key": "t1",\n "symbol": "AAPL"\n}`' - expect(markdownToPortableText(markdown, {keyGenerator})).toEqual([ - { - _type: 'block', - _key: 'k0', - style: 'normal', - markDefs: [], - children: [{_type: 'stockTicker', _key: 't1', symbol: 'AAPL'}], - }, - ]) - }) - - test('a fence inside a blockquote reconstructs inside the quote content', () => { - const keyGenerator = createTestKeyGenerator() - const markdown = [ - '> quoted', - '>', - '> ```json:object', - '> {"_type": "product", "_key": "p1", "sku": "abc-123"}', - '> ```', - ].join('\n') - expect(markdownToPortableText(markdown, {keyGenerator})).toEqual([ - { - _type: 'block', - _key: 'k0', - style: 'blockquote', - markDefs: [], - children: [{_type: 'span', _key: 'k1', text: 'quoted', marks: []}], - }, - {_type: 'product', _key: 'p1', sku: 'abc-123'}, - ]) - }) - - test('emphasis around a tagged span binds the object and drops the emphasis', () => { - const keyGenerator = createTestKeyGenerator() - const markdown = '*json:object`{"_type":"stockTicker","_key":"t1"}`*' - expect(markdownToPortableText(markdown, {keyGenerator})).toEqual([ - { - _type: 'block', - _key: 'k0', - style: 'normal', - markDefs: [], - children: [{_type: 'stockTicker', _key: 't1'}], - }, - ]) - }) - - test('a JSON array degrades to a code block', () => { - const keyGenerator = createTestKeyGenerator() - const markdown = ['```json:object', '[{"_type": "product"}]', '```'].join( - '\n', - ) - expect(markdownToPortableText(markdown, {keyGenerator})).toEqual([ - { - _type: 'code', - _key: 'k0', - code: '[{"_type": "product"}]', - language: 'json:object', - }, - ]) - }) - }) - describe('degradation report', () => { - type DegradationReport = { - degradations: Array - message: string - } - - test('dropped decorator: undeclared `strong` decorator', () => { - const keyGenerator = createTestKeyGenerator() - const onDegradation = vi.fn<(report: DegradationReport) => void>() - - expect( - markdownToPortableText('**foo**', { - keyGenerator, - schema: compileSchema(defineSchema({})), - onDegradation, - }), - ).toEqual([ - { - _type: 'block', - _key: 'k0', - children: [{_type: 'span', _key: 'k1', text: 'foo', marks: []}], - markDefs: [], - style: 'normal', - }, - ]) - - expect(onDegradation).toHaveBeenCalledTimes(1) - expect(onDegradation.mock.calls[0]![0]!.degradations).toEqual([ - { - type: 'decorator-dropped', - message: - 'Removed bold formatting, kept the text: the schema has no `strong` decorator', - line: 1, - snippet: 'foo', - }, - ]) - }) - - test('style fallback: undeclared `h1` heading falls back to `normal`', () => { - const keyGenerator = createTestKeyGenerator() - const onDegradation = vi.fn<(report: DegradationReport) => void>() - - expect( - markdownToPortableText('# foo', { - keyGenerator, - schema: compileSchema(defineSchema({styles: [{name: 'normal'}]})), - onDegradation, - }), - ).toEqual([ - { - _type: 'block', - _key: 'k0', - children: [{_type: 'span', _key: 'k1', text: 'foo', marks: []}], - markDefs: [], - style: 'normal', - }, - ]) - - expect(onDegradation.mock.calls).toEqual([ - [ - { - degradations: [ - { - type: 'style-fallback', - message: - '`#` heading became a normal paragraph: the schema has no `h1` style', - line: 1, - snippet: 'foo', - }, - ], - message: [ - 'Markdown could not be converted without loss:', - '- line 1: `#` heading became a normal paragraph: the schema has no `h1` style ("foo")', - ].join('\n'), - }, - ], - ]) - }) - - test('table flattened: undeclared `table` type', () => { - const keyGenerator = createTestKeyGenerator() - const onDegradation = vi.fn<(report: DegradationReport) => void>() - const markdown = ['| a | b |', '| - | - |', '| 1 | 2 |'].join('\n') - - expect( - markdownToPortableText(markdown, { - keyGenerator, - schema: compileSchema(defineSchema({})), - onDegradation, - }), - ).toEqual([ - { - _type: 'block', - _key: 'k0', - children: [{_type: 'span', _key: 'k1', text: 'a', marks: []}], - markDefs: [], - style: 'normal', - }, - { - _type: 'block', - _key: 'k3', - children: [{_type: 'span', _key: 'k4', text: 'b', marks: []}], - markDefs: [], - style: 'normal', - }, - { - _type: 'block', - _key: 'k7', - children: [{_type: 'span', _key: 'k8', text: '1', marks: []}], - markDefs: [], - style: 'normal', - }, - { - _type: 'block', - _key: 'k10', - children: [{_type: 'span', _key: 'k11', text: '2', marks: []}], - markDefs: [], - style: 'normal', - }, - ]) - - expect(onDegradation).toHaveBeenCalledTimes(1) - expect(onDegradation.mock.calls[0]![0]!.degradations).toEqual([ - { - type: 'table-flattened', - message: - 'Table became plain text blocks, rows and columns lost: the schema has no `table` block object', - line: 1, - }, - ]) - }) - - test('task checkbox stripped: undeclared `task` list', () => { - const keyGenerator = createTestKeyGenerator() - const onDegradation = vi.fn<(report: DegradationReport) => void>() - - expect( - markdownToPortableText('- [x] foo', { - keyGenerator, - schema: compileSchema(defineSchema({lists: [{name: 'bullet'}]})), - onDegradation, - }), - ).toEqual([ - { - _type: 'block', - _key: 'k0', - children: [{_type: 'span', _key: 'k1', text: 'foo', marks: []}], - markDefs: [], - style: 'normal', - listItem: 'bullet', - level: 1, - }, - ]) - - expect(onDegradation).toHaveBeenCalledTimes(1) - expect(onDegradation.mock.calls[0]![0]!.degradations).toEqual([ - { - type: 'task-checkbox-stripped', - message: - 'Removed the `[x]` checkbox, kept a plain list item: the schema has no `task` list', - line: 1, - snippet: 'foo', - }, - ]) - }) - - test('pure task list on a task-only schema: nothing reported, `bullet_list_open` defers the verdict to item resolution', () => { - const onDegradation = vi.fn<(report: DegradationReport) => void>() - - const result = markdownToPortableText( - ['- [x] foo', '- [ ] bar'].join('\n'), - { - keyGenerator: createTestKeyGenerator(), - schema: compileSchema(defineSchema({lists: [{name: 'task'}]})), - onDegradation, - }, - ) - - expect(result).toEqual([ - { - _type: 'block', - _key: 'k0', - children: [{_type: 'span', _key: 'k1', text: 'foo', marks: []}], - markDefs: [], - style: 'normal', - listItem: 'task', - level: 1, - checked: true, - }, - { - _type: 'block', - _key: 'k2', - children: [{_type: 'span', _key: 'k3', text: 'bar', marks: []}], - markDefs: [], - style: 'normal', - listItem: 'task', - level: 1, - checked: false, - }, - ]) - - expect(onDegradation).not.toHaveBeenCalled() - }) - - test('code fence to text: undeclared `code` type names the language', () => { - const keyGenerator = createTestKeyGenerator() - const onDegradation = vi.fn<(report: DegradationReport) => void>() - const markdown = ['```js', 'code', '```'].join('\n') - - expect( - markdownToPortableText(markdown, { - keyGenerator, - schema: compileSchema(defineSchema({})), - onDegradation, - }), - ).toEqual([ - { - _type: 'block', - _key: 'k0', - children: [{_type: 'span', _key: 'k1', text: 'code', marks: []}], - markDefs: [], - style: 'normal', - }, - ]) - - expect(onDegradation).toHaveBeenCalledTimes(1) - expect(onDegradation.mock.calls[0]![0]!.degradations).toEqual([ - { - type: 'code-block-to-text', - message: - '`js` code block became plain text: the schema has no `code` block object', - line: 1, - snippet: 'code', - }, - ]) - }) - - test('multiple degradations arrive in encounter order with their token-map lines, in one callback invocation', () => { - const keyGenerator = createTestKeyGenerator() - const onDegradation = vi.fn<(report: DegradationReport) => void>() - const markdown = [ - '# foo', - '', - '**bar**', - '', - '```js', - 'code', - '```', - ].join('\n') - - expect( - markdownToPortableText(markdown, { - keyGenerator, - schema: compileSchema(defineSchema({})), - onDegradation, - }), - ).toEqual([ - { - _type: 'block', - _key: 'k0', - children: [{_type: 'span', _key: 'k1', text: 'foo', marks: []}], - markDefs: [], - style: 'normal', - }, - { - _type: 'block', - _key: 'k2', - children: [{_type: 'span', _key: 'k3', text: 'bar', marks: []}], - markDefs: [], - style: 'normal', - }, - { - _type: 'block', - _key: 'k4', - children: [{_type: 'span', _key: 'k5', text: 'code', marks: []}], - markDefs: [], - style: 'normal', - }, - ]) - - expect(onDegradation).toHaveBeenCalledTimes(1) - expect(onDegradation.mock.calls[0]![0]).toEqual({ - degradations: [ - { - type: 'style-fallback', - message: - '`#` heading became a normal paragraph: the schema has no `h1` style', - line: 1, - snippet: 'foo', - }, - { - type: 'decorator-dropped', - message: - 'Removed bold formatting, kept the text: the schema has no `strong` decorator', - line: 3, - snippet: 'bar', - }, - { - type: 'code-block-to-text', - message: - '`js` code block became plain text: the schema has no `code` block object', - line: 5, - snippet: 'code', - }, - ], - message: [ - 'Markdown could not be converted without loss:', - '- line 1: `#` heading became a normal paragraph: the schema has no `h1` style ("foo")', - '- line 3: Removed bold formatting, kept the text: the schema has no `strong` decorator ("bar")', - '- line 5: `js` code block became plain text: the schema has no `code` block object ("code")', - ].join('\n'), - }) - }) - - test('nested blockquote declines report in encounter order (innermost closes first)', () => { - const keyGenerator = createTestKeyGenerator() - const onDegradation = vi.fn<(report: DegradationReport) => void>() - const schema = compileSchema( - defineSchema({ - blockObjects: [ - { - name: 'blockquote', - fields: [{name: 'content', type: 'array'}], - }, - ], - }), - ) - - markdownToPortableText('> foo\n>> bar', { - keyGenerator, - schema, - types: {blockquote: () => undefined}, - onDegradation, - }) - - // The inner blockquote (line 2) closes before the outer one (line 1), - // so `degradations` sees line 2 first: closing order, not document order. - expect(onDegradation).toHaveBeenCalledTimes(1) - expect(onDegradation.mock.calls[0]![0]!.degradations).toEqual([ - { - type: 'style-fallback', - message: - 'Blockquote became normal paragraphs: the schema has no `blockquote` style', - line: 2, - }, - { - type: 'style-fallback', - message: - 'Blockquote became normal paragraphs: the schema has no `blockquote` style', - line: 1, - }, - ]) - }) - - test('a consumer throw groups same-message declines and lists their lines top-to-bottom, despite reverse encounter order', () => { - const schema = compileSchema( - defineSchema({ - blockObjects: [ - { - name: 'blockquote', - fields: [{name: 'content', type: 'array'}], - }, - ], - }), - ) - - expect(() => - markdownToPortableText('> foo\n>> bar', { - keyGenerator: createTestKeyGenerator(), - schema, - types: {blockquote: () => undefined}, - onDegradation: ({message}) => { - throw new Error(message) - }, - }), - ).toThrowError( - [ - 'Markdown could not be converted without loss:', - '- Blockquote became normal paragraphs: the schema has no `blockquote` style (2\u00d7: lines 1, 2)', - ].join('\n'), - ) - }) - - test('`onDegradation` fires at most once, and only when something degraded', () => { - const onDegradation = vi.fn<(report: DegradationReport) => void>() - - expect( - markdownToPortableText('foo **bar** baz', { - keyGenerator: createTestKeyGenerator(), - onDegradation, - }), - ).toEqual([ - { - _type: 'block', - _key: 'k0', - children: [ - {_type: 'span', _key: 'k1', text: 'foo ', marks: []}, - {_type: 'span', _key: 'k2', text: 'bar', marks: ['strong']}, - {_type: 'span', _key: 'k3', text: ' baz', marks: []}, - ], - markDefs: [], - style: 'normal', - }, - ]) - expect(onDegradation).not.toHaveBeenCalled() - - markdownToPortableText('**bar**\n\n**baz**', { - keyGenerator: createTestKeyGenerator(), - schema: compileSchema(defineSchema({})), - onDegradation, - }) - expect(onDegradation).toHaveBeenCalledTimes(1) - }) - - test('`onDegradation` not set: conversion degrades silently, nothing reaches `console.warn`', () => { - const keyGenerator = createTestKeyGenerator() - const consoleWarnSpy = vi - .spyOn(console, 'warn') - .mockImplementation(() => {}) - - markdownToPortableText('**bold**', { - keyGenerator, - schema: compileSchema(defineSchema({})), - }) - - expect(consoleWarnSpy).not.toHaveBeenCalled() - consoleWarnSpy.mockRestore() - }) - - test('horizontal rule to text: undeclared `horizontal-rule` type', () => { - const keyGenerator = createTestKeyGenerator() - const onDegradation = vi.fn<(report: DegradationReport) => void>() - - expect( - markdownToPortableText('---', { - keyGenerator, - schema: compileSchema(defineSchema({})), - onDegradation, - }), - ).toEqual([ - { - _type: 'block', - _key: 'k0', - children: [{_type: 'span', _key: 'k1', text: '---', marks: []}], - markDefs: [], - style: 'normal', - }, - ]) - - expect(onDegradation).toHaveBeenCalledTimes(1) - expect(onDegradation.mock.calls[0]![0]!.degradations).toEqual([ - { - type: 'horizontal-rule-to-text', - message: - 'Horizontal rule became the text `---`: the schema has no `horizontal-rule` block object', - line: 1, - }, - ]) - }) - - test('HTML block to text: undeclared `html` type', () => { - const keyGenerator = createTestKeyGenerator() - const onDegradation = vi.fn<(report: DegradationReport) => void>() - - expect( - markdownToPortableText('
Content
', { - keyGenerator, - schema: compileSchema(defineSchema({})), - onDegradation, - }), - ).toEqual([ - { - _type: 'block', - _key: 'k0', - children: [ - {_type: 'span', _key: 'k1', text: '
Content
', marks: []}, - ], - markDefs: [], - style: 'normal', - }, - ]) - - expect(onDegradation).toHaveBeenCalledTimes(1) - expect(onDegradation.mock.calls[0]![0]!.degradations).toEqual([ - { - type: 'html-block-to-text', - message: - 'HTML block became plain text: the schema has no `html` block object', - line: 1, - snippet: '
Content
', - }, - ]) - }) - - test('indented code to text: undeclared `code` type', () => { - const keyGenerator = createTestKeyGenerator() - const onDegradation = vi.fn<(report: DegradationReport) => void>() - - expect( - markdownToPortableText(' const foo = "bar"', { - keyGenerator, - schema: compileSchema(defineSchema({})), - onDegradation, - }), - ).toEqual([ - { - _type: 'block', - _key: 'k0', - children: [ - { - _type: 'span', - _key: 'k1', - text: 'const foo = "bar"', - marks: [], - }, - ], - markDefs: [], - style: 'normal', - }, - ]) - - expect(onDegradation).toHaveBeenCalledTimes(1) - expect(onDegradation.mock.calls[0]![0]!.degradations).toEqual([ - { - type: 'code-block-to-text', - message: - 'Code block became plain text: the schema has no `code` block object', - line: 1, - snippet: 'const foo = "bar"', - }, - ]) - }) - - test('inline HTML dropped: default `skip` mode', () => { - const keyGenerator = createTestKeyGenerator() - const onDegradation = vi.fn<(report: DegradationReport) => void>() - - expect( - markdownToPortableText('foo
bar', { - keyGenerator, - onDegradation, - }), - ).toEqual([ - { - _type: 'block', - _key: 'k0', - children: [{_type: 'span', _key: 'k1', text: 'foo bar', marks: []}], - markDefs: [], - style: 'normal', - }, - ]) - - expect(onDegradation).toHaveBeenCalledTimes(1) - expect(onDegradation.mock.calls[0]![0]!.degradations).toEqual([ - { - type: 'inline-html-dropped', - message: - 'Removed inline HTML tags, kept nothing: `html.inline` is `skip` (the default)', - line: 1, - snippet: '
', - }, - ]) - }) - - test('inline HTML in `text` mode: no degradation, no event', () => { - const keyGenerator = createTestKeyGenerator() - const onDegradation = vi.fn<(report: DegradationReport) => void>() - - expect( - markdownToPortableText('foo
bar', { - keyGenerator, - html: {inline: 'text'}, - onDegradation, - }), - ).toEqual([ - { - _type: 'block', - _key: 'k0', - children: [ - {_type: 'span', _key: 'k1', text: 'foo
bar', marks: []}, - ], - markDefs: [], - style: 'normal', - }, - ]) - - expect(onDegradation).not.toHaveBeenCalled() - }) - - test('image to text: standalone image, undeclared `image` type', () => { - const keyGenerator = createTestKeyGenerator() - const onDegradation = vi.fn<(report: DegradationReport) => void>() - - expect( - markdownToPortableText('![alt](src.png)', { - keyGenerator, - schema: compileSchema(defineSchema({})), - onDegradation, - }), - ).toEqual([ - { - _type: 'block', - _key: 'k0', - children: [ - {_type: 'span', _key: 'k1', text: '![alt](src.png)', marks: []}, - ], - markDefs: [], - style: 'normal', - }, - ]) - - expect(onDegradation).toHaveBeenCalledTimes(1) - expect(onDegradation.mock.calls[0]![0]!.degradations).toEqual([ - { - type: 'image-to-text', - message: - 'Image became its markdown source as plain text: the schema has no `image` object', - line: 1, - snippet: 'alt', - }, - ]) - }) - - test('image to text: inline-position image, undeclared `image` type', () => { - const keyGenerator = createTestKeyGenerator() - const onDegradation = vi.fn<(report: DegradationReport) => void>() - - expect( - markdownToPortableText('foo ![alt](src.png) bar', { - keyGenerator, - schema: compileSchema(defineSchema({})), - onDegradation, - }), - ).toEqual([ - { - _type: 'block', - _key: 'k0', - children: [ - { - _type: 'span', - _key: 'k1', - text: 'foo ![alt](src.png) bar', - marks: [], - }, - ], - markDefs: [], - style: 'normal', - }, - ]) - - expect(onDegradation).toHaveBeenCalledTimes(1) - expect(onDegradation.mock.calls[0]![0]!.degradations).toEqual([ - { - type: 'image-to-text', - message: - 'Image became its markdown source as plain text: the schema has no `image` object', - line: 1, - snippet: 'alt', - }, - ]) - }) - - test('image demoted to inline: standalone image, `image` declared only as an inline object', () => { - const onDegradation = vi.fn<(report: DegradationReport) => void>() - const schema = compileSchema( - defineSchema({ - inlineObjects: [ - { - name: 'image', - fields: [ - {name: 'src', type: 'string'}, - {name: 'alt', type: 'string'}, - {name: 'title', type: 'string'}, - ], - }, - ], - }), - ) - - expect( - markdownToPortableText('![alt](src.png)', { - keyGenerator: createTestKeyGenerator(), - schema, - onDegradation, - }), - ).toEqual([ - { - _type: 'block', - _key: 'k0', - style: 'normal', - children: [{_key: 'k1', _type: 'image', src: 'src.png', alt: 'alt'}], - markDefs: [], - }, - ]) - - expect(onDegradation).toHaveBeenCalledTimes(1) - expect(onDegradation.mock.calls[0]![0]!.degradations).toEqual([ - { - type: 'image-block-to-inline', - message: - 'The image became inline: the schema has no block-level `image`', - line: 1, - snippet: 'alt', - }, - ]) - }) - - test('image promoted to block: inline-position image, `image` declared only as a block object', () => { - const onDegradation = vi.fn<(report: DegradationReport) => void>() - const schema = compileSchema( - defineSchema({ - blockObjects: [ - { - name: 'image', - fields: [ - {name: 'src', type: 'string'}, - {name: 'alt', type: 'string'}, - {name: 'title', type: 'string'}, - ], - }, - ], - }), - ) - - expect( - markdownToPortableText('foo ![alt](src.png) bar', { - keyGenerator: createTestKeyGenerator(), - schema, - onDegradation, - }), - ).toEqual([ - { - _type: 'block', - _key: 'k0', - style: 'normal', - children: [{_type: 'span', _key: 'k1', text: 'foo ', marks: []}], - markDefs: [], - }, - {_key: 'k2', _type: 'image', src: 'src.png', alt: 'alt'}, - { - _type: 'block', - _key: 'k3', - style: 'normal', - children: [{_type: 'span', _key: 'k4', text: ' bar', marks: []}], - markDefs: [], - }, - ]) - - expect(onDegradation).toHaveBeenCalledTimes(1) - expect(onDegradation.mock.calls[0]![0]!.degradations).toEqual([ - { - type: 'image-inline-to-block', - message: - 'The image became its own block, splitting the paragraph: the schema has no inline `image`', - line: 1, - snippet: 'alt', - }, - ]) - }) - - test('image lifted back to block: standalone image inside a table cell', () => { - const keyGenerator = createTestKeyGenerator() - const onDegradation = vi.fn<(report: DegradationReport) => void>() - const markdown = [ - '| a | b |', - '| - | - |', - '| 1 | ![alt](src.png) |', - ].join('\n') - - expect( - markdownToPortableText(markdown, { - keyGenerator, - onDegradation, - }), - ).toEqual([ - { - _key: 'k14', - _type: 'table', - headerRows: 1, - rows: [ - { - _key: 'k6', - _type: 'row', - cells: [ - { - _type: 'cell', - _key: 'k2', - value: [ - { - _type: 'block', - style: 'normal', - children: [ - {_type: 'span', _key: 'k1', text: 'a', marks: []}, - ], - _key: 'k0', - markDefs: [], - }, - ], - }, - { - _type: 'cell', - _key: 'k5', - value: [ - { - _type: 'block', - style: 'normal', - children: [ - {_type: 'span', _key: 'k4', text: 'b', marks: []}, - ], - _key: 'k3', - markDefs: [], - }, - ], - }, - ], - }, - { - _key: 'k13', - _type: 'row', - cells: [ - { - _type: 'cell', - _key: 'k9', - value: [ - { - _type: 'block', - style: 'normal', - children: [ - {_type: 'span', _key: 'k8', text: '1', marks: []}, - ], - _key: 'k7', - markDefs: [], - }, - ], - }, - { - _type: 'cell', - _key: 'k12', - value: [ - {_key: 'k11', _type: 'image', src: 'src.png', alt: 'alt'}, - ], - }, - ], - }, - ], - }, - ]) - - // The default schema declares `image` in both `blockObjects` and - // `inlineObjects`, so the sole-image cell round-trips to the - // canonical block-level shape without loss. - expect(onDegradation).not.toHaveBeenCalled() - }) - - test('image stays inline: standalone image inside a table cell, schema has no block-level `image`', () => { - const keyGenerator = createTestKeyGenerator() - const onDegradation = vi.fn<(report: DegradationReport) => void>() - // `image` only exists as an inline object, so the standalone-image - // site's block attempt declines before its inline fallback matches. - const schema = compileSchema( - defineSchema({ - ...defaultSchema, - blockObjects: defaultSchema.blockObjects.filter( - (blockObject) => blockObject.name !== 'image', - ), - }), - ) - const markdown = ['| a |', '| - |', '| ![alt](src.png) |'].join('\n') - - const result = markdownToPortableText(markdown, { - keyGenerator, - schema, - onDegradation, - }) - - expect(result).toEqual([ - { - _key: 'k8', - _type: 'table', - headerRows: 1, - rows: [ - { - _key: 'k3', - _type: 'row', - cells: [ - { - _type: 'cell', - _key: 'k2', - value: [ - { - _type: 'block', - style: 'normal', - children: [ - {_type: 'span', _key: 'k1', text: 'a', marks: []}, - ], - _key: 'k0', - markDefs: [], - }, - ], - }, - ], - }, - { - _key: 'k7', - _type: 'row', - cells: [ - { - _type: 'cell', - _key: 'k6', - value: [ - { - _type: 'block', - style: 'normal', - children: [ - { - _key: 'k5', - _type: 'image', - src: 'src.png', - alt: 'alt', - }, - ], - _key: 'k4', - markDefs: [], - }, - ], - }, - ], - }, - ], - }, - ]) - - // `image` is declared inline-only, which is a legal inline home, so - // `td_close`'s sole-object lift declines and the image stays put as - // the cell's inline child. - expect(onDegradation).toHaveBeenCalledTimes(1) - expect(onDegradation.mock.calls[0]![0]!.degradations).toEqual([ - { - type: 'image-block-to-inline', - message: - 'The image became inline: the schema has no block-level `image`', - line: 1, - snippet: 'alt', - }, - ]) - }) - - test('image demoted, not lifted: mixed cell content, schema has no block-level `image`', () => { - const keyGenerator = createTestKeyGenerator() - const onDegradation = vi.fn<(report: DegradationReport) => void>() - const schema = compileSchema( - defineSchema({ - ...defaultSchema, - blockObjects: defaultSchema.blockObjects.filter( - (blockObject) => blockObject.name !== 'image', - ), - }), - ) - const markdown = ['| a |', '| - |', '| foo ![alt](src.png) |'].join('\n') - - const result = markdownToPortableText(markdown, { - keyGenerator, - schema, - onDegradation, - }) - - expect(result).toEqual([ - { - _key: 'k9', - _type: 'table', - headerRows: 1, - rows: [ - { - _key: 'k3', - _type: 'row', - cells: [ - { - _type: 'cell', - _key: 'k2', - value: [ - { - _type: 'block', - style: 'normal', - children: [ - {_type: 'span', _key: 'k1', text: 'a', marks: []}, - ], - _key: 'k0', - markDefs: [], - }, - ], - }, - ], - }, - { - _key: 'k8', - _type: 'row', - cells: [ - { - _type: 'cell', - _key: 'k7', - value: [ - { - _type: 'block', - style: 'normal', - children: [ - {_type: 'span', _key: 'k5', text: 'foo ', marks: []}, - { - _key: 'k6', - _type: 'image', - src: 'src.png', - alt: 'alt', - }, - ], - _key: 'k4', - markDefs: [], - }, - ], - }, - ], - }, - ], - }, - ]) - - // The image sits alongside text, so the standalone-image site never - // runs at all: the inline image is the correct, lossless shape here, - // whether the schema has a block-level `image` or not, and the - // sole-image lift never gets a chance to apply. Contrast with the - // sole-image case above: matching schema, no `inTableCell` deferral - // masks a genuine event when the cell actually mixes content. - expect(onDegradation).not.toHaveBeenCalled() - }) - - test('image not lifted back: mixed cell content with `image` declared only as a block object', () => { - const keyGenerator = createTestKeyGenerator() - const onDegradation = vi.fn<(report: DegradationReport) => void>() - // `table` is already a block object on the default schema; only - // `image` is trimmed down to a block-only object here. - const schema = compileSchema( - defineSchema({ - ...defaultSchema, - inlineObjects: defaultSchema.inlineObjects.filter( - (inlineObject) => inlineObject.name !== 'image', - ), - }), - ) - const markdown = ['| a |', '| - |', '| foo ![alt](src.png) |'].join('\n') - - const result = markdownToPortableText(markdown, { - keyGenerator, - schema, - onDegradation, - }) - - expect(result).toEqual([ - { - _key: 'k9', - _type: 'table', - headerRows: 1, - rows: [ - { - _key: 'k3', - _type: 'row', - cells: [ - { - _type: 'cell', - _key: 'k2', - value: [ - { - _type: 'block', - style: 'normal', - children: [ - {_type: 'span', _key: 'k1', text: 'a', marks: []}, - ], - _key: 'k0', - markDefs: [], - }, - ], - }, - ], - }, - { - _key: 'k8', - _type: 'row', - cells: [ - { - _type: 'cell', - _key: 'k7', - value: [ - { - _type: 'block', - style: 'normal', - children: [ - {_type: 'span', _key: 'k5', text: 'foo ', marks: []}, - { - _key: 'k6', - _type: 'image', - src: 'src.png', - alt: 'alt', - }, - ], - _key: 'k4', - markDefs: [], - }, - ], - }, - ], - }, - ], - }, - ]) - - expect(onDegradation).toHaveBeenCalledTimes(1) - expect(onDegradation.mock.calls[0]![0]!.degradations).toEqual([ - { - type: 'image-block-to-inline', - message: - "The image became inline: a table cell can't hold a block-level `image`", - line: 1, - snippet: 'alt', - }, - ]) - }) - - test('image not lifted back: mixed cell content reports dropped fields too', () => { - // Same shape as the sibling test above, but the schema's block-only - // `image` declares only `src`, so the demoted image also drops `alt`. - // The demotion site must report that loss like every other - // accepted-object site does. - const keyGenerator = createTestKeyGenerator() - const onDegradation = vi.fn<(report: DegradationReport) => void>() - const schema = compileSchema( - defineSchema({ - blockObjects: [ - {name: 'image', fields: [{name: 'src', type: 'string'}]}, - defaultSchema.blockObjects.find( - (blockObject) => blockObject.name === 'table', - )!, - ], - }), - ) - const markdown = [ - '| a |', - '| - |', - '| foo ![my alt](https://x.co/i.png) |', - ].join('\n') - - const result = markdownToPortableText(markdown, { - keyGenerator, - schema, - onDegradation, - }) - - expect(result).toEqual([ - { - _key: 'k9', - _type: 'table', - headerRows: 1, - rows: [ - { - _key: 'k3', - _type: 'row', - cells: [ - { - _type: 'cell', - _key: 'k2', - value: [ - { - _type: 'block', - style: 'normal', - children: [ - {_type: 'span', _key: 'k1', text: 'a', marks: []}, - ], - _key: 'k0', - markDefs: [], - }, - ], - }, - ], - }, - { - _key: 'k8', - _type: 'row', - cells: [ - { - _type: 'cell', - _key: 'k7', - value: [ - { - _type: 'block', - style: 'normal', - children: [ - {_type: 'span', _key: 'k5', text: 'foo ', marks: []}, - { - _key: 'k6', - _type: 'image', - src: 'https://x.co/i.png', - }, - ], - _key: 'k4', - markDefs: [], - }, - ], - }, - ], - }, - ], - }, - ]) - - expect(onDegradation).toHaveBeenCalledTimes(1) - expect(onDegradation.mock.calls[0]![0]!.degradations).toEqual([ - { - type: 'fields-dropped', - message: - "Dropped `alt` from `image`: not in the schema's `image` fields", - line: 1, - }, - { - type: 'image-block-to-inline', - message: - "The image became inline: a table cell can't hold a block-level `image`", - line: 1, - snippet: 'https://x.co/i.png', - }, - ]) - }) - - test('heading inside a callout keeps its own style, no `blockquote` style-fallback fires', () => { - // The callout's own content style (`blockquote`, absent here) only - // degrades if a committing block actually took it. A heading commits - // with its own `h1` style, so nothing here needed the fallback. - const keyGenerator = createTestKeyGenerator() - const onDegradation = vi.fn<(report: DegradationReport) => void>() - const schema = compileSchema( - defineSchema({ - styles: [{name: 'normal'}, {name: 'h1'}], - blockObjects: [defaultCalloutObjectDefinition], - }), - ) - - expect( - markdownToPortableText('> [!NOTE]\n> # foo', { - keyGenerator, - schema, - onDegradation, - }), - ).toEqual([ - { - _type: 'callout', - _key: 'k2', - tone: 'note', - content: [ - { - _type: 'block', - _key: 'k0', - style: 'h1', - children: [{_type: 'span', _key: 'k1', text: 'foo', marks: []}], - markDefs: [], - }, - ], - }, - ]) - - expect(onDegradation).not.toHaveBeenCalled() - }) - - test('plain text inside a callout still fires the `blockquote` style-fallback', () => { - // Contrast to the heading case above: a plain paragraph DOES take the - // declined `blockquote` style, so the fallback still fires. - const keyGenerator = createTestKeyGenerator() - const onDegradation = vi.fn<(report: DegradationReport) => void>() - const schema = compileSchema( - defineSchema({ - styles: [{name: 'normal'}, {name: 'h1'}], - blockObjects: [defaultCalloutObjectDefinition], - }), - ) - - expect( - markdownToPortableText('> [!NOTE]\n> plain text', { - keyGenerator, - schema, - onDegradation, - }), - ).toEqual([ - { - _type: 'callout', - _key: 'k2', - tone: 'note', - content: [ - { - _type: 'block', - _key: 'k0', - style: 'normal', - children: [ - {_type: 'span', _key: 'k1', text: 'plain text', marks: []}, - ], - markDefs: [], - }, - ], - }, - ]) - - expect(onDegradation).toHaveBeenCalledTimes(1) - expect(onDegradation.mock.calls[0]![0]!.degradations).toEqual([ - { - type: 'style-fallback', - message: - 'Blockquote became normal paragraphs: the schema has no `blockquote` style', - line: 1, - }, - ]) - }) - - test('a heading falling back to `normal` on its own does not also trigger the callout content style fallback', () => { - // Both the callout's own content style and the heading's `h1` style - // decline to the same `normal` fallback here: two independent - // declines landing on the same value, not one causing the other. The - // heading never touches `currentBlockquoteStyle`, so only its own - // `h1` fallback should fire. - const keyGenerator = createTestKeyGenerator() - const onDegradation = vi.fn<(report: DegradationReport) => void>() - const schema = compileSchema( - defineSchema({ - styles: [{name: 'normal'}], - blockObjects: [defaultCalloutObjectDefinition], - }), - ) - - expect( - markdownToPortableText('> [!NOTE]\n> # foo', { - keyGenerator, - schema, - onDegradation, - }), - ).toEqual([ - { - _type: 'callout', - _key: 'k2', - tone: 'note', - content: [ - { - _type: 'block', - _key: 'k0', - style: 'normal', - children: [{_type: 'span', _key: 'k1', text: 'foo', marks: []}], - markDefs: [], - }, - ], - }, - ]) - - expect(onDegradation).toHaveBeenCalledTimes(1) - expect(onDegradation.mock.calls[0]![0]!.degradations).toEqual([ - { - type: 'style-fallback', - message: - '`#` heading became a normal paragraph: the schema has no `h1` style', - line: 2, - snippet: 'foo', - }, - ]) - }) - - test('callout fallback: undeclared `callout` type names the tone and the content style it fell back to', () => { - const keyGenerator = createTestKeyGenerator() - const onDegradation = vi.fn<(report: DegradationReport) => void>() - // The `> [!NOTE]` marker sits on line 1; only the callout's content - // (`baz`) is on line 2. - const markdown = ['> [!NOTE]', '> baz'].join('\n') - - expect( - markdownToPortableText(markdown, { - keyGenerator, - schema: compileSchema(defineSchema({})), - onDegradation, - }), - ).toEqual([ - { - _type: 'block', - _key: 'k0', - children: [{_type: 'span', _key: 'k1', text: 'baz', marks: []}], - markDefs: [], - style: 'normal', - }, - ]) - - // No `blockquote` style declared, so the content falls back to - // `normal`; the callout message names that, not `blockquote`, which - // is what the content was actually styled as. - expect(onDegradation).toHaveBeenCalledTimes(1) - expect(onDegradation.mock.calls[0]![0]!.degradations).toEqual([ - { - type: 'style-fallback', - message: - 'Blockquote became normal paragraphs: the schema has no `blockquote` style', - line: 1, - }, - { - type: 'callout-fallback', - message: - '`[!NOTE]` callout became normal-styled text: the schema has no `callout` block object', - line: 1, - }, - ]) - }) - - test('callout fallback: neither `blockquote` nor `normal` style declared, both fallbacks reported', () => { - const keyGenerator = createTestKeyGenerator() - const onDegradation = vi.fn<(report: DegradationReport) => void>() - const markdown = ['> [!NOTE]', '> baz'].join('\n') - - const result = markdownToPortableText(markdown, { - keyGenerator, - schema: compileSchema(defineSchema({})), - block: {normal: () => undefined}, - onDegradation, - }) - - expect(result).toEqual([ - { - _type: 'block', - _key: 'k0', - children: [{_type: 'span', _key: 'k1', text: 'baz', marks: []}], - markDefs: [], - style: 'normal', - }, - ]) - - expect(onDegradation).toHaveBeenCalledTimes(1) - expect(onDegradation.mock.calls[0]![0]!.degradations).toEqual([ - { - type: 'style-fallback', - message: - 'Blockquote became normal paragraphs: the schema has no `blockquote` style', - line: 1, - }, - { - type: 'style-fallback', - message: 'Fell back to `normal` style: `normal` not in schema', - line: 1, - }, - { - type: 'callout-fallback', - message: - '`[!NOTE]` callout became normal-styled text: the schema has no `callout` block object', - line: 1, - }, - ]) - }) - - test('fields dropped: accepted `callout` whose schema fields omit `content` destroys the body', () => { - const onDegradation = vi.fn<(report: DegradationReport) => void>() - const schema = compileSchema( - defineSchema({ - blockObjects: [ - {name: 'callout', fields: [{name: 'tone', type: 'string'}]}, - ], - styles: [{name: 'normal'}, {name: 'blockquote'}], - }), - ) - - const result = markdownToPortableText( - ['> [!NOTE]', '> body'].join('\n'), - { - keyGenerator: createTestKeyGenerator(), - schema, - onDegradation, - }, - ) - - expect(result).toEqual([{_key: 'k2', _type: 'callout', tone: 'note'}]) - - expect(onDegradation).toHaveBeenCalledTimes(1) - expect(onDegradation.mock.calls[0]![0]!.degradations).toEqual([ - { - type: 'fields-dropped', - message: - "Dropped `content` from `callout`: not in the schema's `callout` fields", - line: 1, - }, - ]) - }) - - test('accepted callout with no `blockquote` style but no text content either: nothing reported', () => { - const onDegradation = vi.fn<(report: DegradationReport) => void>() - const schema = compileSchema( - defineSchema({ - blockObjects: [ - { - name: 'callout', - fields: [ - {name: 'tone', type: 'string'}, - {name: 'content', type: 'array'}, - ], - }, - { - name: 'image', - fields: [ - {name: 'src', type: 'string'}, - {name: 'alt', type: 'string'}, - ], - }, - ], - }), - ) - - const result = markdownToPortableText( - ['> [!WARNING]', '> ![foo](u)'].join('\n'), - {keyGenerator: createTestKeyGenerator(), schema, onDegradation}, - ) - - expect(result).toEqual([ - { - _key: 'k2', - _type: 'callout', - tone: 'warning', - content: [{_key: 'k1', _type: 'image', src: 'u', alt: 'foo'}], - }, - ]) - - // No `blockquote` style declared, but nothing inside the callout ever - // needed one: its only content is a standalone image, which never - // creates a text block. Reporting the pending style fallback here - // would name a paragraph that never existed. - expect(onDegradation).not.toHaveBeenCalled() - }) - - test('fields dropped: accepted `image` whose schema fields omit `alt` and `title` names both', () => { - const onDegradation = vi.fn<(report: DegradationReport) => void>() - const schema = compileSchema( - defineSchema({ - blockObjects: [ - {name: 'image', fields: [{name: 'src', type: 'string'}]}, - ], - }), - ) - - const result = markdownToPortableText('![alt text](src.png "a title")', { - keyGenerator: createTestKeyGenerator(), - schema, - onDegradation, - }) - - expect(result).toEqual([{_key: 'k1', _type: 'image', src: 'src.png'}]) - - expect(onDegradation).toHaveBeenCalledTimes(1) - expect(onDegradation.mock.calls[0]![0]!.degradations).toEqual([ - { - type: 'fields-dropped', - message: - "Dropped `alt`, `title` from `image`: not in the schema's `image` fields", - line: 1, - }, - ]) - }) - - test('style fallback: heading and `normal` both undeclared reports both, not a silent literal fallback', () => { - const keyGenerator = createTestKeyGenerator() - const onDegradation = vi.fn<(report: DegradationReport) => void>() - - expect( - markdownToPortableText('# foo', { - keyGenerator, - schema: compileSchema(defineSchema({})), - block: {normal: () => undefined}, - onDegradation, - }), - ).toEqual([ - { - _type: 'block', - _key: 'k0', - children: [{_type: 'span', _key: 'k1', text: 'foo', marks: []}], - markDefs: [], - style: 'normal', - }, - ]) - - expect(onDegradation).toHaveBeenCalledTimes(1) - expect(onDegradation.mock.calls[0]![0]!.degradations).toEqual([ - { - type: 'style-fallback', - message: - '`#` heading became a normal paragraph: the schema has no `h1` style', - line: 1, - snippet: 'foo', - }, - { - type: 'style-fallback', - message: 'Fell back to `normal` style: `normal` not in schema', - line: 1, - }, - ]) - }) - - test('style fallback: blockquote and `normal` both undeclared reports both, not a silent literal fallback', () => { - const keyGenerator = createTestKeyGenerator() - const onDegradation = vi.fn<(report: DegradationReport) => void>() - - expect( - markdownToPortableText('> foo', { - keyGenerator, - schema: compileSchema(defineSchema({})), - block: {normal: () => undefined}, - onDegradation, - }), - ).toEqual([ - { - _type: 'block', - _key: 'k0', - children: [{_type: 'span', _key: 'k1', text: 'foo', marks: []}], - markDefs: [], - style: 'normal', - }, - ]) - - expect(onDegradation).toHaveBeenCalledTimes(1) - expect(onDegradation.mock.calls[0]![0]!.degradations).toEqual([ - { - type: 'style-fallback', - message: - 'Blockquote became normal paragraphs: the schema has no `blockquote` style', - line: 1, - }, - { - type: 'style-fallback', - message: 'Fell back to `normal` style: `normal` not in schema', - line: 1, - }, - ]) - }) - - test('style fallback: structural-list paragraph with `normal` undeclared', () => { - const keyGenerator = createTestKeyGenerator() - const onDegradation = vi.fn<(report: DegradationReport) => void>() - - const result = markdownToPortableText(['- foo', '', ' bar'].join('\n'), { - keyGenerator, - schema: compileSchema( - defineSchema({ - blockObjects: [ - {name: 'list', fields: [{name: 'items', type: 'array'}]}, - ], - }), - ), - types: { - list: ({value, context}) => ({ - _type: 'list', - _key: context.keyGenerator(), - kind: value.kind, - items: value.items, - }), - }, - block: {normal: () => undefined}, - onDegradation, - }) - - expect(result).toEqual([ - { - _type: 'list', - _key: 'k5', - kind: 'bullet', - items: [ - { - _type: 'list-item', - _key: 'k0', - content: [ - { - _type: 'block', - _key: 'k1', - children: [ - {_type: 'span', _key: 'k2', text: 'foo', marks: []}, - ], - markDefs: [], - style: 'normal', - }, - { - _type: 'block', - _key: 'k3', - children: [ - {_type: 'span', _key: 'k4', text: 'bar', marks: []}, - ], - markDefs: [], - style: 'normal', - }, - ], - }, - ], - }, - ]) - - // Two paragraphs in the same list item, one style-fallback event - // each, since the structural-list `paragraph_open` path used to fall - // back to the literal `'normal'` without reporting. - expect(onDegradation).toHaveBeenCalledTimes(1) - expect(onDegradation.mock.calls[0]![0]!.degradations).toEqual([ - { - type: 'style-fallback', - message: 'Fell back to `normal` style: `normal` not in schema', - line: 1, - }, - { - type: 'style-fallback', - message: 'Fell back to `normal` style: `normal` not in schema', - line: 3, - }, - ]) - }) - - test('style fallback: standalone inline image in a structural list with `normal` undeclared', () => { - const keyGenerator = createTestKeyGenerator() - const onDegradation = vi.fn<(report: DegradationReport) => void>() - - const result = markdownToPortableText('- ![alt](src.png)', { - keyGenerator, - schema: compileSchema( - defineSchema({ - blockObjects: [ - {name: 'list', fields: [{name: 'items', type: 'array'}]}, - ], - }), - ), - types: { - list: ({value, context}) => ({ - _type: 'list', - _key: context.keyGenerator(), - kind: value.kind, - items: value.items, - }), - image: ({value, isInline}) => - isInline - ? {_type: 'image', _key: 'img', src: value.src, alt: value.alt} - : undefined, - }, - block: {normal: () => undefined}, - onDegradation, - }) - - expect(result).toEqual([ - { - _type: 'list', - _key: 'k2', - kind: 'bullet', - items: [ - { - _type: 'list-item', - _key: 'k0', - content: [ - { - _type: 'block', - _key: 'k1', - children: [ - {_type: 'image', _key: 'img', src: 'src.png', alt: 'alt'}, - ], - markDefs: [], - style: 'normal', - }, - ], - }, - ], - }, - ]) - - expect(onDegradation).toHaveBeenCalledTimes(1) - expect(onDegradation.mock.calls[0]![0]!.degradations).toEqual([ - { - type: 'style-fallback', - message: 'Fell back to `normal` style: `normal` not in schema', - line: 1, - }, - { - type: 'image-block-to-inline', - message: - 'The image became inline: the schema has no block-level `image`', - line: 1, - snippet: 'alt', - }, - ]) - }) - - test('lossless structural list decline: schema has every list kind the content needs, nothing reported', () => { - const onDegradation = vi.fn<(report: DegradationReport) => void>() - const schema = compileSchema( - defineSchema({ - blockObjects: [ - {name: 'list', fields: [{name: 'items', type: 'array'}]}, - ], - lists: [{name: 'bullet'}, {name: 'task'}], - }), - ) - - const declined = markdownToPortableText('- [x] foo', { - keyGenerator: createTestKeyGenerator(), - schema, - types: {list: () => undefined}, - onDegradation, - }) - - const neverRegistered = markdownToPortableText('- [x] foo', { - keyGenerator: createTestKeyGenerator(), - schema, - }) - - // The decline's only visible effect is losing the `list` container - // wrapper; every item still resolves through the schema's `task` - // list, so the flat fallback is lossless and reports nothing. - expect(omitKeys(declined)).toEqual(omitKeys(neverRegistered)) - expect(declined).toEqual([ - { - _type: 'block', - _key: 'k1', - children: [{_type: 'span', _key: 'k2', text: 'foo', marks: []}], - markDefs: [], - style: 'normal', - listItem: 'task', - level: 1, - checked: true, - }, - ]) - expect(onDegradation).not.toHaveBeenCalled() - }) - - test('structural list decline on a taskless schema mirrors the flat path: bullet fallback, not dropped structure', () => { - const declineDegradation = vi.fn<(report: DegradationReport) => void>() - const flatDegradation = vi.fn<(report: DegradationReport) => void>() - const schema = compileSchema( - defineSchema({ - blockObjects: [ - {name: 'list', fields: [{name: 'items', type: 'array'}]}, - ], - lists: [{name: 'bullet'}], - }), - ) - - const declined = markdownToPortableText('- [x] foo', { - keyGenerator: createTestKeyGenerator(), - schema, - types: {list: () => undefined}, - onDegradation: declineDegradation, - }) - - const neverRegistered = markdownToPortableText('- [x] foo', { - keyGenerator: createTestKeyGenerator(), - schema, - onDegradation: flatDegradation, - }) - - expect(omitKeys(declined)).toEqual(omitKeys(neverRegistered)) - expect(declined).toEqual([ - { - _type: 'block', - _key: 'k1', - children: [{_type: 'span', _key: 'k2', text: 'foo', marks: []}], - markDefs: [], - style: 'normal', - listItem: 'bullet', - level: 1, - }, - ]) - - const expectedDegradations = [ - { - type: 'task-checkbox-stripped', - message: - 'Removed the `[x]` checkbox, kept a plain list item: the schema has no `task` list', - line: 1, - snippet: 'foo', - }, - ] - expect(declineDegradation.mock.calls[0]![0]!.degradations).toEqual( - expectedDegradations, - ) - expect(flatDegradation.mock.calls[0]![0]!.degradations).toEqual( - expectedDegradations, - ) - }) - - test('structural list decline keeps a nested blockquote as its own block, mirrors the flat path style boundary', () => { - // A bare style-equality gate would let the callout's `blockquote` - // paragraph merge into the preceding `normal` paragraph just because - // both resolved styles happen to already be resolved by the time the - // merge runs; the flat path never merges across a style change, so - // neither should the decline fallback. - const schema = compileSchema( - defineSchema({ - styles: [{name: 'normal'}, {name: 'blockquote'}], - lists: [{name: 'bullet'}], - }), - ) - const markdown = ['- para one', '', ' > quoted inside item'].join('\n') - - const declined = markdownToPortableText(markdown, { - keyGenerator: createTestKeyGenerator(), - schema, - types: {list: () => undefined}, - }) - - const neverRegistered = markdownToPortableText(markdown, { - keyGenerator: createTestKeyGenerator(), - schema, - }) - - expect(omitKeys(declined)).toEqual(omitKeys(neverRegistered)) - expect(declined).toEqual([ - { - _type: 'block', - _key: 'k1', - style: 'normal', - children: [{_type: 'span', _key: 'k2', text: 'para one', marks: []}], - markDefs: [], - listItem: 'bullet', - level: 1, - }, - { - _type: 'block', - _key: 'k3', - style: 'blockquote', - children: [ - {_type: 'span', _key: 'k4', text: 'quoted inside item', marks: []}, - ], - markDefs: [], - listItem: 'bullet', - level: 1, - }, - ]) - }) - - test('structural list decline keeps a declined blockquote restyle stamped, mirrors the flat path', () => { - // A declined `types.blockquote` restyles its content blocks by - // spreading into a new object; a `plainParagraphBlocks` gate that - // keys off object identity loses the block's provenance across that - // clone, exactly like the fence's fallback-to-text block should never - // regain it. The enclosing list decline's stampable check must still - // recognize the restyled block as a plain paragraph. - const schema = compileSchema( - defineSchema({ - styles: [{name: 'normal'}, {name: 'blockquote'}], - lists: [{name: 'bullet'}], - }), - ) - const markdown = ['- item text', '', ' > quoted inside item'].join('\n') - - const declined = markdownToPortableText(markdown, { - keyGenerator: createTestKeyGenerator(), - schema, - types: {list: () => undefined, blockquote: () => undefined}, - }) - - const neverRegistered = markdownToPortableText(markdown, { - keyGenerator: createTestKeyGenerator(), - schema, - }) - - expect(omitKeys(declined)).toEqual(omitKeys(neverRegistered)) - expect(declined).toEqual([ - { - _type: 'block', - _key: 'k1', - style: 'normal', - children: [{_type: 'span', _key: 'k2', text: 'item text', marks: []}], - markDefs: [], - listItem: 'bullet', - level: 1, - }, - { - _type: 'block', - _key: 'k3', - style: 'blockquote', - children: [ - {_type: 'span', _key: 'k4', text: 'quoted inside item', marks: []}, - ], - markDefs: [], - listItem: 'bullet', - level: 1, - }, - ]) - }) - - test('structural list decline keeps a degraded fence as its own unstamped block, mirrors the flat path', () => { - // The paragraph and the fence's fallback-to-text both resolve to the - // same `normal` style, so a style-only merge gate would still wrongly - // combine them. The flat path never shares `currentBlock` across a - // fence (it always flushes around one), and never stamps the fence's - // fallback block with `listItem` at all. - const schema = compileSchema( - defineSchema({ - styles: [{name: 'normal'}], - lists: [{name: 'bullet'}], - }), - ) - const markdown = ['- para one', '', ' ```', ' code here', ' ```'].join( - '\n', - ) - - const declined = markdownToPortableText(markdown, { - keyGenerator: createTestKeyGenerator(), - schema, - types: {list: () => undefined}, - }) - - const neverRegistered = markdownToPortableText(markdown, { - keyGenerator: createTestKeyGenerator(), - schema, - }) - - expect(omitKeys(declined)).toEqual(omitKeys(neverRegistered)) - expect(declined).toEqual([ - { - _type: 'block', - _key: 'k1', - style: 'normal', - children: [{_type: 'span', _key: 'k2', text: 'para one', marks: []}], - markDefs: [], - listItem: 'bullet', - level: 1, - }, - { - _type: 'block', - _key: 'k3', - style: 'normal', - children: [{_type: 'span', _key: 'k4', text: 'code here', marks: []}], - markDefs: [], - }, - ]) - }) - - test('lossless structural blockquote decline: schema has a `blockquote` style, nothing reported', () => { - const onDegradation = vi.fn<(report: DegradationReport) => void>() - const schema = compileSchema( - defineSchema({ - blockObjects: [ - { - name: 'blockquote', - fields: [{name: 'content', type: 'array'}], - }, - ], - styles: [{name: 'normal'}, {name: 'blockquote'}], - }), - ) - - const declined = markdownToPortableText('> foo', { - keyGenerator: createTestKeyGenerator(), - schema, - types: {blockquote: () => undefined}, - onDegradation, - }) - - const neverRegistered = markdownToPortableText('> foo', { - keyGenerator: createTestKeyGenerator(), - schema, - }) - - expect(omitKeys(declined)).toEqual(omitKeys(neverRegistered)) - expect(declined).toEqual([ - { - _type: 'block', - _key: 'k0', - children: [{_type: 'span', _key: 'k1', text: 'foo', marks: []}], - markDefs: [], - style: 'blockquote', - }, - ]) - expect(onDegradation).not.toHaveBeenCalled() - }) - - test('structural blockquote decline on a style-less schema mirrors the flat path, headings included', () => { - const declineDegradation = vi.fn<(report: DegradationReport) => void>() - const flatDegradation = vi.fn<(report: DegradationReport) => void>() - const schema = compileSchema( - defineSchema({ - blockObjects: [ - { - name: 'blockquote', - fields: [{name: 'content', type: 'array'}], - }, - ], - styles: [{name: 'normal'}, {name: 'h1'}], - }), - ) - const markdown = ['> # Title', '> body'].join('\n') - - const declined = markdownToPortableText(markdown, { - keyGenerator: createTestKeyGenerator(), - schema, - types: {blockquote: () => undefined}, - onDegradation: declineDegradation, - }) - - const neverRegistered = markdownToPortableText(markdown, { - keyGenerator: createTestKeyGenerator(), - schema, - onDegradation: flatDegradation, - }) - - // The flat path keeps the heading's own style; only the plain - // paragraph picks up `blockquote`. - expect(omitKeys(declined)).toEqual(omitKeys(neverRegistered)) - expect(declined).toEqual([ - { - _type: 'block', - _key: 'k0', - style: 'h1', - children: [{_type: 'span', _key: 'k1', text: 'Title', marks: []}], - markDefs: [], - }, - { - _type: 'block', - _key: 'k2', - style: 'normal', - children: [{_type: 'span', _key: 'k3', text: 'body', marks: []}], - markDefs: [], - }, - ]) - - const expectedDegradations = [ - { - type: 'style-fallback', - message: - 'Blockquote became normal paragraphs: the schema has no `blockquote` style', - line: 1, - }, - ] - expect(declineDegradation.mock.calls[0]![0]!.degradations).toEqual( - expectedDegradations, - ) - expect(flatDegradation.mock.calls[0]![0]!.degradations).toEqual( - expectedDegradations, - ) - }) - - test('structural blockquote decline on a schema without `h1`: a heading that already fell back to `normal` mirrors the flat path, not restyled to `blockquote`', () => { - // The heading's own style resolution already declined to `normal` - // before the blockquote fallback runs, so by resolved name alone it's - // indistinguishable from a plain paragraph. The flat path never - // restyles it (only paragraphs pick up `blockquote`), and neither - // should the decline fallback: gated on `plainParagraphBlocks` - // provenance, not the block's resolved style name. - const schema = compileSchema( - defineSchema({styles: [{name: 'normal'}, {name: 'blockquote'}]}), - ) - const markdown = ['> # head', '> body one', '>', '> body two'].join('\n') - - const declined = markdownToPortableText(markdown, { - keyGenerator: createTestKeyGenerator(), - schema, - types: {blockquote: () => undefined}, - }) - - const neverRegistered = markdownToPortableText(markdown, { - keyGenerator: createTestKeyGenerator(), - schema, - }) - - expect(omitKeys(declined)).toEqual(omitKeys(neverRegistered)) - expect(declined).toEqual([ - { - _type: 'block', - _key: 'k0', - style: 'normal', - children: [{_type: 'span', _key: 'k1', text: 'head', marks: []}], - markDefs: [], - }, - { - _type: 'block', - _key: 'k2', - style: 'blockquote', - children: [{_type: 'span', _key: 'k3', text: 'body one', marks: []}], - markDefs: [], - }, - { - _type: 'block', - _key: 'k4', - style: 'blockquote', - children: [{_type: 'span', _key: 'k5', text: 'body two', marks: []}], - markDefs: [], - }, - ]) - }) - - test('structural blockquote decline on a codeless schema: a fence fallen back to text mirrors the flat path, not restyled to `blockquote`', () => { - // The fence's fallback-to-text block resolves to `normal`, same as a - // plain paragraph would, but the flat path never stamps it with - // `blockquote` (only paragraphs pick that up), so neither does the - // decline fallback. - const schema = compileSchema( - defineSchema({styles: [{name: 'normal'}, {name: 'blockquote'}]}), - ) - const markdown = ['> before', '>', '> ```js', '> code()', '> ```'].join( - '\n', - ) - - const declined = markdownToPortableText(markdown, { - keyGenerator: createTestKeyGenerator(), - schema, - types: {blockquote: () => undefined}, - }) - - const neverRegistered = markdownToPortableText(markdown, { - keyGenerator: createTestKeyGenerator(), - schema, - }) - - expect(omitKeys(declined)).toEqual(omitKeys(neverRegistered)) - expect(declined).toEqual([ - { - _type: 'block', - _key: 'k0', - style: 'blockquote', - children: [{_type: 'span', _key: 'k1', text: 'before', marks: []}], - markDefs: [], - }, - { - _type: 'block', - _key: 'k2', - style: 'normal', - children: [{_type: 'span', _key: 'k3', text: 'code()', marks: []}], - markDefs: [], - }, - ]) - }) - - test('annotation dropped: link with an empty href', () => { - const keyGenerator = createTestKeyGenerator() - const onDegradation = vi.fn<(report: DegradationReport) => void>() - - expect( - markdownToPortableText('foo [bar]() baz', { - keyGenerator, - onDegradation, - }), - ).toEqual([ - { - _type: 'block', - _key: 'k0', - children: [ - {_type: 'span', _key: 'k1', text: 'foo bar baz', marks: []}, - ], - markDefs: [], - style: 'normal', - }, - ]) - - expect(onDegradation).toHaveBeenCalledTimes(1) - expect(onDegradation.mock.calls[0]![0]!.degradations).toEqual([ - { - type: 'annotation-dropped', - message: 'Removed a link that has no URL, kept its text', - line: 1, - snippet: 'bar', - }, - ]) - }) - - test('annotation dropped: undeclared `link` annotation', () => { - const keyGenerator = createTestKeyGenerator() - const onDegradation = vi.fn<(report: DegradationReport) => void>() - - expect( - markdownToPortableText('foo [bar](https://example.com) baz', { - keyGenerator, - schema: compileSchema(defineSchema({})), - onDegradation, - }), - ).toEqual([ - { - _type: 'block', - _key: 'k0', - children: [ - {_type: 'span', _key: 'k1', text: 'foo bar baz', marks: []}, - ], - markDefs: [], - style: 'normal', - }, - ]) - - expect(onDegradation).toHaveBeenCalledTimes(1) - expect(onDegradation.mock.calls[0]![0]!.degradations).toEqual([ - { - type: 'annotation-dropped', - message: - 'Removed the link, kept its text: the schema has no `link` annotation', - line: 1, - snippet: 'bar', - }, - ]) - }) - - test('a consumer throw enforces against lossy output, its message the canonical grouped text', () => { - const degradingMarkdown = [ - '# foo', - '', - '**bar**', - '', - '```js', - 'code', - '```', - ].join('\n') - const throwErrorMessage = [ - 'Markdown could not be converted without loss:', - '- line 1: `#` heading became a normal paragraph: the schema has no `h1` style ("foo")', - '- line 3: Removed bold formatting, kept the text: the schema has no `strong` decorator ("bar")', - '- line 5: `js` code block became plain text: the schema has no `code` block object ("code")', - ].join('\n') - - const run = () => - markdownToPortableText(degradingMarkdown, { - keyGenerator: createTestKeyGenerator(), - schema: compileSchema(defineSchema({})), - onDegradation: ({message}) => { - throw new Error(message) - }, - }) - - expect(run).toThrow(Error) - - let thrown: unknown - try { - run() - } catch (error) { - thrown = error - } - - expect(thrown).toBeInstanceOf(Error) - expect((thrown as Error).message).toBe(throwErrorMessage) - }) - - test('a prior call is unaffected by a later call whose consumer throws', () => { - const degradingMarkdown = ['# foo', '', '**bar**'].join('\n') - const degradingSchema = compileSchema(defineSchema({})) - - const blocksBeforeThrow = markdownToPortableText(degradingMarkdown, { - keyGenerator: createTestKeyGenerator(), - schema: degradingSchema, - }) - - expect(() => - markdownToPortableText(degradingMarkdown, { - keyGenerator: createTestKeyGenerator(), - schema: degradingSchema, - onDegradation: ({message}) => { - throw new Error(message) - }, - }), - ).toThrow(Error) - - const blocksAfterThrow = markdownToPortableText(degradingMarkdown, { - keyGenerator: createTestKeyGenerator(), - schema: degradingSchema, - }) - - expect(blocksAfterThrow).toEqual(blocksBeforeThrow) - expect(blocksBeforeThrow).toEqual([ - { - _type: 'block', - _key: 'k0', - children: [{_type: 'span', _key: 'k1', text: 'foo', marks: []}], - markDefs: [], - style: 'normal', - }, - { - _type: 'block', - _key: 'k2', - children: [{_type: 'span', _key: 'k3', text: 'bar', marks: []}], - markDefs: [], - style: 'normal', - }, - ]) - }) - - test('clean input never invokes the callback, even a throwing one', () => { - const keyGenerator = createTestKeyGenerator() - - expect( - markdownToPortableText('foo', { - keyGenerator, - onDegradation: () => { - throw new Error('should not run') - }, - }), - ).toEqual([ - { - _type: 'block', - _key: 'k0', - children: [{_type: 'span', _key: 'k1', text: 'foo', marks: []}], - markDefs: [], - style: 'normal', - }, - ]) - }) - - test('groups repeated same-type declines into one line in the canonical message', () => { - const keyGenerator = createTestKeyGenerator() - const schema = compileSchema(defineSchema({})) - const markdown = [ - '**a**', - '', - '**b**', - '', - '**c**', - '', - '| x |', - '| - |', - '| y |', - ].join('\n') - - expect(() => - markdownToPortableText(markdown, { - keyGenerator, - schema, - onDegradation: ({message}) => { - throw new Error(message) - }, - }), - ).toThrowError( - [ - 'Markdown could not be converted without loss:', - '- Removed bold formatting, kept the text: the schema has no `strong` decorator (3\u00d7: "a", "b", "c")', - '- line 7: Table became plain text blocks, rows and columns lost: the schema has no `table` block object', - ].join('\n'), - ) - }) - - test('the `degradations` array stays ungrouped, one entry per occurrence, for the same repeated markdown', () => { - const keyGenerator = createTestKeyGenerator() - const onDegradation = vi.fn<(report: DegradationReport) => void>() - const schema = compileSchema(defineSchema({})) - const markdown = [ - '**a**', - '', - '**b**', - '', - '**c**', - '', - '| x |', - '| - |', - '| y |', - ].join('\n') - - markdownToPortableText(markdown, { - keyGenerator, - schema, - onDegradation, - }) - - expect(onDegradation).toHaveBeenCalledTimes(1) - expect(onDegradation.mock.calls[0]![0]!.degradations).toEqual([ - { - type: 'decorator-dropped', - message: - 'Removed bold formatting, kept the text: the schema has no `strong` decorator', - line: 1, - snippet: 'a', - }, - { - type: 'decorator-dropped', - message: - 'Removed bold formatting, kept the text: the schema has no `strong` decorator', - line: 3, - snippet: 'b', - }, - { - type: 'decorator-dropped', - message: - 'Removed bold formatting, kept the text: the schema has no `strong` decorator', - line: 5, - snippet: 'c', - }, - { - type: 'table-flattened', - message: - 'Table became plain text blocks, rows and columns lost: the schema has no `table` block object', - line: 7, - }, - ]) - }) - - test('snippet truncation: exactly 40 characters, no ellipsis', () => { - const onDegradation = vi.fn<(report: DegradationReport) => void>() - const text = 'x'.repeat(40) - - markdownToPortableText(`**${text}**`, { - keyGenerator: createTestKeyGenerator(), - schema: compileSchema(defineSchema({})), - onDegradation, - }) - - expect(onDegradation.mock.calls[0]![0]!.degradations[0]!.snippet).toBe( - text, - ) - }) - - test('snippet truncation: 41 characters, cut to 40 with an ellipsis', () => { - const onDegradation = vi.fn<(report: DegradationReport) => void>() - const text = 'x'.repeat(41) - - markdownToPortableText(`**${text}**`, { - keyGenerator: createTestKeyGenerator(), - schema: compileSchema(defineSchema({})), - onDegradation, - }) - - expect(onDegradation.mock.calls[0]![0]!.degradations[0]!.snippet).toBe( - `${'x'.repeat(40)}...`, - ) - }) - - test('snippet truncation: a cut landing mid-surrogate-pair backs off instead of splitting it', () => { - const onDegradation = vi.fn<(report: DegradationReport) => void>() - const text = `${'x'.repeat(39)}\u{1F44D}` - - markdownToPortableText(`**${text}**`, { - keyGenerator: createTestKeyGenerator(), - schema: compileSchema(defineSchema({})), - onDegradation, - }) - - const snippet = onDegradation.mock.calls[0]![0]!.degradations[0]!.snippet! - expect(snippet.isWellFormed()).toBe(true) - expect(snippet).toBe(`${'x'.repeat(39)}...`) - }) - - test('snippet truncation: quoted content is not escaped, and reads oddly in the message (documented, not fixed)', () => { - const onDegradation = vi.fn<(report: DegradationReport) => void>() - - markdownToPortableText('**say "hi" now**', { - keyGenerator: createTestKeyGenerator(), - schema: compileSchema(defineSchema({})), - onDegradation, - }) - - expect(onDegradation.mock.calls[0]![0]!.degradations[0]!.snippet).toBe( - 'say "hi" now', - ) - expect(onDegradation.mock.calls[0]![0]!.message).toBe( - [ - 'Markdown could not be converted without loss:', - '- line 1: Removed bold formatting, kept the text: the schema has no `strong` decorator ("say "hi" now")', - ].join('\n'), - ) - }) - - test('snippet with a hard break: the newline is escaped to literal `\\n`, in both the event and the message', () => { - const onDegradation = vi.fn<(report: DegradationReport) => void>() - - markdownToPortableText('**foo \nbar**', { - keyGenerator: createTestKeyGenerator(), - schema: compileSchema(defineSchema({})), - onDegradation, - }) - - expect(onDegradation.mock.calls[0]![0]!.degradations[0]!.snippet).toBe( - 'foo\\nbar', - ) - expect(onDegradation.mock.calls[0]![0]!.message).toBe( - [ - 'Markdown could not be converted without loss:', - '- line 1: Removed bold formatting, kept the text: the schema has no `strong` decorator ("foo\\nbar")', - ].join('\n'), - ) - }) - - test('empty snippet is omitted, not rendered as `("")`: link decline with no text', () => { - const onDegradation = vi.fn<(report: DegradationReport) => void>() - - markdownToPortableText('foo [](http://example.com) bar', { - keyGenerator: createTestKeyGenerator(), - schema: compileSchema(defineSchema({})), - onDegradation, - }) - - expect(onDegradation.mock.calls[0]![0]!.degradations).toEqual([ - { - type: 'annotation-dropped', - message: - 'Removed the link, kept its text: the schema has no `link` annotation', - line: 1, - }, - ]) - expect(onDegradation.mock.calls[0]![0]!.message).toBe( - [ - 'Markdown could not be converted without loss:', - '- line 1: Removed the link, kept its text: the schema has no `link` annotation', - ].join('\n'), - ) - }) - - test('grouped message: a lines-only suffix always leads with the count, like the snippet and none-of-the-above shapes', () => { - // `types.list` always declines, and the schema has no `bullet` list, - // so both the nested and the outer list report the same message: the - // group has two entries with lines but no snippets. - const markdown = ['- outer', ' - inner'].join('\n') - - expect(() => - markdownToPortableText(markdown, { - keyGenerator: createTestKeyGenerator(), - schema: compileSchema( - defineSchema({ - blockObjects: [ - {name: 'list', fields: [{name: 'items', type: 'array'}]}, - ], - }), - ), - types: {list: () => undefined}, - onDegradation: ({message}) => { - throw new Error(message) - }, - }), - ).toThrowError( - [ - 'Markdown could not be converted without loss:', - '- Bullet list became plain paragraphs: the schema has no `bullet` list (2\u00d7: lines 1, 2)', - ].join('\n'), - ) - }) - - test('grouped message: a lines-only suffix dedupes a line repeated by several entries, not just sorts it', () => { - // All three cells' `fields-dropped` events pin to the same table start - // line (inline tokens inside a cell carry no map of their own), so - // the group's line list has one line repeated three times: the count - // already says how many, the list should say which lines, once each. - const markdown = [ - '| a |', - '| - |', - '| ![x](a.png) |', - '| ![y](b.png) |', - '| ![z](c.png) |', - ].join('\n') - - expect(() => - markdownToPortableText(markdown, { - keyGenerator: createTestKeyGenerator(), - schema: compileSchema( - defineSchema({ - blockObjects: [ - {name: 'image', fields: [{name: 'src', type: 'string'}]}, - { - name: 'table', - fields: [ - {name: 'headerRows', type: 'number'}, - {name: 'rows', type: 'array'}, - ], - }, - ], - }), - ), - onDegradation: ({message}) => { - throw new Error(message) - }, - }), - ).toThrowError( - [ - 'Markdown could not be converted without loss:', - "- Dropped `alt` from `image`: not in the schema's `image` fields (3\u00d7: lines 1)", - ].join('\n'), - ) - }) - - test('grouped message: caps a per-group list at 5 entries, tailed with `and N more`', () => { - const markdown = [ - '**a**', - '', - '**b**', - '', - '**c**', - '', - '**d**', - '', - '**e**', - '', - '**f**', - ].join('\n') - - expect(() => - markdownToPortableText(markdown, { - keyGenerator: createTestKeyGenerator(), - schema: compileSchema(defineSchema({})), - onDegradation: ({message}) => { - throw new Error(message) - }, - }), - ).toThrowError( - [ - 'Markdown could not be converted without loss:', - '- Removed bold formatting, kept the text: the schema has no `strong` decorator (6\u00d7: "a", "b", "c", "d", "e", and 1 more)', - ].join('\n'), - ) - }) - - test("grouped message: groups sort by their minimum line, not their first-encountered entry's", () => { - const schema = compileSchema( - defineSchema({ - blockObjects: [ - { - name: 'blockquote', - fields: [{name: 'content', type: 'array'}], - }, - ], - }), - ) - // The outer blockquote (line 1) closes last, so its `style-fallback` - // entry (line 1) arrives after the inner one's (line 4): the group's - // first-encountered line is 4, but its minimum is 1. The unrelated - // `**bar**` on line 3 falls strictly between the two, so sorting by - // minimum (not first-encountered) line changes which group leads. - const markdown = ['> foo', '>', '> **bar**', '>> baz'].join('\n') - - expect(() => - markdownToPortableText(markdown, { - keyGenerator: createTestKeyGenerator(), - schema, - types: {blockquote: () => undefined}, - onDegradation: ({message}) => { - throw new Error(message) - }, - }), - ).toThrowError( - [ - 'Markdown could not be converted without loss:', - '- Blockquote became normal paragraphs: the schema has no `blockquote` style (2\u00d7: lines 1, 4)', - '- line 3: Removed bold formatting, kept the text: the schema has no `strong` decorator ("bar")', - ].join('\n'), - ) - }) - - test('object carrier invalid: a `json:object` fence with malformed JSON falls back to a code block', () => { - const keyGenerator = createTestKeyGenerator() - const onDegradation = vi.fn<(report: DegradationReport) => void>() - const markdown = ['```json:object', '{"_type": "product",', '```'].join( - '\n', - ) - - expect( - markdownToPortableText(markdown, {keyGenerator, onDegradation}), - ).toEqual([ - { - _type: 'code', - _key: 'k0', - code: '{"_type": "product",', - language: 'json:object', - }, - ]) - - expect(onDegradation).toHaveBeenCalledTimes(1) - expect(onDegradation.mock.calls[0]![0]!.degradations).toEqual([ - { - type: 'object-carrier-invalid', - message: - '`json:object` fence fell back to a code block: the payload is not valid JSON', - line: 1, - snippet: '{"_type": "product",', - }, - ]) - }) - - test('object carrier invalid: a `json:object` fence missing `_type` falls back to a code block', () => { - const keyGenerator = createTestKeyGenerator() - const onDegradation = vi.fn<(report: DegradationReport) => void>() - const markdown = ['```json:object', '{"sku": "abc-123"}', '```'].join( - '\n', - ) - - expect( - markdownToPortableText(markdown, {keyGenerator, onDegradation}), - ).toEqual([ - { - _type: 'code', - _key: 'k0', - code: '{"sku": "abc-123"}', - language: 'json:object', - }, - ]) - - expect(onDegradation).toHaveBeenCalledTimes(1) - expect(onDegradation.mock.calls[0]![0]!.degradations).toEqual([ - { - type: 'object-carrier-invalid', - message: - '`json:object` fence fell back to a code block: the payload has no string `_type`', - line: 1, - snippet: '{"sku": "abc-123"}', - }, - ]) - }) - - test('object carrier invalid: a `json:object`-tagged code span with malformed JSON stays a plain code span', () => { - const keyGenerator = createTestKeyGenerator() - const onDegradation = vi.fn<(report: DegradationReport) => void>() - const markdown = 'json:object`{"_type":"stockTicker"`' - - expect( - markdownToPortableText(markdown, {keyGenerator, onDegradation}), - ).toEqual([ - { - _type: 'block', - _key: 'k0', - style: 'normal', - markDefs: [], - children: [ - {_type: 'span', _key: 'k1', text: 'json:object', marks: []}, - { - _type: 'span', - _key: 'k2', - text: '{"_type":"stockTicker"', - marks: ['code'], - }, - ], - }, - ]) - - expect(onDegradation).toHaveBeenCalledTimes(1) - expect(onDegradation.mock.calls[0]![0]!.degradations).toEqual([ - { - type: 'object-carrier-invalid', - message: - '`json:object`-tagged code span fell back to a plain code span: the payload is not valid JSON', - line: 1, - snippet: '{"_type":"stockTicker"', - }, - ]) - }) - - test('object carrier invalid: a `json:object`-tagged code span missing `_type` stays a plain code span', () => { - const keyGenerator = createTestKeyGenerator() - const onDegradation = vi.fn<(report: DegradationReport) => void>() - const markdown = 'json:object`{"symbol":"AAPL"}`' - - expect( - markdownToPortableText(markdown, {keyGenerator, onDegradation}), - ).toEqual([ - { - _type: 'block', - _key: 'k0', - style: 'normal', - markDefs: [], - children: [ - {_type: 'span', _key: 'k1', text: 'json:object', marks: []}, - { - _type: 'span', - _key: 'k2', - text: '{"symbol":"AAPL"}', - marks: ['code'], - }, - ], - }, - ]) - - expect(onDegradation).toHaveBeenCalledTimes(1) - expect(onDegradation.mock.calls[0]![0]!.degradations).toEqual([ - { - type: 'object-carrier-invalid', - message: - '`json:object`-tagged code span fell back to a plain code span: the payload has no string `_type`', - line: 1, - snippet: '{"symbol":"AAPL"}', - }, - ]) - }) - - test('object carrier invalid: a `json:object`-tagged code span missing `_type` with no open text block', () => { - const keyGenerator = createTestKeyGenerator() - const onDegradation = vi.fn<(report: DegradationReport) => void>() - const markdown = [ - '- foo', - '', - ' ```json:object', - ' {"_type": "product", "_key": "p1"}', - ' ```', - '', - ' json:object`{"symbol":"AAPL"}` bar', - ].join('\n') - - expect( - markdownToPortableText(markdown, { - keyGenerator, - schema: compileSchema(defineSchema({})), - onDegradation, - }), - ).toEqual([ - { - _type: 'block', - _key: 'k0', - style: 'normal', - markDefs: [], - children: [{_type: 'span', _key: 'k1', text: 'foo', marks: []}], - }, - {_type: 'product', _key: 'p1'}, - { - _type: 'block', - _key: 'k2', - style: 'normal', - markDefs: [], - children: [ - { - _type: 'span', - _key: 'k3', - text: 'json:object{"symbol":"AAPL"} bar', - marks: [], - }, - ], - }, - ]) - - expect(onDegradation).toHaveBeenCalledTimes(1) - expect(onDegradation.mock.calls[0]![0]!.degradations).toEqual([ - { - type: 'list-flattened', - message: - 'Bullet list became plain paragraphs: the schema has no `bullet` list', - line: 1, - }, - { - type: 'object-type-undeclared', - message: - 'Kept the block-level `product` object as written: the schema has no block-level `product` object', - line: 3, - snippet: '{"_type": "product", "_key": "p1"}', - }, - { - type: 'object-carrier-invalid', - message: - '`json:object`-tagged code span fell back to a plain code span: the payload has no string `_type`', - line: 7, - snippet: '{"symbol":"AAPL"}', - }, - { - type: 'decorator-dropped', - message: - 'Removed inline-code formatting, kept the text: the schema has no `code` decorator', - line: 7, - snippet: '{"symbol":"AAPL"}', - }, - ]) - }) - - test('object type undeclared: an inline `json:object` payload whose type the schema has no inline object for', () => { - const keyGenerator = createTestKeyGenerator() - const onDegradation = vi.fn<(report: DegradationReport) => void>() - const markdown = - 'Group visits are arranged by json:object`{"_type":"mention","_ref":"person-marta-lind"}`.' - - expect( - markdownToPortableText(markdown, { - keyGenerator, - schema: compileSchema(defineSchema({})), - onDegradation, - }), - ).toEqual([ - { - _type: 'block', - _key: 'k0', - style: 'normal', - markDefs: [], - children: [ - { - _type: 'span', - _key: 'k1', - text: 'Group visits are arranged by ', - marks: [], - }, - {_type: 'mention', _key: 'k2', _ref: 'person-marta-lind'}, - {_type: 'span', _key: 'k3', text: '.', marks: []}, - ], - }, - ]) - - expect(onDegradation).toHaveBeenCalledTimes(1) - expect(onDegradation.mock.calls[0]![0]!.degradations).toEqual([ - { - type: 'object-type-undeclared', - message: - 'Kept the inline `mention` object as written: the schema has no inline `mention` object', - line: 1, - snippet: '{"_type":"mention","_ref":"person-marta-...', - }, - ]) - }) - - test('object type undeclared: a `json:object` fence whose type the schema has no block object for', () => { - const keyGenerator = createTestKeyGenerator() - const onDegradation = vi.fn<(report: DegradationReport) => void>() - const markdown = [ - '```json:object', - '{"_type": "product", "_key": "p1", "sku": "abc-123"}', - '```', - ].join('\n') - - expect( - markdownToPortableText(markdown, { - keyGenerator, - schema: compileSchema(defineSchema({})), - onDegradation, - }), - ).toEqual([{_type: 'product', _key: 'p1', sku: 'abc-123'}]) - - expect(onDegradation).toHaveBeenCalledTimes(1) - expect(onDegradation.mock.calls[0]![0]!.degradations).toEqual([ - { - type: 'object-type-undeclared', - message: - 'Kept the block-level `product` object as written: the schema has no block-level `product` object', - line: 1, - snippet: '{"_type": "product", "_key": "p1", "sku"...', - }, - ]) - }) - - test('object type undeclared: a block-only type in a `json:object`-tagged code span', () => { - const keyGenerator = createTestKeyGenerator() - const onDegradation = vi.fn<(report: DegradationReport) => void>() - const markdown = 'foo json:object`{"_type":"product","_key":"p1"}`' - - expect( - markdownToPortableText(markdown, { - keyGenerator, - schema: compileSchema( - defineSchema({blockObjects: [{name: 'product'}]}), - ), - onDegradation, - }), - ).toEqual([ - { - _type: 'block', - _key: 'k0', - style: 'normal', - markDefs: [], - children: [ - {_type: 'span', _key: 'k1', text: 'foo ', marks: []}, - {_type: 'product', _key: 'p1'}, - ], - }, - ]) - - expect(onDegradation).toHaveBeenCalledTimes(1) - expect(onDegradation.mock.calls[0]![0]!.degradations).toEqual([ - { - type: 'object-type-undeclared', - message: - 'Kept the inline `product` object as written: the schema has no inline `product` object', - line: 1, - snippet: '{"_type":"product","_key":"p1"}', - }, - ]) - }) - - test('object type undeclared: an inline-only type in a `json:object` fence', () => { - const keyGenerator = createTestKeyGenerator() - const onDegradation = vi.fn<(report: DegradationReport) => void>() - const markdown = [ - '```json:object', - '{"_type": "stockTicker", "_key": "t1"}', - '```', - ].join('\n') - - expect( - markdownToPortableText(markdown, { - keyGenerator, - schema: compileSchema( - defineSchema({inlineObjects: [{name: 'stockTicker'}]}), - ), - onDegradation, - }), - ).toEqual([{_type: 'stockTicker', _key: 't1'}]) - - expect(onDegradation).toHaveBeenCalledTimes(1) - expect(onDegradation.mock.calls[0]![0]!.degradations).toEqual([ - { - type: 'object-type-undeclared', - message: - 'Kept the block-level `stockTicker` object as written: the schema has no block-level `stockTicker` object', - line: 1, - snippet: '{"_type": "stockTicker", "_key": "t1"}', - }, - ]) - }) - - test('object type undeclared: a `json:object` payload lifted out of a table cell is judged as a block object', () => { - const keyGenerator = createTestKeyGenerator() - const onDegradation = vi.fn<(report: DegradationReport) => void>() - const markdown = [ - '| a |', - '| - |', - '| json:object`{"_type":"product","_key":"p1"}` |', - ].join('\n') - - expect( - markdownToPortableText(markdown, { - keyGenerator, - schema: compileSchema( - defineSchema({blockObjects: [defaultTableObjectDefinition]}), - ), - onDegradation, - }), - ).toEqual([ - { - _type: 'table', - _key: 'k7', - headerRows: 1, - rows: [ - { - _type: 'row', - _key: 'k3', - cells: [ - { - _type: 'cell', - _key: 'k2', - value: [ - { - _type: 'block', - _key: 'k0', - style: 'normal', - markDefs: [], - children: [ - {_type: 'span', _key: 'k1', text: 'a', marks: []}, - ], - }, - ], - }, - ], - }, - { - _type: 'row', - _key: 'k6', - cells: [ - { - _type: 'cell', - _key: 'k5', - value: [{_type: 'product', _key: 'p1'}], - }, - ], - }, - ], - }, - ]) - - expect(onDegradation).toHaveBeenCalledTimes(1) - expect(onDegradation.mock.calls[0]![0]!.degradations).toEqual([ - { - type: 'object-type-undeclared', - message: - 'Kept the block-level `product` object as written: the schema has no block-level `product` object', - line: 1, - snippet: '{"_type":"product","_key":"p1"}', - }, - ]) - }) - - test('object type undeclared: a `json:object` payload that stays inline in a table cell is judged as an inline object', () => { - const keyGenerator = createTestKeyGenerator() - const onDegradation = vi.fn<(report: DegradationReport) => void>() - const markdown = [ - '| a |', - '| - |', - '| foo json:object`{"_type":"product","_key":"p1"}` |', - ].join('\n') - - expect( - markdownToPortableText(markdown, { - keyGenerator, - schema: compileSchema( - defineSchema({blockObjects: [defaultTableObjectDefinition]}), - ), - onDegradation, - }), - ).toEqual([ - { - _type: 'table', - _key: 'k8', - headerRows: 1, - rows: [ - { - _type: 'row', - _key: 'k3', - cells: [ - { - _type: 'cell', - _key: 'k2', - value: [ - { - _type: 'block', - _key: 'k0', - style: 'normal', - markDefs: [], - children: [ - {_type: 'span', _key: 'k1', text: 'a', marks: []}, - ], - }, - ], - }, - ], - }, - { - _type: 'row', - _key: 'k7', - cells: [ - { - _type: 'cell', - _key: 'k6', - value: [ - { - _type: 'block', - _key: 'k4', - style: 'normal', - markDefs: [], - children: [ - {_type: 'span', _key: 'k5', text: 'foo ', marks: []}, - {_type: 'product', _key: 'p1'}, - ], - }, - ], - }, - ], - }, - ], - }, - ]) - - expect(onDegradation).toHaveBeenCalledTimes(1) - expect(onDegradation.mock.calls[0]![0]!.degradations).toEqual([ - { - type: 'object-type-undeclared', - message: - 'Kept the inline `product` object as written: the schema has no inline `product` object', - line: 1, - snippet: '{"_type":"product","_key":"p1"}', - }, - ]) - }) - - test('object type undeclared: a declared block type in a `json:object` fence does not report', () => { - const keyGenerator = createTestKeyGenerator() - const onDegradation = vi.fn<(report: DegradationReport) => void>() - const markdown = [ - '```json:object', - '{"_type": "product", "_key": "p1", "sku": "abc-123"}', - '```', - ].join('\n') - - expect( - markdownToPortableText(markdown, { - keyGenerator, - schema: compileSchema( - defineSchema({ - blockObjects: [ - {name: 'product', fields: [{name: 'sku', type: 'string'}]}, - ], - }), - ), - onDegradation, - }), - ).toEqual([{_type: 'product', _key: 'p1', sku: 'abc-123'}]) - - expect(onDegradation).not.toHaveBeenCalled() - }) - - test('object type undeclared: a declared inline type in a `json:object`-tagged code span does not report', () => { - const keyGenerator = createTestKeyGenerator() - const onDegradation = vi.fn<(report: DegradationReport) => void>() - const markdown = 'json:object`{"_type":"stockTicker","symbol":"AAPL"}`' - - expect( - markdownToPortableText(markdown, { - keyGenerator, - schema: compileSchema( - defineSchema({ - inlineObjects: [ - { - name: 'stockTicker', - fields: [{name: 'symbol', type: 'string'}], - }, - ], - }), - ), - onDegradation, - }), - ).toEqual([ - { - _type: 'block', - _key: 'k0', - style: 'normal', - markDefs: [], - children: [{_type: 'stockTicker', _key: 'k1', symbol: 'AAPL'}], - }, - ]) - - expect(onDegradation).not.toHaveBeenCalled() - }) - - test('object type undeclared: without a `schema`, an undeclared `json:object` type reports against the default schema', () => { - const keyGenerator = createTestKeyGenerator() - const onDegradation = vi.fn<(report: DegradationReport) => void>() - const markdown = [ - 'json:object`{"_type":"stockTicker","_key":"t1"}`', - '', - '```json:object', - '{"_type": "product", "_key": "p1"}', - '```', - ].join('\n') - - expect( - markdownToPortableText(markdown, {keyGenerator, onDegradation}), - ).toEqual([ - { - _type: 'block', - _key: 'k0', - style: 'normal', - markDefs: [], - children: [{_type: 'stockTicker', _key: 't1'}], - }, - {_type: 'product', _key: 'p1'}, - ]) - - expect(onDegradation).toHaveBeenCalledTimes(1) - expect(onDegradation.mock.calls[0]![0]!.degradations).toEqual([ - { - type: 'object-type-undeclared', - message: - 'Kept the inline `stockTicker` object as written: the schema has no inline `stockTicker` object', - line: 1, - snippet: '{"_type":"stockTicker","_key":"t1"}', - }, - { - type: 'object-type-undeclared', - message: - 'Kept the block-level `product` object as written: the schema has no block-level `product` object', - line: 3, - snippet: '{"_type": "product", "_key": "p1"}', - }, - ]) - }) - }) -}) diff --git a/packages/markdown/src/neutral-form.ts b/packages/markdown/src/neutral-form.ts deleted file mode 100644 index fecb4ead54..0000000000 --- a/packages/markdown/src/neutral-form.ts +++ /dev/null @@ -1,84 +0,0 @@ -export type Node = Record - -/** - * A canonical JSON encoding that erases identity: `_key` properties - * are dropped, object properties are sorted, and annotation `_key` - * references inside `span.marks` are rewritten to the definition's - * position in `markDefs` (dropping `_key` alone would compare the - * stored annotation key against the fresh one and reject an unchanged - * link). - */ -export function neutralForm(node: Node): string { - return encodeNeutral(node, buildAliasMap(node)) -} - -/** - * Aliases each `markDefs` key to a key-independent spelling of the - * definition itself, so `span.marks` references compare by what the - * annotation is rather than which key it carries. The spelling is the - * definition's own neutral form, not its array position: aliasing by - * position made every annotated sibling span's neutral form shift when - * a definition was inserted or removed before its own, so adding one - * link re-keyed unrelated annotated spans. Identical definitions (the - * same link twice) are disambiguated by occurrence order among - * identical forms only, which no unrelated insertion can shift. - */ -export function buildAliasMap(node: Node): Map | undefined { - const markDefs = node['markDefs'] - if (!isTypedObjectArray(markDefs)) { - return undefined - } - const aliasByKey = new Map() - const occurrenceByForm = new Map() - for (const definition of markDefs) { - const key = definition['_key'] - if (typeof key !== 'string') { - continue - } - const form = encodeNeutral(definition, undefined) - const occurrence = occurrenceByForm.get(form) ?? 0 - occurrenceByForm.set(form, occurrence + 1) - aliasByKey.set(key, `@annotation:${occurrence}:${form}`) - } - return aliasByKey -} - -export function encodeNeutral( - value: unknown, - aliasByKey: Map | undefined, -): string { - if (Array.isArray(value)) { - return `[${value.map((item) => encodeNeutral(item, aliasByKey)).join(',')}]` - } - if (typeof value === 'object' && value !== null) { - const entries = Object.entries(value as Node) - .filter(([field]) => field !== '_key') - .sort(([a], [b]) => (a < b ? -1 : a > b ? 1 : 0)) - .map(([field, fieldValue]) => { - if (field === 'marks' && aliasByKey && Array.isArray(fieldValue)) { - const aliased = fieldValue.map((mark) => - typeof mark === 'string' && aliasByKey.has(mark) - ? aliasByKey.get(mark) - : mark, - ) - return `${JSON.stringify(field)}:${JSON.stringify(aliased)}` - } - return `${JSON.stringify(field)}:${encodeNeutral(fieldValue, aliasByKey)}` - }) - return `{${entries.join(',')}}` - } - return JSON.stringify(value) ?? 'undefined' -} - -export function isTypedObjectArray(value: unknown): value is Array { - return ( - Array.isArray(value) && - value.length > 0 && - value.every( - (item) => - typeof item === 'object' && - item !== null && - typeof (item as Node)['_type'] === 'string', - ) - ) -} diff --git a/packages/markdown/src/portable-text-to-markdown.fuzz.test.ts b/packages/markdown/src/portable-text-to-markdown.fuzz.test.ts deleted file mode 100644 index ae99b0be96..0000000000 --- a/packages/markdown/src/portable-text-to-markdown.fuzz.test.ts +++ /dev/null @@ -1,706 +0,0 @@ -import {compileSchema, defineSchema} from '@portabletext/schema' -import {createTestKeyGenerator} from '@portabletext/test' -import {isPortableTextBlock, isPortableTextSpan} from '@portabletext/toolkit' -import type { - ArbitraryTypedObject, - PortableTextBlock, - TypedObject, -} from '@portabletext/types' -import {describe, expect, test} from 'vitest' -import {mulberry32} from '../test-utils/mulberry32' -import { - defaultCalloutObjectDefinition, - defaultCodeObjectDefinition, - defaultHorizontalRuleObjectDefinition, - defaultHtmlObjectDefinition, - defaultImageObjectDefinition, - defaultTableObjectDefinition, -} from './default-schema' -import {portableTextToMarkdown} from './from-portable-text/portable-text-to-markdown' -import {markdownToPortableText} from './to-portable-text/markdown-to-portable-text' - -const HOSTILE_ALPHABET = [ - ' ', - '\t', - ...'0123456789-+*#>=.()[]:\\`~&(random: () => number, items: ReadonlyArray): T { - const item = items[Math.floor(random() * items.length)] - if (item === undefined) { - throw new Error('Expected a non-empty array') - } - return item -} - -function randomInt(random: () => number, min: number, max: number): number { - return min + Math.floor(random() * (max - min + 1)) -} - -function randomLeafText(random: () => number): string { - const length = randomInt(random, 0, 6) - let text = '' - for (let i = 0; i < length; i++) { - text += pick(random, HOSTILE_ALPHABET) - } - return text -} - -function randomWhitespaceOnlyText(random: () => number): string { - const length = randomInt(random, 1, 4) - let text = '' - for (let i = 0; i < length; i++) { - text += pick(random, [' ', '\t']) - } - return text -} - -function randomOpaqueSafeText(random: () => number): string { - const length = randomInt(random, 1, 4) - let text = '' - for (let i = 0; i < length; i++) { - text += pick(random, OPAQUE_SAFE_ALPHABET) - } - return text -} - -type LeafKind = 'text' | 'link' | 'opaque' - -interface GeneratedCase { - context: Context - leafTexts: Array - leafKinds: Array - expectedText: string -} - -/** - * A hard break's own markup (two trailing spaces before the newline) is - * indistinguishable from a genuine trailing space right before it, and an - * empty line after one doesn't round-trip either (there's no next line to - * break to): both are real CommonMark ambiguities a text-escaping fix - * can't resolve, not something in scope here. Only a hard break landing - * between two leaves that both carry real, non-whitespace content right at - * the boundary is generated, so the fuzz oracle only has to account for - * the two *documented* exceptions (leading/trailing trim of the whole - * text, and a linkified substring keeping its text). - */ -function canPlaceHardBreakBetween(before: string, after: string): boolean { - return ( - before.length > 0 && - after.length > 0 && - !/[ \t]$/.test(before) && - !/^[ \t]/.test(after) - ) -} - -function generateCase(random: () => number): GeneratedCase | null { - const context = pick(random, CONTEXTS) - const isHeading = /^h[1-6]$/.test(context) - const leafCount = randomInt(random, 1, 3) - - const leafTexts: Array = [] - for (let i = 0; i < leafCount; i++) { - const roll = random() - if (roll < 0.15) { - leafTexts.push(randomWhitespaceOnlyText(random)) - } else { - leafTexts.push(randomLeafText(random)) - } - } - - const leafKinds: Array = leafTexts.map(() => 'text') - const variantRoll = random() - if (variantRoll < 0.15) { - // An opaque object stands in for an inline object or a leaf a custom - // renderer replaces: at plan time its rendered text is unknown, so a - // hazard scan can't run inline edits over it, only wall it off from - // its neighbors. Its own text is a plain-letter fill so *it* never - // introduces a hazard the oracle would have to model separately. - const index = randomInt(random, 0, leafTexts.length - 1) - leafTexts[index] = randomOpaqueSafeText(random) - leafKinds[index] = 'opaque' - } else if (variantRoll < 0.3 && leafTexts.length >= 3) { - // A link label goes through the same hazard scan as plain text (plus - // unconditional bracket/backslash escaping), so an interior index is - // fair game; the first and last are skipped because the label's own - // `[`/`)` becomes the line's true first/last rendered character - the - // first is a pre-existing gap in line-start hazard planning, and the - // last would defeat the oracle's own leading/trailing whitespace trim, - // neither of which is what this variant is fuzzing. - const index = randomInt(random, 1, leafTexts.length - 2) - const candidate = leafTexts[index] ?? '' - const precedingText = leafTexts[index - 1] ?? '' - if ( - candidate.length > 0 && - !/^[ \t]*$/.test(candidate) && - // A label ending in one of the characters - // `escapeLinkLabelBrackets` escapes, right before a leaf starting - // with `(`/`[`, chains into the unrelated, pre-existing - // `]`-before-link-open hazard and double-escapes the label's own - // bracket - not what this variant is fuzzing. - !/[[\]\\]$/.test(candidate) && - // A leaf ending in `!` right before a link reads back as an image - // (`![...]`), a pre-existing gap this variant isn't fuzzing either. - !precedingText.endsWith('!') - ) { - leafKinds[index] = 'link' - } - } - - // Headings are single-line ATX constructs: a hard break inside one forces - // a structural split (a second block) on reparse, which is a documented, - // separately-tested exception, not a text-identity fuzz property. Every - // other context supports CommonMark's lazy paragraph continuation, so a - // hard break there round-trips inside one block - except a table cell, - // which has no newline syntax of its own (a cell's hard break renders as - // `
`, and default `html.inline: 'skip'` drops it on reparse instead - // of decoding it back into a newline): a separate, pre-existing limit on - // table-cell content, not a text-escaping concern. - if ( - !isHeading && - context !== 'table-cell' && - leafCount > 1 && - leafKinds.every((kind) => kind === 'text') && - random() < 0.4 - ) { - const breakBefore = randomInt(random, 1, leafCount - 1) - const before = leafTexts[breakBefore - 1] ?? '' - const after = leafTexts[breakBefore] ?? '' - if (canPlaceHardBreakBetween(before, after)) { - leafTexts[breakBefore] = `\n${after}` - } - } - - const joined = leafTexts.join('') - if (joined.length === 0) { - return null - } - - // The indented-code-block escape (` `/` `) preserves the *encoded* - // character, not the leading run around it: CommonMark's ordinary 0-3 - // leading-space block indentation trim still applies to whatever literal - // space/tab precedes it. Reproducing that interaction is already the - // directed tests' job (the `roundTripCorpus` includes `' indented'` - // and `'\tx'`); the fuzz oracle only models the simple, whole-text trim. - const firstLine = joined.split('\n')[0] ?? '' - if (/^ {0,3}\t/.test(firstLine) || /^ {4}/.test(firstLine)) { - return null - } - - // A task item's own checkbox (`[ ] `/`[x] `/`[X] `) is a fixed-width - // marker our own parser strips outright, unlike the variable-width - // leading-space indentation every other context trims: the content's - // own leading whitespace, right after that marker, survives untouched. - const expectedText = - context === 'list-task' - ? joined.replace(/[ \t]+$/, '') - : joined.replace(/^[ \t]+/, '').replace(/[ \t]+$/, '') - if (expectedText.length === 0) { - return null - } - - return {context, leafTexts, leafKinds, expectedText} -} - -function buildPortableText( - generated: GeneratedCase, - keyGenerator: () => string, -): Array { - const linkKey = generated.leafKinds.includes('link') - ? keyGenerator() - : undefined - - const children = generated.leafTexts.map((text, index) => { - const kind = generated.leafKinds[index] - - if (kind === 'opaque') { - return {_type: OPAQUE_PROBE_TYPE, _key: keyGenerator(), text} - } - - return { - _type: 'span' as const, - _key: keyGenerator(), - text, - marks: - kind === 'link' && linkKey !== undefined - ? [linkKey] - : // An unrendered mark on every leaf but the last stands in for an - // annotation/decorator the schema doesn't know how to render: it - // splices its children's text back together without adding markup - // of its own. - index < generated.leafTexts.length - 1 - ? ['unrenderedMark'] - : [], - } - }) - - const style = generated.context.startsWith('h') - ? generated.context - : generated.context === 'blockquote' - ? 'blockquote' - : 'normal' - - const block = { - _type: 'block' as const, - _key: keyGenerator(), - style, - markDefs: - linkKey === undefined - ? [] - : [{_key: linkKey, _type: 'link', href: 'https://example.com'}], - children, - } - - if (generated.context === 'list-bullet') { - const result = [{...block, listItem: 'bullet', level: 1}] - return result - } - if (generated.context === 'list-number') { - const result = [{...block, listItem: 'number', level: 1}] - return result - } - if (generated.context === 'list-task') { - const result = [{...block, listItem: 'task', level: 1, checked: false}] - return result - } - if (generated.context === 'table-cell') { - const result = [ - { - _type: 'table', - _key: keyGenerator(), - headerRows: 0, - rows: [ - { - _key: keyGenerator(), - _type: 'row', - cells: [{_key: keyGenerator(), _type: 'cell', value: [block]}], - }, - ], - }, - ] - return result - } - - return [block] -} - -function extractRenderedText( - generated: GeneratedCase, - portableText: Array, -): string { - if (generated.context === 'table-cell') { - const table = portableText[0] as unknown as { - rows: Array<{cells: Array<{value: Array}>}> - } - const cellBlock = table.rows[0]?.cells[0]?.value[0] - if (!cellBlock || !isPortableTextBlock(cellBlock)) { - throw new Error('Expected a text block inside the table cell') - } - return blockText(cellBlock) - } - - const block = portableText[0] - if (!block || !isPortableTextBlock(block)) { - throw new Error('Expected the first node to be a portable text block') - } - return blockText(block) -} - -function blockText(block: PortableTextBlock): string { - return block.children - .filter(isPortableTextSpan) - .map((span) => span.text) - .join('') -} - -const SEEDS = [1, 2, 3, 4, 5, 6, 7, 8, 9, 10] -const CASES_PER_SEED = 200 - -describe('portableTextToMarkdown fuzz (seeded, deterministic)', () => { - test( - 'PT -> MD -> PT text identity, modulo leading/trailing whitespace trim', - {timeout: 30_000}, - () => { - const failures: Array<{ - seed: number - caseIndex: number - generated: GeneratedCase - markdown: string - actual: string - }> = [] - - let attempted = 0 - - for (const seed of SEEDS) { - const random = mulberry32(seed) - for (let caseIndex = 0; caseIndex < CASES_PER_SEED; caseIndex++) { - const generated = generateCase(random) - if (!generated) { - continue - } - attempted++ - - const keyGenerator = createTestKeyGenerator() - const portableText = buildPortableText(generated, keyGenerator) - - let markdown: string - try { - markdown = portableTextToMarkdown(portableText, { - types: { - [OPAQUE_PROBE_TYPE]: ({value}) => - (value as {text: string}).text, - }, - }) - } catch (error) { - failures.push({ - seed, - caseIndex, - generated, - markdown: ``, - actual: '', - }) - continue - } - - let actual: string - try { - const reparsed = markdownToPortableText(markdown) - actual = extractRenderedText(generated, reparsed) - } catch (error) { - failures.push({ - seed, - caseIndex, - generated, - markdown, - actual: ``, - }) - continue - } - - if (actual !== generated.expectedText) { - failures.push({seed, caseIndex, generated, markdown, actual}) - } - } - } - - if (failures.length > 0) { - const sample = failures - .slice(0, 10) - .map( - (failure) => - `seed ${failure.seed} case ${failure.caseIndex} (${failure.generated.context}): leaves ${JSON.stringify(failure.generated.leafTexts)} -> markdown ${JSON.stringify(failure.markdown)} -> got ${JSON.stringify(failure.actual)}, expected ${JSON.stringify(failure.generated.expectedText)}`, - ) - .join('\n') - expect.fail( - `${failures.length} of ${attempted} fuzz cases failed text identity:\n${sample}`, - ) - } - }, - ) -}) - -const GATED_TYPE_DEFINITIONS = { - 'callout': defaultCalloutObjectDefinition, - 'code': defaultCodeObjectDefinition, - 'horizontal-rule': defaultHorizontalRuleObjectDefinition, - 'html': defaultHtmlObjectDefinition, - 'image': defaultImageObjectDefinition, - 'table': defaultTableObjectDefinition, -} as const -const GATED_TYPE_NAMES = Object.keys(GATED_TYPE_DEFINITIONS) as Array< - keyof typeof GATED_TYPE_DEFINITIONS -> - -const GATED_SEEDS = [11, 12, 13, 14, 15, 16, 17, 18, 19, 20] -const GATED_CASES_PER_SEED = 100 - -function buildGatedValue( - type: keyof typeof GATED_TYPE_DEFINITIONS, - keyGenerator: () => string, -): ArbitraryTypedObject { - switch (type) { - case 'code': - return { - _type: 'code', - _key: keyGenerator(), - code: "const foo = 'bar'", - language: 'js', - } - case 'horizontal-rule': - return {_type: 'horizontal-rule', _key: keyGenerator()} - case 'html': - return { - _type: 'html', - _key: keyGenerator(), - html: '
hello
', - } - case 'image': - return { - _type: 'image', - _key: keyGenerator(), - src: 'https://example.com/image.png', - alt: 'alt text', - } - case 'callout': - return { - _type: 'callout', - _key: keyGenerator(), - tone: 'note', - content: [ - { - _type: 'block', - _key: keyGenerator(), - style: 'normal', - markDefs: [], - children: [ - { - _type: 'span', - _key: keyGenerator(), - text: 'hello', - marks: [], - }, - ], - }, - ], - } - case 'table': - return { - _type: 'table', - _key: keyGenerator(), - headerRows: 1, - rows: [ - { - _type: 'row', - _key: keyGenerator(), - cells: [ - { - _type: 'cell', - _key: keyGenerator(), - value: [ - { - _type: 'block', - _key: keyGenerator(), - style: 'normal', - markDefs: [], - children: [ - { - _type: 'span', - _key: keyGenerator(), - text: 'Header', - marks: [], - }, - ], - }, - ], - }, - ], - }, - { - _type: 'row', - _key: keyGenerator(), - cells: [ - { - _type: 'cell', - _key: keyGenerator(), - value: [ - { - _type: 'block', - _key: keyGenerator(), - style: 'normal', - markDefs: [], - children: [ - { - _type: 'span', - _key: keyGenerator(), - text: 'Cell', - marks: [], - }, - ], - }, - ], - }, - ], - }, - ], - } - } -} - -function buildGatedPlainTextBlock( - random: () => number, - keyGenerator: () => string, -): PortableTextBlock { - const length = randomInt(random, 1, 6) - let text = '' - for (let i = 0; i < length; i++) { - text += pick(random, OPAQUE_SAFE_ALPHABET) - } - return { - _type: 'block', - _key: keyGenerator(), - style: 'normal', - markDefs: [], - children: [{_type: 'span', _key: keyGenerator(), text, marks: []}], - } -} - -interface GeneratedGatedCase { - declaredNames: Array - document: Array -} - -function generateGatedCase( - random: () => number, - keyGenerator: () => string, -): GeneratedGatedCase { - const declaredNames = GATED_TYPE_NAMES.filter(() => random() < 0.5) - const nodeCount = randomInt(random, 1, 3) - const document: Array = [] - for (let i = 0; i < nodeCount; i++) { - if (random() < 0.4) { - document.push(buildGatedPlainTextBlock(random, keyGenerator)) - } else { - document.push( - buildGatedValue(pick(random, GATED_TYPE_NAMES), keyGenerator), - ) - } - } - return {declaredNames, document} -} - -/** - * Recursively drops `_key`: a declared type's default renderer doesn't - * encode keys in markdown, so `markdownToPortableText` assigns fresh ones - * on reparse. Comparing gated round trips for content identity means - * comparing with keys erased; the two dedicated contract tests already pin - * exact key preservation for the fence path. - */ -function stripKeys(value: unknown): unknown { - if (Array.isArray(value)) { - return value.map(stripKeys) - } - if (value && typeof value === 'object') { - return Object.fromEntries( - Object.entries(value) - .filter(([key]) => key !== '_key') - .sort(([a], [b]) => a.localeCompare(b)) - .map(([key, nested]) => [key, stripKeys(nested)]), - ) - } - return value -} - -describe('portableTextToMarkdown schema-gated fuzz (seeded, deterministic)', () => { - test( - 'PT -> MD -> PT content identity under a schema, modulo keys', - {timeout: 30_000}, - () => { - const failures: Array<{ - seed: number - caseIndex: number - generated: GeneratedGatedCase - markdown: string - actual: unknown - }> = [] - - for (const seed of GATED_SEEDS) { - const random = mulberry32(seed) - for (let caseIndex = 0; caseIndex < GATED_CASES_PER_SEED; caseIndex++) { - const buildKeyGenerator = createTestKeyGenerator() - const generated = generateGatedCase(random, buildKeyGenerator) - const schema = compileSchema( - defineSchema({ - blockObjects: generated.declaredNames.map( - (name) => GATED_TYPE_DEFINITIONS[name], - ), - }), - ) - - let markdown: string - try { - markdown = portableTextToMarkdown(generated.document, {schema}) - } catch (error) { - failures.push({ - seed, - caseIndex, - generated, - markdown: ``, - actual: '', - }) - continue - } - - let actual: unknown - try { - actual = markdownToPortableText(markdown, { - schema, - keyGenerator: createTestKeyGenerator(), - }) - } catch (error) { - failures.push({ - seed, - caseIndex, - generated, - markdown, - actual: ``, - }) - continue - } - - const expected = stripKeys(generated.document) - if (JSON.stringify(stripKeys(actual)) !== JSON.stringify(expected)) { - failures.push({seed, caseIndex, generated, markdown, actual}) - } - } - } - - if (failures.length > 0) { - const sample = failures - .slice(0, 5) - .map( - (failure) => - `seed ${failure.seed} case ${failure.caseIndex}: declared ${JSON.stringify(failure.generated.declaredNames)}, document ${JSON.stringify(failure.generated.document)} -> markdown ${JSON.stringify(failure.markdown)} -> got ${JSON.stringify(failure.actual)}`, - ) - .join('\n') - expect.fail( - `${failures.length} of ${GATED_SEEDS.length * GATED_CASES_PER_SEED} gated fuzz cases failed content identity:\n${sample}`, - ) - } - }, - ) -}) diff --git a/packages/markdown/src/portable-text-to-markdown.test.ts b/packages/markdown/src/portable-text-to-markdown.test.ts deleted file mode 100644 index f44135c3c4..0000000000 --- a/packages/markdown/src/portable-text-to-markdown.test.ts +++ /dev/null @@ -1,9145 +0,0 @@ -import { - compileSchema, - defineSchema, - type BlockObjectDefinition, -} from '@portabletext/schema' -import {createTestKeyGenerator} from '@portabletext/test' -import { - isPortableTextBlock, - isPortableTextListItemBlock, - isPortableTextSpan, -} from '@portabletext/toolkit' -import type { - ArbitraryTypedObject, - PortableTextBlock, - TypedObject, -} from '@portabletext/types' -import {describe, expect, test, vi} from 'vitest' -import { - defaultCalloutObjectDefinition, - defaultCodeObjectDefinition, - defaultHorizontalRuleObjectDefinition, - defaultHtmlObjectDefinition, - defaultImageObjectDefinition, - defaultSchema, - defaultTableObjectDefinition, -} from './default-schema' -import type {SerializeDegradation} from './from-portable-text/degradation-report' -import {portableTextToMarkdown} from './from-portable-text/portable-text-to-markdown' -import {DefaultListItemRenderer} from './from-portable-text/renderers/list-item' -import { - DefaultBlockquoteObjectRenderer, - DefaultCodeBlockRenderer, - DefaultListRenderer, - DefaultTableRenderer, -} from './from-portable-text/renderers/type' -import {markdownToPortableText} from './to-portable-text/markdown-to-portable-text' -import {buildObjectMatcher} from './to-portable-text/matchers' - -describe(portableTextToMarkdown.name, () => { - test('empty array', () => { - const markdown = '' - const portableText = markdownToPortableText(markdown) - expect(portableTextToMarkdown(portableText)).toBe(markdown) - }) - - describe('custom block name', () => { - test('numbered list starting deeper than level 1', () => { - const keyGenerator = createTestKeyGenerator() - - expect( - portableTextToMarkdown([ - { - _type: 'myBlock', - _key: keyGenerator(), - style: 'normal', - listItem: 'number', - level: 3, - markDefs: [], - children: [ - {_type: 'span', _key: keyGenerator(), text: 'foo', marks: []}, - ], - }, - { - _type: 'myBlock', - _key: keyGenerator(), - style: 'normal', - listItem: 'number', - level: 3, - markDefs: [], - children: [ - {_type: 'span', _key: keyGenerator(), text: 'bar', marks: []}, - ], - }, - { - _type: 'myBlock', - _key: keyGenerator(), - style: 'normal', - listItem: 'number', - level: 4, - markDefs: [], - children: [ - {_type: 'span', _key: keyGenerator(), text: 'baz', marks: []}, - ], - }, - ]), - ).toBe('1. foo\n2. bar\n 1. baz') - }) - - test('callout content in the blockquote style', () => { - const keyGenerator = createTestKeyGenerator() - - expect( - portableTextToMarkdown([ - { - _type: 'callout', - _key: keyGenerator(), - tone: 'note', - content: [ - { - _type: 'myBlock', - _key: keyGenerator(), - style: 'blockquote', - markDefs: [], - children: [ - { - _type: 'span', - _key: keyGenerator(), - text: 'foo', - marks: [], - }, - ], - }, - ], - }, - ]), - ).toBe('> [!NOTE]\n> foo') - }) - - test('structured blockquote content in the blockquote style', () => { - const keyGenerator = createTestKeyGenerator() - - expect( - portableTextToMarkdown( - [ - { - _type: 'blockquote', - _key: keyGenerator(), - content: [ - { - _type: 'myBlock', - _key: keyGenerator(), - style: 'blockquote', - markDefs: [], - children: [ - { - _type: 'span', - _key: keyGenerator(), - text: 'foo', - marks: [], - }, - ], - }, - ], - }, - ], - {types: {blockquote: DefaultBlockquoteObjectRenderer}}, - ), - ).toBe('> foo') - }) - - test('an empty line between hard breaks uses a backslash break', () => { - const keyGenerator = createTestKeyGenerator() - - expect( - portableTextToMarkdown([ - { - _type: 'myBlock', - _key: keyGenerator(), - style: 'blockquote', - markDefs: [], - children: [ - { - _type: 'span', - _key: keyGenerator(), - text: 'foo\n\nbar', - marks: [], - }, - ], - }, - ]), - ).toBe('> foo \n> \\\n> bar') - }) - }) - - describe('paragraph', () => { - test('one paragraph', () => { - const markdown = 'foo' - const portableText = markdownToPortableText(markdown) - expect(portableTextToMarkdown(portableText)).toBe(markdown) - }) - - test('one paragraph with hard breaks', () => { - const markdown = 'foo \nbar \nbaz' - const portableText = markdownToPortableText(markdown) - expect(portableTextToMarkdown(portableText)).toBe(markdown) - }) - - test('multiple lines', () => { - const markdown = ['foo', '', 'bar', '', 'baz'].join('') - const portableText = markdownToPortableText(markdown) - expect(portableTextToMarkdown(portableText)).toBe(markdown) - }) - }) - - describe('block spacing', () => { - const markdown = ['foo', '', 'bar', '', 'baz', '', '- fizz', '- buzz'].join( - '\n', - ) - const portableText = markdownToPortableText(markdown) - - test('default', () => { - expect(portableTextToMarkdown(portableText)).toBe(markdown) - }) - - test('custom list item spacing', () => { - expect( - portableTextToMarkdown(portableText, { - blockSpacing: ({current, next}) => { - if ( - isPortableTextListItemBlock(current) && - isPortableTextListItemBlock(next) - ) { - return '\n\n' - } - - return undefined - }, - }), - ).toBe( - ['foo', '', 'bar', '', 'baz', '', '- fizz', '', '- buzz'].join('\n'), - ) - }) - - test('custom blockquote spacing', () => { - const markdown = ['foo', '', '> bar', '>', '> baz', '', 'fizz'].join('\n') - const portableText = markdownToPortableText(markdown) - expect( - portableTextToMarkdown(portableText, { - blockSpacing: ({current, next}) => { - if ( - isPortableTextBlock(current) && - isPortableTextBlock(next) && - current.style === 'blockquote' && - next.style === 'blockquote' - ) { - return '\n\n' - } - - return undefined - }, - }), - ).toBe(['foo', '', '> bar', '', '> baz', '', 'fizz'].join('\n')) - }) - - test('an empty text block emits no blank lines', () => { - const keyGenerator = createTestKeyGenerator() - const portableText = [ - ...markdownToPortableText('# foo', {keyGenerator}), - { - _key: keyGenerator(), - _type: 'block', - style: 'normal', - markDefs: [], - children: [ - {_key: keyGenerator(), _type: 'span', text: '', marks: []}, - ], - }, - ...markdownToPortableText('bar', {keyGenerator}), - ] - - expect(portableTextToMarkdown(portableText)).toEqual('# foo\n\nbar') - }) - - test('a whitespace-only text block emits no blank lines', () => { - const keyGenerator = createTestKeyGenerator() - const portableText = [ - ...markdownToPortableText('# foo', {keyGenerator}), - { - _key: keyGenerator(), - _type: 'block', - style: 'normal', - markDefs: [], - children: [ - {_key: keyGenerator(), _type: 'span', text: ' ', marks: []}, - ], - }, - ...markdownToPortableText('bar', {keyGenerator}), - ] - - expect(portableTextToMarkdown(portableText)).toEqual('# foo\n\nbar') - }) - - test('an empty block between blockquote-style blocks keeps them intact', () => { - const stored = [ - { - _type: 'block', - _key: 'q1', - style: 'blockquote', - markDefs: [], - children: [{_type: 'span', _key: 's1', text: 'quote one', marks: []}], - }, - { - _type: 'block', - _key: 'e1', - style: 'normal', - markDefs: [], - children: [{_type: 'span', _key: 'es1', text: '', marks: []}], - }, - { - _type: 'block', - _key: 'q2', - style: 'blockquote', - markDefs: [], - children: [{_type: 'span', _key: 's2', text: 'quote two', marks: []}], - }, - ] - const markdown = portableTextToMarkdown(stored) - expect(markdown).toEqual('> quote one\n>\n> quote two') - const keyGenerator = createTestKeyGenerator() - expect(markdownToPortableText(markdown, {keyGenerator})).toEqual([ - { - _type: 'block', - _key: 'k0', - style: 'blockquote', - markDefs: [], - children: [{_type: 'span', _key: 'k1', text: 'quote one', marks: []}], - }, - { - _type: 'block', - _key: 'k2', - style: 'blockquote', - markDefs: [], - children: [{_type: 'span', _key: 'k3', text: 'quote two', marks: []}], - }, - ]) - }) - - test('an empty block between list items joins the list', () => { - expect( - portableTextToMarkdown([ - { - _type: 'block', - _key: 'l1', - style: 'normal', - listItem: 'bullet', - level: 1, - markDefs: [], - children: [{_type: 'span', _key: 's1', text: 'item a', marks: []}], - }, - { - _type: 'block', - _key: 'e1', - style: 'normal', - markDefs: [], - children: [{_type: 'span', _key: 'es1', text: '', marks: []}], - }, - { - _type: 'block', - _key: 'l2', - style: 'normal', - listItem: 'bullet', - level: 1, - markDefs: [], - children: [{_type: 'span', _key: 's2', text: 'item b', marks: []}], - }, - ]), - ).toEqual('- item a\n- item b') - }) - - test('an empty list item keeps its visible marker', () => { - expect( - portableTextToMarkdown([ - { - _type: 'block', - _key: 'l1', - style: 'normal', - listItem: 'bullet', - level: 1, - markDefs: [], - children: [{_type: 'span', _key: 's1', text: 'item a', marks: []}], - }, - { - _type: 'block', - _key: 'l2', - style: 'normal', - listItem: 'bullet', - level: 1, - markDefs: [], - children: [{_type: 'span', _key: 's2', text: '', marks: []}], - }, - { - _type: 'block', - _key: 'l3', - style: 'normal', - listItem: 'bullet', - level: 1, - markDefs: [], - children: [{_type: 'span', _key: 's3', text: 'item c', marks: []}], - }, - ]), - ).toEqual('- item a\n- \n- item c') - }) - - test('blank lines inside a code block are content and survive', () => { - expect( - portableTextToMarkdown([ - { - _type: 'code', - _key: 'c1', - language: 'js', - code: 'one\n\ntwo\n\n\nthree', - }, - ]), - ).toEqual('```js\none\n\ntwo\n\n\nthree\n```') - }) - - test('an empty block next to a code fence emits no blank lines', () => { - expect( - portableTextToMarkdown([ - {_type: 'code', _key: 'c1', language: 'js', code: 'const a = 1'}, - { - _type: 'block', - _key: 'e1', - style: 'normal', - markDefs: [], - children: [{_type: 'span', _key: 'es1', text: '', marks: []}], - }, - { - _type: 'block', - _key: 'b1', - style: 'normal', - markDefs: [], - children: [{_type: 'span', _key: 's1', text: 'after', marks: []}], - }, - ]), - ).toEqual('```js\nconst a = 1\n```\n\nafter') - }) - - test('an empty block inside a callout emits no blank quote lines', () => { - expect( - portableTextToMarkdown([ - { - _type: 'callout', - _key: 'co1', - tone: 'note', - content: [ - { - _type: 'block', - _key: 'c1', - style: 'normal', - markDefs: [], - children: [ - {_type: 'span', _key: 's1', text: 'first', marks: []}, - ], - }, - { - _type: 'block', - _key: 'e1', - style: 'normal', - markDefs: [], - children: [{_type: 'span', _key: 'es1', text: '', marks: []}], - }, - { - _type: 'block', - _key: 'c2', - style: 'normal', - markDefs: [], - children: [ - {_type: 'span', _key: 's2', text: 'second', marks: []}, - ], - }, - ], - }, - ]), - ).toEqual('> [!NOTE]\n> first\n>\n> second') - }) - - test('an empty block inside a structured blockquote emits no blank quote lines', () => { - expect( - portableTextToMarkdown( - [ - { - _type: 'blockquote', - _key: 'bq1', - content: [ - { - _type: 'block', - _key: 'c1', - style: 'normal', - markDefs: [], - children: [ - {_type: 'span', _key: 's1', text: 'first', marks: []}, - ], - }, - { - _type: 'block', - _key: 'e1', - style: 'normal', - markDefs: [], - children: [{_type: 'span', _key: 'es1', text: '', marks: []}], - }, - { - _type: 'block', - _key: 'c2', - style: 'normal', - markDefs: [], - children: [ - {_type: 'span', _key: 's2', text: 'second', marks: []}, - ], - }, - ], - }, - ], - {types: {blockquote: DefaultBlockquoteObjectRenderer}}, - ), - ).toEqual('> first\n>\n> second') - }) - - test('an empty block inside a list item leaves no gap', () => { - expect( - portableTextToMarkdown( - [ - { - _type: 'list', - _key: 'l1', - kind: 'bullet', - items: [ - { - _type: 'list-item', - _key: 'li1', - content: [ - { - _type: 'block', - _key: 'c1', - style: 'normal', - markDefs: [], - children: [ - {_type: 'span', _key: 's1', text: 'item', marks: []}, - ], - }, - { - _type: 'block', - _key: 'e1', - style: 'normal', - markDefs: [], - children: [ - {_type: 'span', _key: 'es1', text: '', marks: []}, - ], - }, - { - _type: 'block', - _key: 'c2', - style: 'normal', - markDefs: [], - children: [ - {_type: 'span', _key: 's2', text: 'more', marks: []}, - ], - }, - ], - }, - ], - }, - ], - {types: {list: DefaultListRenderer}}, - ), - ).toEqual('- item\n\n more') - }) - - test('a leading empty block does not promote a nested list onto the marker line', () => { - expect( - portableTextToMarkdown( - [ - { - _type: 'list', - _key: 'l1', - kind: 'bullet', - items: [ - { - _type: 'list-item', - _key: 'li1', - content: [ - { - _type: 'block', - _key: 'e1', - style: 'normal', - markDefs: [], - children: [ - {_type: 'span', _key: 'es1', text: '', marks: []}, - ], - }, - { - _type: 'list', - _key: 'l2', - kind: 'bullet', - items: [ - { - _type: 'list-item', - _key: 'li2', - content: [ - { - _type: 'block', - _key: 'c1', - style: 'normal', - markDefs: [], - children: [ - { - _type: 'span', - _key: 's1', - text: 'sub', - marks: [], - }, - ], - }, - ], - }, - ], - }, - ], - }, - ], - }, - ], - {types: {list: DefaultListRenderer}}, - ), - ).toEqual('-\n - sub') - }) - - test('a leading empty block promotes the following text block onto the marker line', () => { - expect( - portableTextToMarkdown( - [ - { - _type: 'list', - _key: 'l1', - kind: 'bullet', - items: [ - { - _type: 'list-item', - _key: 'li1', - content: [ - { - _type: 'block', - _key: 'e1', - style: 'normal', - markDefs: [], - children: [ - {_type: 'span', _key: 'es1', text: '', marks: []}, - ], - }, - { - _type: 'block', - _key: 'c1', - style: 'normal', - markDefs: [], - children: [ - {_type: 'span', _key: 's1', text: 'more', marks: []}, - ], - }, - ], - }, - ], - }, - ], - {types: {list: DefaultListRenderer}}, - ), - ).toEqual('- more') - }) - - test('a multi-line block on the marker line keeps its continuation lines inside the item', () => { - expect( - portableTextToMarkdown( - [ - { - _type: 'list', - _key: 'l1', - kind: 'bullet', - items: [ - { - _type: 'list-item', - _key: 'li1', - content: [ - {_type: 'code', _key: 'c1', code: 'foo', language: 'js'}, - ], - }, - { - _type: 'list-item', - _key: 'li2', - content: [ - { - _type: 'block', - _key: 'c2', - style: 'normal', - markDefs: [], - children: [ - {_type: 'span', _key: 's2', text: 'bar', marks: []}, - ], - }, - ], - }, - ], - }, - ], - {types: {list: DefaultListRenderer, code: DefaultCodeBlockRenderer}}, - ), - ).toEqual('- ```js\n foo\n ```\n- bar') - }) - - test('a multi-line block promoted past a leading empty block stays inside the item', () => { - const markdown = portableTextToMarkdown( - [ - { - _type: 'list', - _key: 'l1', - kind: 'bullet', - items: [ - { - _type: 'list-item', - _key: 'li1', - content: [ - { - _type: 'block', - _key: 'e1', - style: 'normal', - markDefs: [], - children: [ - {_type: 'span', _key: 'es1', text: '', marks: []}, - ], - }, - {_type: 'code', _key: 'c1', code: 'foo', language: 'js'}, - ], - }, - ], - }, - ], - {types: {list: DefaultListRenderer, code: DefaultCodeBlockRenderer}}, - ) - expect(markdown).toEqual('- ```js\n foo\n ```') - - // The flat form of "an item whose content is a code block": the - // `listItem` block carries the item, the code block follows it, the - // same shape `- intro` plus an indented fence parses to. - const keyGenerator = createTestKeyGenerator() - expect(markdownToPortableText(markdown, {keyGenerator})).toEqual([ - { - _type: 'block', - _key: 'k0', - style: 'normal', - markDefs: [], - children: [{_type: 'span', _key: 'k1', text: '', marks: []}], - listItem: 'bullet', - level: 1, - }, - {_type: 'code', _key: 'k2', code: 'foo', language: 'js'}, - ]) - }) - - test('a task item never takes a non-text block onto its checkbox line', () => { - const markdown = portableTextToMarkdown( - [ - { - _type: 'list', - _key: 'l1', - kind: 'task', - items: [ - { - _type: 'list-item', - _key: 'li1', - checked: true, - content: [ - { - _type: 'block', - _key: 'e1', - style: 'normal', - markDefs: [], - children: [ - {_type: 'span', _key: 'es1', text: '', marks: []}, - ], - }, - {_type: 'code', _key: 'c1', code: 'foo', language: 'js'}, - ], - }, - ], - }, - ], - {types: {list: DefaultListRenderer, code: DefaultCodeBlockRenderer}}, - ) - expect(markdown).toEqual('- [x]\n\n ```js\n foo\n ```') - - const keyGenerator = createTestKeyGenerator() - expect(markdownToPortableText(markdown, {keyGenerator})).toEqual([ - { - _type: 'block', - _key: 'k0', - style: 'normal', - markDefs: [], - children: [{_type: 'span', _key: 'k1', text: '[x]', marks: []}], - listItem: 'bullet', - level: 1, - }, - {_type: 'code', _key: 'k2', code: 'foo', language: 'js'}, - ]) - }) - - test('a task item promotes a text block onto its checkbox line', () => { - expect( - portableTextToMarkdown( - [ - { - _type: 'list', - _key: 'l1', - kind: 'task', - items: [ - { - _type: 'list-item', - _key: 'li1', - checked: false, - content: [ - { - _type: 'block', - _key: 'e1', - style: 'normal', - markDefs: [], - children: [ - {_type: 'span', _key: 'es1', text: '', marks: []}, - ], - }, - { - _type: 'block', - _key: 'c1', - style: 'normal', - markDefs: [], - children: [ - {_type: 'span', _key: 's1', text: 'do it', marks: []}, - ], - }, - ], - }, - ], - }, - ], - {types: {list: DefaultListRenderer}}, - ), - ).toEqual('- [ ] do it') - }) - - test('a nested list as the only content of a task item outlives the checkbox', () => { - const markdown = portableTextToMarkdown( - [ - { - _type: 'list', - _key: 'l1', - kind: 'task', - items: [ - { - _type: 'list-item', - _key: 'li1', - checked: true, - content: [ - { - _type: 'list', - _key: 'l2', - kind: 'bullet', - items: [ - { - _type: 'list-item', - _key: 'li2', - content: [ - { - _type: 'block', - _key: 'c1', - style: 'normal', - markDefs: [], - children: [ - { - _type: 'span', - _key: 's1', - text: 'sub', - marks: [], - }, - ], - }, - ], - }, - ], - }, - ], - }, - ], - }, - ], - {types: {list: DefaultListRenderer}}, - ) - // The nested list's structure wins over the checkbox: `- [x] - sub` - // would reparse with the whole nested list flattened into the task - // item's text, while the bare checkbox merely reparses as literal - // `[x]` text on a plain bullet. - expect(markdown).toEqual('- [x]\n - sub') - - const keyGenerator = createTestKeyGenerator() - expect(markdownToPortableText(markdown, {keyGenerator})).toEqual([ - { - _type: 'block', - _key: 'k0', - style: 'normal', - markDefs: [], - children: [{_type: 'span', _key: 'k1', text: '[x]', marks: []}], - listItem: 'bullet', - level: 1, - }, - { - _type: 'block', - _key: 'k2', - style: 'normal', - markDefs: [], - children: [{_type: 'span', _key: 'k3', text: 'sub', marks: []}], - listItem: 'bullet', - level: 2, - }, - ]) - }) - - test('a nested list as the first content of an item stays below a bare marker', () => { - const markdown = portableTextToMarkdown( - [ - { - _type: 'list', - _key: 'l1', - kind: 'bullet', - items: [ - { - _type: 'list-item', - _key: 'li1', - content: [ - { - _type: 'list', - _key: 'l2', - kind: 'bullet', - items: [ - { - _type: 'list-item', - _key: 'li2', - content: [ - { - _type: 'block', - _key: 'c1', - style: 'normal', - markDefs: [], - children: [ - { - _type: 'span', - _key: 's1', - text: 'sub', - marks: [], - }, - ], - }, - ], - }, - ], - }, - ], - }, - ], - }, - ], - {types: {list: DefaultListRenderer}}, - ) - expect(markdown).toEqual('-\n - sub') - - const keyGenerator = createTestKeyGenerator() - expect(markdownToPortableText(markdown, {keyGenerator})).toEqual([ - { - _type: 'block', - _key: 'k0', - style: 'normal', - markDefs: [], - children: [{_type: 'span', _key: 'k1', text: '', marks: []}], - listItem: 'bullet', - level: 1, - }, - { - _type: 'block', - _key: 'k2', - style: 'normal', - markDefs: [], - children: [{_type: 'span', _key: 'k3', text: 'sub', marks: []}], - listItem: 'bullet', - level: 2, - }, - ]) - }) - - test('a hard break in the marker-line block keeps its trailing spaces', () => { - const markdown = portableTextToMarkdown( - [ - { - _type: 'list', - _key: 'l1', - kind: 'bullet', - items: [ - { - _type: 'list-item', - _key: 'li1', - content: [ - { - _type: 'block', - _key: 'c1', - style: 'normal', - markDefs: [], - children: [ - { - _type: 'span', - _key: 's1', - text: 'one\ntwo', - marks: [], - }, - ], - }, - ], - }, - ], - }, - ], - {types: {list: DefaultListRenderer}}, - ) - expect(markdown).toEqual('- one \n two') - - const keyGenerator = createTestKeyGenerator() - expect(markdownToPortableText(markdown, {keyGenerator})).toEqual([ - { - _type: 'block', - _key: 'k0', - style: 'normal', - markDefs: [], - children: [{_type: 'span', _key: 'k1', text: 'one\ntwo', marks: []}], - listItem: 'bullet', - level: 1, - }, - ]) - }) - - test('empty blocks do not make a list loose', () => { - expect( - portableTextToMarkdown( - [ - { - _type: 'list', - _key: 'l1', - kind: 'bullet', - items: [ - { - _type: 'list-item', - _key: 'li1', - content: [ - { - _type: 'block', - _key: 'c1', - style: 'normal', - markDefs: [], - children: [ - {_type: 'span', _key: 's1', text: 'foo', marks: []}, - ], - }, - { - _type: 'block', - _key: 'e1', - style: 'normal', - markDefs: [], - children: [ - {_type: 'span', _key: 'es1', text: '', marks: []}, - ], - }, - ], - }, - { - _type: 'list-item', - _key: 'li2', - content: [ - { - _type: 'block', - _key: 'c2', - style: 'normal', - markDefs: [], - children: [ - {_type: 'span', _key: 's2', text: 'bar', marks: []}, - ], - }, - { - _type: 'block', - _key: 'e2', - style: 'normal', - markDefs: [], - children: [ - {_type: 'span', _key: 'es2', text: '', marks: []}, - ], - }, - ], - }, - ], - }, - ], - {types: {list: DefaultListRenderer}}, - ), - ).toEqual('- foo\n- bar') - }) - - test('a custom renderer returning an empty string leaves no residue', () => { - const keyGenerator = createTestKeyGenerator() - const portableText = [ - ...markdownToPortableText('first', {keyGenerator}), - {_key: keyGenerator(), _type: 'x'}, - ...markdownToPortableText('second', {keyGenerator}), - ] - - expect( - portableTextToMarkdown(portableText, {types: {x: () => ''}}), - ).toEqual('first\n\nsecond') - }) - - test('the empty-block fixture reparses to just the two non-empty blocks', () => { - const keyGenerator = createTestKeyGenerator() - const portableText = [ - ...markdownToPortableText('# foo', {keyGenerator}), - { - _key: keyGenerator(), - _type: 'block', - style: 'normal', - markDefs: [], - children: [ - {_key: keyGenerator(), _type: 'span', text: '', marks: []}, - ], - }, - ...markdownToPortableText('bar', {keyGenerator}), - ] - const markdown = portableTextToMarkdown(portableText) - - const reparseKeyGenerator = createTestKeyGenerator() - const expectedKeyGenerator = createTestKeyGenerator() - expect( - markdownToPortableText(markdown, {keyGenerator: reparseKeyGenerator}), - ).toEqual([ - { - _key: expectedKeyGenerator(), - _type: 'block', - style: 'h1', - markDefs: [], - children: [ - { - _key: expectedKeyGenerator(), - _type: 'span', - text: 'foo', - marks: [], - }, - ], - }, - { - _key: expectedKeyGenerator(), - _type: 'block', - style: 'normal', - markDefs: [], - children: [ - { - _key: expectedKeyGenerator(), - _type: 'span', - text: 'bar', - marks: [], - }, - ], - }, - ]) - }) - }) - - describe('decorators', () => { - describe('strong', () => { - const markdown = 'foo **bar** baz' - const portableText = markdownToPortableText(markdown) - - test('default renderer', () => { - expect(portableTextToMarkdown(portableText)).toBe(markdown) - }) - - test('custom renderer', () => { - expect( - portableTextToMarkdown(portableText, { - marks: { - strong: ({children}) => `*${children}*`, - }, - }), - ).toBe('foo *bar* baz') - }) - }) - - describe('emphasis', () => { - const markdown = 'foo _bar_ baz' - const portableText = markdownToPortableText(markdown) - - test('default renderer', () => { - expect(portableTextToMarkdown(portableText)).toBe(markdown) - }) - }) - - describe('strike-through', () => { - const markdown = 'foo ~~bar~~ baz' - const portableText = markdownToPortableText(markdown) - - test('default renderer', () => { - expect(portableTextToMarkdown(portableText)).toBe(markdown) - }) - }) - - describe('underline', () => { - const markdown = 'foo bar baz' - const portableText = markdownToPortableText(markdown) - - test('default renderer', () => { - expect(portableTextToMarkdown(portableText)).toBe(markdown) - }) - - test('round-trips back to portable text with the underline', () => { - const keyGenerator = createTestKeyGenerator() - const stored = [ - { - _type: 'block', - _key: 'b1', - style: 'normal', - markDefs: [], - children: [ - {_type: 'span', _key: 's1', text: 'foo ', marks: []}, - {_type: 'span', _key: 's2', text: 'bar', marks: ['underline']}, - {_type: 'span', _key: 's3', text: ' baz', marks: []}, - ], - }, - ] - expect( - markdownToPortableText(portableTextToMarkdown(stored), { - keyGenerator, - }), - ).toEqual([ - { - _type: 'block', - _key: 'k0', - style: 'normal', - markDefs: [], - children: [ - {_type: 'span', _key: 'k1', text: 'foo ', marks: []}, - {_type: 'span', _key: 'k2', text: 'bar', marks: ['underline']}, - {_type: 'span', _key: 'k3', text: ' baz', marks: []}, - ], - }, - ]) - }) - }) - }) - - describe('block renderer', () => { - test('a single function renders every block style', () => { - const portableText = markdownToPortableText('# Title\n\npara') - - expect( - portableTextToMarkdown(portableText, { - block: ({value}) => `[${value.style}]`, - }), - ).toBe('[h1]\n\n[normal]') - }) - }) - - describe('link', () => { - const markdown = 'foo [bar](https://example.com) baz' - const portableText = markdownToPortableText(markdown) - - test('default renderer', () => { - expect(portableTextToMarkdown(portableText)).toBe(markdown) - }) - - test('custom renderer', () => { - expect( - portableTextToMarkdown(portableText, { - marks: { - link: ({children, value}) => - `${children}`, - }, - }), - ).toBe('foo bar baz') - }) - - test('with title', () => { - const markdown = 'foo [bar](https://example.com "Link Title") baz' - const portableText = markdownToPortableText(markdown) - expect(portableTextToMarkdown(portableText)).toBe(markdown) - }) - - test('with quote in title', () => { - const keyGenerator = createTestKeyGenerator() - const linkKey = keyGenerator() - const portableText = [ - { - _key: keyGenerator(), - _type: 'block', - children: [ - { - _key: keyGenerator(), - _type: 'span', - text: 'click here', - marks: [linkKey], - }, - ], - style: 'normal', - markDefs: [ - { - _key: linkKey, - _type: 'link', - href: 'https://example.com', - title: 'My "Cool" Page', - }, - ], - }, - ] - expect(portableTextToMarkdown(portableText)).toBe( - '[click here](https://example.com "My \\"Cool\\" Page")', - ) - }) - - test('escaped link', () => { - const markdownIn = 'foo [b\\[ar](https://example.com) baz' - const markdownOut = 'foo [b\\[ar](https://example.com) baz' - const portableText = markdownToPortableText(markdownIn) - expect(portableTextToMarkdown(portableText)).toBe(markdownOut) - }) - - test('link with bracket in text', () => { - const keyGenerator = createTestKeyGenerator() - const linkKey = keyGenerator() - const portableText = [ - { - _key: keyGenerator(), - _type: 'block', - children: [ - { - _key: keyGenerator(), - _type: 'span', - text: 'foo ', - marks: [], - }, - { - _key: keyGenerator(), - _type: 'span', - text: 'b[ar', - marks: [linkKey], - }, - { - _key: keyGenerator(), - _type: 'span', - text: ' baz', - marks: [], - }, - ], - style: 'normal', - markDefs: [ - { - _key: linkKey, - _type: 'link', - href: 'https://example.com', - }, - ], - }, - ] - expect(portableTextToMarkdown(portableText)).toBe( - 'foo [b\\[ar](https://example.com) baz', - ) - }) - - test('link with bracket in text and link', () => { - const keyGenerator = createTestKeyGenerator() - const linkKey = keyGenerator() - const portableText = [ - { - _type: 'block', - _key: keyGenerator(), - style: 'normal', - children: [ - { - _type: 'span', - _key: keyGenerator(), - text: 'foo ', - marks: [], - }, - { - _type: 'span', - _key: keyGenerator(), - text: 'b[ar', - marks: [linkKey], - }, - { - _type: 'span', - _key: keyGenerator(), - text: ' baz', - marks: [], - }, - ], - markDefs: [ - { - _key: linkKey, - _type: 'link', - href: 'https://en.wikipedia.org/wiki/Antenna_(radio)', - }, - ], - }, - ] - expect(portableTextToMarkdown(portableText)).toBe( - 'foo [b\\[ar](https://en.wikipedia.org/wiki/Antenna_(radio)) baz', - ) - }) - - test('link with backslash in text', () => { - const markdownIn = 'foo [b\\ar](https://example.com) baz' - const markdownOut = 'foo [b\\\\ar](https://example.com) baz' - const portableText = markdownToPortableText(markdownIn) - expect(portableTextToMarkdown(portableText)).toBe(markdownOut) - }) - - test('link with backslash before bracket in text', () => { - const keyGenerator = createTestKeyGenerator() - const linkKey = keyGenerator() - const portableText = [ - { - _key: keyGenerator(), - _type: 'block', - children: [ - { - _key: keyGenerator(), - _type: 'span', - text: 'foo ', - marks: [], - }, - { - _key: keyGenerator(), - _type: 'span', - text: 'b\\]ar', - marks: [linkKey], - }, - { - _key: keyGenerator(), - _type: 'span', - text: ' baz', - marks: [], - }, - ], - style: 'normal', - markDefs: [ - { - _key: linkKey, - _type: 'link', - href: 'https://example.com', - }, - ], - }, - ] - // Should produce: click [a\\\]b](https://example.com) here - // But currently produces: click [a\\]b](https://example.com) here - // which parses as link text "a\" with "b](..." outside the link - expect(portableTextToMarkdown(portableText)).toBe( - 'foo [b\\\\\\]ar](https://example.com) baz', - ) - }) - - test('link label containing "](" round-trips instead of double-escaping into a lost label and destination', () => { - // The label's own `]` is already escaped once by - // `escapeLinkLabelBrackets`; the line-level `]`-before-`(` rule must - // skip it too, or the second backslash reopens the label early on - // reparse. - const inKeys = createTestKeyGenerator() - const linkKey = inKeys() - const portableText = [ - { - _type: 'block', - _key: inKeys(), - style: 'normal', - children: [ - {_type: 'span', _key: inKeys(), text: 'a](b', marks: [linkKey]}, - ], - markDefs: [ - {_key: linkKey, _type: 'link', href: 'https://example.com'}, - ], - }, - ] - - const markdown = portableTextToMarkdown(portableText) - expect(markdown).toBe('[a\\](b](https://example.com)') - - const outKeys = createTestKeyGenerator() - const outBlockKey = outKeys() - const outLinkKey = outKeys() - const outSpanKey = outKeys() - const reparsed = markdownToPortableText(markdown, { - keyGenerator: createTestKeyGenerator(), - }) - expect(reparsed).toEqual([ - { - _type: 'block', - _key: outBlockKey, - style: 'normal', - markDefs: [ - {_key: outLinkKey, _type: 'link', href: 'https://example.com'}, - ], - children: [ - { - _type: 'span', - _key: outSpanKey, - text: 'a](b', - marks: [outLinkKey], - }, - ], - }, - ]) - }) - }) - - describe('hard breaks', () => { - test('default', () => { - const markdown = 'foo \nbar \nbaz' - const portableText = markdownToPortableText(markdown) - expect(portableTextToMarkdown(portableText)).toBe(markdown) - }) - - test('trailing newline', () => { - const markdownIn = 'foo\n' - const markdownOut = 'foo' - const portableText = markdownToPortableText(markdownIn) - expect(portableTextToMarkdown(portableText)).toBe(markdownOut) - }) - - test('custom renderer', () => { - const markdownIn = 'foo \nbar \nbaz' - const markdownOut = 'foo
bar
baz' - const portableText = markdownToPortableText(markdownIn) - expect( - portableTextToMarkdown(portableText, { - hardBreak: () => '
', - }), - ).toBe(markdownOut) - }) - - test('from explicit PT with newline in span', () => { - const portableText = [ - { - _type: 'block', - _key: 'k0', - style: 'normal', - children: [{_type: 'span', _key: 'k1', text: 'foo\nbar', marks: []}], - markDefs: [], - }, - ] - expect(portableTextToMarkdown(portableText)).toBe('foo \nbar') - }) - - test('an emphasis run spanning a custom hard break with no newline of its own round-trips instead of turning into markup', () => { - // `
` carries no newline: the leaves on either side land on the - // same rendered line, so the two `_` can pair up into an `em` on - // reparse unless the plan walls the break off instead of treating it - // as a real line boundary. - const portableText = [ - { - _type: 'block', - _key: 'k0', - style: 'normal', - children: [{_type: 'span', _key: 'k1', text: 'a _\n_ b', marks: []}], - markDefs: [], - }, - ] - - const markdown = portableTextToMarkdown(portableText, { - hardBreak: () => '
', - }) - expect(markdown).toBe('a \\_
\\_ b') - - const reparsed = markdownToPortableText(markdown, { - html: {inline: 'text'}, - }) - const block = reparsed.at(0) - if (!block || !isPortableTextBlock(block)) { - throw new Error('Expected the first node to be a portable text block') - } - expect( - block.children - .filter(isPortableTextSpan) - .map((span) => span.text) - .join(''), - ).toBe('a _
_ b') - }) - - test('the same underscore shape stays unescaped and round-trips with the default hard break, unaffected by the sentinel fix', () => { - // markdown-it itself never pairs a delimiter run across a `hardbreak` - // token, so the default renderer (a real newline) never needed - // escaping here; the sentinel only changes behavior for a renderer - // whose hard-break output carries no newline. - const portableText = [ - { - _type: 'block', - _key: 'k0', - style: 'normal', - children: [{_type: 'span', _key: 'k1', text: 'a _\n_ b', marks: []}], - markDefs: [], - }, - ] - - const markdown = portableTextToMarkdown(portableText) - expect(markdown).toBe('a _ \n_ b') - - const reparsed = markdownToPortableText(markdown) - const block = reparsed.at(0) - if (!block || !isPortableTextBlock(block)) { - throw new Error('Expected the first node to be a portable text block') - } - expect( - block.children - .filter(isPortableTextSpan) - .map((span) => span.text) - .join(''), - ).toBe('a _\n_ b') - expect( - block.children.every( - (child) => !isPortableTextSpan(child) || !child.marks?.includes('em'), - ), - ).toBe(true) - }) - - test('multiple hard breaks from explicit PT', () => { - const portableText = [ - { - _type: 'block', - _key: 'k0', - style: 'normal', - children: [ - {_type: 'span', _key: 'k1', text: 'foo\nbar\nbaz', marks: []}, - ], - markDefs: [], - }, - ] - expect(portableTextToMarkdown(portableText)).toBe('foo \nbar \nbaz') - }) - - test('hard break in list item from explicit PT', () => { - const portableText = [ - { - _type: 'block', - _key: 'k0', - style: 'normal', - listItem: 'bullet', - level: 1, - children: [{_type: 'span', _key: 'k1', text: 'foo\nbar', marks: []}], - markDefs: [], - }, - ] - expect(portableTextToMarkdown(portableText)).toBe('- foo \nbar') - }) - - test('an empty line between hard breaks uses a backslash break', () => { - const keyGenerator = createTestKeyGenerator() - const portableText = [ - { - _type: 'block', - _key: keyGenerator(), - style: 'normal', - children: [ - { - _type: 'span', - _key: keyGenerator(), - text: 'foo\n\nbar', - marks: [], - }, - ], - markDefs: [], - }, - ] - expect(portableTextToMarkdown(portableText)).toBe('foo \n\\\nbar') - }) - - test('two empty lines between hard breaks use a backslash break each', () => { - const keyGenerator = createTestKeyGenerator() - const portableText = [ - { - _type: 'block', - _key: keyGenerator(), - style: 'normal', - children: [ - { - _type: 'span', - _key: keyGenerator(), - text: 'foo\n\n\nbar', - marks: [], - }, - ], - markDefs: [], - }, - ] - expect(portableTextToMarkdown(portableText)).toBe('foo \n\\\n\\\nbar') - }) - - test('an empty line between hard breaks in a blockquote uses a backslash break', () => { - const keyGenerator = createTestKeyGenerator() - const portableText = [ - { - _type: 'block', - _key: keyGenerator(), - style: 'blockquote', - children: [ - { - _type: 'span', - _key: keyGenerator(), - text: 'foo\n\nbar', - marks: [], - }, - ], - markDefs: [], - }, - ] - expect(portableTextToMarkdown(portableText)).toBe('> foo \n> \\\n> bar') - }) - - test('an empty line between hard breaks in a list item uses a backslash break', () => { - const keyGenerator = createTestKeyGenerator() - const portableText = [ - { - _type: 'block', - _key: keyGenerator(), - style: 'normal', - listItem: 'bullet', - level: 1, - children: [ - { - _type: 'span', - _key: keyGenerator(), - text: 'foo\n\nbar', - marks: [], - }, - ], - markDefs: [], - }, - ] - expect(portableTextToMarkdown(portableText)).toBe('- foo \n\\\nbar') - }) - - test('an empty line between hard breaks in a nested list item keeps the default break', () => { - const keyGenerator = createTestKeyGenerator() - const portableText = [ - { - _type: 'block', - _key: keyGenerator(), - style: 'normal', - listItem: 'bullet', - level: 2, - children: [ - { - _type: 'span', - _key: keyGenerator(), - text: 'foo\n\nbar', - marks: [], - }, - ], - markDefs: [], - }, - ] - expect(portableTextToMarkdown(portableText)).toBe('- foo \n \nbar') - }) - - test('an empty line between hard breaks in a heading keeps the default break', () => { - const keyGenerator = createTestKeyGenerator() - const portableText = [ - { - _type: 'block', - _key: keyGenerator(), - style: 'h3', - children: [ - { - _type: 'span', - _key: keyGenerator(), - text: 'foo\n\nbar', - marks: [], - }, - ], - markDefs: [], - }, - ] - expect(portableTextToMarkdown(portableText)).toBe('### foo \n \nbar') - }) - - test('an empty line between hard breaks keeps a custom hard break', () => { - const keyGenerator = createTestKeyGenerator() - const portableText = [ - { - _type: 'block', - _key: keyGenerator(), - style: 'normal', - children: [ - { - _type: 'span', - _key: keyGenerator(), - text: 'foo\n\nbar', - marks: [], - }, - ], - markDefs: [], - }, - ] - expect( - portableTextToMarkdown(portableText, {hardBreak: () => ' \n'}), - ).toBe('foo \n \nbar') - }) - - test('an empty line between hard breaks keeps the default break with a custom block spacing', () => { - const keyGenerator = createTestKeyGenerator() - const portableText = [ - { - _type: 'block', - _key: keyGenerator(), - style: 'normal', - children: [ - { - _type: 'span', - _key: keyGenerator(), - text: 'foo\n\nbar', - marks: [], - }, - ], - markDefs: [], - }, - { - _type: 'block', - _key: keyGenerator(), - style: 'normal', - children: [ - {_type: 'span', _key: keyGenerator(), text: 'baz', marks: []}, - ], - markDefs: [], - }, - ] - expect( - portableTextToMarkdown(portableText, {blockSpacing: () => ''}), - ).toBe('foo \n \nbarbaz') - }) - - test('an empty line between hard breaks keeps the default break when strong delimiters turn the lines into thematic breaks', () => { - const keyGenerator = createTestKeyGenerator() - const portableText = [ - { - _type: 'block', - _key: keyGenerator(), - style: 'normal', - children: [ - { - _type: 'span', - _key: keyGenerator(), - text: '**\n\n**', - marks: ['strong'], - }, - ], - markDefs: [], - }, - ] - expect(portableTextToMarkdown(portableText)).toBe('**** \n \n****') - }) - - test('an empty line between hard breaks keeps the default break when a closing strong delimiter turns the last line into a thematic break', () => { - const keyGenerator = createTestKeyGenerator() - const portableText = [ - { - _type: 'block', - _key: keyGenerator(), - style: 'normal', - children: [ - { - _type: 'span', - _key: keyGenerator(), - text: 'foo\n\n**', - marks: ['strong'], - }, - ], - markDefs: [], - }, - ] - expect(portableTextToMarkdown(portableText)).toBe('**foo \n \n****') - }) - - test('an empty line between hard breaks keeps the default break when an empty strike-through span opens a fence', () => { - const keyGenerator = createTestKeyGenerator() - const portableText = [ - { - _type: 'block', - _key: keyGenerator(), - style: 'normal', - children: [ - {_type: 'span', _key: keyGenerator(), text: 'foo\n\n', marks: []}, - { - _type: 'span', - _key: keyGenerator(), - text: '', - marks: ['strike-through'], - }, - ], - markDefs: [], - }, - ] - expect(portableTextToMarkdown(portableText)).toBe('foo \n \n~~~~') - }) - - test('an empty line between hard breaks keeps the default break when a leading underline tag opens an HTML block', () => { - const keyGenerator = createTestKeyGenerator() - const portableText = [ - { - _type: 'block', - _key: keyGenerator(), - style: 'normal', - children: [ - { - _type: 'span', - _key: keyGenerator(), - text: '\nfoo\n\nbar', - marks: ['underline'], - }, - ], - markDefs: [], - }, - ] - expect(portableTextToMarkdown(portableText)).toBe( - ' \nfoo \n \nbar', - ) - }) - - test('an empty line between hard breaks keeps the default break when a leading empty strike-through span opens a fence', () => { - const keyGenerator = createTestKeyGenerator() - const portableText = [ - { - _type: 'block', - _key: keyGenerator(), - style: 'normal', - children: [ - { - _type: 'span', - _key: keyGenerator(), - text: '', - marks: ['strike-through'], - }, - { - _type: 'span', - _key: keyGenerator(), - text: 'foo\n\nbar', - marks: [], - }, - ], - markDefs: [], - }, - ] - expect(portableTextToMarkdown(portableText)).toBe('~~~~foo \n \nbar') - }) - - test('an empty line between hard breaks keeps the default break inside an HTML block the previous block leaves open', () => { - const keyGenerator = createTestKeyGenerator() - const portableText = [ - {_type: 'html', _key: keyGenerator(), html: '
'},
-        {
-          _type: 'block',
-          _key: keyGenerator(),
-          style: 'normal',
-          children: [
-            {
-              _type: 'span',
-              _key: keyGenerator(),
-              text: 'foo\n\nbar',
-              marks: [],
-            },
-          ],
-          markDefs: [],
-        },
-      ]
-      expect(portableTextToMarkdown(portableText)).toBe(
-        '
\n\nfoo  \n  \nbar',
-      )
-    })
-
-    test('an empty line between hard breaks keeps the default break inside an HTML comment the previous block leaves open', () => {
-      const keyGenerator = createTestKeyGenerator()
-      const portableText = [
-        {_type: 'html', _key: keyGenerator(), html: '