Archived
1
0

Merge commit 'be3e8236086165e5e45a5a10783823874b3f3ebd' as 'lib/vscode'

This commit is contained in:
Joe Previte
2020-12-15 15:52:33 -07:00
4649 changed files with 1311795 additions and 0 deletions

View File

@ -0,0 +1,36 @@
/*---------------------------------------------------------------------------------------------
* Copyright (c) Microsoft Corporation. All rights reserved.
* Licensed under the MIT License. See License.txt in the project root for license information.
*--------------------------------------------------------------------------------------------*/
import * as vscode from 'vscode';
export interface Command {
readonly id: string;
execute(...args: any[]): void;
}
export class CommandManager {
private readonly commands = new Map<string, vscode.Disposable>();
public dispose() {
for (const registration of this.commands.values()) {
registration.dispose();
}
this.commands.clear();
}
public register<T extends Command>(command: T): T {
this.registerCommand(command.id, command.execute, command);
return command;
}
private registerCommand(id: string, impl: (...args: any[]) => void, thisArg?: any) {
if (this.commands.has(id)) {
return;
}
this.commands.set(id, vscode.commands.registerCommand(id, impl, thisArg));
}
}

View File

@ -0,0 +1,13 @@
/*---------------------------------------------------------------------------------------------
* Copyright (c) Microsoft Corporation. All rights reserved.
* Licensed under the MIT License. See License.txt in the project root for license information.
*--------------------------------------------------------------------------------------------*/
export { OpenDocumentLinkCommand } from './openDocumentLink';
export { ShowPreviewCommand, ShowPreviewToSideCommand, ShowLockedPreviewToSideCommand } from './showPreview';
export { ShowSourceCommand } from './showSource';
export { RefreshPreviewCommand } from './refreshPreview';
export { ShowPreviewSecuritySelectorCommand } from './showPreviewSecuritySelector';
export { MoveCursorToPositionCommand } from './moveCursorToPosition';
export { ToggleLockCommand } from './toggleLock';
export { RenderDocument } from './renderDocument';

View File

@ -0,0 +1,22 @@
/*---------------------------------------------------------------------------------------------
* Copyright (c) Microsoft Corporation. All rights reserved.
* Licensed under the MIT License. See License.txt in the project root for license information.
*--------------------------------------------------------------------------------------------*/
import * as vscode from 'vscode';
import { Command } from '../commandManager';
export class MoveCursorToPositionCommand implements Command {
public readonly id = '_markdown.moveCursorToPosition';
public execute(line: number, character: number) {
if (!vscode.window.activeTextEditor) {
return;
}
const position = new vscode.Position(line, character);
const selection = new vscode.Selection(position, position);
vscode.window.activeTextEditor.revealRange(selection);
vscode.window.activeTextEditor.selection = selection;
}
}

View File

@ -0,0 +1,157 @@
/*---------------------------------------------------------------------------------------------
* Copyright (c) Microsoft Corporation. All rights reserved.
* Licensed under the MIT License. See License.txt in the project root for license information.
*--------------------------------------------------------------------------------------------*/
import * as vscode from 'vscode';
import { extname } from 'path';
import { Command } from '../commandManager';
import { MarkdownEngine } from '../markdownEngine';
import { TableOfContentsProvider } from '../tableOfContentsProvider';
import { isMarkdownFile } from '../util/file';
export interface OpenDocumentLinkArgs {
readonly path: {};
readonly fragment: string;
readonly fromResource: {};
}
enum OpenMarkdownLinks {
beside = 'beside',
currentGroup = 'currentGroup',
}
export class OpenDocumentLinkCommand implements Command {
private static readonly id = '_markdown.openDocumentLink';
public readonly id = OpenDocumentLinkCommand.id;
public static createCommandUri(
fromResource: vscode.Uri,
path: vscode.Uri,
fragment: string,
): vscode.Uri {
const toJson = (uri: vscode.Uri) => {
return {
scheme: uri.scheme,
authority: uri.authority,
path: uri.path,
fragment: uri.fragment,
query: uri.query,
};
};
return vscode.Uri.parse(`command:${OpenDocumentLinkCommand.id}?${encodeURIComponent(JSON.stringify(<OpenDocumentLinkArgs>{
path: toJson(path),
fragment,
fromResource: toJson(fromResource),
}))}`);
}
public constructor(
private readonly engine: MarkdownEngine
) { }
public async execute(args: OpenDocumentLinkArgs) {
return OpenDocumentLinkCommand.execute(this.engine, args);
}
public static async execute(engine: MarkdownEngine, args: OpenDocumentLinkArgs) {
const fromResource = vscode.Uri.parse('').with(args.fromResource);
const targetResource = vscode.Uri.parse('').with(args.path);
const column = this.getViewColumn(fromResource);
try {
return await this.tryOpen(engine, targetResource, args, column);
} catch {
if (extname(targetResource.path) === '') {
return this.tryOpen(engine, targetResource.with({ path: targetResource.path + '.md' }), args, column);
}
await vscode.commands.executeCommand('vscode.open', targetResource, column);
return undefined;
}
}
private static async tryOpen(engine: MarkdownEngine, resource: vscode.Uri, args: OpenDocumentLinkArgs, column: vscode.ViewColumn) {
if (vscode.window.activeTextEditor && isMarkdownFile(vscode.window.activeTextEditor.document)) {
if (vscode.window.activeTextEditor.document.uri.fsPath === resource.fsPath) {
return this.tryRevealLine(engine, vscode.window.activeTextEditor, args.fragment);
}
}
const stat = await vscode.workspace.fs.stat(resource);
if (stat.type === vscode.FileType.Directory) {
return vscode.commands.executeCommand('revealInExplorer', resource);
}
return vscode.workspace.openTextDocument(resource)
.then(document => vscode.window.showTextDocument(document, column))
.then(editor => this.tryRevealLine(engine, editor, args.fragment));
}
private static getViewColumn(resource: vscode.Uri): vscode.ViewColumn {
const config = vscode.workspace.getConfiguration('markdown', resource);
const openLinks = config.get<OpenMarkdownLinks>('links.openLocation', OpenMarkdownLinks.currentGroup);
switch (openLinks) {
case OpenMarkdownLinks.beside:
return vscode.ViewColumn.Beside;
case OpenMarkdownLinks.currentGroup:
default:
return vscode.ViewColumn.Active;
}
}
private static async tryRevealLine(engine: MarkdownEngine, editor: vscode.TextEditor, fragment?: string) {
if (editor && fragment) {
const toc = new TableOfContentsProvider(engine, editor.document);
const entry = await toc.lookup(fragment);
if (entry) {
const lineStart = new vscode.Range(entry.line, 0, entry.line, 0);
editor.selection = new vscode.Selection(lineStart.start, lineStart.end);
return editor.revealRange(lineStart, vscode.TextEditorRevealType.AtTop);
}
const lineNumberFragment = fragment.match(/^L(\d+)$/i);
if (lineNumberFragment) {
const line = +lineNumberFragment[1] - 1;
if (!isNaN(line)) {
const lineStart = new vscode.Range(line, 0, line, 0);
editor.selection = new vscode.Selection(lineStart.start, lineStart.end);
return editor.revealRange(lineStart, vscode.TextEditorRevealType.AtTop);
}
}
}
}
}
export async function resolveLinkToMarkdownFile(path: string): Promise<vscode.Uri | undefined> {
try {
const standardLink = await tryResolveLinkToMarkdownFile(path);
if (standardLink) {
return standardLink;
}
} catch {
// Noop
}
// If no extension, try with `.md` extension
if (extname(path) === '') {
return tryResolveLinkToMarkdownFile(path + '.md');
}
return undefined;
}
async function tryResolveLinkToMarkdownFile(path: string): Promise<vscode.Uri | undefined> {
const resource = vscode.Uri.file(path);
let document: vscode.TextDocument;
try {
document = await vscode.workspace.openTextDocument(resource);
} catch {
return undefined;
}
if (isMarkdownFile(document)) {
return document.uri;
}
return undefined;
}

View File

@ -0,0 +1,22 @@
/*---------------------------------------------------------------------------------------------
* Copyright (c) Microsoft Corporation. All rights reserved.
* Licensed under the MIT License. See License.txt in the project root for license information.
*--------------------------------------------------------------------------------------------*/
import { Command } from '../commandManager';
import { MarkdownPreviewManager } from '../features/previewManager';
import { MarkdownEngine } from '../markdownEngine';
export class RefreshPreviewCommand implements Command {
public readonly id = 'markdown.preview.refresh';
public constructor(
private readonly webviewManager: MarkdownPreviewManager,
private readonly engine: MarkdownEngine
) { }
public execute() {
this.engine.cleanCache();
this.webviewManager.refresh();
}
}

View File

@ -0,0 +1,20 @@
/*---------------------------------------------------------------------------------------------
* Copyright (c) Microsoft Corporation. All rights reserved.
* Licensed under the MIT License. See License.txt in the project root for license information.
*--------------------------------------------------------------------------------------------*/
import { Command } from '../commandManager';
import { MarkdownEngine } from '../markdownEngine';
import { SkinnyTextDocument } from '../tableOfContentsProvider';
export class RenderDocument implements Command {
public readonly id = 'markdown.api.render';
public constructor(
private readonly engine: MarkdownEngine
) { }
public async execute(document: SkinnyTextDocument | string): Promise<string> {
return this.engine.render(document);
}
}

View File

@ -0,0 +1,102 @@
/*---------------------------------------------------------------------------------------------
* Copyright (c) Microsoft Corporation. All rights reserved.
* Licensed under the MIT License. See License.txt in the project root for license information.
*--------------------------------------------------------------------------------------------*/
import * as vscode from 'vscode';
import { Command } from '../commandManager';
import { MarkdownPreviewManager, DynamicPreviewSettings } from '../features/previewManager';
import { TelemetryReporter } from '../telemetryReporter';
interface ShowPreviewSettings {
readonly sideBySide?: boolean;
readonly locked?: boolean;
}
async function showPreview(
webviewManager: MarkdownPreviewManager,
telemetryReporter: TelemetryReporter,
uri: vscode.Uri | undefined,
previewSettings: ShowPreviewSettings,
): Promise<any> {
let resource = uri;
if (!(resource instanceof vscode.Uri)) {
if (vscode.window.activeTextEditor) {
// we are relaxed and don't check for markdown files
resource = vscode.window.activeTextEditor.document.uri;
}
}
if (!(resource instanceof vscode.Uri)) {
if (!vscode.window.activeTextEditor) {
// this is most likely toggling the preview
return vscode.commands.executeCommand('markdown.showSource');
}
// nothing found that could be shown or toggled
return;
}
const resourceColumn = (vscode.window.activeTextEditor && vscode.window.activeTextEditor.viewColumn) || vscode.ViewColumn.One;
webviewManager.openDynamicPreview(resource, {
resourceColumn: resourceColumn,
previewColumn: previewSettings.sideBySide ? resourceColumn + 1 : resourceColumn,
locked: !!previewSettings.locked
});
telemetryReporter.sendTelemetryEvent('openPreview', {
where: previewSettings.sideBySide ? 'sideBySide' : 'inPlace',
how: (uri instanceof vscode.Uri) ? 'action' : 'pallete'
});
}
export class ShowPreviewCommand implements Command {
public readonly id = 'markdown.showPreview';
public constructor(
private readonly webviewManager: MarkdownPreviewManager,
private readonly telemetryReporter: TelemetryReporter
) { }
public execute(mainUri?: vscode.Uri, allUris?: vscode.Uri[], previewSettings?: DynamicPreviewSettings) {
for (const uri of Array.isArray(allUris) ? allUris : [mainUri]) {
showPreview(this.webviewManager, this.telemetryReporter, uri, {
sideBySide: false,
locked: previewSettings && previewSettings.locked
});
}
}
}
export class ShowPreviewToSideCommand implements Command {
public readonly id = 'markdown.showPreviewToSide';
public constructor(
private readonly webviewManager: MarkdownPreviewManager,
private readonly telemetryReporter: TelemetryReporter
) { }
public execute(uri?: vscode.Uri, previewSettings?: DynamicPreviewSettings) {
showPreview(this.webviewManager, this.telemetryReporter, uri, {
sideBySide: true,
locked: previewSettings && previewSettings.locked
});
}
}
export class ShowLockedPreviewToSideCommand implements Command {
public readonly id = 'markdown.showLockedPreviewToSide';
public constructor(
private readonly webviewManager: MarkdownPreviewManager,
private readonly telemetryReporter: TelemetryReporter
) { }
public execute(uri?: vscode.Uri) {
showPreview(this.webviewManager, this.telemetryReporter, uri, {
sideBySide: true,
locked: true
});
}
}

View File

@ -0,0 +1,30 @@
/*---------------------------------------------------------------------------------------------
* Copyright (c) Microsoft Corporation. All rights reserved.
* Licensed under the MIT License. See License.txt in the project root for license information.
*--------------------------------------------------------------------------------------------*/
import * as vscode from 'vscode';
import { Command } from '../commandManager';
import { PreviewSecuritySelector } from '../security';
import { isMarkdownFile } from '../util/file';
import { MarkdownPreviewManager } from '../features/previewManager';
export class ShowPreviewSecuritySelectorCommand implements Command {
public readonly id = 'markdown.showPreviewSecuritySelector';
public constructor(
private readonly previewSecuritySelector: PreviewSecuritySelector,
private readonly previewManager: MarkdownPreviewManager
) { }
public execute(resource: string | undefined) {
if (this.previewManager.activePreviewResource) {
this.previewSecuritySelector.showSecuritySelectorForResource(this.previewManager.activePreviewResource);
} else if (resource) {
const source = vscode.Uri.parse(resource);
this.previewSecuritySelector.showSecuritySelectorForResource(source.query ? vscode.Uri.parse(source.query) : source);
} else if (vscode.window.activeTextEditor && isMarkdownFile(vscode.window.activeTextEditor.document)) {
this.previewSecuritySelector.showSecuritySelectorForResource(vscode.window.activeTextEditor.document.uri);
}
}
}

View File

@ -0,0 +1,26 @@
/*---------------------------------------------------------------------------------------------
* Copyright (c) Microsoft Corporation. All rights reserved.
* Licensed under the MIT License. See License.txt in the project root for license information.
*--------------------------------------------------------------------------------------------*/
import * as vscode from 'vscode';
import { Command } from '../commandManager';
import { MarkdownPreviewManager } from '../features/previewManager';
export class ShowSourceCommand implements Command {
public readonly id = 'markdown.showSource';
public constructor(
private readonly previewManager: MarkdownPreviewManager
) { }
public execute() {
const { activePreviewResource, activePreviewResourceColumn } = this.previewManager;
if (activePreviewResource && activePreviewResourceColumn) {
return vscode.workspace.openTextDocument(activePreviewResource).then(document => {
vscode.window.showTextDocument(document, activePreviewResourceColumn);
});
}
return undefined;
}
}

View File

@ -0,0 +1,19 @@
/*---------------------------------------------------------------------------------------------
* Copyright (c) Microsoft Corporation. All rights reserved.
* Licensed under the MIT License. See License.txt in the project root for license information.
*--------------------------------------------------------------------------------------------*/
import { Command } from '../commandManager';
import { MarkdownPreviewManager } from '../features/previewManager';
export class ToggleLockCommand implements Command {
public readonly id = 'markdown.preview.toggleLock';
public constructor(
private readonly previewManager: MarkdownPreviewManager
) { }
public execute() {
this.previewManager.toggleLock();
}
}

View File

@ -0,0 +1,90 @@
/*---------------------------------------------------------------------------------------------
* Copyright (c) Microsoft Corporation. All rights reserved.
* Licensed under the MIT License. See License.txt in the project root for license information.
*--------------------------------------------------------------------------------------------*/
import * as vscode from 'vscode';
import { CommandManager } from './commandManager';
import * as commands from './commands/index';
import LinkProvider from './features/documentLinkProvider';
import MDDocumentSymbolProvider from './features/documentSymbolProvider';
import MarkdownFoldingProvider from './features/foldingProvider';
import MarkdownSmartSelect from './features/smartSelect';
import { MarkdownContentProvider } from './features/previewContentProvider';
import { MarkdownPreviewManager } from './features/previewManager';
import MarkdownWorkspaceSymbolProvider from './features/workspaceSymbolProvider';
import { Logger } from './logger';
import { MarkdownEngine } from './markdownEngine';
import { getMarkdownExtensionContributions } from './markdownExtensions';
import { ContentSecurityPolicyArbiter, ExtensionContentSecurityPolicyArbiter, PreviewSecuritySelector } from './security';
import { githubSlugifier } from './slugify';
import { loadDefaultTelemetryReporter, TelemetryReporter } from './telemetryReporter';
export function activate(context: vscode.ExtensionContext) {
const telemetryReporter = loadDefaultTelemetryReporter();
context.subscriptions.push(telemetryReporter);
const contributions = getMarkdownExtensionContributions(context);
context.subscriptions.push(contributions);
const cspArbiter = new ExtensionContentSecurityPolicyArbiter(context.globalState, context.workspaceState);
const engine = new MarkdownEngine(contributions, githubSlugifier);
const logger = new Logger();
const contentProvider = new MarkdownContentProvider(engine, context, cspArbiter, contributions, logger);
const symbolProvider = new MDDocumentSymbolProvider(engine);
const previewManager = new MarkdownPreviewManager(contentProvider, logger, contributions, engine);
context.subscriptions.push(previewManager);
context.subscriptions.push(registerMarkdownLanguageFeatures(symbolProvider, engine));
context.subscriptions.push(registerMarkdownCommands(previewManager, telemetryReporter, cspArbiter, engine));
context.subscriptions.push(vscode.workspace.onDidChangeConfiguration(() => {
logger.updateConfiguration();
previewManager.updateConfiguration();
}));
}
function registerMarkdownLanguageFeatures(
symbolProvider: MDDocumentSymbolProvider,
engine: MarkdownEngine
): vscode.Disposable {
const selector: vscode.DocumentSelector = { language: 'markdown', scheme: '*' };
const charPattern = '(\\p{Alphabetic}|\\p{Number}|\\p{Nonspacing_Mark})';
return vscode.Disposable.from(
vscode.languages.setLanguageConfiguration('markdown', {
wordPattern: new RegExp(`${charPattern}((${charPattern}|[_])?${charPattern})*`, 'ug'),
}),
vscode.languages.registerDocumentSymbolProvider(selector, symbolProvider),
vscode.languages.registerDocumentLinkProvider(selector, new LinkProvider()),
vscode.languages.registerFoldingRangeProvider(selector, new MarkdownFoldingProvider(engine)),
vscode.languages.registerSelectionRangeProvider(selector, new MarkdownSmartSelect(engine)),
vscode.languages.registerWorkspaceSymbolProvider(new MarkdownWorkspaceSymbolProvider(symbolProvider))
);
}
function registerMarkdownCommands(
previewManager: MarkdownPreviewManager,
telemetryReporter: TelemetryReporter,
cspArbiter: ContentSecurityPolicyArbiter,
engine: MarkdownEngine
): vscode.Disposable {
const previewSecuritySelector = new PreviewSecuritySelector(cspArbiter, previewManager);
const commandManager = new CommandManager();
commandManager.register(new commands.ShowPreviewCommand(previewManager, telemetryReporter));
commandManager.register(new commands.ShowPreviewToSideCommand(previewManager, telemetryReporter));
commandManager.register(new commands.ShowLockedPreviewToSideCommand(previewManager, telemetryReporter));
commandManager.register(new commands.ShowSourceCommand(previewManager));
commandManager.register(new commands.RefreshPreviewCommand(previewManager, engine));
commandManager.register(new commands.MoveCursorToPositionCommand());
commandManager.register(new commands.ShowPreviewSecuritySelectorCommand(previewSecuritySelector, previewManager));
commandManager.register(new commands.OpenDocumentLinkCommand(engine));
commandManager.register(new commands.ToggleLockCommand(previewManager));
commandManager.register(new commands.RenderDocument(engine));
return commandManager;
}

View File

