Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
20 changes: 10 additions & 10 deletions third_party/muya/src/assets/styles/inlineSyntax.css
Original file line number Diff line number Diff line change
Expand Up @@ -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;

Expand All @@ -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;
}

Expand Down Expand Up @@ -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;

Expand Down
Original file line number Diff line number Diff line change
@@ -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<typeof tokenizer>[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);
});
});
7 changes: 6 additions & 1 deletion third_party/muya/src/inlineRenderer/renderer/image.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 `<span>`, not an `<a>`: these hover controls carry no href, and an `<a>`
// 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 `<a>`, hoisting the image out of the link
// (#4865).
const selector = `span.${className}`;
const iconVnode = h(
'i.icon',
h(
Expand Down
6 changes: 5 additions & 1 deletion third_party/muya/src/inlineRenderer/rules.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
72 changes: 72 additions & 0 deletions third_party/muya/src/selection/__tests__/linkedImageAnchor.spec.ts
Original file line number Diff line number Diff line change
@@ -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<typeof Muya>[1]);
muya.init();
bootedMuyas.push(muya);
return muya;
}

// #4865: the image hover controls used to render as nested `<a>` elements.
// Inside a real anchor (reference link or raw HTML) the illegal `<a>`-in-`<a>`
// 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(`<a href="https://link.example.com"><img src="${src}"></a>`);

const wrapper = muya.domNode.querySelector<HTMLElement>(
`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<HTMLElement>(
`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<HTMLElement>(
`span.${CLASS_NAMES.MU_INLINE_IMAGE}`,
)!;
expect(wrapper).not.toBeNull();
// The loading/fail/close controls are spans now — an `<a>` 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();
});
});
Loading