diff --git a/extensions/markdown/src/documentLinkProvider.ts b/extensions/markdown/src/documentLinkProvider.ts index bab89479a3d..a8442519adf 100644 --- a/extensions/markdown/src/documentLinkProvider.ts +++ b/extensions/markdown/src/documentLinkProvider.ts @@ -8,15 +8,22 @@ import * as vscode from 'vscode'; import * as path from 'path'; +import { MarkdownEngine } from './markdownEngine'; +import { TableOfContentProvider } from './tableOfContentsProvider'; + export default class MarkdownDocumentLinkProvider implements vscode.DocumentLinkProvider { private _linkPattern = /(\[[^\]]*\]\(\s*?)(\S+?)(\s+[^\)]*)?\)/g; + constructor(private engine: MarkdownEngine) { } + public provideDocumentLinks(document: vscode.TextDocument, token: vscode.CancellationToken): vscode.DocumentLink[] { const results: vscode.DocumentLink[] = []; const base = path.dirname(document.uri.fsPath); const text = document.getText(); + const toc = new TableOfContentProvider(this.engine, document); + this._linkPattern.lastIndex = 0; let match: RegExpMatchArray | null; while ((match = this._linkPattern.exec(text))) { @@ -26,20 +33,9 @@ export default class MarkdownDocumentLinkProvider implements vscode.DocumentLink const linkStart = document.positionAt(offset); const linkEnd = document.positionAt(offset + link.length); try { - let uri = vscode.Uri.parse(link); - if (!uri.scheme) { - // assume it must be a file - let file; - if (uri.path[0] === '/') { - file = path.join(vscode.workspace.rootPath, uri.path); - } else { - file = path.join(base, uri.path); - } - uri = vscode.Uri.file(file); - } results.push(new vscode.DocumentLink( new vscode.Range(linkStart, linkEnd), - uri)); + this.normalizeLink(link, base, toc))); } catch (e) { // noop } @@ -47,4 +43,27 @@ export default class MarkdownDocumentLinkProvider implements vscode.DocumentLink return results; } -}; + + private normalizeLink(link: string, base: string, toc: TableOfContentProvider): vscode.Uri { + let uri = vscode.Uri.parse(link); + if (!uri.scheme) { + if (uri.fragment && !uri.path) { + // local link + const line = toc.lookup(uri.fragment); + if (!isNaN(line)) { + return vscode.Uri.parse(`command:revealLine?${encodeURIComponent(JSON.stringify({ lineNumber: line, at: 'top' }))}`); + } + } + + // assume it must be a file + let file; + if (uri.path[0] === '/') { + file = path.join(vscode.workspace.rootPath, uri.path); + } else { + file = path.join(base, uri.path); + } + uri = vscode.Uri.file(file); + } + return uri; + } +} diff --git a/extensions/markdown/src/documentSymbolProvider.ts b/extensions/markdown/src/documentSymbolProvider.ts index 01b69324637..c3af584ba1f 100644 --- a/extensions/markdown/src/documentSymbolProvider.ts +++ b/extensions/markdown/src/documentSymbolProvider.ts @@ -8,28 +8,16 @@ import * as vscode from 'vscode'; import { MarkdownEngine } from './markdownEngine'; +import { TableOfContentProvider } from './tableOfContentsProvider'; export default class MDDocumentSymbolProvider implements vscode.DocumentSymbolProvider { constructor(private engine: MarkdownEngine) { } provideDocumentSymbols(document: vscode.TextDocument): vscode.ProviderResult { - const tokens = this.engine.parse(document.getText()); - const headings = tokens.filter(token => token.type === 'heading_open'); - - return headings.map(heading => { - const lineNumber = heading.map[0]; - const line = document.lineAt(lineNumber); - const location = new vscode.Location(document.uri, line.range); - - // # Header => 'Header' - // ## Header ## => 'Header' - // ## Header #### => 'Header' - // Header ## => 'Header ##' - // ========= - const text = line.text.replace(/^\s*(#)+\s*(.*?)\s*\1*$/, '$2'); - - return new vscode.SymbolInformation(text, vscode.SymbolKind.Module, '', location); + const toc = new TableOfContentProvider(this.engine, document); + return toc.getToc().map(entry => { + return new vscode.SymbolInformation(entry.text, vscode.SymbolKind.Module, '', entry.location); }); } } \ No newline at end of file diff --git a/extensions/markdown/src/extension.ts b/extensions/markdown/src/extension.ts index edd43ca85cb..0d4b54aa999 100644 --- a/extensions/markdown/src/extension.ts +++ b/extensions/markdown/src/extension.ts @@ -33,7 +33,7 @@ export function activate(context: vscode.ExtensionContext) { const symbolsProviderRegistration = vscode.languages.registerDocumentSymbolProvider({ language: 'markdown' }, symbolsProvider); context.subscriptions.push(contentProviderRegistration, symbolsProviderRegistration); - context.subscriptions.push(vscode.languages.registerDocumentLinkProvider('markdown', new DocumentLinkProvider())); + context.subscriptions.push(vscode.languages.registerDocumentLinkProvider('markdown', new DocumentLinkProvider(engine))); context.subscriptions.push(vscode.commands.registerCommand('markdown.showPreview', showPreview)); context.subscriptions.push(vscode.commands.registerCommand('markdown.showPreviewToSide', uri => showPreview(uri, true))); diff --git a/extensions/markdown/src/tableOfContentsProvider.ts b/extensions/markdown/src/tableOfContentsProvider.ts new file mode 100644 index 00000000000..d2864ba66df --- /dev/null +++ b/extensions/markdown/src/tableOfContentsProvider.ts @@ -0,0 +1,77 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +'use strict'; + +import * as vscode from 'vscode'; + +import { MarkdownEngine, IToken } from './markdownEngine'; + +export interface TocEntry { + slug: string; + text: string; + line: number; + location: vscode.Location; +} + +export class TableOfContentProvider { + private toc: TocEntry[]; + + public constructor( + private engine: MarkdownEngine, + private document: vscode.TextDocument) { } + + public getToc(): TocEntry[] { + if (!this.toc) { + try { + this.toc = this.buildToc(this.document); + } catch (e) { + this.toc = []; + } + } + return this.toc; + } + + public lookup(fragment: string): number { + const slug = TableOfContentProvider.slugify(fragment); + for (const entry of this.getToc()) { + if (entry.slug === slug) { + return entry.line; + } + } + return NaN; + } + + private buildToc(document: vscode.TextDocument): any { + const toc: TocEntry[] = []; + const tokens: IToken[] = this.engine.parse(this.document.getText()); + + for (const heading of tokens.filter(token => token.type === 'heading_open')) { + const lineNumber = heading.map[0]; + const line = document.lineAt(lineNumber); + const href = TableOfContentProvider.slugify(line.text); + if (href) { + toc.push({ + slug: href, + text: TableOfContentProvider.getHeaderText(line.text), + line: lineNumber, + location: new vscode.Location(document.uri, line.range) + }); + } + } + return toc; + } + + private static getHeaderText(header: string): string { + return header.replace(/^\s*(#)+\s*(.*?)\s*\1*$/, '$2').trim(); + } + + public static slugify(header: string): string { + return encodeURI(TableOfContentProvider.getHeaderText(header) + .toLowerCase() + .replace(/\s/g, '-')); + } +} +