@ -0,0 +1,209 @@
/*---------------------------------------------------------------------------------------------
* Copyright (c) Microsoft Corporation. All rights reserved.
* Licensed under the MIT License. See License.txt in the project root for license information.
*--------------------------------------------------------------------------------------------*/
import * as path from 'path';
import * as vscode from 'vscode';
import * as nls from 'vscode-nls';
import { OpenDocumentLinkCommand } from '../commands/openDocumentLink';
import { getUriForLinkWithKnownExternalScheme, isOfScheme, Schemes } from '../util/links';
const localize = nls.loadMessageBundle();
function parseLink(
document: vscode.TextDocument,
link: string,
): { uri: vscode.Uri, tooltip?: string } | undefined {
const externalSchemeUri = getUriForLinkWithKnownExternalScheme(link);
if (externalSchemeUri) {
// Normalize VS Code links to target currently running version
if (isOfScheme(Schemes.vscode, link) || isOfScheme(Schemes['vscode-insiders'], link)) {
return { uri: vscode.Uri.parse(link).with({ scheme: vscode.env.uriScheme }) };
}
return { uri: externalSchemeUri };
}
// Assume it must be an relative or absolute file path
// Use a fake scheme to avoid parse warnings
const tempUri = vscode.Uri.parse(`vscode-resource:${link}`);
let resourceUri: vscode.Uri | undefined;
if (!tempUri.path) {
resourceUri = document.uri;
} else if (tempUri.path[0] === '/') {
const root = getWorkspaceFolder(document);
if (root) {
resourceUri = vscode.Uri.joinPath(root, tempUri.path);
}
} else {
if (document.uri.scheme === Schemes.untitled) {
const root = getWorkspaceFolder(document);
if (root) {
resourceUri = vscode.Uri.joinPath(root, tempUri.path);
}
} else {
const base = document.uri.with({ path: path.dirname(document.uri.fsPath) });
resourceUri = vscode.Uri.joinPath(base, tempUri.path);
}
}
if (!resourceUri) {
return undefined;
}
resourceUri = resourceUri.with({ fragment: tempUri.fragment });
return {
uri: OpenDocumentLinkCommand.createCommandUri(document.uri, resourceUri, tempUri.fragment),
tooltip: localize('documentLink.tooltip', 'Follow link')
};
}
function getWorkspaceFolder(document: vscode.TextDocument) {
return vscode.workspace.getWorkspaceFolder(document.uri)?.uri
|| vscode.workspace.workspaceFolders?.[0]?.uri;
}
function matchAll(
pattern: RegExp,
text: string
): Array<RegExpMatchArray> {
const out: RegExpMatchArray[] = [];
pattern.lastIndex = 0;
let match: RegExpMatchArray | null;
while ((match = pattern.exec(text))) {
out.push(match);
}
return out;
}
function extractDocumentLink(
document: vscode.TextDocument,
pre: number,
link: string,
matchIndex: number | undefined
): vscode.DocumentLink | undefined {
const offset = (matchIndex || 0) + pre;
const linkStart = document.positionAt(offset);
const linkEnd = document.positionAt(offset + link.length);
try {
const linkData = parseLink(document, link);
if (!linkData) {
return undefined;
}
const documentLink = new vscode.DocumentLink(
new vscode.Range(linkStart, linkEnd),
linkData.uri);
documentLink.tooltip = linkData.tooltip;
return documentLink;
} catch (e) {
return undefined;
}
}
export default class LinkProvider implements vscode.DocumentLinkProvider {
private readonly linkPattern = /(\[((!\[[^\]]*?\]\(\s*)([^\s\(\)]+?)\s*\)\]|(?:\\\]|[^\]])*\])\(\s*)(([^\s\(\)]|\(\S*?\))+)\s*(".*?")?\)/g;
private readonly referenceLinkPattern = /(\[((?:\\\]|[^\]])+)\]\[\s*?)([^\s\]]*?)\]/g;
private readonly definitionPattern = /^([\t ]*\[(?!\^)((?:\\\]|[^\]])+)\]:\s*)(\S+)/gm;
public provideDocumentLinks(
document: vscode.TextDocument,
_token: vscode.CancellationToken
): vscode.DocumentLink[] {
const text = document.getText();
return [
...this.providerInlineLinks(text, document),
...this.provideReferenceLinks(text, document)
];
}
private providerInlineLinks(
text: string,
document: vscode.TextDocument,
): vscode.DocumentLink[] {
const results: vscode.DocumentLink[] = [];
for (const match of matchAll(this.linkPattern, text)) {
const matchImage = match[4] && extractDocumentLink(document, match[3].length + 1, match[4], match.index);
if (matchImage) {
results.push(matchImage);
}
const matchLink = extractDocumentLink(document, match[1].length, match[5], match.index);
if (matchLink) {
results.push(matchLink);
}
}
return results;
}
private provideReferenceLinks(
text: string,
document: vscode.TextDocument,
): vscode.DocumentLink[] {
const results: vscode.DocumentLink[] = [];
const definitions = this.getDefinitions(text, document);
for (const match of matchAll(this.referenceLinkPattern, text)) {
let linkStart: vscode.Position;
let linkEnd: vscode.Position;
let reference = match[3];
if (reference) { // [text][ref]
const pre = match[1];
const offset = (match.index || 0) + pre.length;
linkStart = document.positionAt(offset);
linkEnd = document.positionAt(offset + reference.length);
} else if (match[2]) { // [ref][]
reference = match[2];
const offset = (match.index || 0) + 1;
linkStart = document.positionAt(offset);
linkEnd = document.positionAt(offset + match[2].length);
} else {
continue;
}
try {
const link = definitions.get(reference);
if (link) {
results.push(new vscode.DocumentLink(
new vscode.Range(linkStart, linkEnd),
vscode.Uri.parse(`command:_markdown.moveCursorToPosition?${encodeURIComponent(JSON.stringify([link.linkRange.start.line, link.linkRange.start.character]))}`)));
}
} catch (e) {
// noop
}
}
for (const definition of definitions.values()) {
try {
const linkData = parseLink(document, definition.link);
if (linkData) {
results.push(new vscode.DocumentLink(definition.linkRange, linkData.uri));
}
} catch (e) {
// noop
}
}
return results;
}
private getDefinitions(text: string, document: vscode.TextDocument) {
const out = new Map<string, { link: string, linkRange: vscode.Range }>();
for (const match of matchAll(this.definitionPattern, text)) {
const pre = match[1];
const reference = match[2];
const link = match[3].trim();
const offset = (match.index || 0) + pre.length;
const linkStart = document.positionAt(offset);
const linkEnd = document.positionAt(offset + link.length);
out.set(reference, {
link: link,
linkRange: new vscode.Range(linkStart, linkEnd)
});
}
return out;
}
}

View File

@ -0,0 +1,75 @@
/*---------------------------------------------------------------------------------------------
* Copyright (c) Microsoft Corporation. All rights reserved.
* Licensed under the MIT License. See License.txt in the project root for license information.
*--------------------------------------------------------------------------------------------*/
import * as vscode from 'vscode';
import { MarkdownEngine } from '../markdownEngine';
import { TableOfContentsProvider, SkinnyTextDocument, TocEntry } from '../tableOfContentsProvider';
interface MarkdownSymbol {
readonly level: number;
readonly parent: MarkdownSymbol | undefined;
readonly children: vscode.DocumentSymbol[];
}
export default class MDDocumentSymbolProvider implements vscode.DocumentSymbolProvider {
constructor(
private readonly engine: MarkdownEngine
) { }
public async provideDocumentSymbolInformation(document: SkinnyTextDocument): Promise<vscode.SymbolInformation[]> {
const toc = await new TableOfContentsProvider(this.engine, document).getToc();
return toc.map(entry => this.toSymbolInformation(entry));
}
public async provideDocumentSymbols(document: SkinnyTextDocument): Promise<vscode.DocumentSymbol[]> {
const toc = await new TableOfContentsProvider(this.engine, document).getToc();
const root: MarkdownSymbol = {
level: -Infinity,
children: [],
parent: undefined
};
this.buildTree(root, toc);
return root.children;
}
private buildTree(parent: MarkdownSymbol, entries: TocEntry[]) {
if (!entries.length) {
return;
}
const entry = entries[0];
const symbol = this.toDocumentSymbol(entry);
symbol.children = [];
while (parent && entry.level <= parent.level) {
parent = parent.parent!;
}
parent.children.push(symbol);
this.buildTree({ level: entry.level, children: symbol.children, parent }, entries.slice(1));
}
private toSymbolInformation(entry: TocEntry): vscode.SymbolInformation {
return new vscode.SymbolInformation(
this.getSymbolName(entry),
vscode.SymbolKind.String,
'',
entry.location);
}
private toDocumentSymbol(entry: TocEntry) {
return new vscode.DocumentSymbol(
this.getSymbolName(entry),
'',
vscode.SymbolKind.String,
entry.location.range,
entry.location.range);
}
private getSymbolName(entry: TocEntry): string {
return '#'.repeat(entry.level) + ' ' + entry.text;
}
}

View File

@ -0,0 +1,106 @@
/*---------------------------------------------------------------------------------------------
* Copyright (c) Microsoft Corporation. All rights reserved.
* Licensed under the MIT License. See License.txt in the project root for license information.
*--------------------------------------------------------------------------------------------*/
import { Token } from 'markdown-it';
import * as vscode from 'vscode';
import { MarkdownEngine } from '../markdownEngine';
import { TableOfContentsProvider } from '../tableOfContentsProvider';
import { flatten } from '../util/arrays';
const rangeLimit = 5000;
export default class MarkdownFoldingProvider implements vscode.FoldingRangeProvider {
constructor(
private readonly engine: MarkdownEngine
) { }
public async provideFoldingRanges(
document: vscode.TextDocument,
_: vscode.FoldingContext,
_token: vscode.CancellationToken
): Promise<vscode.FoldingRange[]> {
const foldables = await Promise.all([
this.getRegions(document),
this.getHeaderFoldingRanges(document),
this.getBlockFoldingRanges(document)
]);
return flatten(foldables).slice(0, rangeLimit);
}
private async getRegions(document: vscode.TextDocument): Promise<vscode.FoldingRange[]> {
const tokens = await this.engine.parse(document);
const regionMarkers = tokens.filter(isRegionMarker)
.map(token => ({ line: token.map[0], isStart: isStartRegion(token.content) }));
const nestingStack: { line: number, isStart: boolean }[] = [];
return regionMarkers
.map(marker => {
if (marker.isStart) {
nestingStack.push(marker);
} else if (nestingStack.length && nestingStack[nestingStack.length - 1].isStart) {
return new vscode.FoldingRange(nestingStack.pop()!.line, marker.line, vscode.FoldingRangeKind.Region);
} else {
// noop: invalid nesting (i.e. [end, start] or [start, end, end])
}
return null;
})
.filter((region: vscode.FoldingRange | null): region is vscode.FoldingRange => !!region);
}
private async getHeaderFoldingRanges(document: vscode.TextDocument) {
const tocProvider = new TableOfContentsProvider(this.engine, document);
const toc = await tocProvider.getToc();
return toc.map(entry => {
let endLine = entry.location.range.end.line;
if (document.lineAt(endLine).isEmptyOrWhitespace && endLine >= entry.line + 1) {
endLine = endLine - 1;
}
return new vscode.FoldingRange(entry.line, endLine);
});
}
private async getBlockFoldingRanges(document: vscode.TextDocument): Promise<vscode.FoldingRange[]> {
const tokens = await this.engine.parse(document);
const multiLineListItems = tokens.filter(isFoldableToken);
return multiLineListItems.map(listItem => {
const start = listItem.map[0];
let end = listItem.map[1] - 1;
if (document.lineAt(end).isEmptyOrWhitespace && end >= start + 1) {
end = end - 1;
}
return new vscode.FoldingRange(start, end, this.getFoldingRangeKind(listItem));
});
}
private getFoldingRangeKind(listItem: Token): vscode.FoldingRangeKind | undefined {
return listItem.type === 'html_block' && listItem.content.startsWith('<!--')
? vscode.FoldingRangeKind.Comment
: undefined;
}
}
const isStartRegion = (t: string) => /^\s*<!--\s*#?region\b.*-->/.test(t);
const isEndRegion = (t: string) => /^\s*<!--\s*#?endregion\b.*-->/.test(t);
const isRegionMarker = (token: Token) =>
token.type === 'html_block' && (isStartRegion(token.content) || isEndRegion(token.content));
const isFoldableToken = (token: Token): boolean => {
switch (token.type) {
case 'fence':
case 'list_item_open':
return token.map[1] > token.map[0];
case 'html_block':
if (isRegionMarker(token)) {
return false;
}
return token.map[1] > token.map[0] + 1;
default:
return false;
}
};

View File

@ -0,0 +1,745 @@
/*---------------------------------------------------------------------------------------------
* Copyright (c) Microsoft Corporation. All rights reserved.
* Licensed under the MIT License. See License.txt in the project root for license information.
*--------------------------------------------------------------------------------------------*/
import * as path from 'path';
import * as vscode from 'vscode';
import * as nls from 'vscode-nls';
import { OpenDocumentLinkCommand, resolveLinkToMarkdownFile } from '../commands/openDocumentLink';
import { Logger } from '../logger';
import { MarkdownContributionProvider } from '../markdownExtensions';
import { Disposable } from '../util/dispose';
import { isMarkdownFile } from '../util/file';
import { normalizeResource, WebviewResourceProvider } from '../util/resources';
import { getVisibleLine, TopmostLineMonitor } from '../util/topmostLineMonitor';
import { MarkdownPreviewConfigurationManager } from './previewConfig';
import { MarkdownContentProvider } from './previewContentProvider';
import { MarkdownEngine } from '../markdownEngine';
const localize = nls.loadMessageBundle();
interface WebviewMessage {
readonly source: string;
}
interface CacheImageSizesMessage extends WebviewMessage {
readonly type: 'cacheImageSizes';
readonly body: { id: string, width: number, height: number; }[];
}
interface RevealLineMessage extends WebviewMessage {
readonly type: 'revealLine';
readonly body: {
readonly line: number;
};
}
interface DidClickMessage extends WebviewMessage {
readonly type: 'didClick';
readonly body: {
readonly line: number;
};
}
interface ClickLinkMessage extends WebviewMessage {
readonly type: 'openLink';
readonly body: {
readonly href: string;
};
}
interface ShowPreviewSecuritySelectorMessage extends WebviewMessage {
readonly type: 'showPreviewSecuritySelector';
}
interface PreviewStyleLoadErrorMessage extends WebviewMessage {
readonly type: 'previewStyleLoadError';
readonly body: {
readonly unloadedStyles: string[];
};
}
export class PreviewDocumentVersion {
private readonly resource: vscode.Uri;
private readonly version: number;
public constructor(document: vscode.TextDocument) {
this.resource = document.uri;
this.version = document.version;
}
public equals(other: PreviewDocumentVersion): boolean {
return this.resource.fsPath === other.resource.fsPath
&& this.version === other.version;
}
}
interface MarkdownPreviewDelegate {
getTitle?(resource: vscode.Uri): string;
getAdditionalState(): {},
openPreviewLinkToMarkdownFile(markdownLink: vscode.Uri, fragment: string): void;
}
class StartingScrollLine {
public readonly type = 'line';
constructor(
public readonly line: number,
) { }
}
class StartingScrollFragment {
public readonly type = 'fragment';
constructor(
public readonly fragment: string,
) { }
}
type StartingScrollLocation = StartingScrollLine | StartingScrollFragment;
class MarkdownPreview extends Disposable implements WebviewResourceProvider {
private readonly delay = 300;
private readonly _resource: vscode.Uri;
private readonly _webviewPanel: vscode.WebviewPanel;
private throttleTimer: any;
private line: number | undefined;
private scrollToFragment: string | undefined;
private firstUpdate = true;
private currentVersion?: PreviewDocumentVersion;
private isScrolling = false;
private _disposed: boolean = false;
private imageInfo: { readonly id: string, readonly width: number, readonly height: number; }[] = [];
constructor(
webview: vscode.WebviewPanel,
resource: vscode.Uri,
startingScroll: StartingScrollLocation | undefined,
private readonly delegate: MarkdownPreviewDelegate,
private readonly engine: MarkdownEngine,
private readonly _contentProvider: MarkdownContentProvider,
private readonly _previewConfigurations: MarkdownPreviewConfigurationManager,
private readonly _logger: Logger,
private readonly _contributionProvider: MarkdownContributionProvider,
) {
super();
this._webviewPanel = webview;
this._resource = resource;
switch (startingScroll?.type) {
case 'line':
if (!isNaN(startingScroll.line!)) {
this.line = startingScroll.line;
}
break;
case 'fragment':
this.scrollToFragment = startingScroll.fragment;
break;
}
this._register(_contributionProvider.onContributionsChanged(() => {
setImmediate(() => this.refresh());
}));
this._register(vscode.workspace.onDidChangeTextDocument(event => {
if (this.isPreviewOf(event.document.uri)) {
this.refresh();
}
}));
this._register(this._webviewPanel.webview.onDidReceiveMessage((e: CacheImageSizesMessage | RevealLineMessage | DidClickMessage | ClickLinkMessage | ShowPreviewSecuritySelectorMessage | PreviewStyleLoadErrorMessage) => {
if (e.source !== this._resource.toString()) {
return;
}
switch (e.type) {
case 'cacheImageSizes':
this.imageInfo = e.body;
break;
case 'revealLine':
this.onDidScrollPreview(e.body.line);
break;
case 'didClick':
this.onDidClickPreview(e.body.line);
break;
case 'openLink':
this.onDidClickPreviewLink(e.body.href);
break;
case 'showPreviewSecuritySelector':
vscode.commands.executeCommand('markdown.showPreviewSecuritySelector', e.source);
break;
case 'previewStyleLoadError':
vscode.window.showWarningMessage(
localize('onPreviewStyleLoadError',
"Could not load 'markdown.styles': {0}",
e.body.unloadedStyles.join(', ')));
break;
}
}));
this.updatePreview();
}
dispose() {
super.dispose();
this._disposed = true;
clearTimeout(this.throttleTimer);
}
public get resource(): vscode.Uri {
return this._resource;
}
public get state() {
return {
resource: this._resource.toString(),
line: this.line,
imageInfo: this.imageInfo,
fragment: this.scrollToFragment,
...this.delegate.getAdditionalState(),
};
}
public refresh() {
// Schedule update if none is pending
if (!this.throttleTimer) {
if (this.firstUpdate) {
this.updatePreview(true);
} else {
this.throttleTimer = setTimeout(() => this.updatePreview(true), this.delay);
}
}
this.firstUpdate = false;
}
private get iconPath() {
const root = vscode.Uri.joinPath(this._contributionProvider.extensionUri, 'media');
return {
light: vscode.Uri.joinPath(root, 'preview-light.svg'),
dark: vscode.Uri.joinPath(root, 'preview-dark.svg'),
};
}
public isPreviewOf(resource: vscode.Uri): boolean {
return this._resource.fsPath === resource.fsPath;
}
public postMessage(msg: any) {
if (!this._disposed) {
this._webviewPanel.webview.postMessage(msg);
}
}
public scrollTo(topLine: number) {
if (this._disposed) {
return;
}
if (this.isScrolling) {
this.isScrolling = false;
return;
}
this._logger.log('updateForView', { markdownFile: this._resource });
this.line = topLine;
this.postMessage({
type: 'updateView',
line: topLine,
source: this._resource.toString()
});
}
private async updatePreview(forceUpdate?: boolean): Promise<void> {
clearTimeout(this.throttleTimer);
this.throttleTimer = undefined;
if (this._disposed) {
return;
}
let document: vscode.TextDocument;
try {
document = await vscode.workspace.openTextDocument(this._resource);
} catch {
await this.showFileNotFoundError();
return;
}
if (this._disposed) {
return;
}
const pendingVersion = new PreviewDocumentVersion(document);
if (!forceUpdate && this.currentVersion?.equals(pendingVersion)) {
if (this.line) {
this.scrollTo(this.line);
}
return;
}
this.currentVersion = pendingVersion;
const content = await this._contentProvider.provideTextDocumentContent(document, this, this._previewConfigurations, this.line, this.state);
// Another call to `doUpdate` may have happened.
// Make sure we are still updating for the correct document
if (this.currentVersion?.equals(pendingVersion)) {
this.setContent(content);
}
}
private onDidScrollPreview(line: number) {
this.line = line;
const config = this._previewConfigurations.loadAndCacheConfiguration(this._resource);
if (!config.scrollEditorWithPreview) {
return;
}
for (const editor of vscode.window.visibleTextEditors) {
if (!this.isPreviewOf(editor.document.uri)) {
continue;
}
this.isScrolling = true;
const sourceLine = Math.floor(line);
const fraction = line - sourceLine;
const text = editor.document.lineAt(sourceLine).text;
const start = Math.floor(fraction * text.length);
editor.revealRange(
new vscode.Range(sourceLine, start, sourceLine + 1, 0),
vscode.TextEditorRevealType.AtTop);
}
}
private async onDidClickPreview(line: number): Promise<void> {
// fix #82457, find currently opened but unfocused source tab
await vscode.commands.executeCommand('markdown.showSource');
for (const visibleEditor of vscode.window.visibleTextEditors) {
if (this.isPreviewOf(visibleEditor.document.uri)) {
const editor = await vscode.window.showTextDocument(visibleEditor.document, visibleEditor.viewColumn);
const position = new vscode.Position(line, 0);
editor.selection = new vscode.Selection(position, position);
return;
}
}
vscode.workspace.openTextDocument(this._resource)
.then(vscode.window.showTextDocument)
.then(undefined, () => {
vscode.window.showErrorMessage(localize('preview.clickOpenFailed', 'Could not open {0}', this._resource.toString()));
});
}
private async showFileNotFoundError() {
this._webviewPanel.webview.html = this._contentProvider.provideFileNotFoundContent(this._resource);
}
private setContent(html: string): void {
if (this._disposed) {
return;
}
if (this.delegate.getTitle) {
this._webviewPanel.title = this.delegate.getTitle(this._resource);
}
this._webviewPanel.iconPath = this.iconPath;
this._webviewPanel.webview.options = this.getWebviewOptions();
this._webviewPanel.webview.html = html;
}
private getWebviewOptions(): vscode.WebviewOptions {
return {
enableScripts: true,
localResourceRoots: this.getLocalResourceRoots()
};
}
private getLocalResourceRoots(): ReadonlyArray<vscode.Uri> {
const baseRoots = Array.from(this._contributionProvider.contributions.previewResourceRoots);
const folder = vscode.workspace.getWorkspaceFolder(this._resource);
if (folder) {
const workspaceRoots = vscode.workspace.workspaceFolders?.map(folder => folder.uri);
if (workspaceRoots) {
baseRoots.push(...workspaceRoots);
}
} else if (!this._resource.scheme || this._resource.scheme === 'file') {
baseRoots.push(vscode.Uri.file(path.dirname(this._resource.fsPath)));
}
return baseRoots.map(root => normalizeResource(this._resource, root));
}
private async onDidClickPreviewLink(href: string) {
let [hrefPath, fragment] = decodeURIComponent(href).split('#');
// We perviously already resolve absolute paths.
// Now make sure we handle relative file paths
if (hrefPath[0] !== '/') {
// Fix #93691, use this.resource.fsPath instead of this.resource.path
hrefPath = path.join(path.dirname(this.resource.fsPath), hrefPath);
}
const config = vscode.workspace.getConfiguration('markdown', this.resource);
const openLinks = config.get<string>('preview.openMarkdownLinks', 'inPreview');
if (openLinks === 'inPreview') {
const markdownLink = await resolveLinkToMarkdownFile(hrefPath);
if (markdownLink) {
this.delegate.openPreviewLinkToMarkdownFile(markdownLink, fragment);
return;
}
}
OpenDocumentLinkCommand.execute(this.engine, { path: hrefPath, fragment, fromResource: this.resource.toJSON() });
}
//#region WebviewResourceProvider
asWebviewUri(resource: vscode.Uri) {
return this._webviewPanel.webview.asWebviewUri(normalizeResource(this._resource, resource));
}
get cspSource() {
return this._webviewPanel.webview.cspSource;
}
//#endregion
}
export interface ManagedMarkdownPreview {
readonly resource: vscode.Uri;
readonly resourceColumn: vscode.ViewColumn;
readonly onDispose: vscode.Event<void>;
readonly onDidChangeViewState: vscode.Event<vscode.WebviewPanelOnDidChangeViewStateEvent>;
dispose(): void;
refresh(): void;
updateConfiguration(): void;
matchesResource(
otherResource: vscode.Uri,
otherPosition: vscode.ViewColumn | undefined,
otherLocked: boolean
): boolean;
}
export class StaticMarkdownPreview extends Disposable implements ManagedMarkdownPreview {
public static revive(
resource: vscode.Uri,
webview: vscode.WebviewPanel,
contentProvider: MarkdownContentProvider,
previewConfigurations: MarkdownPreviewConfigurationManager,
logger: Logger,
contributionProvider: MarkdownContributionProvider,
engine: MarkdownEngine,
): StaticMarkdownPreview {
return new StaticMarkdownPreview(webview, resource, contentProvider, previewConfigurations, logger, contributionProvider, engine);
}
private readonly preview: MarkdownPreview;
private constructor(
private readonly _webviewPanel: vscode.WebviewPanel,
resource: vscode.Uri,
contentProvider: MarkdownContentProvider,
private readonly _previewConfigurations: MarkdownPreviewConfigurationManager,
logger: Logger,
contributionProvider: MarkdownContributionProvider,
engine: MarkdownEngine,
) {
super();
this.preview = this._register(new MarkdownPreview(this._webviewPanel, resource, undefined, {
getAdditionalState: () => { return {}; },
openPreviewLinkToMarkdownFile: () => { /* todo */ }
}, engine, contentProvider, _previewConfigurations, logger, contributionProvider));
this._register(this._webviewPanel.onDidDispose(() => {
this.dispose();
}));
this._register(this._webviewPanel.onDidChangeViewState(e => {
this._onDidChangeViewState.fire(e);
}));
}
private readonly _onDispose = this._register(new vscode.EventEmitter<void>());
public readonly onDispose = this._onDispose.event;
private readonly _onDidChangeViewState = this._register(new vscode.EventEmitter<vscode.WebviewPanelOnDidChangeViewStateEvent>());
public readonly onDidChangeViewState = this._onDidChangeViewState.event;
dispose() {
this._onDispose.fire();
super.dispose();
}
public matchesResource(
_otherResource: vscode.Uri,
_otherPosition: vscode.ViewColumn | undefined,
_otherLocked: boolean
): boolean {
return false;
}
public refresh() {
this.preview.refresh();
}
public updateConfiguration() {
if (this._previewConfigurations.hasConfigurationChanged(this.preview.resource)) {
this.refresh();
}
}
public get resource() {
return this.preview.resource;
}
public get resourceColumn() {
return this._webviewPanel.viewColumn || vscode.ViewColumn.One;
}
}
interface DynamicPreviewInput {
readonly resource: vscode.Uri;
readonly resourceColumn: vscode.ViewColumn;
readonly locked: boolean;
readonly line?: number;
}
/**
* A
*/
export class DynamicMarkdownPreview extends Disposable implements ManagedMarkdownPreview {
public static readonly viewType = 'markdown.preview';
private readonly _resourceColumn: vscode.ViewColumn;
private _locked: boolean;
private readonly _webviewPanel: vscode.WebviewPanel;
private _preview: MarkdownPreview;
public static revive(
input: DynamicPreviewInput,
webview: vscode.WebviewPanel,
contentProvider: MarkdownContentProvider,
previewConfigurations: MarkdownPreviewConfigurationManager,
logger: Logger,
topmostLineMonitor: TopmostLineMonitor,
contributionProvider: MarkdownContributionProvider,
engine: MarkdownEngine,
): DynamicMarkdownPreview {
return new DynamicMarkdownPreview(webview, input,
contentProvider, previewConfigurations, logger, topmostLineMonitor, contributionProvider, engine);
}
public static create(
input: DynamicPreviewInput,
previewColumn: vscode.ViewColumn,
contentProvider: MarkdownContentProvider,
previewConfigurations: MarkdownPreviewConfigurationManager,
logger: Logger,
topmostLineMonitor: TopmostLineMonitor,
contributionProvider: MarkdownContributionProvider,
engine: MarkdownEngine,
): DynamicMarkdownPreview {
const webview = vscode.window.createWebviewPanel(
DynamicMarkdownPreview.viewType,
DynamicMarkdownPreview.getPreviewTitle(input.resource, input.locked),
previewColumn, { enableFindWidget: true, });
return new DynamicMarkdownPreview(webview, input,
contentProvider, previewConfigurations, logger, topmostLineMonitor, contributionProvider, engine);
}
private constructor(
webview: vscode.WebviewPanel,
input: DynamicPreviewInput,
private readonly _contentProvider: MarkdownContentProvider,
private readonly _previewConfigurations: MarkdownPreviewConfigurationManager,
private readonly _logger: Logger,
private readonly _topmostLineMonitor: TopmostLineMonitor,
private readonly _contributionProvider: MarkdownContributionProvider,
private readonly _engine: MarkdownEngine,
) {
super();
this._webviewPanel = webview;
this._resourceColumn = input.resourceColumn;
this._locked = input.locked;
this._preview = this.createPreview(input.resource, typeof input.line === 'number' ? new StartingScrollLine(input.line) : undefined);
this._register(webview.onDidDispose(() => { this.dispose(); }));
this._register(this._webviewPanel.onDidChangeViewState(e => {
this._onDidChangeViewStateEmitter.fire(e);
}));
this._register(this._topmostLineMonitor.onDidChanged(event => {
if (this._preview.isPreviewOf(event.resource)) {
this._preview.scrollTo(event.line);
}
}));
this._register(vscode.window.onDidChangeTextEditorSelection(event => {
if (this._preview.isPreviewOf(event.textEditor.document.uri)) {
this._preview.postMessage({
type: 'onDidChangeTextEditorSelection',
line: event.selections[0].active.line,
source: this._preview.resource.toString()
});
}
}));
this._register(vscode.window.onDidChangeActiveTextEditor(editor => {
// Only allow previewing normal text editors which have a viewColumn: See #101514
if (typeof editor?.viewColumn === 'undefined') {
return;
}
if (isMarkdownFile(editor.document) && !this._locked && !this._preview.isPreviewOf(editor.document.uri)) {
const line = getVisibleLine(editor);
this.update(editor.document.uri, line ? new StartingScrollLine(line) : undefined);
}
}));
}
private readonly _onDisposeEmitter = this._register(new vscode.EventEmitter<void>());
public readonly onDispose = this._onDisposeEmitter.event;
private readonly _onDidChangeViewStateEmitter = this._register(new vscode.EventEmitter<vscode.WebviewPanelOnDidChangeViewStateEvent>());
public readonly onDidChangeViewState = this._onDidChangeViewStateEmitter.event;
dispose() {
this._preview.dispose();
this._webviewPanel.dispose();
this._onDisposeEmitter.fire();
this._onDisposeEmitter.dispose();
super.dispose();
}
public get resource() {
return this._preview.resource;
}
public get resourceColumn() {
return this._resourceColumn;
}
public reveal(viewColumn: vscode.ViewColumn) {
this._webviewPanel.reveal(viewColumn);
}
public refresh() {
this._preview.refresh();
}
public updateConfiguration() {
if (this._previewConfigurations.hasConfigurationChanged(this._preview.resource)) {
this.refresh();
}
}
public update(newResource: vscode.Uri, scrollLocation?: StartingScrollLocation) {
if (this._preview.isPreviewOf(newResource)) {
switch (scrollLocation?.type) {
case 'line':
this._preview.scrollTo(scrollLocation.line);
return;
case 'fragment':
// Workaround. For fragments, just reload the entire preview
break;
default:
return;
}
}
this._preview.dispose();
this._preview = this.createPreview(newResource, scrollLocation);
}
public toggleLock() {
this._locked = !this._locked;
this._webviewPanel.title = DynamicMarkdownPreview.getPreviewTitle(this._preview.resource, this._locked);
}
private static getPreviewTitle(resource: vscode.Uri, locked: boolean): string {
return locked
? localize('lockedPreviewTitle', '[Preview] {0}', path.basename(resource.fsPath))
: localize('previewTitle', 'Preview {0}', path.basename(resource.fsPath));
}
public get position(): vscode.ViewColumn | undefined {
return this._webviewPanel.viewColumn;
}
public matchesResource(
otherResource: vscode.Uri,
otherPosition: vscode.ViewColumn | undefined,
otherLocked: boolean
): boolean {
if (this.position !== otherPosition) {
return false;
}
if (this._locked) {
return otherLocked && this._preview.isPreviewOf(otherResource);
} else {
return !otherLocked;
}
}
public matches(otherPreview: DynamicMarkdownPreview): boolean {
return this.matchesResource(otherPreview._preview.resource, otherPreview.position, otherPreview._locked);
}
private createPreview(resource: vscode.Uri, startingScroll?: StartingScrollLocation): MarkdownPreview {
return new MarkdownPreview(this._webviewPanel, resource, startingScroll, {
getTitle: (resource) => DynamicMarkdownPreview.getPreviewTitle(resource, this._locked),
getAdditionalState: () => {
return {
resourceColumn: this.resourceColumn,
locked: this._locked,
};
},
openPreviewLinkToMarkdownFile: (link: vscode.Uri, fragment?: string) => {
this.update(link, fragment ? new StartingScrollFragment(fragment) : undefined);
}
},
this._engine,
this._contentProvider,
this._previewConfigurations,
this._logger,
this._contributionProvider);
}
}

