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
73 changes: 37 additions & 36 deletions dist/lite/markedit-preview.js

Large diffs are not rendered by default.

945 changes: 473 additions & 472 deletions dist/markedit-preview.js

Large diffs are not rendered by default.

52 changes: 52 additions & 0 deletions src/features/anchors.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,52 @@
import type MarkdownIt from 'markdown-it';

export const appAnchorAttribute = 'data-markedit-anchor';

export function headingAnchors(markdown: ReturnType<typeof MarkdownIt>) {
markdown.core.ruler.after('anchor', 'markedit_anchors', state => {
const lines = state.src.split('\n');
state.tokens.forEach((token, index) => {
const inline = state.tokens[index + 1];
if (token.type !== 'heading_open' || inline?.type !== 'inline' || token.map === null) {
return;
}

const sourceLine = lines[token.map[0]];
const title = token.markup.startsWith('#')
? sourceLine.slice(sourceLine.indexOf(token.markup)).replace(/ {0,3}#+ +/, '')
: inline.content.split('\n')[0].trim();
const appAnchor = (title.length > 64 ? title.substring(0, 64) + '...' : title)
.normalize('NFKD')
.trim()
.toLowerCase()
.replace(/[^\p{Letter}\p{Number}\s\-_]/gu, '')
.replace(/\s+/g, '-');

token.attrSet(appAnchorAttribute, appAnchor);
});
});
}

export function findAnchorTarget<T>(targets: readonly T[], destination: string, attribute: (target: T, name: string) => string | number | null) {
if (!destination.startsWith('#')) {
return undefined;
}

const id = decodeAnchor(destination.substring(1));
const appID = id.toLowerCase();
return targets.find(target => attribute(target, appAnchorAttribute) === null && attribute(target, 'id') === id)
?? targets.find(target => attribute(target, appAnchorAttribute) === appID)
?? targets.find(target => attribute(target, 'id') === id)
?? targets.find(target => {
const rendererID = attribute(target, 'id');
return rendererID !== null && decodeAnchor(String(rendererID)) === id;
});
}

function decodeAnchor(anchor: string) {
try {
return decodeURIComponent(anchor);
} catch {
return anchor;
}
}
13 changes: 3 additions & 10 deletions src/features/navigation.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import { scrollToElement } from '../shared/utils';
import { findAnchorTarget } from './anchors';

export function handlePreviewLinkClick(container: HTMLElement, event: MouseEvent) {
const anchor = event.target instanceof Element ? event.target.closest('a') : null;
Expand All @@ -20,20 +21,12 @@ export function followPreviewLinkAnchor(container: HTMLElement, destination: str
return false;
}

const id = decodeAnchor(destination.substring(1));
const target = [...container.querySelectorAll<HTMLElement>('[id]')].find(element => element.id === id);
const targets = [...container.querySelectorAll<HTMLElement>('[id]')];
const target = findAnchorTarget(targets, destination, (element, name) => element.getAttribute(name));
if (target === undefined) {
return false;
}

scrollToElement(container, target, 0, false);
return true;
}

function decodeAnchor(anchor: string) {
try {
return decodeURIComponent(anchor);
} catch {
return anchor;
}
}
31 changes: 4 additions & 27 deletions src/render.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import githubAlerts from 'markdown-it-github-alerts';

import { MarkEdit } from 'markedit-api';
import { createFrontMatterPlugin } from './features/frontMatter';
import { findAnchorTarget, headingAnchors } from './features/anchors';
import { loadMermaid, renderMermaidDiagrams } from './features/mermaid';
import { coreCss, previewThemeCss, alertsCss, hljsCss, codeCopyCss } from './styling';
import { localized } from './shared/strings';
Expand Down Expand Up @@ -60,33 +61,8 @@ export async function headingLineForAnchor(markdown: string, destination: string
}

await pluginsReady;
const anchorID = mdit.normalizeLink(destination).substring(1);
const appAnchorID = mdit.normalizeLink(destination.toLowerCase()).substring(1).toLowerCase();
const tokens = mdit.parse(markdown, {});
const lines = markdown.split(/\r\n?|\n/);
const heading = tokens.find((token, index) => {
const inline = tokens[index + 1];
if (token.type !== 'heading_open' || inline?.type !== 'inline' || token.map === null) {
return false;
}

const sourceLine = lines[token.map[0]];
const title = token.markup.startsWith('#')
? sourceLine.slice(sourceLine.indexOf(token.markup)).replace(/ {0,3}#+ +/, '')
: inline.content.split('\n')[0].trim();

const appAnchor = (title.length > 64 ? title.substring(0, 64) + '...' : title)
.normalize('NFKD')
.trim()
.toLowerCase()
.replace(/[^\p{Letter}\p{Number}\s\-_]/gu, '')
.replace(/\s+/g, '-');

return mdit.normalizeLink(`#${appAnchor}`).substring(1).toLowerCase() === appAnchorID;
}) ?? tokens.find(token => {
return token.type === 'heading_open' && token.attrGet('id') === anchorID;
});

const headings = mdit.parse(markdown, {}).filter(token => token.type === 'heading_open');
const heading = findAnchorTarget(headings, destination, (token, name) => token.attrGet(name));
return heading?.map?.[0];
}

Expand Down Expand Up @@ -207,6 +183,7 @@ mdit.use(createFrontMatterPlugin());

// Link attributes
mdit.use(anchor);
mdit.use(headingAnchors);
mdit.use(mila, {
matcher: (href: string) => !href.startsWith('#'),
attrs: {
Expand Down
2 changes: 1 addition & 1 deletion tests/frontMatter.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -78,7 +78,7 @@ describe('frontMatter rendering', () => {
expect(await renderFm(`title: "${value}"`)).toContain(row('title', value));
const html = await renderMarkdown(`---\ntitle: ${value}\n---\n\n# Body`, false);
expect(html).not.toContain('markdown-frontMatter');
expect(html).toContain('<h1 id="body" tabindex="-1">Body</h1>');
expect(html).toContain('<h1 id="body" tabindex="-1" data-markedit-anchor="body">Body</h1>');
});

it('strips single and double quotes', async () => {
Expand Down
55 changes: 54 additions & 1 deletion tests/navigation.test.ts
Original file line number Diff line number Diff line change
@@ -1,15 +1,68 @@
// @vitest-environment happy-dom
import { beforeEach, describe, expect, test, vi } from 'vitest';
import { followPreviewLinkAnchor, handlePreviewLinkClick } from '../src/features/navigation';
import { headingLineForAnchor, renderMarkdown } from '../src/render';
import type * as utils from '../src/shared/utils';

const mocks = vi.hoisted(() => ({ scrollToElement: vi.fn() }));
vi.mock('../src/shared/utils', () => ({ scrollToElement: mocks.scrollToElement }));
vi.mock('markedit-api', () => ({ MarkEdit: {} }));
vi.mock('../src/shared/utils', async importOriginal => ({
...await importOriginal<typeof utils>(),
scrollToElement: mocks.scrollToElement,
}));

beforeEach(() => {
mocks.scrollToElement.mockClear();
});

describe('preview link navigation', () => {
test.each([
['## 1. Test Link', '#1-test-link', 0],
['Test (Link)\n-----------', '#test-link', 0],
['## Héllo, World!', '#hello-world', 0],
['# λ', '#Λ', 0],
['# λ', '#%CE%9B', 0],
['# Closing #', '#closing-', 0],
[`## ${'a'.repeat(65)}`, `#${'a'.repeat(64)}`, 0],
['# Heading\n# Heading\n# Heading-1', '#heading-1', 2],
['# Heading\n# Heading', '#heading-1', 1],
['## 1. Test Link', '#1.-test-link', 0],
['# Héllo, World! _One_', '#h%C3%A9llo%2C-world!-one', 0],
] as const)('shares hidden-mode targets for %s via %s', async (heading, destination, line) => {
const markdown = `[test](${destination})\n\n${heading}`;
const container = document.createElement('div');
container.innerHTML = await renderMarkdown(markdown);

const target = container.querySelector(`[data-markedit-anchor][data-line-from="${line + 2}"]`);
expect(target).not.toBeNull();
expect(await headingLineForAnchor(markdown, destination)).toBe(line + 2);

const event = new MouseEvent('click', { cancelable: true });
Object.defineProperty(event, 'target', { value: container.querySelector('a') });
expect(handlePreviewLinkClick(container, event)).toBe(true);
expect(event.defaultPrevented).toBe(true);
expect(mocks.scrollToElement).toHaveBeenCalledWith(container, target, 0, false);
});

test.each([
['# FN1!\n\nText[^note]\n\n[^note]: Footnote', '#fn1', 'li#fn1'],
['# FNREF1!\n\nText[^note]\n\n[^note]: Footnote', '#fnref1', 'a#fnref1'],
['[jump](#custom)\n\n# Custom!\n\n<span id="custom">Target</span>', '#custom', 'span#custom'],
])('preserves exact non-heading targets in %s', async (markdown, destination, selector) => {
const container = document.createElement('div');
container.innerHTML = await renderMarkdown(markdown);

const target = container.querySelector(selector);
expect(target).not.toBeNull();
expect(container.querySelector(`[data-markedit-anchor="${destination.substring(1)}"]`)).not.toBeNull();

const event = new MouseEvent('click', { cancelable: true });
Object.defineProperty(event, 'target', { value: container.querySelector(`a[href="${destination}"]`) });
expect(handlePreviewLinkClick(container, event)).toBe(true);
expect(event.defaultPrevented).toBe(true);
expect(mocks.scrollToElement).toHaveBeenCalledWith(container, target, 0, false);
});

test('positions internal anchors at the top of the preview pane', () => {
const container = document.createElement('div');
const heading = container.appendChild(document.createElement('h2'));
Expand Down
Loading