From c04e688368b473d6316fc64c84a5216e1b68d6b2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Gau=C2=B7Beo?= Date: Wed, 11 Nov 2026 16:39:58 +0700 Subject: [PATCH] fix(core): preserve external image URLs in mdx/markdoc and avoid phantom TrackedMap deletions Fixes #1625 Signed-off-by: Xuan Thinh Nguyen --- .changeset/fix-mdx-external-image-urls.md | 5 ++++ packages/keystatic/src/app/useItemData.ts | 4 ++- .../fields/markdoc/editor/markdoc/parse.ts | 26 +++++++++++++---- .../markdoc/editor/markdoc/serialize.ts | 7 +++++ .../form/fields/markdoc/editor/mdx/parse.ts | 22 ++++++++++---- .../fields/markdoc/editor/mdx/serialize.ts | 8 +++++ .../src/form/fields/markdoc/editor/schema.tsx | 29 ++++++++++++++----- .../fields/markdoc/editor/tests/mdx.test.tsx | 6 ++++ 8 files changed, 87 insertions(+), 20 deletions(-) create mode 100644 .changeset/fix-mdx-external-image-urls.md diff --git a/.changeset/fix-mdx-external-image-urls.md b/.changeset/fix-mdx-external-image-urls.md new file mode 100644 index 000000000..a1b935e08 --- /dev/null +++ b/.changeset/fix-mdx-external-image-urls.md @@ -0,0 +1,5 @@ +--- +'@keystatic/core': patch +--- + +Fix `fields.mdx()` and `fields.markdoc()` handling of external `http://` / `https://` image URLs so remote images are preserved on save without corrupting document content or registering phantom local file deletions in `TrackedMap`. diff --git a/packages/keystatic/src/app/useItemData.ts b/packages/keystatic/src/app/useItemData.ts index 48ba8d896..870d270d5 100644 --- a/packages/keystatic/src/app/useItemData.ts +++ b/packages/keystatic/src/app/useItemData.ts @@ -39,7 +39,9 @@ class TrackedMap extends Map { this.#onGet = onGet; } get(key: K) { - this.#onGet(key); + if (super.has(key)) { + this.#onGet(key); + } return super.get(key); } } diff --git a/packages/keystatic/src/form/fields/markdoc/editor/markdoc/parse.ts b/packages/keystatic/src/form/fields/markdoc/editor/markdoc/parse.ts index 6e6f67b25..008e48221 100644 --- a/packages/keystatic/src/form/fields/markdoc/editor/markdoc/parse.ts +++ b/packages/keystatic/src/form/fields/markdoc/editor/markdoc/parse.ts @@ -384,15 +384,29 @@ function markdocNodeToProseMirrorNode( return childrenToProseMirrorNodes(node.children, parentType); } if (node.type === 'image') { + if ( + typeof node.attributes.src === 'string' && + /^(?:https?:)?\/\//i.test(node.attributes.src) && + schema.nodes.image + ) { + return schema.nodes.image.createChecked({ + src: node.attributes.src, + alt: node.attributes.alt, + title: node.attributes.title, + filename: '', + }); + } const prefix = getSrcPrefixForImageBlock(schema.config, getState().slug); const fullFilename = decodeURIComponent(node.attributes.src); const filename = fullFilename.slice(prefix.length); - const content = ( - typeof schema.config.image === 'object' && - typeof schema.config.image.directory === 'string' - ? getState().otherFiles.get(fixPath(schema.config.image.directory)) - : getState().extraFiles - )?.get(filename); + const content = fullFilename.startsWith(prefix) + ? ( + typeof schema.config.image === 'object' && + typeof schema.config.image.directory === 'string' + ? getState().otherFiles.get(fixPath(schema.config.image.directory)) + : getState().extraFiles + )?.get(filename) + : undefined; if (content && schema.nodes.image) { return schema.nodes.image.createChecked({ diff --git a/packages/keystatic/src/form/fields/markdoc/editor/markdoc/serialize.ts b/packages/keystatic/src/form/fields/markdoc/editor/markdoc/serialize.ts index 3831248fd..f3884dadc 100644 --- a/packages/keystatic/src/form/fields/markdoc/editor/markdoc/serialize.ts +++ b/packages/keystatic/src/form/fields/markdoc/editor/markdoc/serialize.ts @@ -52,6 +52,13 @@ function getLeafContent( } if (node.type === schema.nodes.image) { const { src, filename } = node.attrs; + if (typeof src === 'string') { + return new Ast.Node('image', { + src, + alt: node.attrs.alt, + title: node.attrs.title, + }); + } if ( typeof state.schema.config.image === 'object' && diff --git a/packages/keystatic/src/form/fields/markdoc/editor/mdx/parse.ts b/packages/keystatic/src/form/fields/markdoc/editor/mdx/parse.ts index e5e416b70..d9100a752 100644 --- a/packages/keystatic/src/form/fields/markdoc/editor/mdx/parse.ts +++ b/packages/keystatic/src/form/fields/markdoc/editor/mdx/parse.ts @@ -420,15 +420,25 @@ function markdocNodeToProseMirrorNode( ); return null; } + if (/^(?:https?:)?\/\//i.test(data.url) && schema.nodes.image) { + return schema.nodes.image.createChecked({ + src: data.url, + alt: node.alt, + title: data.title, + filename: '', + }); + } const prefix = getSrcPrefixForImageBlock(schema.config, getState().slug); const fullFilename = decodeURI(data.url); const filename = fullFilename.slice(prefix.length); - const content = ( - typeof schema.config.image === 'object' && - typeof schema.config.image.directory === 'string' - ? getState().otherFiles.get(fixPath(schema.config.image.directory)) - : getState().extraFiles - )?.get(filename); + const content = fullFilename.startsWith(prefix) + ? ( + typeof schema.config.image === 'object' && + typeof schema.config.image.directory === 'string' + ? getState().otherFiles.get(fixPath(schema.config.image.directory)) + : getState().extraFiles + )?.get(filename) + : undefined; if (content && schema.nodes.image) { return schema.nodes.image.createChecked({ diff --git a/packages/keystatic/src/form/fields/markdoc/editor/mdx/serialize.ts b/packages/keystatic/src/form/fields/markdoc/editor/mdx/serialize.ts index b596fdcec..cad54df02 100644 --- a/packages/keystatic/src/form/fields/markdoc/editor/mdx/serialize.ts +++ b/packages/keystatic/src/form/fields/markdoc/editor/mdx/serialize.ts @@ -63,6 +63,14 @@ function getLeafContent( } if (node.type === schema.nodes.image) { const { src, filename } = node.attrs; + if (typeof src === 'string') { + return { + type: 'image', + url: src, + alt: node.attrs.alt, + title: node.attrs.title, + }; + } if ( typeof state.schema.config.image === 'object' && diff --git a/packages/keystatic/src/form/fields/markdoc/editor/schema.tsx b/packages/keystatic/src/form/fields/markdoc/editor/schema.tsx index 4f708a8cc..480ede744 100644 --- a/packages/keystatic/src/form/fields/markdoc/editor/schema.tsx +++ b/packages/keystatic/src/form/fields/markdoc/editor/schema.tsx @@ -369,13 +369,16 @@ const nodeSpecs = { }, }, nodeView(node) { - const blob = new Blob([node.attrs.src], { - type: node.attrs.filename.endsWith('.svg') - ? 'image/svg+xml' - : undefined, - }); + const isExternalUrl = typeof node.attrs.src === 'string'; + const blob = isExternalUrl + ? undefined + : new Blob([node.attrs.src], { + type: node.attrs.filename.endsWith('.svg') + ? 'image/svg+xml' + : undefined, + }); const dom = document.createElement('img'); - dom.src = URL.createObjectURL(blob); + dom.src = isExternalUrl ? node.attrs.src : URL.createObjectURL(blob!); dom.alt = node.attrs.alt; dom.title = node.attrs.title; dom.dataset.filename = node.attrs.filename; @@ -395,11 +398,23 @@ const nodeSpecs = { return { dom, destroy() { - URL.revokeObjectURL(dom.src); + if (!isExternalUrl) { + URL.revokeObjectURL(dom.src); + } }, }; }, toDOM(node) { + if (typeof node.attrs.src === 'string') { + return [ + 'img', + { + src: node.attrs.src, + alt: node.attrs.alt, + title: node.attrs.title, + }, + ]; + } return [ 'img', { diff --git a/packages/keystatic/src/form/fields/markdoc/editor/tests/mdx.test.tsx b/packages/keystatic/src/form/fields/markdoc/editor/tests/mdx.test.tsx index a023e2e2b..89d5b5100 100644 --- a/packages/keystatic/src/form/fields/markdoc/editor/tests/mdx.test.tsx +++ b/packages/keystatic/src/form/fields/markdoc/editor/tests/mdx.test.tsx @@ -1067,3 +1067,9 @@ test('ordered list with start', () => { " `); }); + +test('external https:// image URL is preserved on round-trip without escaping or phantom local file registration', () => { + const mdx = `![External diagram](https://example.com/assets/diagram.png)\n`; + const editor = fromMDX(mdx); + expect(toMDX(editor)).toBe(mdx); +});