View File

@ -0,0 +1,93 @@
/*---------------------------------------------------------------------------------------------
* Copyright (c) Microsoft Corporation. All rights reserved.
* Licensed under the MIT License. See License.txt in the project root for license information.
*--------------------------------------------------------------------------------------------*/
import * as vscode from 'vscode';
import { equals } from '../util/arrays';
export class MarkdownPreviewConfiguration {
public static getForResource(resource: vscode.Uri) {
return new MarkdownPreviewConfiguration(resource);
}
public readonly scrollBeyondLastLine: boolean;
public readonly wordWrap: boolean;
public readonly lineBreaks: boolean;
public readonly doubleClickToSwitchToEditor: boolean;
public readonly scrollEditorWithPreview: boolean;
public readonly scrollPreviewWithEditor: boolean;
public readonly markEditorSelection: boolean;
public readonly lineHeight: number;
public readonly fontSize: number;
public readonly fontFamily: string | undefined;
public readonly styles: readonly string[];
private constructor(resource: vscode.Uri) {
const editorConfig = vscode.workspace.getConfiguration('editor', resource);
const markdownConfig = vscode.workspace.getConfiguration('markdown', resource);
const markdownEditorConfig = vscode.workspace.getConfiguration('[markdown]', resource);
this.scrollBeyondLastLine = editorConfig.get<boolean>('scrollBeyondLastLine', false);
this.wordWrap = editorConfig.get<string>('wordWrap', 'off') !== 'off';
if (markdownEditorConfig && markdownEditorConfig['editor.wordWrap']) {
this.wordWrap = markdownEditorConfig['editor.wordWrap'] !== 'off';
}
this.scrollPreviewWithEditor = !!markdownConfig.get<boolean>('preview.scrollPreviewWithEditor', true);
this.scrollEditorWithPreview = !!markdownConfig.get<boolean>('preview.scrollEditorWithPreview', true);
this.lineBreaks = !!markdownConfig.get<boolean>('preview.breaks', false);
this.doubleClickToSwitchToEditor = !!markdownConfig.get<boolean>('preview.doubleClickToSwitchToEditor', true);
this.markEditorSelection = !!markdownConfig.get<boolean>('preview.markEditorSelection', true);
this.fontFamily = markdownConfig.get<string | undefined>('preview.fontFamily', undefined);
this.fontSize = Math.max(8, +markdownConfig.get<number>('preview.fontSize', NaN));
this.lineHeight = Math.max(0.6, +markdownConfig.get<number>('preview.lineHeight', NaN));
this.styles = markdownConfig.get<string[]>('styles', []);
}
public isEqualTo(otherConfig: MarkdownPreviewConfiguration) {
for (const key in this) {
if (this.hasOwnProperty(key) && key !== 'styles') {
if (this[key] !== otherConfig[key]) {
return false;
}
}
}
return equals(this.styles, otherConfig.styles);
}
[key: string]: any;
}
export class MarkdownPreviewConfigurationManager {
private readonly previewConfigurationsForWorkspaces = new Map<string, MarkdownPreviewConfiguration>();
public loadAndCacheConfiguration(
resource: vscode.Uri
): MarkdownPreviewConfiguration {
const config = MarkdownPreviewConfiguration.getForResource(resource);
this.previewConfigurationsForWorkspaces.set(this.getKey(resource), config);
return config;
}
public hasConfigurationChanged(
resource: vscode.Uri
): boolean {
const key = this.getKey(resource);
const currentConfig = this.previewConfigurationsForWorkspaces.get(key);
const newConfig = MarkdownPreviewConfiguration.getForResource(resource);
return (!currentConfig || !currentConfig.isEqualTo(newConfig));
}
private getKey(
resource: vscode.Uri
): string {
const folder = vscode.workspace.getWorkspaceFolder(resource);
return folder ? folder.uri.toString() : '';
}
}

View File

