From 8b4e6f03e5570b0367d934bd32c9a3328156f818 Mon Sep 17 00:00:00 2001 From: SecPan Date: Wed, 8 Jul 2026 14:09:45 -0700 Subject: [PATCH] Port upstream reference linked image anchor fix Ports the rendering substance of marktext/marktext#4866 (#4865): the reference_link tokenizer now captures bracketed link text so [![alt](img)][ref] parses as one reference link wrapping the image, and the inline-image hover controls render as spans instead of anchors, so a linked image no longer breaks its enclosing through illegal nesting. Image icon CSS follows the span controls. The desktop-only modifier-click routing from the upstream chain is not ported (closed with #73); regression specs pin the tokenizer shape and the fixed DOM structure instead. --- .../muya/src/assets/styles/inlineSyntax.css | 20 +++--- .../referenceLinkImageAnchor.spec.ts | 43 +++++++++++ .../muya/src/inlineRenderer/renderer/image.ts | 7 +- third_party/muya/src/inlineRenderer/rules.ts | 6 +- .../__tests__/linkedImageAnchor.spec.ts | 72 +++++++++++++++++++ 5 files changed, 136 insertions(+), 12 deletions(-) create mode 100644 third_party/muya/src/inlineRenderer/__tests__/referenceLinkImageAnchor.spec.ts create mode 100644 third_party/muya/src/selection/__tests__/linkedImageAnchor.spec.ts diff --git a/third_party/muya/src/assets/styles/inlineSyntax.css b/third_party/muya/src/assets/styles/inlineSyntax.css index 0f0c284..c776ae7 100644 --- a/third_party/muya/src/assets/styles/inlineSyntax.css +++ b/third_party/muya/src/assets/styles/inlineSyntax.css @@ -408,9 +408,9 @@ blockquote .mu-hide.mu-math > .mu-math-render { background: transparent; } -.mu-inline-image a.mu-image-icon-success, -.mu-inline-image a.mu-image-icon-fail, -.mu-inline-image a.mu-image-icon-close { +.mu-inline-image .mu-image-icon-success, +.mu-inline-image .mu-image-icon-fail, +.mu-inline-image .mu-image-icon-close { position: absolute; top: 15px; @@ -419,8 +419,8 @@ blockquote .mu-hide.mu-math > .mu-math-render { height: 20px; } -.mu-inline-image a.mu-image-icon-success, -.mu-inline-image a.mu-image-icon-fail { +.mu-inline-image .mu-image-icon-success, +.mu-inline-image .mu-image-icon-fail { left: 15px; } @@ -493,17 +493,17 @@ blockquote .mu-hide.mu-math > .mu-math-render { content: attr(fail-text); } -.mu-inline-image.mu-image-loading a.mu-image-icon-success, -.mu-inline-image.mu-empty-image a.mu-image-icon-success { +.mu-inline-image.mu-image-loading .mu-image-icon-success, +.mu-inline-image.mu-empty-image .mu-image-icon-success { display: block; } -.mu-inline-image.mu-image-fail a.mu-image-icon-fail { +.mu-inline-image.mu-image-fail .mu-image-icon-fail { display: block; } -.mu-inline-image.mu-empty-image:hover a.mu-image-icon-close, -.mu-inline-image.mu-image-fail:hover a.mu-image-icon-close { +.mu-inline-image.mu-empty-image:hover .mu-image-icon-close, +.mu-inline-image.mu-image-fail:hover .mu-image-icon-close { right: 15px; z-index: 1; diff --git a/third_party/muya/src/inlineRenderer/__tests__/referenceLinkImageAnchor.spec.ts b/third_party/muya/src/inlineRenderer/__tests__/referenceLinkImageAnchor.spec.ts new file mode 100644 index 0000000..e4d43f7 --- /dev/null +++ b/third_party/muya/src/inlineRenderer/__tests__/referenceLinkImageAnchor.spec.ts @@ -0,0 +1,43 @@ +import type { ReferenceLinkToken, Token } from '../types'; +import { describe, expect, it } from 'vitest'; +import { tokenizer } from '../lexer'; + +function toks(src: string): Token[] { + const labels = new Map([['ref', { href: 'https://example.com/dst', title: '' }]]); + return tokenizer(src, { labels } as Parameters[1]); +} + +describe('reference link with an image anchor (#4865)', () => { + it('tokenizes `[![alt](img)][ref]` as a single reference_link wrapping the image', () => { + const result = toks('[![alt](https://example.com/badge.svg)][ref]'); + + expect(result).toHaveLength(1); + const link = result[0] as ReferenceLinkToken; + expect(link.type).toBe('reference_link'); + expect(link.isFullLink).toBe(true); + expect(link.label).toBe('ref'); + + expect(link.children).toHaveLength(1); + const image = link.children[0] as Token & { attrs?: { src: string; alt: string } }; + expect(image.type).toBe('image'); + expect(image.attrs?.src).toBe('https://example.com/badge.svg'); + expect(image.attrs?.alt).toBe('alt'); + }); + + it('still tokenizes a plain-text reference link `[text][ref]` unchanged', () => { + const result = toks('[text][ref]'); + + expect(result).toHaveLength(1); + const link = result[0] as ReferenceLinkToken; + expect(link.type).toBe('reference_link'); + expect(link.children).toHaveLength(1); + expect(link.children[0].type).toBe('text'); + }); + + it('does not treat `[![alt](img)][ref]` as a link when the ref is undefined', () => { + const result = tokenizer('[![alt](https://example.com/badge.svg)][missing]'); + + expect(result.some(t => t.type === 'reference_link')).toBe(false); + expect(result.some(t => t.type === 'image')).toBe(true); + }); +}); diff --git a/third_party/muya/src/inlineRenderer/renderer/image.ts b/third_party/muya/src/inlineRenderer/renderer/image.ts index 9286df1..1bdcc93 100644 --- a/third_party/muya/src/inlineRenderer/renderer/image.ts +++ b/third_party/muya/src/inlineRenderer/renderer/image.ts @@ -10,7 +10,12 @@ import { CLASS_NAMES } from '../../config'; import { getImageSrc } from '../../utils/image'; function renderIcon(h: H, className: string, icon: string) { - const selector = `a.${className}`; + // A ``, not an ``: these hover controls carry no href, and an `` + // here nests illegally when the image sits inside a real anchor (e.g. a + // reference-linked image `[![alt](img)][ref]`). The HTML parser closes the + // outer anchor early on the nested ``, hoisting the image out of the link + // (#4865). + const selector = `span.${className}`; const iconVnode = h( 'i.icon', h( diff --git a/third_party/muya/src/inlineRenderer/rules.ts b/third_party/muya/src/inlineRenderer/rules.ts index 0a1f6cd..eee16b1 100644 --- a/third_party/muya/src/inlineRenderer/rules.ts +++ b/third_party/muya/src/inlineRenderer/rules.ts @@ -31,8 +31,12 @@ export const commonMarkRules = { image: /^(!\[)(.*?)(\\*)\]\((.*)(\\*)\)/, // eslint-disable-next-line regexp/no-super-linear-backtracking, regexp/optimal-quantifier-concatenation, regexp/no-misleading-capturing-group link: /^(\[)((?:\[[^\]]*\]|[^[\]]|\](?=[^[]*\]))*?)(\\*)\]\((.*)(\\*)\)/, // can nest + // Link text can hold balanced brackets — notably an image `![alt](src)` — + // so mirror `link`'s nesting-capable anchor group instead of the bracket- + // free `[^\]]+?`, which stopped at the image's inner `]` and broke + // `[![alt](img)][ref]` (#4865). // eslint-disable-next-line regexp/no-super-linear-backtracking - reference_link: /^\[([^\]]+?)(\\*)\](?:\[([^\]]*?)(\\*)\])?/, + reference_link: /^\[((?:\[[^\]]*\]|[^[\]]|\](?=[^[]*\]))*?)(\\*)\](?:\[([^\]]*?)(\\*)\])?/, // eslint-disable-next-line regexp/no-super-linear-backtracking reference_image: /^!\[([^\]]+?)(\\*)\](?:\[([^\]]*?)(\\*)\])?/, html_tag: diff --git a/third_party/muya/src/selection/__tests__/linkedImageAnchor.spec.ts b/third_party/muya/src/selection/__tests__/linkedImageAnchor.spec.ts new file mode 100644 index 0000000..6e3e5b0 --- /dev/null +++ b/third_party/muya/src/selection/__tests__/linkedImageAnchor.spec.ts @@ -0,0 +1,72 @@ +// @vitest-environment jsdom + +import { afterEach, beforeEach, describe, expect, it } from 'vitest'; +import { CLASS_NAMES } from '../../config'; +import { Muya } from '../../muya'; + +const bootedMuyas: Muya[] = []; + +beforeEach(() => { + (window as unknown as { MUYA_VERSION?: string }).MUYA_VERSION = 'test'; +}); + +afterEach(() => { + while (bootedMuyas.length) + bootedMuyas.pop()!.destroy(); + delete (window as unknown as { MUYA_VERSION?: string }).MUYA_VERSION; +}); + +function boot(markdown: string): Muya { + const host = document.createElement('div'); + document.body.appendChild(host); + const muya = new Muya(host, { markdown } as ConstructorParameters[1]); + muya.init(); + bootedMuyas.push(muya); + return muya; +} + +// #4865: the image hover controls used to render as nested `` elements. +// Inside a real anchor (reference link or raw HTML) the illegal ``-in-`` +// nesting made the HTML parser close the outer anchor early, structurally +// hoisting the image out of its link. These specs pin the fixed DOM shape; +// the desktop-only modifier-click routing that upstream also touched is not +// ported (no Android consumer). +describe('linked image stays inside its anchor (#4865)', () => { + it('keeps a raw-HTML linked image inside a.mu-raw-html', () => { + const src = 'https://example.com/pic.png'; + const muya = boot(``); + + const wrapper = muya.domNode.querySelector( + `span.${CLASS_NAMES.MU_INLINE_IMAGE}`, + )!; + expect(wrapper).not.toBeNull(); + expect(wrapper.closest(`a.${CLASS_NAMES.MU_RAW_HTML}`)).not.toBeNull(); + expect(wrapper.querySelector(`.${CLASS_NAMES.MU_IMAGE_CONTAINER}`)).not.toBeNull(); + }); + + it('keeps a reference-linked image inside a.mu-reference-link', () => { + const src = 'https://example.com/badge.svg'; + const muya = boot(`[![alt](${src})][ref]\n\n[ref]: https://link.example.com`); + + const wrapper = muya.domNode.querySelector( + `span.${CLASS_NAMES.MU_INLINE_IMAGE}`, + )!; + expect(wrapper).not.toBeNull(); + expect(wrapper.closest(`a.${CLASS_NAMES.MU_REFERENCE_LINK}`)).not.toBeNull(); + }); + + it('renders no anchor-tagged hover controls inside the image wrapper', () => { + const muya = boot('![alt](https://example.com/pic.png)'); + + const wrapper = muya.domNode.querySelector( + `span.${CLASS_NAMES.MU_INLINE_IMAGE}`, + )!; + expect(wrapper).not.toBeNull(); + // The loading/fail/close controls are spans now — an `` here is what + // used to break outer anchors. + expect(wrapper.querySelector('a.mu-image-icon-success')).toBeNull(); + expect(wrapper.querySelector('a.mu-image-icon-fail')).toBeNull(); + expect(wrapper.querySelector('a.mu-image-icon-close')).toBeNull(); + expect(wrapper.querySelector('span.mu-image-icon-success')).not.toBeNull(); + }); +});