Rewrite how we handle links in the md preview

Try to simplify how we resolve links:

- Move most logic out of the preview itself.
- Simplify the amount of rewriting we do in the markdown engine
This commit is contained in:
Matt Bierner 2019-10-04 17:15:11 -07:00
parent 7f5a4a3f5b
commit 36aa903d5a
7 changed files with 96 additions and 1347 deletions

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View file

@ -314,7 +314,7 @@
"watch": "npm run build-preview && gulp watch-extension:markdown-language-features",
"vscode:prepublish": "npm run build-ext && npm run build-preview",
"build-ext": "node ../../node_modules/gulp/bin/gulp.js --gulpfile ../../build/gulpfile.extensions.js compile-extension:markdown-language-features ./tsconfig.json",
"build-preview": "webpack --mode development"
"build-preview": "webpack --mode production"
},
"dependencies": {
"highlight.js": "9.15.8",

View file

@ -19,7 +19,7 @@ const settings = getSettings();
const vscode = acquireVsCodeApi();
// Set VS Code state
let state = getData<{ line: number, fragment: string }>('data-state');
let state = getData<{ line: number; fragment: string; }>('data-state');
vscode.setState(state);
const messaging = createPosterForVsCode(vscode);
@ -67,7 +67,7 @@ const onUpdateView = (() => {
})();
let updateImageSizes = throttle(() => {
const imageInfo: { id: string, height: number, width: number }[] = [];
const imageInfo: { id: string, height: number, width: number; }[] = [];
let images = document.getElementsByTagName('img');
if (images) {
let i;
@ -129,6 +129,8 @@ document.addEventListener('dblclick', event => {
}
});
const passThroughLinkSchemes = ['http:', 'https:', 'mailto:', 'vscode:', 'vscode-insiders'];
document.addEventListener('click', event => {
if (!event) {
return;
@ -138,20 +140,25 @@ document.addEventListener('click', event => {
while (node) {
if (node.tagName && node.tagName === 'A' && node.href) {
if (node.getAttribute('href').startsWith('#')) {
break;
return;
}
if (node.href.startsWith('file://') || node.href.startsWith('vscode-resource:') || node.href.startsWith(settings.webviewResourceRoot)) {
const [path, fragment] = node.href
.replace(/^file:\/\//i, '')
.replace(/^vscode-resource:\/\/[^\/]+\//i, '')
.replace(new RegExp(`^${escapeRegExp(settings.webviewResourceRoot)}`))
.split('#');
messaging.postMessage('clickLink', { path, fragment });
// Pass through known schemes
if (passThroughLinkSchemes.some(scheme => node.href.startsWith(scheme))) {
return;
}
const hrefText = node.getAttribute('data-href') || node.getAttribute('href');
// If original link doesn't look like a url, delegate back to VS Code to resolve
if (!/^[a-z\-]+:/i.test(hrefText)) {
messaging.postMessage('openLink', { href: hrefText });
event.preventDefault();
event.stopPropagation();
break;
return;
}
break;
return;
}
node = node.parentNode;
}
@ -170,6 +177,3 @@ window.addEventListener('scroll', throttle(() => {
}
}, 50));
function escapeRegExp(text: string) {
return text.replace(/[-[\]{}()*+?.,\\^$|#\s]/g, '\\$&');
}

View file

@ -25,7 +25,7 @@ interface WebviewMessage {
interface CacheImageSizesMessage extends WebviewMessage {
readonly type: 'cacheImageSizes';
readonly body: { id: string, width: number, height: number }[];
readonly body: { id: string, width: number, height: number; }[];
}
interface RevealLineMessage extends WebviewMessage {
@ -43,10 +43,9 @@ interface DidClickMessage extends WebviewMessage {
}
interface ClickLinkMessage extends WebviewMessage {
readonly type: 'clickLink';
readonly type: 'openLink';
readonly body: {
readonly path: string;
readonly fragment?: string;
readonly href: string;
};
}
@ -88,7 +87,7 @@ export class MarkdownPreview extends Disposable {
private forceUpdate = false;
private isScrolling = false;
private _disposed: boolean = false;
private imageInfo: { id: string, width: number, height: number }[] = [];
private imageInfo: { id: string, width: number, height: number; }[] = [];
private scrollToFragment: string | undefined;
public static async revive(
@ -202,8 +201,8 @@ export class MarkdownPreview extends Disposable {
this.onDidClickPreview(e.body.line);
break;
case 'clickLink':
this.onDidClickPreviewLink(e.body.path, e.body.fragment);
case 'openLink':
this.onDidClickPreviewLink(e.body.href);
break;
case 'showPreviewSecuritySelector':
@ -536,12 +535,19 @@ export class MarkdownPreview extends Disposable {
this.editor.webview.html = html;
}
private async onDidClickPreviewLink(path: string, fragment: string | undefined) {
this.scrollToFragment = undefined;
private async onDidClickPreviewLink(href: string) {
let [hrefPath, fragment] = href.split('#');
// We perviously already resolve absolute paths.
// Now make sure we handle relative file paths
if (hrefPath[0] !== '/') {
hrefPath = path.join(path.dirname(this.resource.path), hrefPath);
}
const config = vscode.workspace.getConfiguration('markdown', this.resource);
const openLinks = config.get<string>('preview.openMarkdownLinks', 'inPreview');
if (openLinks === 'inPreview') {
const markdownLink = await resolveLinkToMarkdownFile(path);
const markdownLink = await resolveLinkToMarkdownFile(hrefPath);
if (markdownLink) {
if (fragment) {
this.scrollToFragment = fragment;
@ -551,10 +557,10 @@ export class MarkdownPreview extends Disposable {
}
}
vscode.commands.executeCommand('_markdown.openDocumentLink', { path, fragment, fromResource: this.resource });
vscode.commands.executeCommand('_markdown.openDocumentLink', { path: hrefPath, fragment, fromResource: this.resource });
}
private async onCacheImageSizes(imageInfo: { id: string, width: number, height: number }[]) {
private async onCacheImageSizes(imageInfo: { id: string, width: number, height: number; }[]) {
this.imageInfo = imageInfo;
}
}

View file

@ -4,13 +4,13 @@
*--------------------------------------------------------------------------------------------*/
import * as crypto from 'crypto';
import { MarkdownIt, Token } from 'markdown-it';
import * as path from 'path';
import { MarkdownIt, Token } from 'markdown-it';
import * as vscode from 'vscode';
import { MarkdownContributionProvider as MarkdownContributionProvider } from './markdownExtensions';
import { Slugifier } from './slugify';
import { SkinnyTextDocument } from './tableOfContentsProvider';
import { getUriForLinkWithKnownExternalScheme } from './util/links';
import { Schemes, isOfScheme } from './util/links';
const UNICODE_NEWLINE_REGEX = /\u2028|\u2029/g;
@ -105,10 +105,10 @@ export class MarkdownEngine {
this.addImageStabilizer(md);
this.addFencedRenderer(md);
this.addLinkNormalizer(md);
this.addLinkValidator(md);
this.addNamedHeaders(md);
this.addLinkRenderer(md);
return md;
});
}
@ -143,6 +143,7 @@ export class MarkdownEngine {
public async render(input: SkinnyTextDocument | string): Promise<string> {
const config = this.getConfig(typeof input === 'string' ? undefined : input.uri);
const engine = await this.getEngine(config);
const tokens = typeof input === 'string'
? this.tokenizeString(input, engine)
: this.tokenizeDocument(input, config, engine);
@ -226,36 +227,28 @@ export class MarkdownEngine {
const normalizeLink = md.normalizeLink;
md.normalizeLink = (link: string) => {
try {
const externalSchemeUri = getUriForLinkWithKnownExternalScheme(link);
if (externalSchemeUri) {
// set true to skip encoding
return normalizeLink(externalSchemeUri.toString(true));
}
// If original link doesn't look like a url with a scheme, assume it must be a link to a file in workspace
if (!/^[a-z\-]+:/i.test(link)) {
// Use a fake scheme for parsing
let uri = vscode.Uri.parse('markdown-link:' + link);
// Assume it must be an relative or absolute file path
// Use a fake scheme to avoid parse warnings
let uri = vscode.Uri.parse(`vscode-resource:${link}`);
if (uri.path) {
// Assume it must be a file
const fragment = uri.fragment;
// Relative paths should be resolved correctly inside the preview but we need to
// handle absolute paths specially (for images) to resolve them relative to the workspace root
if (uri.path[0] === '/') {
const root = vscode.workspace.getWorkspaceFolder(this.currentDocument!);
if (root) {
uri = vscode.Uri.file(path.join(root.uri.fsPath, uri.path));
uri = uri.with({
path: path.join(root.uri.fsPath, uri.path),
});
}
} else {
uri = vscode.Uri.file(path.join(path.dirname(this.currentDocument!.path), uri.path));
}
if (fragment) {
if (uri.fragment) {
uri = uri.with({
fragment: this.slugifier.fromHeading(fragment).value
fragment: this.slugifier.fromHeading(uri.fragment).value
});
}
return normalizeLink(uri.with({ scheme: 'vscode-resource' }).toString(true));
} else if (!uri.path && uri.fragment) {
return `#${this.slugifier.fromHeading(uri.fragment).value}`;
return normalizeLink(uri.toString(true).replace(/^markdown-link:/, ''));
}
} catch (e) {
// noop
@ -268,7 +261,7 @@ export class MarkdownEngine {
const validateLink = md.validateLink;
md.validateLink = (link: string) => {
// support file:// links
return validateLink(link) || link.startsWith('file:') || /^data:image\/.*?;/.test(link);
return validateLink(link) || isOfScheme(Schemes.file, link) || /^data:image\/.*?;/.test(link);
};
}
@ -296,6 +289,22 @@ export class MarkdownEngine {
}
};
}
private addLinkRenderer(md: any): void {
const old_render = md.renderer.rules.link_open || ((tokens: any, idx: number, options: any, _env: any, self: any) => {
return self.renderToken(tokens, idx, options);
});
md.renderer.rules.link_open = (tokens: any, idx: number, options: any, env: any, self: any) => {
const token = tokens[idx];
const hrefIndex = token.attrIndex('href');
if (hrefIndex >= 0) {
const href = token.attrs[hrefIndex][1];
token.attrPush(['data-href', href]);
}
return old_render(tokens, idx, options, env, self);
};
}
}
async function getMarkdownOptions(md: () => MarkdownIt) {

View file

@ -5,14 +5,30 @@
import * as vscode from 'vscode';
const knownSchemes = ['http:', 'https:', 'file:', 'mailto:', 'data:', `${vscode.env.uriScheme}:`, 'vscode:', 'vscode-insiders:', 'vscode-resource:'];
export const Schemes = {
http: 'http:',
https: 'https:',
file: 'file:',
mailto: 'mailto:',
data: 'data:',
vscode: 'vscode:',
'vscode-insiders': 'vscode-insiders:',
'vscode-resource': 'vscode-resource',
} as const;
export function getUriForLinkWithKnownExternalScheme(
link: string,
): vscode.Uri | undefined {
if (knownSchemes.some(knownScheme => link.toLowerCase().startsWith(knownScheme))) {
const knownSchemes = [
...Object.values(Schemes),
`${vscode.env.uriScheme}:`
] as const;
export function getUriForLinkWithKnownExternalScheme(link: string): vscode.Uri | undefined {
if (knownSchemes.some(knownScheme => isOfScheme(knownScheme, link))) {
return vscode.Uri.parse(link);
}
return undefined;
}
export function isOfScheme(scheme: string, link: string): boolean {
return link.toLowerCase().startsWith(scheme);
}