@ -0,0 +1,219 @@
/*---------------------------------------------------------------------------------------------
* Copyright (c) Microsoft Corporation. All rights reserved.
* Licensed under the MIT License. See License.txt in the project root for license information.
*--------------------------------------------------------------------------------------------*/
import * as path from 'path';
import * as vscode from 'vscode';
import * as nls from 'vscode-nls';
import { Logger } from '../logger';
import { MarkdownEngine } from '../markdownEngine';
import { MarkdownContributionProvider } from '../markdownExtensions';
import { ContentSecurityPolicyArbiter, MarkdownPreviewSecurityLevel } from '../security';
import { WebviewResourceProvider } from '../util/resources';
import { MarkdownPreviewConfiguration, MarkdownPreviewConfigurationManager } from './previewConfig';
const localize = nls.loadMessageBundle();
/**
* Strings used inside the markdown preview.
*
* Stored here and then injected in the preview so that they
* can be localized using our normal localization process.
*/
const previewStrings = {
cspAlertMessageText: localize(
'preview.securityMessage.text',
'Some content has been disabled in this document'),
cspAlertMessageTitle: localize(
'preview.securityMessage.title',
'Potentially unsafe or insecure content has been disabled in the markdown preview. Change the Markdown preview security setting to allow insecure content or enable scripts'),
cspAlertMessageLabel: localize(
'preview.securityMessage.label',
'Content Disabled Security Warning')
};
function escapeAttribute(value: string | vscode.Uri): string {
return value.toString().replace(/"/g, '&quot;');
}
export class MarkdownContentProvider {
constructor(
private readonly engine: MarkdownEngine,
private readonly context: vscode.ExtensionContext,
private readonly cspArbiter: ContentSecurityPolicyArbiter,
private readonly contributionProvider: MarkdownContributionProvider,
private readonly logger: Logger
) { }
public async provideTextDocumentContent(
markdownDocument: vscode.TextDocument,
resourceProvider: WebviewResourceProvider,
previewConfigurations: MarkdownPreviewConfigurationManager,
initialLine: number | undefined = undefined,
state?: any
): Promise<string> {
const sourceUri = markdownDocument.uri;
const config = previewConfigurations.loadAndCacheConfiguration(sourceUri);
const initialData = {
source: sourceUri.toString(),
line: initialLine,
lineCount: markdownDocument.lineCount,
scrollPreviewWithEditor: config.scrollPreviewWithEditor,
scrollEditorWithPreview: config.scrollEditorWithPreview,
doubleClickToSwitchToEditor: config.doubleClickToSwitchToEditor,
disableSecurityWarnings: this.cspArbiter.shouldDisableSecurityWarnings(),
webviewResourceRoot: resourceProvider.asWebviewUri(markdownDocument.uri).toString(),
};
this.logger.log('provideTextDocumentContent', initialData);
// Content Security Policy
const nonce = new Date().getTime() + '' + new Date().getMilliseconds();
const csp = this.getCsp(resourceProvider, sourceUri, nonce);
const body = await this.engine.render(markdownDocument);
return `<!DOCTYPE html>
<html style="${escapeAttribute(this.getSettingsOverrideStyles(config))}">
<head>
<meta http-equiv="Content-type" content="text/html;charset=UTF-8">
${csp}
<meta id="vscode-markdown-preview-data"
data-settings="${escapeAttribute(JSON.stringify(initialData))}"
data-strings="${escapeAttribute(JSON.stringify(previewStrings))}"
data-state="${escapeAttribute(JSON.stringify(state || {}))}">
<script src="${this.extensionResourcePath(resourceProvider, 'pre.js')}" nonce="${nonce}"></script>
${this.getStyles(resourceProvider, sourceUri, config, state)}
<base href="${resourceProvider.asWebviewUri(markdownDocument.uri)}">
</head>
<body class="vscode-body ${config.scrollBeyondLastLine ? 'scrollBeyondLastLine' : ''} ${config.wordWrap ? 'wordWrap' : ''} ${config.markEditorSelection ? 'showEditorSelection' : ''}">
${body}
<div class="code-line" data-line="${markdownDocument.lineCount}"></div>
${this.getScripts(resourceProvider, nonce)}
</body>
</html>`;
}
public provideFileNotFoundContent(
resource: vscode.Uri,
): string {
const resourcePath = path.basename(resource.fsPath);
const body = localize('preview.notFound', '{0} cannot be found', resourcePath);
return `<!DOCTYPE html>
<html>
<body class="vscode-body">
${body}
</body>
</html>`;
}
private extensionResourcePath(resourceProvider: WebviewResourceProvider, mediaFile: string): string {
const webviewResource = resourceProvider.asWebviewUri(
vscode.Uri.joinPath(this.context.extensionUri, 'media', mediaFile));
return webviewResource.toString();
}
private fixHref(resourceProvider: WebviewResourceProvider, resource: vscode.Uri, href: string): string {
if (!href) {
return href;
}
if (href.startsWith('http:') || href.startsWith('https:') || href.startsWith('file:')) {
return href;
}
// Assume it must be a local file
if (path.isAbsolute(href)) {
return resourceProvider.asWebviewUri(vscode.Uri.file(href)).toString();
}
// Use a workspace relative path if there is a workspace
const root = vscode.workspace.getWorkspaceFolder(resource);
if (root) {
return resourceProvider.asWebviewUri(vscode.Uri.joinPath(root.uri, href)).toString();
}
// Otherwise look relative to the markdown file
return resourceProvider.asWebviewUri(vscode.Uri.file(path.join(path.dirname(resource.fsPath), href))).toString();
}
private computeCustomStyleSheetIncludes(resourceProvider: WebviewResourceProvider, resource: vscode.Uri, config: MarkdownPreviewConfiguration): string {
if (!Array.isArray(config.styles)) {
return '';
}
const out: string[] = [];
for (const style of config.styles) {
out.push(`<link rel="stylesheet" class="code-user-style" data-source="${escapeAttribute(style)}" href="${escapeAttribute(this.fixHref(resourceProvider, resource, style))}" type="text/css" media="screen">`);
}
return out.join('\n');
}
private getSettingsOverrideStyles(config: MarkdownPreviewConfiguration): string {
return [
config.fontFamily ? `--markdown-font-family: ${config.fontFamily};` : '',
isNaN(config.fontSize) ? '' : `--markdown-font-size: ${config.fontSize}px;`,
isNaN(config.lineHeight) ? '' : `--markdown-line-height: ${config.lineHeight};`,
].join(' ');
}
private getImageStabilizerStyles(state?: any) {
let ret = '<style>\n';
if (state && state.imageInfo) {
state.imageInfo.forEach((imgInfo: any) => {
ret += `#${imgInfo.id}.loading {
height: ${imgInfo.height}px;
width: ${imgInfo.width}px;
}\n`;
});
}
ret += '</style>\n';
return ret;
}
private getStyles(resourceProvider: WebviewResourceProvider, resource: vscode.Uri, config: MarkdownPreviewConfiguration, state?: any): string {
const baseStyles: string[] = [];
for (const resource of this.contributionProvider.contributions.previewStyles) {
baseStyles.push(`<link rel="stylesheet" type="text/css" href="${escapeAttribute(resourceProvider.asWebviewUri(resource))}">`);
}
return `${baseStyles.join('\n')}
${this.computeCustomStyleSheetIncludes(resourceProvider, resource, config)}
${this.getImageStabilizerStyles(state)}`;
}
private getScripts(resourceProvider: WebviewResourceProvider, nonce: string): string {
const out: string[] = [];
for (const resource of this.contributionProvider.contributions.previewScripts) {
out.push(`<script async
src="${escapeAttribute(resourceProvider.asWebviewUri(resource))}"
nonce="${nonce}"
charset="UTF-8"></script>`);
}
return out.join('\n');
}
private getCsp(
provider: WebviewResourceProvider,
resource: vscode.Uri,
nonce: string
): string {
const rule = provider.cspSource;
switch (this.cspArbiter.getSecurityLevelForResource(resource)) {
case MarkdownPreviewSecurityLevel.AllowInsecureContent:
return `<meta http-equiv="Content-Security-Policy" content="default-src 'none'; img-src 'self' ${rule} http: https: data:; media-src 'self' ${rule} http: https: data:; script-src 'nonce-${nonce}'; style-src 'self' ${rule} 'unsafe-inline' http: https: data:; font-src 'self' ${rule} http: https: data:;">`;
case MarkdownPreviewSecurityLevel.AllowInsecureLocalContent:
return `<meta http-equiv="Content-Security-Policy" content="default-src 'none'; img-src 'self' ${rule} https: data: http://localhost:* http://127.0.0.1:*; media-src 'self' ${rule} https: data: http://localhost:* http://127.0.0.1:*; script-src 'nonce-${nonce}'; style-src 'self' ${rule} 'unsafe-inline' https: data: http://localhost:* http://127.0.0.1:*; font-src 'self' ${rule} https: data: http://localhost:* http://127.0.0.1:*;">`;
case MarkdownPreviewSecurityLevel.AllowScriptsAndAllContent:
return '<meta http-equiv="Content-Security-Policy" content="">';
case MarkdownPreviewSecurityLevel.Strict:
default:
return `<meta http-equiv="Content-Security-Policy" content="default-src 'none'; img-src 'self' ${rule} https: data:; media-src 'self' ${rule} https: data:; script-src 'nonce-${nonce}'; style-src 'self' ${rule} 'unsafe-inline' https: data:; font-src 'self' ${rule} https: data:;">`;
}
}
}

View File

@ -0,0 +1,239 @@
/*---------------------------------------------------------------------------------------------
* Copyright (c) Microsoft Corporation. All rights reserved.
* Licensed under the MIT License. See License.txt in the project root for license information.
*--------------------------------------------------------------------------------------------*/
import * as vscode from 'vscode';
import { Logger } from '../logger';
import { MarkdownEngine } from '../markdownEngine';
import { MarkdownContributionProvider } from '../markdownExtensions';
import { Disposable, disposeAll } from '../util/dispose';
import { TopmostLineMonitor } from '../util/topmostLineMonitor';
import { DynamicMarkdownPreview, ManagedMarkdownPreview, StaticMarkdownPreview } from './preview';
import { MarkdownPreviewConfigurationManager } from './previewConfig';
import { MarkdownContentProvider } from './previewContentProvider';
export interface DynamicPreviewSettings {
readonly resourceColumn: vscode.ViewColumn;
readonly previewColumn: vscode.ViewColumn;
readonly locked: boolean;
}
class PreviewStore<T extends ManagedMarkdownPreview> extends Disposable {
private readonly _previews = new Set<T>();
public dispose(): void {
super.dispose();
for (const preview of this._previews) {
preview.dispose();
}
this._previews.clear();
}
[Symbol.iterator](): Iterator<T> {
return this._previews[Symbol.iterator]();
}
public get(resource: vscode.Uri, previewSettings: DynamicPreviewSettings): T | undefined {
for (const preview of this._previews) {
if (preview.matchesResource(resource, previewSettings.previewColumn, previewSettings.locked)) {
return preview;
}
}
return undefined;
}
public add(preview: T) {
this._previews.add(preview);
}
public delete(preview: T) {
this._previews.delete(preview);
}
}
export class MarkdownPreviewManager extends Disposable implements vscode.WebviewPanelSerializer, vscode.CustomTextEditorProvider {
private static readonly markdownPreviewActiveContextKey = 'markdownPreviewFocus';
private readonly _topmostLineMonitor = new TopmostLineMonitor();
private readonly _previewConfigurations = new MarkdownPreviewConfigurationManager();
private readonly _dynamicPreviews = this._register(new PreviewStore<DynamicMarkdownPreview>());
private readonly _staticPreviews = this._register(new PreviewStore<StaticMarkdownPreview>());
private _activePreview: ManagedMarkdownPreview | undefined = undefined;
private readonly customEditorViewType = 'vscode.markdown.preview.editor';
public constructor(
private readonly _contentProvider: MarkdownContentProvider,
private readonly _logger: Logger,
private readonly _contributions: MarkdownContributionProvider,
private readonly _engine: MarkdownEngine,
) {
super();
this._register(vscode.window.registerWebviewPanelSerializer(DynamicMarkdownPreview.viewType, this));
this._register(vscode.window.registerCustomEditorProvider(this.customEditorViewType, this));
}
public refresh() {
for (const preview of this._dynamicPreviews) {
preview.refresh();
}
for (const preview of this._staticPreviews) {
preview.refresh();
}
}
public updateConfiguration() {
for (const preview of this._dynamicPreviews) {
preview.updateConfiguration();
}
for (const preview of this._staticPreviews) {
preview.updateConfiguration();
}
}
public openDynamicPreview(
resource: vscode.Uri,
settings: DynamicPreviewSettings
): void {
let preview = this._dynamicPreviews.get(resource, settings);
if (preview) {
preview.reveal(settings.previewColumn);
} else {
preview = this.createNewDynamicPreview(resource, settings);
}
preview.update(resource);
}
public get activePreviewResource() {
return this._activePreview?.resource;
}
public get activePreviewResourceColumn() {
return this._activePreview?.resourceColumn;
}
public toggleLock() {
const preview = this._activePreview;
if (preview instanceof DynamicMarkdownPreview) {
preview.toggleLock();
// Close any previews that are now redundant, such as having two dynamic previews in the same editor group
for (const otherPreview of this._dynamicPreviews) {
if (otherPreview !== preview && preview.matches(otherPreview)) {
otherPreview.dispose();
}
}
}
}
public async deserializeWebviewPanel(
webview: vscode.WebviewPanel,
state: any
): Promise<void> {
const resource = vscode.Uri.parse(state.resource);
const locked = state.locked;
const line = state.line;
const resourceColumn = state.resourceColumn;
const preview = await DynamicMarkdownPreview.revive(
{ resource, locked, line, resourceColumn },
webview,
this._contentProvider,
this._previewConfigurations,
this._logger,
this._topmostLineMonitor,
this._contributions,
this._engine);
this.registerDynamicPreview(preview);
}
public async resolveCustomTextEditor(
document: vscode.TextDocument,
webview: vscode.WebviewPanel
): Promise<void> {
const preview = StaticMarkdownPreview.revive(
document.uri,
webview,
this._contentProvider,
this._previewConfigurations,
this._logger,
this._contributions,
this._engine);
this.registerStaticPreview(preview);
}
private createNewDynamicPreview(
resource: vscode.Uri,
previewSettings: DynamicPreviewSettings
): DynamicMarkdownPreview {
const preview = DynamicMarkdownPreview.create(
{
resource,
resourceColumn: previewSettings.resourceColumn,
locked: previewSettings.locked,
},
previewSettings.previewColumn,
this._contentProvider,
this._previewConfigurations,
this._logger,
this._topmostLineMonitor,
this._contributions,
this._engine);
this.setPreviewActiveContext(true);
this._activePreview = preview;
return this.registerDynamicPreview(preview);
}
private registerDynamicPreview(preview: DynamicMarkdownPreview): DynamicMarkdownPreview {
this._dynamicPreviews.add(preview);
preview.onDispose(() => {
this._dynamicPreviews.delete(preview);
});
this.trackActive(preview);
preview.onDidChangeViewState(() => {
// Remove other dynamic previews in our column
disposeAll(Array.from(this._dynamicPreviews).filter(otherPreview => preview !== otherPreview && preview.matches(otherPreview)));
});
return preview;
}
private registerStaticPreview(preview: StaticMarkdownPreview): StaticMarkdownPreview {
this._staticPreviews.add(preview);
preview.onDispose(() => {
this._staticPreviews.delete(preview);
});
this.trackActive(preview);
return preview;
}
private trackActive(preview: ManagedMarkdownPreview): void {
preview.onDidChangeViewState(({ webviewPanel }) => {
this.setPreviewActiveContext(webviewPanel.active);
this._activePreview = webviewPanel.active ? preview : undefined;
});
preview.onDispose(() => {
if (this._activePreview === preview) {
this.setPreviewActiveContext(false);
this._activePreview = undefined;
}
});
}
private setPreviewActiveContext(value: boolean) {
vscode.commands.executeCommand('setContext', MarkdownPreviewManager.markdownPreviewActiveContextKey, value);
}
}

View File

@ -0,0 +1,238 @@
/*---------------------------------------------------------------------------------------------
* Copyright (c) Microsoft Corporation. All rights reserved.
* Licensed under the MIT License. See License.txt in the project root for license information.
*--------------------------------------------------------------------------------------------*/
import { Token } from 'markdown-it';
import * as vscode from 'vscode';
import { MarkdownEngine } from '../markdownEngine';
import { TableOfContentsProvider, TocEntry } from '../tableOfContentsProvider';
export default class MarkdownSmartSelect implements vscode.SelectionRangeProvider {
constructor(
private readonly engine: MarkdownEngine
) { }
public async provideSelectionRanges(document: vscode.TextDocument, positions: vscode.Position[], _token: vscode.CancellationToken): Promise<vscode.SelectionRange[] | undefined> {
let promises = await Promise.all(positions.map((position) => {
return this.provideSelectionRange(document, position, _token);
}));
return promises.filter(item => item !== undefined) as vscode.SelectionRange[];
}
private async provideSelectionRange(document: vscode.TextDocument, position: vscode.Position, _token: vscode.CancellationToken): Promise<vscode.SelectionRange | undefined> {
const headerRange = await this.getHeaderSelectionRange(document, position);
const blockRange = await this.getBlockSelectionRange(document, position, headerRange);
return blockRange ? blockRange : headerRange ? headerRange : undefined;
}
private async getBlockSelectionRange(document: vscode.TextDocument, position: vscode.Position, headerRange?: vscode.SelectionRange): Promise<vscode.SelectionRange | undefined> {
const tokens = await this.engine.parse(document);
let blockTokens = getTokensForPosition(tokens, position);
if (blockTokens.length === 0) {
return undefined;
}
let parentRange = headerRange ? headerRange : createBlockRange(document, position.line, blockTokens.shift());
let currentRange: vscode.SelectionRange | undefined;
for (const token of blockTokens) {
currentRange = createBlockRange(document, position.line, token, parentRange);
if (currentRange) {
parentRange = currentRange;
}
}
if (currentRange) {
return currentRange;
} else {
return parentRange;
}
}
private async getHeaderSelectionRange(document: vscode.TextDocument, position: vscode.Position): Promise<vscode.SelectionRange | undefined> {
const tocProvider = new TableOfContentsProvider(this.engine, document);
const toc = await tocProvider.getToc();
let headerInfo = getHeadersForPosition(toc, position);
let headers = headerInfo.headers;
let parentRange: vscode.SelectionRange | undefined;
let currentRange: vscode.SelectionRange | undefined;
for (let i = 0; i < headers.length; i++) {
currentRange = createHeaderRange(i === headers.length - 1, headerInfo.headerOnThisLine, headers[i], parentRange, getFirstChildHeader(document, headers[i], toc));
if (currentRange && currentRange.parent) {
parentRange = currentRange;
}
}
return currentRange;
}
}
function getFirstChildHeader(document: vscode.TextDocument, header?: TocEntry, toc?: TocEntry[]): vscode.Position | undefined {
let childRange: vscode.Position | undefined;
if (header && toc) {
let children = toc.filter(t => header.location.range.contains(t.location.range) && t.location.range.start.line > header.location.range.start.line).sort((t1, t2) => t1.line - t2.line);
if (children.length > 0) {
childRange = children[0].location.range.start;
let lineText = document.lineAt(childRange.line - 1).text;
return childRange ? childRange.translate(-1, lineText.length) : undefined;
}
}
return undefined;
}
function getTokensForPosition(tokens: Token[], position: vscode.Position): Token[] {
let enclosingTokens = tokens.filter(token => token.map && (token.map[0] <= position.line && token.map[1] > position.line) && isBlockElement(token));
if (enclosingTokens.length === 0) {
return [];
}
let sortedTokens = enclosingTokens.sort((token1, token2) => (token2.map[1] - token2.map[0]) - (token1.map[1] - token1.map[0]));
return sortedTokens;
}
function getHeadersForPosition(toc: TocEntry[], position: vscode.Position): { headers: TocEntry[], headerOnThisLine: boolean } {
let enclosingHeaders = toc.filter(header => header.location.range.start.line <= position.line && header.location.range.end.line >= position.line);
let sortedHeaders = enclosingHeaders.sort((header1, header2) => (header1.line - position.line) - (header2.line - position.line));
let onThisLine = toc.find(header => header.line === position.line) !== undefined;
return {
headers: sortedHeaders,
headerOnThisLine: onThisLine
};
}
function isBlockElement(token: Token): boolean {
return !['list_item_close', 'paragraph_close', 'bullet_list_close', 'inline', 'heading_close', 'heading_open'].includes(token.type);
}
function createHeaderRange(isClosestHeaderToPosition: boolean, onHeaderLine: boolean, header?: TocEntry, parent?: vscode.SelectionRange, childStart?: vscode.Position): vscode.SelectionRange | undefined {
if (header) {
let contentRange = new vscode.Range(header.location.range.start.translate(1), header.location.range.end);
let headerPlusContentRange = header.location.range;
let partialContentRange = childStart && isClosestHeaderToPosition ? contentRange.with(undefined, childStart) : undefined;
if (onHeaderLine && isClosestHeaderToPosition && childStart) {
return new vscode.SelectionRange(header.location.range.with(undefined, childStart), new vscode.SelectionRange(header.location.range, parent));
} else if (onHeaderLine && isClosestHeaderToPosition) {
return new vscode.SelectionRange(header.location.range, parent);
} else if (parent && parent.range.contains(headerPlusContentRange)) {
if (partialContentRange) {
return new vscode.SelectionRange(partialContentRange, new vscode.SelectionRange(contentRange, (new vscode.SelectionRange(headerPlusContentRange, parent))));
} else {
return new vscode.SelectionRange(contentRange, new vscode.SelectionRange(headerPlusContentRange, parent));
}
} else if (partialContentRange) {
return new vscode.SelectionRange(partialContentRange, new vscode.SelectionRange(contentRange, (new vscode.SelectionRange(headerPlusContentRange))));
} else {
return new vscode.SelectionRange(contentRange, new vscode.SelectionRange(headerPlusContentRange));
}
} else {
return undefined;
}
}
function createBlockRange(document: vscode.TextDocument, cursorLine: number, block?: Token, parent?: vscode.SelectionRange): vscode.SelectionRange | undefined {
if (block) {
if (block.type === 'fence') {
return createFencedRange(block, cursorLine, document, parent);
} else {
let startLine = document.lineAt(block.map[0]).isEmptyOrWhitespace ? block.map[0] + 1 : block.map[0];
let endLine = startLine !== block.map[1] && isList(block.type) ? block.map[1] - 1 : block.map[1];
let startPos = new vscode.Position(startLine, 0);
let endPos = new vscode.Position(endLine, getEndCharacter(document, startLine, endLine));
let range = new vscode.Range(startPos, endPos);
if (parent && parent.range.contains(range) && !parent.range.isEqual(range)) {
return new vscode.SelectionRange(range, parent);
} else if (parent) {
if (rangeLinesEqual(range, parent.range)) {
return range.end.character > parent.range.end.character ? new vscode.SelectionRange(range) : parent;
} else if (parent.range.end.line + 1 === range.end.line) {
let adjustedRange = new vscode.Range(range.start, range.end.translate(-1, parent.range.end.character));
if (adjustedRange.isEqual(parent.range)) {
return parent;
} else {
return new vscode.SelectionRange(adjustedRange, parent);
}
} else if (parent.range.end.line === range.end.line) {
let adjustedRange = new vscode.Range(parent.range.start, range.end.translate(undefined, parent.range.end.character));
if (adjustedRange.isEqual(parent.range)) {
return parent;
} else {
return new vscode.SelectionRange(adjustedRange, parent.parent);
}
} else {
return parent;
}
} else {
return new vscode.SelectionRange(range);
}
}
} else {
return undefined;
}
}
function createFencedRange(token: Token, cursorLine: number, document: vscode.TextDocument, parent?: vscode.SelectionRange): vscode.SelectionRange {
const startLine = token.map[0];
const endLine = token.map[1] - 1;
let onFenceLine = cursorLine === startLine || cursorLine === endLine;
let fenceRange = new vscode.Range(new vscode.Position(startLine, 0), new vscode.Position(endLine, document.lineAt(endLine).text.length));
let contentRange = endLine - startLine > 2 && !onFenceLine ? new vscode.Range(new vscode.Position(startLine + 1, 0), new vscode.Position(endLine - 1, getEndCharacter(document, startLine + 1, endLine))) : undefined;
if (parent && contentRange) {
if (parent.range.contains(fenceRange) && !parent.range.isEqual(fenceRange)) {
return new vscode.SelectionRange(contentRange, new vscode.SelectionRange(fenceRange, parent));
} else if (parent.range.isEqual(fenceRange)) {
return new vscode.SelectionRange(contentRange, parent);
} else if (rangeLinesEqual(fenceRange, parent.range)) {
let revisedRange = fenceRange.end.character > parent.range.end.character ? fenceRange : parent.range;
return new vscode.SelectionRange(contentRange, new vscode.SelectionRange(revisedRange, getRealParent(parent, revisedRange)));
} else if (parent.range.end.line === fenceRange.end.line) {
parent.range.end.translate(undefined, fenceRange.end.character);
return new vscode.SelectionRange(contentRange, new vscode.SelectionRange(fenceRange, parent));
}
} else if (contentRange) {
return new vscode.SelectionRange(contentRange, new vscode.SelectionRange(fenceRange));
} else if (parent) {
if (parent.range.contains(fenceRange) && !parent.range.isEqual(fenceRange)) {
return new vscode.SelectionRange(fenceRange, parent);
} else if (parent.range.isEqual(fenceRange)) {
return parent;
} else if (rangeLinesEqual(fenceRange, parent.range)) {
let revisedRange = fenceRange.end.character > parent.range.end.character ? fenceRange : parent.range;
return new vscode.SelectionRange(revisedRange, parent.parent);
} else if (parent.range.end.line === fenceRange.end.line) {
parent.range.end.translate(undefined, fenceRange.end.character);
return new vscode.SelectionRange(fenceRange, parent);
}
}
return new vscode.SelectionRange(fenceRange, parent);
}
function isList(type: string): boolean {
return type ? ['ordered_list_open', 'list_item_open', 'bullet_list_open'].includes(type) : false;
}
function getEndCharacter(document: vscode.TextDocument, startLine: number, endLine: number): number {
let startLength = document.lineAt(startLine).text ? document.lineAt(startLine).text.length : 0;
let endLength = document.lineAt(startLine).text ? document.lineAt(startLine).text.length : 0;
let endChar = Math.max(startLength, endLength);
return startLine !== endLine ? 0 : endChar;
}
function getRealParent(parent: vscode.SelectionRange, range: vscode.Range) {
let currentParent: vscode.SelectionRange | undefined = parent;
while (currentParent && !currentParent.range.contains(range)) {
currentParent = currentParent.parent;
}
return currentParent;
}
function rangeLinesEqual(range: vscode.Range, parent: vscode.Range) {
return range.start.line === parent.start.line && range.end.line === parent.end.line;
}

View File

@ -0,0 +1,163 @@
/*---------------------------------------------------------------------------------------------
* Copyright (c) Microsoft Corporation. All rights reserved.
* Licensed under the MIT License. See License.txt in the project root for license information.
*--------------------------------------------------------------------------------------------*/
import * as vscode from 'vscode';
import { Disposable } from '../util/dispose';
import { isMarkdownFile } from '../util/file';
import { Lazy, lazy } from '../util/lazy';
import MDDocumentSymbolProvider from './documentSymbolProvider';
import { SkinnyTextDocument, SkinnyTextLine } from '../tableOfContentsProvider';
import { flatten } from '../util/arrays';
export interface WorkspaceMarkdownDocumentProvider {
getAllMarkdownDocuments(): Thenable<Iterable<SkinnyTextDocument>>;
readonly onDidChangeMarkdownDocument: vscode.Event<SkinnyTextDocument>;
readonly onDidCreateMarkdownDocument: vscode.Event<SkinnyTextDocument>;
readonly onDidDeleteMarkdownDocument: vscode.Event<vscode.Uri>;
}
class VSCodeWorkspaceMarkdownDocumentProvider extends Disposable implements WorkspaceMarkdownDocumentProvider {
private readonly _onDidChangeMarkdownDocumentEmitter = this._register(new vscode.EventEmitter<SkinnyTextDocument>());
private readonly _onDidCreateMarkdownDocumentEmitter = this._register(new vscode.EventEmitter<SkinnyTextDocument>());
private readonly _onDidDeleteMarkdownDocumentEmitter = this._register(new vscode.EventEmitter<vscode.Uri>());
private _watcher: vscode.FileSystemWatcher | undefined;
async getAllMarkdownDocuments() {
const resources = await vscode.workspace.findFiles('**/*.md', '**/node_modules/**');
const docs = await Promise.all(resources.map(doc => this.getMarkdownDocument(doc)));
return docs.filter(doc => !!doc) as SkinnyTextDocument[];
}
public get onDidChangeMarkdownDocument() {
this.ensureWatcher();
return this._onDidChangeMarkdownDocumentEmitter.event;
}
public get onDidCreateMarkdownDocument() {
this.ensureWatcher();
return this._onDidCreateMarkdownDocumentEmitter.event;
}
public get onDidDeleteMarkdownDocument() {
this.ensureWatcher();
return this._onDidDeleteMarkdownDocumentEmitter.event;
}
private ensureWatcher(): void {
if (this._watcher) {
return;
}
this._watcher = this._register(vscode.workspace.createFileSystemWatcher('**/*.md'));
this._watcher.onDidChange(async resource => {
const document = await this.getMarkdownDocument(resource);
if (document) {
this._onDidChangeMarkdownDocumentEmitter.fire(document);
}
}, null, this._disposables);
this._watcher.onDidCreate(async resource => {
const document = await this.getMarkdownDocument(resource);
if (document) {
this._onDidCreateMarkdownDocumentEmitter.fire(document);
}
}, null, this._disposables);
this._watcher.onDidDelete(async resource => {
this._onDidDeleteMarkdownDocumentEmitter.fire(resource);
}, null, this._disposables);
vscode.workspace.onDidChangeTextDocument(e => {
if (isMarkdownFile(e.document)) {
this._onDidChangeMarkdownDocumentEmitter.fire(e.document);
}
}, null, this._disposables);
}
private async getMarkdownDocument(resource: vscode.Uri): Promise<SkinnyTextDocument | undefined> {
const matchingDocuments = vscode.workspace.textDocuments.filter((doc) => doc.uri.toString() === resource.toString());
if (matchingDocuments.length !== 0) {
return matchingDocuments[0];
}
const bytes = await vscode.workspace.fs.readFile(resource);
// We assume that markdown is in UTF-8
const text = Buffer.from(bytes).toString('utf-8');
const lines: SkinnyTextLine[] = [];
const parts = text.split(/(\r?\n)/);
const lineCount = Math.floor(parts.length / 2) + 1;
for (let line = 0; line < lineCount; line++) {
lines.push({
text: parts[line * 2]
});
}
return {
uri: resource,
version: 0,
lineCount: lineCount,
lineAt: (index) => {
return lines[index];
},
getText: () => {
return text;
}
};
}
}
export default class MarkdownWorkspaceSymbolProvider extends Disposable implements vscode.WorkspaceSymbolProvider {
private _symbolCache = new Map<string, Lazy<Thenable<vscode.SymbolInformation[]>>>();
private _symbolCachePopulated: boolean = false;
public constructor(
private _symbolProvider: MDDocumentSymbolProvider,
private _workspaceMarkdownDocumentProvider: WorkspaceMarkdownDocumentProvider = new VSCodeWorkspaceMarkdownDocumentProvider()
) {
super();
}
public async provideWorkspaceSymbols(query: string): Promise<vscode.SymbolInformation[]> {
if (!this._symbolCachePopulated) {
await this.populateSymbolCache();
this._symbolCachePopulated = true;
this._workspaceMarkdownDocumentProvider.onDidChangeMarkdownDocument(this.onDidChangeDocument, this, this._disposables);
this._workspaceMarkdownDocumentProvider.onDidCreateMarkdownDocument(this.onDidChangeDocument, this, this._disposables);
this._workspaceMarkdownDocumentProvider.onDidDeleteMarkdownDocument(this.onDidDeleteDocument, this, this._disposables);
}
const allSymbolsSets = await Promise.all(Array.from(this._symbolCache.values()).map(x => x.value));
const allSymbols = flatten(allSymbolsSets);
return allSymbols.filter(symbolInformation => symbolInformation.name.toLowerCase().indexOf(query.toLowerCase()) !== -1);
}
public async populateSymbolCache(): Promise<void> {
const markdownDocumentUris = await this._workspaceMarkdownDocumentProvider.getAllMarkdownDocuments();
for (const document of markdownDocumentUris) {
this._symbolCache.set(document.uri.fsPath, this.getSymbols(document));
}
}
private getSymbols(document: SkinnyTextDocument): Lazy<Thenable<vscode.SymbolInformation[]>> {
return lazy(async () => {
return this._symbolProvider.provideDocumentSymbolInformation(document);
});
}
private onDidChangeDocument(document: SkinnyTextDocument) {
this._symbolCache.set(document.uri.fsPath, this.getSymbols(document));
}
private onDidDeleteDocument(resource: vscode.Uri) {
this._symbolCache.delete(resource.fsPath);
}
}

View File

@ -0,0 +1,87 @@
/*---------------------------------------------------------------------------------------------
* Copyright (c) Microsoft Corporation. All rights reserved.
* Licensed under the MIT License. See License.txt in the project root for license information.
*--------------------------------------------------------------------------------------------*/
import * as vscode from 'vscode';
import { lazy } from './util/lazy';
enum Trace {
Off,
Verbose
}
namespace Trace {
export function fromString(value: string): Trace {
value = value.toLowerCase();
switch (value) {
case 'off':
return Trace.Off;
case 'verbose':
return Trace.Verbose;
default:
return Trace.Off;
}
}
}
function isString(value: any): value is string {
return Object.prototype.toString.call(value) === '[object String]';
}
export class Logger {
private trace?: Trace;
private readonly outputChannel = lazy(() => vscode.window.createOutputChannel('Markdown'));
constructor() {
this.updateConfiguration();
}
public log(message: string, data?: any): void {
if (this.trace === Trace.Verbose) {
this.appendLine(`[Log - ${this.now()}] ${message}`);
if (data) {
this.appendLine(Logger.data2String(data));
}
}
}
private now(): string {
const now = new Date();
return padLeft(now.getUTCHours() + '', 2, '0')
+ ':' + padLeft(now.getMinutes() + '', 2, '0')
+ ':' + padLeft(now.getUTCSeconds() + '', 2, '0') + '.' + now.getMilliseconds();
}
public updateConfiguration() {
this.trace = this.readTrace();
}
private appendLine(value: string) {
return this.outputChannel.value.appendLine(value);
}
private readTrace(): Trace {
return Trace.fromString(vscode.workspace.getConfiguration().get<string>('markdown.trace', 'off'));
}
private static data2String(data: any): string {
if (data instanceof Error) {
if (isString(data.stack)) {
return data.stack;
}
return (data as Error).message;
}
if (isString(data)) {
return data;
}
return JSON.stringify(data, undefined, 2);
}
}
function padLeft(s: string, n: number, pad = ' ') {
return pad.repeat(Math.max(0, n - s.length)) + s;
}

View File

@ -0,0 +1,358 @@
/*---------------------------------------------------------------------------------------------
* Copyright (c) Microsoft Corporation. All rights reserved.
* Licensed under the MIT License. See License.txt in the project root for license information.
*--------------------------------------------------------------------------------------------*/
import { MarkdownIt, Token } from 'markdown-it';
import * as path from 'path';
import * as vscode from 'vscode';
import { MarkdownContributionProvider as MarkdownContributionProvider } from './markdownExtensions';
import { Slugifier } from './slugify';
import { SkinnyTextDocument } from './tableOfContentsProvider';
import { hash } from './util/hash';
import { isOfScheme, MarkdownFileExtensions, Schemes } from './util/links';
const UNICODE_NEWLINE_REGEX = /\u2028|\u2029/g;
interface MarkdownItConfig {
readonly breaks: boolean;
readonly linkify: boolean;
}
class TokenCache {
private cachedDocument?: {
readonly uri: vscode.Uri;
readonly version: number;
readonly config: MarkdownItConfig;
};
private tokens?: Token[];
public tryGetCached(document: SkinnyTextDocument, config: MarkdownItConfig): Token[] | undefined {
if (this.cachedDocument
&& this.cachedDocument.uri.toString() === document.uri.toString()
&& this.cachedDocument.version === document.version
&& this.cachedDocument.config.breaks === config.breaks
&& this.cachedDocument.config.linkify === config.linkify
) {
return this.tokens;
}
return undefined;
}
public update(document: SkinnyTextDocument, config: MarkdownItConfig, tokens: Token[]) {
this.cachedDocument = {
uri: document.uri,
version: document.version,
config,
};
this.tokens = tokens;
}
public clean(): void {
this.cachedDocument = undefined;
this.tokens = undefined;
}
}
export class MarkdownEngine {
private md?: Promise<MarkdownIt>;
private currentDocument?: vscode.Uri;
private _slugCount = new Map<string, number>();
private _tokenCache = new TokenCache();
public constructor(
private readonly contributionProvider: MarkdownContributionProvider,
private readonly slugifier: Slugifier,
) {
contributionProvider.onContributionsChanged(() => {
// Markdown plugin contributions may have changed
this.md = undefined;
});
}
private async getEngine(config: MarkdownItConfig): Promise<MarkdownIt> {
if (!this.md) {
this.md = import('markdown-it').then(async markdownIt => {
let md: MarkdownIt = markdownIt(await getMarkdownOptions(() => md));
for (const plugin of this.contributionProvider.contributions.markdownItPlugins.values()) {
try {
md = (await plugin)(md);
} catch {
// noop
}
}
const frontMatterPlugin = require('markdown-it-front-matter');
// Extract rules from front matter plugin and apply at a lower precedence
let fontMatterRule: any;
frontMatterPlugin({
block: {
ruler: {
before: (_id: any, _id2: any, rule: any) => { fontMatterRule = rule; }
}
}
}, () => { /* noop */ });
md.block.ruler.before('fence', 'front_matter', fontMatterRule, {
alt: ['paragraph', 'reference', 'blockquote', 'list']
});
for (const renderName of ['paragraph_open', 'heading_open', 'image', 'code_block', 'fence', 'blockquote_open', 'list_item_open']) {
this.addLineNumberRenderer(md, renderName);
}
this.addImageStabilizer(md);
this.addFencedRenderer(md);
this.addLinkNormalizer(md);
this.addLinkValidator(md);
this.addNamedHeaders(md);
this.addLinkRenderer(md);
return md;
});
}
const md = await this.md!;
md.set(config);
return md;
}
private tokenizeDocument(
document: SkinnyTextDocument,
config: MarkdownItConfig,
engine: MarkdownIt
): Token[] {
const cached = this._tokenCache.tryGetCached(document, config);
if (cached) {
return cached;
}
this.currentDocument = document.uri;
const tokens = this.tokenizeString(document.getText(), engine);
this._tokenCache.update(document, config, tokens);
return tokens;
}
private tokenizeString(text: string, engine: MarkdownIt) {
this._slugCount = new Map<string, number>();
return engine.parse(text.replace(UNICODE_NEWLINE_REGEX, ''), {});
}
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);
return engine.renderer.render(tokens, {
...(engine as any).options,
...config
}, {});
}
public async parse(document: SkinnyTextDocument): Promise<Token[]> {
const config = this.getConfig(document.uri);
const engine = await this.getEngine(config);
return this.tokenizeDocument(document, config, engine);
}
public cleanCache(): void {
this._tokenCache.clean();
}
private getConfig(resource?: vscode.Uri): MarkdownItConfig {
const config = vscode.workspace.getConfiguration('markdown', resource);
return {
breaks: config.get<boolean>('preview.breaks', false),
linkify: config.get<boolean>('preview.linkify', true)
};
}
private addLineNumberRenderer(md: any, ruleName: string): void {
const original = md.renderer.rules[ruleName];
md.renderer.rules[ruleName] = (tokens: any, idx: number, options: any, env: any, self: any) => {
const token = tokens[idx];
if (token.map && token.map.length) {
token.attrSet('data-line', token.map[0]);
token.attrJoin('class', 'code-line');
}
if (original) {
return original(tokens, idx, options, env, self);
} else {
return self.renderToken(tokens, idx, options, env, self);
}
};
}
private addImageStabilizer(md: any): void {
const original = md.renderer.rules.image;
md.renderer.rules.image = (tokens: any, idx: number, options: any, env: any, self: any) => {
const token = tokens[idx];
token.attrJoin('class', 'loading');
const src = token.attrGet('src');
if (src) {
const imgHash = hash(src);
token.attrSet('id', `image-hash-${imgHash}`);
}
if (original) {
return original(tokens, idx, options, env, self);
} else {
return self.renderToken(tokens, idx, options, env, self);
}
};
}
private addFencedRenderer(md: any): void {
const original = md.renderer.rules['fenced'];
md.renderer.rules['fenced'] = (tokens: any, idx: number, options: any, env: any, self: any) => {
const token = tokens[idx];
if (token.map && token.map.length) {
token.attrJoin('class', 'hljs');
}
return original(tokens, idx, options, env, self);
};
}
private addLinkNormalizer(md: any): void {
const normalizeLink = md.normalizeLink;
md.normalizeLink = (link: string) => {
try {
// Normalize VS Code schemes to target the current version
if (isOfScheme(Schemes.vscode, link) || isOfScheme(Schemes['vscode-insiders'], link)) {
return normalizeLink(vscode.Uri.parse(link).with({ scheme: vscode.env.uriScheme }).toString());
}
// 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);
// 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) {
const fileUri = vscode.Uri.joinPath(root.uri, uri.fsPath);
uri = fileUri.with({
scheme: uri.scheme,
fragment: uri.fragment,
query: uri.query,
});
}
}
const extname = path.extname(uri.fsPath);
if (uri.fragment && (extname === '' || MarkdownFileExtensions.includes(extname))) {
uri = uri.with({
fragment: this.slugifier.fromHeading(uri.fragment).value
});
}
return normalizeLink(uri.toString(true).replace(/^markdown-link:/, ''));
}
} catch (e) {
// noop
}
return normalizeLink(link);
};
}
private addLinkValidator(md: any): void {
const validateLink = md.validateLink;
md.validateLink = (link: string) => {
// support file:// links
return validateLink(link)
|| isOfScheme(Schemes.file, link)
|| isOfScheme(Schemes.vscode, link)
|| isOfScheme(Schemes['vscode-insiders'], link)
|| /^data:image\/.*?;/.test(link);
};
}
private addNamedHeaders(md: any): void {
const original = md.renderer.rules.heading_open;
md.renderer.rules.heading_open = (tokens: any, idx: number, options: any, env: any, self: any) => {
const title = tokens[idx + 1].children.reduce((acc: string, t: any) => acc + t.content, '');
let slug = this.slugifier.fromHeading(title);
if (this._slugCount.has(slug.value)) {
const count = this._slugCount.get(slug.value)!;
this._slugCount.set(slug.value, count + 1);
slug = this.slugifier.fromHeading(slug.value + '-' + (count + 1));
} else {
this._slugCount.set(slug.value, 0);
}
tokens[idx].attrs = tokens[idx].attrs || [];
tokens[idx].attrs.push(['id', slug.value]);
if (original) {
return original(tokens, idx, options, env, self);
} else {
return self.renderToken(tokens, idx, options, env, self);
}
};
}
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) {
const hljs = await import('highlight.js');
return {
html: true,
highlight: (str: string, lang?: string) => {
lang = normalizeHighlightLang(lang);
if (lang && hljs.getLanguage(lang)) {
try {
return `<div>${hljs.highlight(lang, str, true).value}</div>`;
}
catch (error) { }
}
return `<code><div>${md().utils.escapeHtml(str)}</div></code>`;
}
};
}
function normalizeHighlightLang(lang: string | undefined) {
switch (lang && lang.toLowerCase()) {
case 'tsx':
case 'typescriptreact':
// Workaround for highlight not supporting tsx: https://github.com/isagalaev/highlight.js/issues/1155
return 'jsx';
case 'json5':
case 'jsonc':
return 'json';
case 'c#':
case 'csharp':
return 'cs';
default:
return lang;
}
}

View File

@ -0,0 +1,163 @@
/*---------------------------------------------------------------------------------------------
* Copyright (c) Microsoft Corporation. All rights reserved.
* Licensed under the MIT License. See License.txt in the project root for license information.
*--------------------------------------------------------------------------------------------*/
import * as vscode from 'vscode';
import * as arrays from './util/arrays';
import { Disposable } from './util/dispose';
const resolveExtensionResource = (extension: vscode.Extension<any>, resourcePath: string): vscode.Uri => {
return vscode.Uri.joinPath(extension.extensionUri, resourcePath);
};
const resolveExtensionResources = (extension: vscode.Extension<any>, resourcePaths: unknown): vscode.Uri[] => {
const result: vscode.Uri[] = [];
if (Array.isArray(resourcePaths)) {
for (const resource of resourcePaths) {
try {
result.push(resolveExtensionResource(extension, resource));
} catch (e) {
// noop
}
}
}
return result;
};
export interface MarkdownContributions {
readonly previewScripts: ReadonlyArray<vscode.Uri>;
readonly previewStyles: ReadonlyArray<vscode.Uri>;
readonly previewResourceRoots: ReadonlyArray<vscode.Uri>;
readonly markdownItPlugins: Map<string, Thenable<(md: any) => any>>;
}
export namespace MarkdownContributions {
export const Empty: MarkdownContributions = {
previewScripts: [],
previewStyles: [],
previewResourceRoots: [],
markdownItPlugins: new Map()
};
export function merge(a: MarkdownContributions, b: MarkdownContributions): MarkdownContributions {
return {
previewScripts: [...a.previewScripts, ...b.previewScripts],
previewStyles: [...a.previewStyles, ...b.previewStyles],
previewResourceRoots: [...a.previewResourceRoots, ...b.previewResourceRoots],
markdownItPlugins: new Map([...a.markdownItPlugins.entries(), ...b.markdownItPlugins.entries()]),
};
}
function uriEqual(a: vscode.Uri, b: vscode.Uri): boolean {
return a.toString() === b.toString();
}
export function equal(a: MarkdownContributions, b: MarkdownContributions): boolean {
return arrays.equals(a.previewScripts, b.previewScripts, uriEqual)
&& arrays.equals(a.previewStyles, b.previewStyles, uriEqual)
&& arrays.equals(a.previewResourceRoots, b.previewResourceRoots, uriEqual)
&& arrays.equals(Array.from(a.markdownItPlugins.keys()), Array.from(b.markdownItPlugins.keys()));
}
export function fromExtension(
extension: vscode.Extension<any>
): MarkdownContributions {
const contributions = extension.packageJSON && extension.packageJSON.contributes;
if (!contributions) {
return MarkdownContributions.Empty;
}
const previewStyles = getContributedStyles(contributions, extension);
const previewScripts = getContributedScripts(contributions, extension);
const previewResourceRoots = previewStyles.length || previewScripts.length ? [extension.extensionUri] : [];
const markdownItPlugins = getContributedMarkdownItPlugins(contributions, extension);
return {
previewScripts,
previewStyles,
previewResourceRoots,
markdownItPlugins
};
}
function getContributedMarkdownItPlugins(
contributes: any,
extension: vscode.Extension<any>
): Map<string, Thenable<(md: any) => any>> {
const map = new Map<string, Thenable<(md: any) => any>>();
if (contributes['markdown.markdownItPlugins']) {
map.set(extension.id, extension.activate().then(() => {
if (extension.exports && extension.exports.extendMarkdownIt) {
return (md: any) => extension.exports.extendMarkdownIt(md);
}
return (md: any) => md;
}));
}
return map;
}
function getContributedScripts(
contributes: any,
extension: vscode.Extension<any>
) {
return resolveExtensionResources(extension, contributes['markdown.previewScripts']);
}
function getContributedStyles(
contributes: any,
extension: vscode.Extension<any>
) {
return resolveExtensionResources(extension, contributes['markdown.previewStyles']);
}
}
export interface MarkdownContributionProvider {
readonly extensionUri: vscode.Uri;
readonly contributions: MarkdownContributions;
readonly onContributionsChanged: vscode.Event<this>;
dispose(): void;
}
class VSCodeExtensionMarkdownContributionProvider extends Disposable implements MarkdownContributionProvider {
private _contributions?: MarkdownContributions;
public constructor(
private readonly _extensionContext: vscode.ExtensionContext,
) {
super();
vscode.extensions.onDidChange(() => {
const currentContributions = this.getCurrentContributions();
const existingContributions = this._contributions || MarkdownContributions.Empty;
if (!MarkdownContributions.equal(existingContributions, currentContributions)) {
this._contributions = currentContributions;
this._onContributionsChanged.fire(this);
}
}, undefined, this._disposables);
}
public get extensionUri() { return this._extensionContext.extensionUri; }
private readonly _onContributionsChanged = this._register(new vscode.EventEmitter<this>());
public readonly onContributionsChanged = this._onContributionsChanged.event;
public get contributions(): MarkdownContributions {
if (!this._contributions) {
this._contributions = this.getCurrentContributions();
}
return this._contributions;
}
private getCurrentContributions(): MarkdownContributions {
return vscode.extensions.all
.map(MarkdownContributions.fromExtension)
.reduce(MarkdownContributions.merge, MarkdownContributions.Empty);
}
}
export function getMarkdownExtensionContributions(context: vscode.ExtensionContext): MarkdownContributionProvider {
return new VSCodeExtensionMarkdownContributionProvider(context);
}

View File

@ -0,0 +1,159 @@
/*---------------------------------------------------------------------------------------------
* Copyright (c) Microsoft Corporation. All rights reserved.
* Licensed under the MIT License. See License.txt in the project root for license information.
*--------------------------------------------------------------------------------------------*/
import * as vscode from 'vscode';
import { MarkdownPreviewManager } from './features/previewManager';
import * as nls from 'vscode-nls';
const localize = nls.loadMessageBundle();
export const enum MarkdownPreviewSecurityLevel {
Strict = 0,
AllowInsecureContent = 1,
AllowScriptsAndAllContent = 2,
AllowInsecureLocalContent = 3
}
export interface ContentSecurityPolicyArbiter {
getSecurityLevelForResource(resource: vscode.Uri): MarkdownPreviewSecurityLevel;
setSecurityLevelForResource(resource: vscode.Uri, level: MarkdownPreviewSecurityLevel): Thenable<void>;
shouldAllowSvgsForResource(resource: vscode.Uri): void;
shouldDisableSecurityWarnings(): boolean;
setShouldDisableSecurityWarning(shouldShow: boolean): Thenable<void>;
}
export class ExtensionContentSecurityPolicyArbiter implements ContentSecurityPolicyArbiter {
private readonly old_trusted_workspace_key = 'trusted_preview_workspace:';
private readonly security_level_key = 'preview_security_level:';
private readonly should_disable_security_warning_key = 'preview_should_show_security_warning:';
constructor(
private readonly globalState: vscode.Memento,
private readonly workspaceState: vscode.Memento
) { }
public getSecurityLevelForResource(resource: vscode.Uri): MarkdownPreviewSecurityLevel {
// Use new security level setting first
const level = this.globalState.get<MarkdownPreviewSecurityLevel | undefined>(this.security_level_key + this.getRoot(resource), undefined);
if (typeof level !== 'undefined') {
return level;
}
// Fallback to old trusted workspace setting
if (this.globalState.get<boolean>(this.old_trusted_workspace_key + this.getRoot(resource), false)) {
return MarkdownPreviewSecurityLevel.AllowScriptsAndAllContent;
}
return MarkdownPreviewSecurityLevel.Strict;
}
public setSecurityLevelForResource(resource: vscode.Uri, level: MarkdownPreviewSecurityLevel): Thenable<void> {
return this.globalState.update(this.security_level_key + this.getRoot(resource), level);
}
public shouldAllowSvgsForResource(resource: vscode.Uri) {
const securityLevel = this.getSecurityLevelForResource(resource);
return securityLevel === MarkdownPreviewSecurityLevel.AllowInsecureContent || securityLevel === MarkdownPreviewSecurityLevel.AllowScriptsAndAllContent;
}
public shouldDisableSecurityWarnings(): boolean {
return this.workspaceState.get<boolean>(this.should_disable_security_warning_key, false);
}
public setShouldDisableSecurityWarning(disabled: boolean): Thenable<void> {
return this.workspaceState.update(this.should_disable_security_warning_key, disabled);
}
private getRoot(resource: vscode.Uri): vscode.Uri {
if (vscode.workspace.workspaceFolders) {
const folderForResource = vscode.workspace.getWorkspaceFolder(resource);
if (folderForResource) {
return folderForResource.uri;
}
if (vscode.workspace.workspaceFolders.length) {
return vscode.workspace.workspaceFolders[0].uri;
}
}
return resource;
}
}
export class PreviewSecuritySelector {
public constructor(
private readonly cspArbiter: ContentSecurityPolicyArbiter,
private readonly webviewManager: MarkdownPreviewManager
) { }
public async showSecuritySelectorForResource(resource: vscode.Uri): Promise<void> {
interface PreviewSecurityPickItem extends vscode.QuickPickItem {
readonly type: 'moreinfo' | 'toggle' | MarkdownPreviewSecurityLevel;
}
function markActiveWhen(when: boolean): string {
return when ? '• ' : '';
}
const currentSecurityLevel = this.cspArbiter.getSecurityLevelForResource(resource);
const selection = await vscode.window.showQuickPick<PreviewSecurityPickItem>(
[
{
type: MarkdownPreviewSecurityLevel.Strict,
label: markActiveWhen(currentSecurityLevel === MarkdownPreviewSecurityLevel.Strict) + localize('strict.title', 'Strict'),
description: localize('strict.description', 'Only load secure content'),
}, {
type: MarkdownPreviewSecurityLevel.AllowInsecureLocalContent,
label: markActiveWhen(currentSecurityLevel === MarkdownPreviewSecurityLevel.AllowInsecureLocalContent) + localize('insecureLocalContent.title', 'Allow insecure local content'),
description: localize('insecureLocalContent.description', 'Enable loading content over http served from localhost'),
}, {
type: MarkdownPreviewSecurityLevel.AllowInsecureContent,
label: markActiveWhen(currentSecurityLevel === MarkdownPreviewSecurityLevel.AllowInsecureContent) + localize('insecureContent.title', 'Allow insecure content'),
description: localize('insecureContent.description', 'Enable loading content over http'),
}, {
type: MarkdownPreviewSecurityLevel.AllowScriptsAndAllContent,
label: markActiveWhen(currentSecurityLevel === MarkdownPreviewSecurityLevel.AllowScriptsAndAllContent) + localize('disable.title', 'Disable'),
description: localize('disable.description', 'Allow all content and script execution. Not recommended'),
}, {
type: 'moreinfo',
label: localize('moreInfo.title', 'More Information'),
description: ''
}, {
type: 'toggle',
label: this.cspArbiter.shouldDisableSecurityWarnings()
? localize('enableSecurityWarning.title', "Enable preview security warnings in this workspace")
: localize('disableSecurityWarning.title', "Disable preview security warning in this workspace"),
description: localize('toggleSecurityWarning.description', 'Does not affect the content security level')
},
], {
placeHolder: localize(
'preview.showPreviewSecuritySelector.title',
'Select security settings for Markdown previews in this workspace'),
});
if (!selection) {
return;
}
if (selection.type === 'moreinfo') {
vscode.commands.executeCommand('vscode.open', vscode.Uri.parse('https://go.microsoft.com/fwlink/?linkid=854414'));
return;
}
if (selection.type === 'toggle') {
this.cspArbiter.setShouldDisableSecurityWarning(!this.cspArbiter.shouldDisableSecurityWarnings());
this.webviewManager.refresh();
return;
} else {
await this.cspArbiter.setSecurityLevelForResource(resource, selection.type);
}
this.webviewManager.refresh();
}
}

View File

@ -0,0 +1,32 @@
/*---------------------------------------------------------------------------------------------
* Copyright (c) Microsoft Corporation. All rights reserved.
* Licensed under the MIT License. See License.txt in the project root for license information.
*--------------------------------------------------------------------------------------------*/
export class Slug {
public constructor(
public readonly value: string
) { }
public equals(other: Slug): boolean {
return this.value === other.value;
}
}
export interface Slugifier {
fromHeading(heading: string): Slug;
}
export const githubSlugifier: Slugifier = new class implements Slugifier {
fromHeading(heading: string): Slug {
const slugifiedHeading = encodeURI(
heading.trim()
.toLowerCase()
.replace(/\s+/g, '-') // Replace whitespace with -
.replace(/[\]\[\!\'\#\$\%\&\(\)\*\+\,\.\/\:\;\<\=\>\?\@\\\^\_\{\|\}\~\`。,、;:?!…—·ˉ¨‘’“”々~‖∶"'`|〃〔〕〈〉《》「」『』.〖〗【】()[]{}]/g, '') // Remove known punctuators
.replace(/^\-+/, '') // Remove leading -
.replace(/\-+$/, '') // Remove trailing -
);
return new Slug(slugifiedHeading);
}
};

View File

@ -0,0 +1,118 @@
/*---------------------------------------------------------------------------------------------
* Copyright (c) Microsoft Corporation. All rights reserved.
* Licensed under the MIT License. See License.txt in the project root for license information.
*--------------------------------------------------------------------------------------------*/
import * as vscode from 'vscode';
import { MarkdownEngine } from './markdownEngine';
import { Slug, githubSlugifier } from './slugify';
export interface TocEntry {
readonly slug: Slug;
readonly text: string;
readonly level: number;
readonly line: number;
readonly location: vscode.Location;
}
export interface SkinnyTextLine {
text: string;
}
export interface SkinnyTextDocument {
readonly uri: vscode.Uri;
readonly version: number;
readonly lineCount: number;
lineAt(line: number): SkinnyTextLine;
getText(): string;
}
export class TableOfContentsProvider {
private toc?: TocEntry[];
public constructor(
private engine: MarkdownEngine,
private document: SkinnyTextDocument
) { }
public async getToc(): Promise<TocEntry[]> {
if (!this.toc) {
try {
this.toc = await this.buildToc(this.document);
} catch (e) {
this.toc = [];
}
}
return this.toc;
}
public async lookup(fragment: string): Promise<TocEntry | undefined> {
const toc = await this.getToc();
const slug = githubSlugifier.fromHeading(fragment);
return toc.find(entry => entry.slug.equals(slug));
}
private async buildToc(document: SkinnyTextDocument): Promise<TocEntry[]> {
const toc: TocEntry[] = [];
const tokens = await this.engine.parse(document);
const existingSlugEntries = new Map<string, { count: number }>();
for (const heading of tokens.filter(token => token.type === 'heading_open')) {
const lineNumber = heading.map[0];
const line = document.lineAt(lineNumber);
let slug = githubSlugifier.fromHeading(line.text);
const existingSlugEntry = existingSlugEntries.get(slug.value);
if (existingSlugEntry) {
++existingSlugEntry.count;
slug = githubSlugifier.fromHeading(slug.value + '-' + existingSlugEntry.count);
} else {
existingSlugEntries.set(slug.value, { count: 0 });
}
toc.push({
slug,
text: TableOfContentsProvider.getHeaderText(line.text),
level: TableOfContentsProvider.getHeaderLevel(heading.markup),
line: lineNumber,
location: new vscode.Location(document.uri,
new vscode.Range(lineNumber, 0, lineNumber, line.text.length))
});
}
// Get full range of section
return toc.map((entry, startIndex): TocEntry => {
let end: number | undefined = undefined;
for (let i = startIndex + 1; i < toc.length; ++i) {
if (toc[i].level <= entry.level) {
end = toc[i].line - 1;
break;
}
}
const endLine = end ?? document.lineCount - 1;
return {
...entry,
location: new vscode.Location(document.uri,
new vscode.Range(
entry.location.range.start,
new vscode.Position(endLine, document.lineAt(endLine).text.length)))
};
});
}
private static getHeaderLevel(markup: string): number {
if (markup === '=') {
return 1;
} else if (markup === '-') {
return 2;
} else { // '#', '##', ...
return markup.length;
}
}
private static getHeaderText(header: string): string {
return header.replace(/^\s*#+\s*(.*?)\s*#*$/, (_, word) => word.trim());
}
}

View File

@ -0,0 +1,60 @@
/*---------------------------------------------------------------------------------------------
* Copyright (c) Microsoft Corporation. All rights reserved.
* Licensed under the MIT License. See License.txt in the project root for license information.
*--------------------------------------------------------------------------------------------*/
import * as vscode from 'vscode';
import { default as VSCodeTelemetryReporter } from 'vscode-extension-telemetry';
interface IPackageInfo {
name: string;
version: string;
aiKey: string;
}
export interface TelemetryReporter {
dispose(): void;
sendTelemetryEvent(eventName: string, properties?: {
[key: string]: string;
}): void;
}
const nullReporter = new class NullTelemetryReporter implements TelemetryReporter {
sendTelemetryEvent() { /** noop */ }
dispose() { /** noop */ }
};
class ExtensionReporter implements TelemetryReporter {
private readonly _reporter: VSCodeTelemetryReporter;
constructor(
packageInfo: IPackageInfo
) {
this._reporter = new VSCodeTelemetryReporter(packageInfo.name, packageInfo.version, packageInfo.aiKey);
}
sendTelemetryEvent(eventName: string, properties?: {
[key: string]: string;
}) {
this._reporter.sendTelemetryEvent(eventName, properties);
}
dispose() {
this._reporter.dispose();
}
}
export function loadDefaultTelemetryReporter(): TelemetryReporter {
const packageInfo = getPackageInfo();
return packageInfo ? new ExtensionReporter(packageInfo) : nullReporter;
}
function getPackageInfo(): IPackageInfo | null {
const extension = vscode.extensions.getExtension('Microsoft.vscode-markdown');
if (extension && extension.packageJSON) {
return {
name: extension.packageJSON.name,
version: extension.packageJSON.version,
aiKey: extension.packageJSON.aiKey
};
}
return null;
}

View File

@ -0,0 +1,145 @@
/*---------------------------------------------------------------------------------------------
* Copyright (c) Microsoft Corporation. All rights reserved.
* Licensed under the MIT License. See License.txt in the project root for license information.
*--------------------------------------------------------------------------------------------*/
import * as assert from 'assert';
import 'mocha';
import * as vscode from 'vscode';
import { joinLines } from './util';
const testFileA = workspaceFile('a.md');
function workspaceFile(...segments: string[]) {
return vscode.Uri.joinPath(vscode.workspace.workspaceFolders![0].uri, ...segments);
}
async function getLinksForFile(file: vscode.Uri): Promise<vscode.DocumentLink[]> {
return (await vscode.commands.executeCommand<vscode.DocumentLink[]>('vscode.executeLinkProvider', file))!;
}
suite('Markdown Document links', () => {
teardown(async () => {
await vscode.commands.executeCommand('workbench.action.closeAllEditors');
});
test('Should navigate to markdown file', async () => {
await withFileContents(testFileA, '[b](b.md)');
const [link] = await getLinksForFile(testFileA);
await executeLink(link);
assertActiveDocumentUri(workspaceFile('b.md'));
});
test('Should navigate to markdown file with leading ./', async () => {
await withFileContents(testFileA, '[b](./b.md)');
const [link] = await getLinksForFile(testFileA);
await executeLink(link);
assertActiveDocumentUri(workspaceFile('b.md'));
});
test('Should navigate to markdown file with leading /', async () => {
await withFileContents(testFileA, '[b](./b.md)');
const [link] = await getLinksForFile(testFileA);
await executeLink(link);
assertActiveDocumentUri(workspaceFile('b.md'));
});
test('Should navigate to markdown file without file extension', async () => {
await withFileContents(testFileA, '[b](b)');
const [link] = await getLinksForFile(testFileA);
await executeLink(link);
assertActiveDocumentUri(workspaceFile('b.md'));
});
test('Should navigate to markdown file in directory', async () => {
await withFileContents(testFileA, '[b](sub/c)');
const [link] = await getLinksForFile(testFileA);
await executeLink(link);
assertActiveDocumentUri(workspaceFile('sub', 'c.md'));
});
test('Should navigate to fragment by title in file', async () => {
await withFileContents(testFileA, '[b](sub/c#second)');
const [link] = await getLinksForFile(testFileA);
await executeLink(link);
assertActiveDocumentUri(workspaceFile('sub', 'c.md'));
assert.strictEqual(vscode.window.activeTextEditor!.selection.start.line, 1);
});
test('Should navigate to fragment by line', async () => {
await withFileContents(testFileA, '[b](sub/c#L2)');
const [link] = await getLinksForFile(testFileA);
await executeLink(link);
assertActiveDocumentUri(workspaceFile('sub', 'c.md'));
assert.strictEqual(vscode.window.activeTextEditor!.selection.start.line, 1);
});
test('Should navigate to fragment within current file', async () => {
await withFileContents(testFileA, joinLines(
'[](a#header)',
'[](#header)',
'# Header'));
const links = await getLinksForFile(testFileA);
{
await executeLink(links[0]);
assertActiveDocumentUri(workspaceFile('a.md'));
assert.strictEqual(vscode.window.activeTextEditor!.selection.start.line, 2);
}
{
await executeLink(links[1]);
assertActiveDocumentUri(workspaceFile('a.md'));
assert.strictEqual(vscode.window.activeTextEditor!.selection.start.line, 2);
}
});
test('Should navigate to fragment within current untitled file', async () => {
const testFile = workspaceFile('x.md').with({ scheme: 'untitled' });
await withFileContents(testFile, joinLines(
'[](#second)',
'# Second'));
const [link] = await getLinksForFile(testFile);
await executeLink(link);
assertActiveDocumentUri(testFile);
assert.strictEqual(vscode.window.activeTextEditor!.selection.start.line, 1);
});
});
function assertActiveDocumentUri(expectedUri: vscode.Uri) {
assert.strictEqual(
vscode.window.activeTextEditor!.document.uri.fsPath,
expectedUri.fsPath
);
}
async function withFileContents(file: vscode.Uri, contents: string): Promise<void> {
const document = await vscode.workspace.openTextDocument(file);
const editor = await vscode.window.showTextDocument(document);
await editor.edit(edit => {
edit.replace(new vscode.Range(0, 0, 1000, 0), contents);
});
}
async function executeLink(link: vscode.DocumentLink) {
const args = JSON.parse(decodeURIComponent(link.target!.query));
await vscode.commands.executeCommand(link.target!.path, args);
}

View File

@ -0,0 +1,149 @@
/*---------------------------------------------------------------------------------------------
* Copyright (c) Microsoft Corporation. All rights reserved.
* Licensed under the MIT License. See License.txt in the project root for license information.
*--------------------------------------------------------------------------------------------*/
import * as assert from 'assert';
import 'mocha';
import * as vscode from 'vscode';
import LinkProvider from '../features/documentLinkProvider';
import { InMemoryDocument } from './inMemoryDocument';
const testFile = vscode.Uri.joinPath(vscode.workspace.workspaceFolders![0].uri, 'x.md');
const noopToken = new class implements vscode.CancellationToken {
private _onCancellationRequestedEmitter = new vscode.EventEmitter<void>();
public onCancellationRequested = this._onCancellationRequestedEmitter.event;
get isCancellationRequested() { return false; }
};
function getLinksForFile(fileContents: string) {
const doc = new InMemoryDocument(testFile, fileContents);
const provider = new LinkProvider();
return provider.provideDocumentLinks(doc, noopToken);
}
function assertRangeEqual(expected: vscode.Range, actual: vscode.Range) {
assert.strictEqual(expected.start.line, actual.start.line);
assert.strictEqual(expected.start.character, actual.start.character);
assert.strictEqual(expected.end.line, actual.end.line);
assert.strictEqual(expected.end.character, actual.end.character);
}
suite('markdown.DocumentLinkProvider', () => {
test('Should not return anything for empty document', () => {
const links = getLinksForFile('');
assert.strictEqual(links.length, 0);
});
test('Should not return anything for simple document without links', () => {
const links = getLinksForFile('# a\nfdasfdfsafsa');
assert.strictEqual(links.length, 0);
});
test('Should detect basic http links', () => {
const links = getLinksForFile('a [b](https://example.com) c');
assert.strictEqual(links.length, 1);
const [link] = links;
assertRangeEqual(link.range, new vscode.Range(0, 6, 0, 25));
});
test('Should detect basic workspace links', () => {
{
const links = getLinksForFile('a [b](./file) c');
assert.strictEqual(links.length, 1);
const [link] = links;
assertRangeEqual(link.range, new vscode.Range(0, 6, 0, 12));
}
{
const links = getLinksForFile('a [b](file.png) c');
assert.strictEqual(links.length, 1);
const [link] = links;
assertRangeEqual(link.range, new vscode.Range(0, 6, 0, 14));
}
});
test('Should detect links with title', () => {
const links = getLinksForFile('a [b](https://example.com "abc") c');
assert.strictEqual(links.length, 1);
const [link] = links;
assertRangeEqual(link.range, new vscode.Range(0, 6, 0, 25));
});
// #35245
test('Should handle links with escaped characters in name', () => {
const links = getLinksForFile('a [b\\]](./file)');
assert.strictEqual(links.length, 1);
const [link] = links;
assertRangeEqual(link.range, new vscode.Range(0, 8, 0, 14));
});
test('Should handle links with balanced parens', () => {
{
const links = getLinksForFile('a [b](https://example.com/a()c) c');
assert.strictEqual(links.length, 1);
const [link] = links;
assertRangeEqual(link.range, new vscode.Range(0, 6, 0, 30));
}
{
const links = getLinksForFile('a [b](https://example.com/a(b)c) c');
assert.strictEqual(links.length, 1);
const [link] = links;
assertRangeEqual(link.range, new vscode.Range(0, 6, 0, 31));
}
{
// #49011
const links = getLinksForFile('[A link](http://ThisUrlhasParens/A_link(in_parens))');
assert.strictEqual(links.length, 1);
const [link] = links;
assertRangeEqual(link.range, new vscode.Range(0, 9, 0, 50));
}
});
test('Should handle two links without space', () => {
const links = getLinksForFile('a ([test](test)[test2](test2)) c');
assert.strictEqual(links.length, 2);
const [link1, link2] = links;
assertRangeEqual(link1.range, new vscode.Range(0, 10, 0, 14));
assertRangeEqual(link2.range, new vscode.Range(0, 23, 0, 28));
});
// #49238
test('should handle hyperlinked images', () => {
{
const links = getLinksForFile('[![alt text](image.jpg)](https://example.com)');
assert.strictEqual(links.length, 2);
const [link1, link2] = links;
assertRangeEqual(link1.range, new vscode.Range(0, 13, 0, 22));
assertRangeEqual(link2.range, new vscode.Range(0, 25, 0, 44));
}
{
const links = getLinksForFile('[![a]( whitespace.jpg )]( https://whitespace.com )');
assert.strictEqual(links.length, 2);
const [link1, link2] = links;
assertRangeEqual(link1.range, new vscode.Range(0, 7, 0, 21));
assertRangeEqual(link2.range, new vscode.Range(0, 26, 0, 48));
}
{
const links = getLinksForFile('[![a](img1.jpg)](file1.txt) text [![a](img2.jpg)](file2.txt)');
assert.strictEqual(links.length, 4);
const [link1, link2, link3, link4] = links;
assertRangeEqual(link1.range, new vscode.Range(0, 6, 0, 14));
assertRangeEqual(link2.range, new vscode.Range(0, 17, 0, 26));
assertRangeEqual(link3.range, new vscode.Range(0, 39, 0, 47));
assertRangeEqual(link4.range, new vscode.Range(0, 50, 0, 59));
}
});
// #107471
test('Should not consider link references starting with ^ character valid', () => {
const links = getLinksForFile('[^reference]: https://example.com');
assert.strictEqual(links.length, 0);
});
});

View File

@ -0,0 +1,97 @@
/*---------------------------------------------------------------------------------------------
* Copyright (c) Microsoft Corporation. All rights reserved.
* Licensed under the MIT License. See License.txt in the project root for license information.
*--------------------------------------------------------------------------------------------*/
import * as assert from 'assert';
import 'mocha';
import * as vscode from 'vscode';
import SymbolProvider from '../features/documentSymbolProvider';
import { InMemoryDocument } from './inMemoryDocument';
import { createNewMarkdownEngine } from './engine';
const testFileName = vscode.Uri.file('test.md');
function getSymbolsForFile(fileContents: string) {
const doc = new InMemoryDocument(testFileName, fileContents);
const provider = new SymbolProvider(createNewMarkdownEngine());
return provider.provideDocumentSymbols(doc);
}
suite('markdown.DocumentSymbolProvider', () => {
test('Should not return anything for empty document', async () => {
const symbols = await getSymbolsForFile('');
assert.strictEqual(symbols.length, 0);
});
test('Should not return anything for document with no headers', async () => {
const symbols = await getSymbolsForFile('a\na');
assert.strictEqual(symbols.length, 0);
});
test('Should not return anything for document with # but no real headers', async () => {
const symbols = await getSymbolsForFile('a#a\na#');
assert.strictEqual(symbols.length, 0);
});
test('Should return single symbol for single header', async () => {
const symbols = await getSymbolsForFile('# h');
assert.strictEqual(symbols.length, 1);
assert.strictEqual(symbols[0].name, '# h');
});
test('Should not care about symbol level for single header', async () => {
const symbols = await getSymbolsForFile('### h');
assert.strictEqual(symbols.length, 1);
assert.strictEqual(symbols[0].name, '### h');
});
test('Should put symbols of same level in flat list', async () => {
const symbols = await getSymbolsForFile('## h\n## h2');
assert.strictEqual(symbols.length, 2);
assert.strictEqual(symbols[0].name, '## h');
assert.strictEqual(symbols[1].name, '## h2');
});
test('Should nest symbol of level - 1 under parent', async () => {
const symbols = await getSymbolsForFile('# h\n## h2\n## h3');
assert.strictEqual(symbols.length, 1);
assert.strictEqual(symbols[0].name, '# h');
assert.strictEqual(symbols[0].children.length, 2);
assert.strictEqual(symbols[0].children[0].name, '## h2');
assert.strictEqual(symbols[0].children[1].name, '## h3');
});
test('Should nest symbol of level - n under parent', async () => {
const symbols = await getSymbolsForFile('# h\n#### h2');
assert.strictEqual(symbols.length, 1);
assert.strictEqual(symbols[0].name, '# h');
assert.strictEqual(symbols[0].children.length, 1);
assert.strictEqual(symbols[0].children[0].name, '#### h2');
});
test('Should flatten children where lower level occurs first', async () => {
const symbols = await getSymbolsForFile('# h\n### h2\n## h3');
assert.strictEqual(symbols.length, 1);
assert.strictEqual(symbols[0].name, '# h');
assert.strictEqual(symbols[0].children.length, 2);
assert.strictEqual(symbols[0].children[0].name, '### h2');
assert.strictEqual(symbols[0].children[1].name, '## h3');
});
test('Should handle line separator in file. Issue #63749', async () => {
const symbols = await getSymbolsForFile(`# A
- foo
# B
- bar`);
assert.strictEqual(symbols.length, 2);
assert.strictEqual(symbols[0].name, '# A');
assert.strictEqual(symbols[1].name, '# B');
});
});

View File

@ -0,0 +1,32 @@
/*---------------------------------------------------------------------------------------------
* Copyright (c) Microsoft Corporation. All rights reserved.
* Licensed under the MIT License. See License.txt in the project root for license information.
*--------------------------------------------------------------------------------------------*/
import * as assert from 'assert';
import * as vscode from 'vscode';
import 'mocha';
import { InMemoryDocument } from './inMemoryDocument';
import { createNewMarkdownEngine } from './engine';
const testFileName = vscode.Uri.file('test.md');
suite('markdown.engine', () => {
suite('rendering', () => {
const input = '# hello\n\nworld!';
const output = '<h1 id="hello" data-line="0" class="code-line">hello</h1>\n'
+ '<p data-line="2" class="code-line">world!</p>\n';
test('Renders a document', async () => {
const doc = new InMemoryDocument(testFileName, input);
const engine = createNewMarkdownEngine();
assert.strictEqual(await engine.render(doc), output);
});
test('Renders a string', async () => {
const engine = createNewMarkdownEngine();
assert.strictEqual(await engine.render(input), output);
});
});
});

View File

@ -0,0 +1,20 @@
/*---------------------------------------------------------------------------------------------
* Copyright (c) Microsoft Corporation. All rights reserved.
* Licensed under the MIT License. See License.txt in the project root for license information.
*--------------------------------------------------------------------------------------------*/
import * as vscode from 'vscode';
import { MarkdownEngine } from '../markdownEngine';
import { MarkdownContributionProvider, MarkdownContributions } from '../markdownExtensions';
import { githubSlugifier } from '../slugify';
import { Disposable } from '../util/dispose';
const emptyContributions = new class extends Disposable implements MarkdownContributionProvider {
readonly extensionUri = vscode.Uri.file('/');
readonly contributions = MarkdownContributions.Empty;
readonly onContributionsChanged = this._register(new vscode.EventEmitter<this>()).event;
};
export function createNewMarkdownEngine(): MarkdownEngine {
return new MarkdownEngine(emptyContributions, githubSlugifier);
}

View File

@ -0,0 +1,197 @@
/*---------------------------------------------------------------------------------------------
* Copyright (c) Microsoft Corporation. All rights reserved.
* Licensed under the MIT License. See License.txt in the project root for license information.
*--------------------------------------------------------------------------------------------*/
import * as assert from 'assert';
import * as vscode from 'vscode';
import 'mocha';
import MarkdownFoldingProvider from '../features/foldingProvider';
import { InMemoryDocument } from './inMemoryDocument';
import { createNewMarkdownEngine } from './engine';
const testFileName = vscode.Uri.file('test.md');
suite('markdown.FoldingProvider', () => {
test('Should not return anything for empty document', async () => {
const folds = await getFoldsForDocument(``);
assert.strictEqual(folds.length, 0);
});
test('Should not return anything for document without headers', async () => {
const folds = await getFoldsForDocument(`a
**b** afas
a#b
a`);
assert.strictEqual(folds.length, 0);
});
test('Should fold from header to end of document', async () => {
const folds = await getFoldsForDocument(`a
# b
c
d`);
assert.strictEqual(folds.length, 1);
const firstFold = folds[0];
assert.strictEqual(firstFold.start, 1);
assert.strictEqual(firstFold.end, 3);
});
test('Should leave single newline before next header', async () => {
const folds = await getFoldsForDocument(`
# a
x
# b
y`);
assert.strictEqual(folds.length, 2);
const firstFold = folds[0];
assert.strictEqual(firstFold.start, 1);
assert.strictEqual(firstFold.end, 3);
});
test('Should collapse multuple newlines to single newline before next header', async () => {
const folds = await getFoldsForDocument(`
# a
x
# b
y`);
assert.strictEqual(folds.length, 2);
const firstFold = folds[0];
assert.strictEqual(firstFold.start, 1);
assert.strictEqual(firstFold.end, 5);
});
test('Should not collapse if there is no newline before next header', async () => {
const folds = await getFoldsForDocument(`
# a
x
# b
y`);
assert.strictEqual(folds.length, 2);
const firstFold = folds[0];
assert.strictEqual(firstFold.start, 1);
assert.strictEqual(firstFold.end, 2);
});
test('Should fold nested <!-- #region --> markers', async () => {
const folds = await getFoldsForDocument(`a
<!-- #region -->
b
<!-- #region hello!-->
b.a
<!-- #endregion -->
b
<!-- #region: foo! -->
b.b
<!-- #endregion: foo -->
b
<!-- #endregion -->
a`);
assert.strictEqual(folds.length, 3);
const [outer, first, second] = folds.sort((a, b) => a.start - b.start);
assert.strictEqual(outer.start, 1);
assert.strictEqual(outer.end, 11);
assert.strictEqual(first.start, 3);
assert.strictEqual(first.end, 5);
assert.strictEqual(second.start, 7);
assert.strictEqual(second.end, 9);
});
test('Should fold from list to end of document', async () => {
const folds = await getFoldsForDocument(`a
- b
c
d`);
assert.strictEqual(folds.length, 1);
const firstFold = folds[0];
assert.strictEqual(firstFold.start, 1);
assert.strictEqual(firstFold.end, 3);
});
test('lists folds should span multiple lines of content', async () => {
const folds = await getFoldsForDocument(`a
- This list item\n spans multiple\n lines.`);
assert.strictEqual(folds.length, 1);
const firstFold = folds[0];
assert.strictEqual(firstFold.start, 1);
assert.strictEqual(firstFold.end, 3);
});
test('List should leave single blankline before new element', async () => {
const folds = await getFoldsForDocument(`- a
a
b`);
assert.strictEqual(folds.length, 1);
const firstFold = folds[0];
assert.strictEqual(firstFold.start, 0);
assert.strictEqual(firstFold.end, 3);
});
test('Should fold fenced code blocks', async () => {
const folds = await getFoldsForDocument(`~~~ts
a
~~~
b`);
assert.strictEqual(folds.length, 1);
const firstFold = folds[0];
assert.strictEqual(firstFold.start, 0);
assert.strictEqual(firstFold.end, 2);
});
test('Should fold fenced code blocks with yaml front matter', async () => {
const folds = await getFoldsForDocument(`---
title: bla
---
~~~ts
a
~~~
a
a
b
a`);
assert.strictEqual(folds.length, 1);
const firstFold = folds[0];
assert.strictEqual(firstFold.start, 4);
assert.strictEqual(firstFold.end, 6);
});
test('Should fold html blocks', async () => {
const folds = await getFoldsForDocument(`x
<div>
fa
</div>`);
assert.strictEqual(folds.length, 1);
const firstFold = folds[0];
assert.strictEqual(firstFold.start, 1);
assert.strictEqual(firstFold.end, 3);
});
test('Should fold html block comments', async () => {
const folds = await getFoldsForDocument(`x
<!--
fa
-->`);
assert.strictEqual(folds.length, 1);
const firstFold = folds[0];
assert.strictEqual(firstFold.start, 1);
assert.strictEqual(firstFold.end, 3);
assert.strictEqual(firstFold.kind, vscode.FoldingRangeKind.Comment);
});
});
async function getFoldsForDocument(contents: string) {
const doc = new InMemoryDocument(testFileName, contents);
const provider = new MarkdownFoldingProvider(createNewMarkdownEngine());
return await provider.provideFoldingRanges(doc, {}, new vscode.CancellationTokenSource().token);
}

View File

@ -0,0 +1,70 @@
/*---------------------------------------------------------------------------------------------
* Copyright (c) Microsoft Corporation. All rights reserved.
* Licensed under the MIT License. See License.txt in the project root for license information.
*--------------------------------------------------------------------------------------------*/
import * as vscode from 'vscode';
export class InMemoryDocument implements vscode.TextDocument {
private readonly _lines: string[];
constructor(
public readonly uri: vscode.Uri,
private readonly _contents: string,
public readonly version = 1,
) {
this._lines = this._contents.split(/\n/g);
}
isUntitled: boolean = false;
languageId: string = '';
isDirty: boolean = false;
isClosed: boolean = false;
eol: vscode.EndOfLine = vscode.EndOfLine.LF;
notebook: undefined;
get fileName(): string {
return this.uri.fsPath;
}
get lineCount(): number {
return this._lines.length;
}
lineAt(line: any): vscode.TextLine {
return {
lineNumber: line,
text: this._lines[line],
range: new vscode.Range(0, 0, 0, 0),
firstNonWhitespaceCharacterIndex: 0,
rangeIncludingLineBreak: new vscode.Range(0, 0, 0, 0),
isEmptyOrWhitespace: false
};
}
offsetAt(_position: vscode.Position): never {
throw new Error('Method not implemented.');
}
positionAt(offset: number): vscode.Position {
const before = this._contents.slice(0, offset);
const newLines = before.match(/\n/g);
const line = newLines ? newLines.length : 0;
const preCharacters = before.match(/(\n|^).*$/g);
return new vscode.Position(line, preCharacters ? preCharacters[0].length : 0);
}
getText(_range?: vscode.Range | undefined): string {
return this._contents;
}
getWordRangeAtPosition(_position: vscode.Position, _regex?: RegExp | undefined): never {
throw new Error('Method not implemented.');
}
validateRange(_range: vscode.Range): never {
throw new Error('Method not implemented.');
}
validatePosition(_position: vscode.Position): never {
throw new Error('Method not implemented.');
}
save(): never {
throw new Error('Method not implemented.');
}
}

View File

@ -0,0 +1,40 @@
/*---------------------------------------------------------------------------------------------
* Copyright (c) Microsoft Corporation. All rights reserved.
* Licensed under the MIT License. See License.txt in the project root for license information.
*--------------------------------------------------------------------------------------------*/
const path = require('path');
const testRunner = require('vscode/lib/testrunner');
const options: any = {
ui: 'tdd',
useColors: (!process.env.BUILD_ARTIFACTSTAGINGDIRECTORY && process.platform !== 'win32'),
timeout: 60000
};
// These integration tests is being run in multiple environments (electron, web, remote)
// so we need to set the suite name based on the environment as the suite name is used
// for the test results file name
let suite = '';
if (process.env.VSCODE_BROWSER) {
suite = `${process.env.VSCODE_BROWSER} Browser Integration Markdown Tests`;
} else if (process.env.REMOTE_VSCODE) {
suite = 'Remote Integration Markdown Tests';
} else {
suite = 'Integration Markdown Tests';
}
if (process.env.BUILD_ARTIFACTSTAGINGDIRECTORY) {
options.reporter = 'mocha-multi-reporters';
options.reporterOptions = {
reporterEnabled: 'spec, mocha-junit-reporter',
mochaJunitReporterReporterOptions: {
testsuitesTitle: `${suite} ${process.platform}`,
mochaFile: path.join(process.env.BUILD_ARTIFACTSTAGINGDIRECTORY, `test-results/${process.platform}-${process.arch}-${suite.toLowerCase().replace(/[^\w]/g, '-')}-results.xml`)
}
};
}
testRunner.configure(options);
export = testRunner;

View File

@ -0,0 +1,411 @@
/*---------------------------------------------------------------------------------------------
* Copyright (c) Microsoft Corporation. All rights reserved.
* Licensed under the MIT License. See License.txt in the project root for license information.
*--------------------------------------------------------------------------------------------*/
import * as assert from 'assert';
import * as vscode from 'vscode';
import MarkdownSmartSelect from '../features/smartSelect';
import { InMemoryDocument } from './inMemoryDocument';
import { createNewMarkdownEngine } from './engine';
import { joinLines } from './util';
const CURSOR = '$$CURSOR$$';
const testFileName = vscode.Uri.file('test.md');
suite.only('markdown.SmartSelect', () => {
test('Smart select single word', async () => {
const ranges = await getSelectionRangesForDocument(`Hel${CURSOR}lo`);
assertNestedRangesEqual(ranges![0], [0, 1]);
});
test('Smart select multi-line paragraph', async () => {
const ranges = await getSelectionRangesForDocument(
joinLines(
`Many of the core components and extensions to ${CURSOR}VS Code live in their own repositories on GitHub. `,
`For example, the[node debug adapter](https://github.com/microsoft/vscode-node-debug) and the [mono debug adapter]`,
`(https://github.com/microsoft/vscode-mono-debug) have their own repositories. For a complete list, please visit the [Related Projects](https://github.com/microsoft/vscode/wiki/Related-Projects) page on our [wiki](https://github.com/microsoft/vscode/wiki).`
));
assertNestedRangesEqual(ranges![0], [0, 3]);
});
test('Smart select paragraph', async () => {
const ranges = await getSelectionRangesForDocument(`Many of the core components and extensions to ${CURSOR}VS Code live in their own repositories on GitHub. For example, the [node debug adapter](https://github.com/microsoft/vscode-node-debug) and the [mono debug adapter](https://github.com/microsoft/vscode-mono-debug) have their own repositories. For a complete list, please visit the [Related Projects](https://github.com/microsoft/vscode/wiki/Related-Projects) page on our [wiki](https://github.com/microsoft/vscode/wiki).`);
assertNestedRangesEqual(ranges![0], [0, 1]);
});
test('Smart select html block', async () => {
const ranges = await getSelectionRangesForDocument(
joinLines(
`<p align="center">`,
`${CURSOR}<img alt="VS Code in action" src="https://user-images.githubusercontent.com/1487073/58344409-70473b80-7e0a-11e9-8570-b2efc6f8fa44.png">`,
`</p>`));
assertNestedRangesEqual(ranges![0], [0, 3]);
});
test('Smart select header on header line', async () => {
const ranges = await getSelectionRangesForDocument(
joinLines(
`# Header${CURSOR}`,
`Hello`));
assertNestedRangesEqual(ranges![0], [0, 1]);
});
test('Smart select single word w grandparent header on text line', async () => {
const ranges = await getSelectionRangesForDocument(
joinLines(
`## ParentHeader`,
`# Header`,
`${CURSOR}Hello`
));
assertNestedRangesEqual(ranges![0], [2, 2], [1, 2]);
});
test('Smart select html block w parent header', async () => {
const ranges = await getSelectionRangesForDocument(
joinLines(
`# Header`,
`${CURSOR}<p align="center">`,
`<img alt="VS Code in action" src="https://user-images.githubusercontent.com/1487073/58344409-70473b80-7e0a-11e9-8570-b2efc6f8fa44.png">`,
`</p>`));
assertNestedRangesEqual(ranges![0], [1, 3], [1, 3], [0, 3]);
});
test('Smart select fenced code block', async () => {
const ranges = await getSelectionRangesForDocument(
joinLines(
`~~~`,
`a${CURSOR}`,
`~~~`));
assertNestedRangesEqual(ranges![0], [0, 2]);
});
test('Smart select list', async () => {
const ranges = await getSelectionRangesForDocument(
joinLines(
`- item 1`,
`- ${CURSOR}item 2`,
`- item 3`,
`- item 4`));
assertNestedRangesEqual(ranges![0], [1, 1], [0, 3]);
});
test('Smart select list with fenced code block', async () => {
const ranges = await getSelectionRangesForDocument(
joinLines(
`- item 1`,
`- ~~~`,
` ${CURSOR}a`,
` ~~~`,
`- item 3`,
`- item 4`));
assertNestedRangesEqual(ranges![0], [1, 3], [0, 5]);
});
test('Smart select multi cursor', async () => {
const ranges = await getSelectionRangesForDocument(
joinLines(
`- ${CURSOR}item 1`,
`- ~~~`,
` a`,
` ~~~`,
`- ${CURSOR}item 3`,
`- item 4`));
assertNestedRangesEqual(ranges![0], [0, 0], [0, 5]);
assertNestedRangesEqual(ranges![1], [4, 4], [0, 5]);
});
test('Smart select nested block quotes', async () => {
const ranges = await getSelectionRangesForDocument(
joinLines(
`> item 1`,
`> item 2`,
`>> ${CURSOR}item 3`,
`>> item 4`));
assertNestedRangesEqual(ranges![0], [2, 4], [0, 4]);
});
test('Smart select multi nested block quotes', async () => {
const ranges = await getSelectionRangesForDocument(
joinLines(
`> item 1`,
`>> item 2`,
`>>> ${CURSOR}item 3`,
`>>>> item 4`));
assertNestedRangesEqual(ranges![0], [2, 3], [2, 4], [1, 4], [0, 4]);
});
test('Smart select subheader content', async () => {
const ranges = await getSelectionRangesForDocument(
joinLines(
`# main header 1`,
`content 1`,
`## sub header 1`,
`${CURSOR}content 2`,
`# main header 2`));
assertNestedRangesEqual(ranges![0], [3, 3], [2, 3], [1, 3], [0, 3]);
});
test('Smart select subheader line', async () => {
const ranges = await getSelectionRangesForDocument(
joinLines(
`# main header 1`,
`content 1`,
`## sub header 1${CURSOR}`,
`content 2`,
`# main header 2`));
assertNestedRangesEqual(ranges![0], [2, 3], [1, 3], [0, 3]);
});
test('Smart select blank line', async () => {
const ranges = await getSelectionRangesForDocument(
joinLines(
`# main header 1`,
`content 1`,
`${CURSOR} `,
`content 2`,
`# main header 2`));
assertNestedRangesEqual(ranges![0], [1, 3], [0, 3]);
});
test('Smart select line between paragraphs', async () => {
const ranges = await getSelectionRangesForDocument(
joinLines(
`paragraph 1`,
`${CURSOR}`,
`paragraph 2`));
assertNestedRangesEqual(ranges![0], [0, 3]);
});
test('Smart select empty document', async () => {
const ranges = await getSelectionRangesForDocument(``, [new vscode.Position(0, 0)]);
assert.strictEqual(ranges!.length, 0);
});
test('Smart select fenced code block then list then subheader content then subheader then header content then header', async () => {
const ranges = await getSelectionRangesForDocument(
joinLines(
`# main header 1`,
`content 1`,
`## sub header 1`,
`- item 1`,
`- ~~~`,
` ${CURSOR}a`,
` ~~~`,
`- item 3`,
`- item 4`,
``,
`more content`,
`# main header 2`));
assertNestedRangesEqual(ranges![0], [4, 6], [3, 9], [3, 10], [2, 10], [1, 10], [0, 10]);
});
test('Smart select list with one element without selecting child subheader', async () => {
const ranges = await getSelectionRangesForDocument(
joinLines(
`# main header 1`,
``,
`- list ${CURSOR}`,
``,
`## sub header`,
``,
`content 2`,
`# main header 2`));
assertNestedRangesEqual(ranges![0], [2, 3], [1, 3], [1, 6], [0, 6]);
});
test('Smart select content under header then subheaders and their content', async () => {
const ranges = await getSelectionRangesForDocument(
joinLines(
`# main ${CURSOR}header 1`,
``,
`- list`,
`paragraph`,
`## sub header`,
``,
`content 2`,
`# main header 2`));
assertNestedRangesEqual(ranges![0], [0, 3], [0, 6]);
});
test('Smart select last blockquote element under header then subheaders and their content', async () => {
const ranges = await getSelectionRangesForDocument(
joinLines(
`# main header 1`,
``,
`> block`,
`> block`,
`>> block`,
`>> ${CURSOR}block`,
``,
`paragraph`,
`## sub header`,
``,
`content 2`,
`# main header 2`));
assertNestedRangesEqual(ranges![0], [4, 6], [2, 6], [1, 7], [1, 10], [0, 10]);
});
test('Smart select content of subheader then subheader then content of main header then main header', async () => {
const ranges = await getSelectionRangesForDocument(
joinLines(
`# main header 1`,
``,
`> block`,
`> block`,
`>> block`,
`>> block`,
``,
`paragraph`,
`## sub header`,
``,
``,
`${CURSOR}`,
``,
`### main header 2`,
`- content 2`,
`- content 2`,
`- content 2`,
`content 2`));
assertNestedRangesEqual(ranges![0], [11, 12], [9, 12], [9, 17], [8, 17], [1, 17], [0, 17]);
});
test('Smart select last line content of subheader then subheader then content of main header then main header', async () => {
const ranges = await getSelectionRangesForDocument(
joinLines(
`# main header 1`,
``,
`> block`,
`> block`,
`>> block`,
`>> block`,
``,
`paragraph`,
`## sub header`,
``,
``,
``,
``,
`### main header 2`,
`- content 2`,
`- content 2`,
`- content 2`,
`${CURSOR}content 2`));
assertNestedRangesEqual(ranges![0], [16, 17], [14, 17], [14, 17], [13, 17], [9, 17], [8, 17], [1, 17], [0, 17]);
});
test('Smart select last line content after content of subheader then subheader then content of main header then main header', async () => {
const ranges = await getSelectionRangesForDocument(
joinLines(
`# main header 1`,
``,
`> block`,
`> block`,
`>> block`,
`>> block`,
``,
`paragraph`,
`## sub header`,
``,
``,
``,
``,
`### main header 2`,
`- content 2`,
`- content 2`,
`- content 2`,
`content 2${CURSOR}`));
assertNestedRangesEqual(ranges![0], [16, 17], [14, 17], [14, 17], [13, 17], [9, 17], [8, 17], [1, 17], [0, 17]);
});
test('Smart select fenced code block then list then rest of content', async () => {
const ranges = await getSelectionRangesForDocument(
joinLines(
`# main header 1`,
``,
`> block`,
`> block`,
`>> block`,
`>> block`,
``,
`- paragraph`,
`- ~~~`,
` my`,
` ${CURSOR}code`,
` goes here`,
` ~~~`,
`- content`,
`- content 2`,
`- content 2`,
`- content 2`,
`- content 2`));
assertNestedRangesEqual(ranges![0], [9, 11], [8, 12], [7, 17], [1, 17], [0, 17]);
});
test('Smart select fenced code block then list then rest of content on fenced line', async () => {
const ranges = await getSelectionRangesForDocument(
joinLines(
`# main header 1`,
``,
`> block`,
`> block`,
`>> block`,
`>> block`,
``,
`- paragraph`,
`- ~~~${CURSOR}`,
` my`,
` code`,
` goes here`,
` ~~~`,
`- content`,
`- content 2`,
`- content 2`,
`- content 2`,
`- content 2`));
assertNestedRangesEqual(ranges![0], [8, 12], [7, 17], [1, 17], [0, 17]);
});
});
function assertNestedRangesEqual(range: vscode.SelectionRange, ...expectedRanges: [number, number][]) {
const lineage = getLineage(range);
assert.strictEqual(lineage.length, expectedRanges.length, `expected depth: ${expectedRanges.length}, but was ${lineage.length}`);
for (let i = 0; i < lineage.length; i++) {
assertRangesEqual(lineage[i], expectedRanges[i][0], expectedRanges[i][1], `parent at a depth of ${i}`);
}
}
function getLineage(range: vscode.SelectionRange): vscode.SelectionRange[] {
const result: vscode.SelectionRange[] = [];
let currentRange: vscode.SelectionRange | undefined = range;
while (currentRange) {
result.push(currentRange);
currentRange = currentRange.parent;
}
return result;
}
function assertRangesEqual(selectionRange: vscode.SelectionRange, startLine: number, endLine: number, message: string) {
assert.strictEqual(selectionRange.range.start.line, startLine, `failed on start line ${message}`);
assert.strictEqual(selectionRange.range.end.line, endLine, `failed on end line ${message}`);
}
async function getSelectionRangesForDocument(contents: string, pos?: vscode.Position[]) {
const doc = new InMemoryDocument(testFileName, contents);
const provider = new MarkdownSmartSelect(createNewMarkdownEngine());
const positions = pos ? pos : getCursorPositions(contents, doc);
return await provider.provideSelectionRanges(doc, positions, new vscode.CancellationTokenSource().token);
}
let getCursorPositions = (contents: string, doc: InMemoryDocument): vscode.Position[] => {
let positions: vscode.Position[] = [];
let index = 0;
let wordLength = 0;
while (index !== -1) {
index = contents.indexOf(CURSOR, index + wordLength);
if (index !== -1) {
positions.push(doc.positionAt(index));
}
wordLength = CURSOR.length;
}
return positions;
};

View File

@ -0,0 +1,130 @@
/*---------------------------------------------------------------------------------------------
* Copyright (c) Microsoft Corporation. All rights reserved.
* Licensed under the MIT License. See License.txt in the project root for license information.
*--------------------------------------------------------------------------------------------*/
import * as assert from 'assert';
import * as vscode from 'vscode';
import 'mocha';
import { TableOfContentsProvider } from '../tableOfContentsProvider';
import { InMemoryDocument } from './inMemoryDocument';
import { createNewMarkdownEngine } from './engine';
const testFileName = vscode.Uri.file('test.md');
suite('markdown.TableOfContentsProvider', () => {
test('Lookup should not return anything for empty document', async () => {
const doc = new InMemoryDocument(testFileName, '');
const provider = new TableOfContentsProvider(createNewMarkdownEngine(), doc);
assert.strictEqual(await provider.lookup(''), undefined);
assert.strictEqual(await provider.lookup('foo'), undefined);
});
test('Lookup should not return anything for document with no headers', async () => {
const doc = new InMemoryDocument(testFileName, 'a *b*\nc');
const provider = new TableOfContentsProvider(createNewMarkdownEngine(), doc);
assert.strictEqual(await provider.lookup(''), undefined);
assert.strictEqual(await provider.lookup('foo'), undefined);
assert.strictEqual(await provider.lookup('a'), undefined);
assert.strictEqual(await provider.lookup('b'), undefined);
});
test('Lookup should return basic #header', async () => {
const doc = new InMemoryDocument(testFileName, `# a\nx\n# c`);
const provider = new TableOfContentsProvider(createNewMarkdownEngine(), doc);
{
const entry = await provider.lookup('a');
assert.ok(entry);
assert.strictEqual(entry!.line, 0);
}
{
assert.strictEqual(await provider.lookup('x'), undefined);
}
{
const entry = await provider.lookup('c');
assert.ok(entry);
assert.strictEqual(entry!.line, 2);
}
});
test('Lookups should be case in-sensitive', async () => {
const doc = new InMemoryDocument(testFileName, `# fOo\n`);
const provider = new TableOfContentsProvider(createNewMarkdownEngine(), doc);
assert.strictEqual((await provider.lookup('fOo'))!.line, 0);
assert.strictEqual((await provider.lookup('foo'))!.line, 0);
assert.strictEqual((await provider.lookup('FOO'))!.line, 0);
});
test('Lookups should ignore leading and trailing white-space, and collapse internal whitespace', async () => {
const doc = new InMemoryDocument(testFileName, `# f o o \n`);
const provider = new TableOfContentsProvider(createNewMarkdownEngine(), doc);
assert.strictEqual((await provider.lookup('f o o'))!.line, 0);
assert.strictEqual((await provider.lookup(' f o o'))!.line, 0);
assert.strictEqual((await provider.lookup(' f o o '))!.line, 0);
assert.strictEqual((await provider.lookup('f o o'))!.line, 0);
assert.strictEqual((await provider.lookup('f o o'))!.line, 0);
assert.strictEqual(await provider.lookup('f'), undefined);
assert.strictEqual(await provider.lookup('foo'), undefined);
assert.strictEqual(await provider.lookup('fo o'), undefined);
});
test('should handle special characters #44779', async () => {
const doc = new InMemoryDocument(testFileName, `# Indentação\n`);
const provider = new TableOfContentsProvider(createNewMarkdownEngine(), doc);
assert.strictEqual((await provider.lookup('indentação'))!.line, 0);
});
test('should handle special characters 2, #48482', async () => {
const doc = new InMemoryDocument(testFileName, `# Инструкция - Делай Раз, Делай Два\n`);
const provider = new TableOfContentsProvider(createNewMarkdownEngine(), doc);
assert.strictEqual((await provider.lookup('инструкция---делай-раз-делай-два'))!.line, 0);
});
test('should handle special characters 3, #37079', async () => {
const doc = new InMemoryDocument(testFileName, `## Header 2
### Header 3
## Заголовок 2
### Заголовок 3
### Заголовок Header 3
## Заголовок`);
const provider = new TableOfContentsProvider(createNewMarkdownEngine(), doc);
assert.strictEqual((await provider.lookup('header-2'))!.line, 0);
assert.strictEqual((await provider.lookup('header-3'))!.line, 1);
assert.strictEqual((await provider.lookup('Заголовок-2'))!.line, 2);
assert.strictEqual((await provider.lookup('Заголовок-3'))!.line, 3);
assert.strictEqual((await provider.lookup('Заголовок-header-3'))!.line, 4);
assert.strictEqual((await provider.lookup('Заголовок'))!.line, 5);
});
test('Lookup should support suffixes for repeated headers', async () => {
const doc = new InMemoryDocument(testFileName, `# a\n# a\n## a`);
const provider = new TableOfContentsProvider(createNewMarkdownEngine(), doc);
{
const entry = await provider.lookup('a');
assert.ok(entry);
assert.strictEqual(entry!.line, 0);
}
{
const entry = await provider.lookup('a-1');
assert.ok(entry);
assert.strictEqual(entry!.line, 1);
}
{
const entry = await provider.lookup('a-2');
assert.ok(entry);
assert.strictEqual(entry!.line, 2);
}
});
});

View File

@ -0,0 +1,8 @@
/*---------------------------------------------------------------------------------------------
* Copyright (c) Microsoft Corporation. All rights reserved.
* Licensed under the MIT License. See License.txt in the project root for license information.
*--------------------------------------------------------------------------------------------*/
import * as os from 'os';
export const joinLines = (...args: string[]) =>
args.join(os.platform() === 'win32' ? '\r\n' : '\n');

View File

@ -0,0 +1,142 @@
/*---------------------------------------------------------------------------------------------
* Copyright (c) Microsoft Corporation. All rights reserved.
* Licensed under the MIT License. See License.txt in the project root for license information.
*--------------------------------------------------------------------------------------------*/
import * as assert from 'assert';
import 'mocha';
import * as vscode from 'vscode';
import MDDocumentSymbolProvider from '../features/documentSymbolProvider';
import MarkdownWorkspaceSymbolProvider, { WorkspaceMarkdownDocumentProvider } from '../features/workspaceSymbolProvider';
import { createNewMarkdownEngine } from './engine';
import { InMemoryDocument } from './inMemoryDocument';
const symbolProvider = new MDDocumentSymbolProvider(createNewMarkdownEngine());
suite('markdown.WorkspaceSymbolProvider', () => {
test('Should not return anything for empty workspace', async () => {
const provider = new MarkdownWorkspaceSymbolProvider(symbolProvider, new InMemoryWorkspaceMarkdownDocumentProvider([]));
assert.deepEqual(await provider.provideWorkspaceSymbols(''), []);
});
test('Should return symbols from workspace with one markdown file', async () => {
const testFileName = vscode.Uri.file('test.md');
const provider = new MarkdownWorkspaceSymbolProvider(symbolProvider, new InMemoryWorkspaceMarkdownDocumentProvider([
new InMemoryDocument(testFileName, `# header1\nabc\n## header2`)
]));
const symbols = await provider.provideWorkspaceSymbols('');
assert.strictEqual(symbols.length, 2);
assert.strictEqual(symbols[0].name, '# header1');
assert.strictEqual(symbols[1].name, '## header2');
});
test('Should return all content basic workspace', async () => {
const fileNameCount = 10;
const files: vscode.TextDocument[] = [];
for (let i = 0; i < fileNameCount; ++i) {
const testFileName = vscode.Uri.file(`test${i}.md`);
files.push(new InMemoryDocument(testFileName, `# common\nabc\n## header${i}`));
}
const provider = new MarkdownWorkspaceSymbolProvider(symbolProvider, new InMemoryWorkspaceMarkdownDocumentProvider(files));
const symbols = await provider.provideWorkspaceSymbols('');
assert.strictEqual(symbols.length, fileNameCount * 2);
});
test('Should update results when markdown file changes symbols', async () => {
const testFileName = vscode.Uri.file('test.md');
const workspaceFileProvider = new InMemoryWorkspaceMarkdownDocumentProvider([
new InMemoryDocument(testFileName, `# header1`, 1 /* version */)
]);
const provider = new MarkdownWorkspaceSymbolProvider(symbolProvider, workspaceFileProvider);
assert.strictEqual((await provider.provideWorkspaceSymbols('')).length, 1);
// Update file
workspaceFileProvider.updateDocument(new InMemoryDocument(testFileName, `# new header\nabc\n## header2`, 2 /* version */));
const newSymbols = await provider.provideWorkspaceSymbols('');
assert.strictEqual(newSymbols.length, 2);
assert.strictEqual(newSymbols[0].name, '# new header');
assert.strictEqual(newSymbols[1].name, '## header2');
});
test('Should remove results when file is deleted', async () => {
const testFileName = vscode.Uri.file('test.md');
const workspaceFileProvider = new InMemoryWorkspaceMarkdownDocumentProvider([
new InMemoryDocument(testFileName, `# header1`)
]);
const provider = new MarkdownWorkspaceSymbolProvider(symbolProvider, workspaceFileProvider);
assert.strictEqual((await provider.provideWorkspaceSymbols('')).length, 1);
// delete file
workspaceFileProvider.deleteDocument(testFileName);
const newSymbols = await provider.provideWorkspaceSymbols('');
assert.strictEqual(newSymbols.length, 0);
});
test('Should update results when markdown file is created', async () => {
const testFileName = vscode.Uri.file('test.md');
const workspaceFileProvider = new InMemoryWorkspaceMarkdownDocumentProvider([
new InMemoryDocument(testFileName, `# header1`)
]);
const provider = new MarkdownWorkspaceSymbolProvider(symbolProvider, workspaceFileProvider);
assert.strictEqual((await provider.provideWorkspaceSymbols('')).length, 1);
// Creat file
workspaceFileProvider.createDocument(new InMemoryDocument(vscode.Uri.file('test2.md'), `# new header\nabc\n## header2`));
const newSymbols = await provider.provideWorkspaceSymbols('');
assert.strictEqual(newSymbols.length, 3);
});
});
class InMemoryWorkspaceMarkdownDocumentProvider implements WorkspaceMarkdownDocumentProvider {
private readonly _documents = new Map<string, vscode.TextDocument>();
constructor(documents: vscode.TextDocument[]) {
for (const doc of documents) {
this._documents.set(doc.fileName, doc);
}
}
async getAllMarkdownDocuments() {
return Array.from(this._documents.values());
}
private readonly _onDidChangeMarkdownDocumentEmitter = new vscode.EventEmitter<vscode.TextDocument>();
public onDidChangeMarkdownDocument = this._onDidChangeMarkdownDocumentEmitter.event;
private readonly _onDidCreateMarkdownDocumentEmitter = new vscode.EventEmitter<vscode.TextDocument>();
public onDidCreateMarkdownDocument = this._onDidCreateMarkdownDocumentEmitter.event;
private readonly _onDidDeleteMarkdownDocumentEmitter = new vscode.EventEmitter<vscode.Uri>();
public onDidDeleteMarkdownDocument = this._onDidDeleteMarkdownDocumentEmitter.event;
public updateDocument(document: vscode.TextDocument) {
this._documents.set(document.fileName, document);
this._onDidChangeMarkdownDocumentEmitter.fire(document);
}
public createDocument(document: vscode.TextDocument) {
assert.ok(!this._documents.has(document.uri.fsPath));
this._documents.set(document.uri.fsPath, document);
this._onDidCreateMarkdownDocumentEmitter.fire(document);
}
public deleteDocument(resource: vscode.Uri) {
this._documents.delete(resource.fsPath);
this._onDidDeleteMarkdownDocumentEmitter.fire(resource);
}
}

View File

@ -0,0 +1,8 @@
/*---------------------------------------------------------------------------------------------
* Copyright (c) Microsoft Corporation. All rights reserved.
* Licensed under the MIT License. See License.txt in the project root for license information.
*--------------------------------------------------------------------------------------------*/
/// <reference path='../../../../src/vs/vscode.d.ts'/>
/// <reference path='../../../../src/vs/vscode.proposed.d.ts'/>
/// <reference types='@types/node'/>

View File

@ -0,0 +1,22 @@
/*---------------------------------------------------------------------------------------------
* Copyright (c) Microsoft Corporation. All rights reserved.
* Licensed under the MIT License. See License.txt in the project root for license information.
*--------------------------------------------------------------------------------------------*/
export function equals<T>(one: ReadonlyArray<T>, other: ReadonlyArray<T>, itemEquals: (a: T, b: T) => boolean = (a, b) => a === b): boolean {
if (one.length !== other.length) {
return false;
}
for (let i = 0, len = one.length; i < len; i++) {
if (!itemEquals(one[i], other[i])) {
return false;
}
}
return true;
}
export function flatten<T>(arr: ReadonlyArray<T>[]): T[] {
return ([] as T[]).concat.apply([], arr);
}

View File

@ -0,0 +1,42 @@
/*---------------------------------------------------------------------------------------------
* Copyright (c) Microsoft Corporation. All rights reserved.
* Licensed under the MIT License. See License.txt in the project root for license information.
*--------------------------------------------------------------------------------------------*/
import * as vscode from 'vscode';
export function disposeAll(disposables: vscode.Disposable[]) {
while (disposables.length) {
const item = disposables.pop();
if (item) {
item.dispose();
}
}
}
export abstract class Disposable {
private _isDisposed = false;
protected _disposables: vscode.Disposable[] = [];
public dispose(): any {
if (this._isDisposed) {
return;
}
this._isDisposed = true;
disposeAll(this._disposables);
}
protected _register<T extends vscode.Disposable>(value: T): T {
if (this._isDisposed) {
value.dispose();
} else {
this._disposables.push(value);
}
return value;
}
protected get isDisposed() {
return this._isDisposed;
}
}

View File

@ -0,0 +1,10 @@
/*---------------------------------------------------------------------------------------------
* Copyright (c) Microsoft Corporation. All rights reserved.
* Licensed under the MIT License. See License.txt in the project root for license information.
*--------------------------------------------------------------------------------------------*/
import * as vscode from 'vscode';
export function isMarkdownFile(document: vscode.TextDocument) {
return document.languageId === 'markdown';
}

View File

@ -0,0 +1,58 @@
/*---------------------------------------------------------------------------------------------
* Copyright (c) Microsoft Corporation. All rights reserved.
* Licensed under the MIT License. See License.txt in the project root for license information.
*--------------------------------------------------------------------------------------------*/
/**
* Return a hash value for an object.
*/
export function hash(obj: any, hashVal = 0): number {
switch (typeof obj) {
case 'object':
if (obj === null) {
return numberHash(349, hashVal);
} else if (Array.isArray(obj)) {
return arrayHash(obj, hashVal);
}
return objectHash(obj, hashVal);
case 'string':
return stringHash(obj, hashVal);
case 'boolean':
return booleanHash(obj, hashVal);
case 'number':
return numberHash(obj, hashVal);
case 'undefined':
return 937 * 31;
default:
return numberHash(obj, 617);
}
}
function numberHash(val: number, initialHashVal: number): number {
return (((initialHashVal << 5) - initialHashVal) + val) | 0; // hashVal * 31 + ch, keep as int32
}
function booleanHash(b: boolean, initialHashVal: number): number {
return numberHash(b ? 433 : 863, initialHashVal);
}
function stringHash(s: string, hashVal: number) {
hashVal = numberHash(149417, hashVal);
for (let i = 0, length = s.length; i < length; i++) {
hashVal = numberHash(s.charCodeAt(i), hashVal);
}
return hashVal;
}
function arrayHash(arr: any[], initialHashVal: number): number {
initialHashVal = numberHash(104579, initialHashVal);
return arr.reduce((hashVal, item) => hash(item, hashVal), initialHashVal);
}
function objectHash(obj: any, initialHashVal: number): number {
initialHashVal = numberHash(181387, initialHashVal);
return Object.keys(obj).sort().reduce((hashVal, key) => {
hashVal = stringHash(key, hashVal);
return hash(obj[key], hashVal);
}, initialHashVal);
}

View File

@ -0,0 +1,39 @@
/*---------------------------------------------------------------------------------------------
* Copyright (c) Microsoft Corporation. All rights reserved.
* Licensed under the MIT License. See License.txt in the project root for license information.
*--------------------------------------------------------------------------------------------*/
export interface Lazy<T> {
readonly value: T;
readonly hasValue: boolean;
map<R>(f: (x: T) => R): Lazy<R>;
}
class LazyValue<T> implements Lazy<T> {
private _hasValue: boolean = false;
private _value?: T;
constructor(
private readonly _getValue: () => T
) { }
get value(): T {
if (!this._hasValue) {
this._hasValue = true;
this._value = this._getValue();
}
return this._value!;
}
get hasValue(): boolean {
return this._hasValue;
}
public map<R>(f: (x: T) => R): Lazy<R> {
return new LazyValue(() => f(this.value));
}
}
export function lazy<T>(getValue: () => T): Lazy<T> {
return new LazyValue<T>(getValue);
}

View File

@ -0,0 +1,47 @@
/*---------------------------------------------------------------------------------------------
* Copyright (c) Microsoft Corporation. All rights reserved.
* Licensed under the MIT License. See License.txt in the project root for license information.
*--------------------------------------------------------------------------------------------*/
import * as vscode from 'vscode';
export const Schemes = {
http: 'http:',
https: 'https:',
file: 'file:',
untitled: 'untitled',
mailto: 'mailto:',
data: 'data:',
vscode: 'vscode:',
'vscode-insiders': 'vscode-insiders:',
'vscode-resource': 'vscode-resource:',
};
const knownSchemes = [
...Object.values(Schemes),
`${vscode.env.uriScheme}:`
];
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);
}
export const MarkdownFileExtensions: readonly string[] = [
'.md',
'.mkd',
'.mdwn',
'.mdown',
'.markdown',
'.markdn',
'.mdtxt',
'.mdtext',
'.workbook',
];

View File

@ -0,0 +1,33 @@
/*---------------------------------------------------------------------------------------------
* Copyright (c) Microsoft Corporation. All rights reserved.
* Licensed under the MIT License. See License.txt in the project root for license information.
*--------------------------------------------------------------------------------------------*/
import * as vscode from 'vscode';
export interface WebviewResourceProvider {
asWebviewUri(resource: vscode.Uri): vscode.Uri;
readonly cspSource: string;
}
export function normalizeResource(
base: vscode.Uri,
resource: vscode.Uri
): vscode.Uri {
// If we have a windows path and are loading a workspace with an authority,
// make sure we use a unc path with an explicit localhost authority.
//
// Otherwise, the `<base>` rule will insert the authority into the resolved resource
// URI incorrectly.
if (base.authority && !resource.authority) {
const driveMatch = resource.path.match(/^\/(\w):\//);
if (driveMatch) {
return vscode.Uri.file(`\\\\localhost\\${driveMatch[1]}$\\${resource.fsPath.replace(/^\w:\\/, '')}`).with({
fragment: resource.fragment,
query: resource.query
});
}
}
return resource;
}

View File

@ -0,0 +1,70 @@
/*---------------------------------------------------------------------------------------------
* Copyright (c) Microsoft Corporation. All rights reserved.
* Licensed under the MIT License. See License.txt in the project root for license information.
*--------------------------------------------------------------------------------------------*/
import * as vscode from 'vscode';
import { Disposable } from '../util/dispose';
import { isMarkdownFile } from './file';
export class TopmostLineMonitor extends Disposable {
private readonly pendingUpdates = new Map<string, number>();
private readonly throttle = 50;
constructor() {
super();
this._register(vscode.window.onDidChangeTextEditorVisibleRanges(event => {
if (isMarkdownFile(event.textEditor.document)) {
const line = getVisibleLine(event.textEditor);
if (typeof line === 'number') {
this.updateLine(event.textEditor.document.uri, line);
}
}
}));
}
private readonly _onChanged = this._register(new vscode.EventEmitter<{ readonly resource: vscode.Uri, readonly line: number }>());
public readonly onDidChanged = this._onChanged.event;
private updateLine(
resource: vscode.Uri,
line: number
) {
const key = resource.toString();
if (!this.pendingUpdates.has(key)) {
// schedule update
setTimeout(() => {
if (this.pendingUpdates.has(key)) {
this._onChanged.fire({
resource,
line: this.pendingUpdates.get(key) as number
});
this.pendingUpdates.delete(key);
}
}, this.throttle);
}
this.pendingUpdates.set(key, line);
}
}
/**
* Get the top-most visible range of `editor`.
*
* Returns a fractional line number based the visible character within the line.
* Floor to get real line number
*/
export function getVisibleLine(
editor: vscode.TextEditor
): number | undefined {
if (!editor.visibleRanges.length) {
return undefined;
}
const firstVisiblePosition = editor.visibleRanges[0].start;
const lineNumber = firstVisiblePosition.line;
const line = editor.document.lineAt(lineNumber);
const progress = firstVisiblePosition.character / (line.text.length + 2);
return lineNumber + progress;
}