Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

preferences: Render markdown descriptions #10431

Merged
merged 1 commit into from
Dec 14, 2021
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions packages/core/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -50,6 +50,7 @@
"keytar": "7.2.0",
"lodash.debounce": "^4.0.8",
"lodash.throttle": "^4.1.1",
"markdown-it": "^8.4.0",
"nsfw": "^2.1.2",
"p-debounce": "^2.1.0",
"perfect-scrollbar": "^1.3.0",
Expand Down
5 changes: 2 additions & 3 deletions packages/core/src/browser/core-preferences.ts
Original file line number Diff line number Diff line change
Expand Up @@ -58,8 +58,7 @@ export const corePreferenceSchema: PreferenceSchema = {
'keyCode',
],
default: 'code',
description: nls.localizeByDefault(
'Controls the dispatching logic for key presses to use either `code` (recommended) or `keyCode`.')
markdownDescription: nls.localizeByDefault('Controls the dispatching logic for key presses to use either `code` (recommended) or `keyCode`.')
},
'window.menuBarVisibility': {
type: 'string',
Expand Down Expand Up @@ -89,7 +88,7 @@ export const corePreferenceSchema: PreferenceSchema = {
'workbench.editor.highlightModifiedTabs': {
'type': 'boolean',
// eslint-disable-next-line max-len
'description': nls.localizeByDefault('Controls whether a top border is drawn on modified (dirty) editor tabs or not. This value is ignored when `#workbench.editor.showTabs#` is disabled.'),
'markdownDescription': nls.localize('theia/core/highlightModifiedTabs', 'Controls whether a top border is drawn on modified (dirty) editor tabs or not.'),
'default': false
},
'workbench.editor.closeOnFileDelete': {
Expand Down
1 change: 1 addition & 0 deletions packages/core/src/browser/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -41,3 +41,4 @@ export * from './core-preferences';
export * from './view-container';
export * from './breadcrumbs';
export * from './tooltip-service';
export * from './markdown-renderer';
78 changes: 78 additions & 0 deletions packages/core/src/browser/markdown-renderer.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,78 @@
/********************************************************************************
* Copyright (C) 2021 TypeFox and others.
*
* This program and the accompanying materials are made available under the
* terms of the Eclipse Public License v. 2.0 which is available at
* http://www.eclipse.org/legal/epl-2.0.
*
* This Source Code may also be made available under the following Secondary
* Licenses when the conditions for such availability set forth in the Eclipse
* Public License v. 2.0 are satisfied: GNU General Public License, version 2
* with the GNU Classpath Exception which is available at
* https://www.gnu.org/software/classpath/license.html.
*
* SPDX-License-Identifier: EPL-2.0 OR GPL-2.0 WITH Classpath-exception-2.0
********************************************************************************/

import { enableJSDOM } from '../browser/test/jsdom';
let disableJSDOM = enableJSDOM();

import { expect } from 'chai';
import * as markdownit from 'markdown-it';
import { MarkdownRenderer } from './markdown-renderer';

disableJSDOM();

describe('MarkdownRenderer', () => {

before(() => disableJSDOM = enableJSDOM());
after(() => disableJSDOM());

it('Should render markdown', () => {
const markdownRenderer = new MarkdownRenderer();
const result = markdownRenderer.renderInline('[title](link)').innerHTML;
expect(result).to.be.equal('<a href="link">title</a>');
});

it('Should accept and use custom engine', () => {
const engine = markdownit();
const originalTextRenderer = engine.renderer.rules.text!;
engine.renderer.rules.text = (tokens, idx, options, env, self) => `[${originalTextRenderer(tokens, idx, options, env, self)}]`;
const markdownRenderer = new MarkdownRenderer(engine);
const result = markdownRenderer.renderInline('text').innerHTML;
expect(result).to.be.equal('[text]');
});

it('Should modify rendered markdown in place', () => {
const markdownRenderer = new MarkdownRenderer().modify('a', a => {
a.href = 'something-else';
});
const result = markdownRenderer.renderInline('[title](link)').innerHTML;
expect(result).to.be.equal('<a href="something-else">title</a>');
});

it('Should modify descendants of children', () => {
const markdownRenderer = new MarkdownRenderer().modify('em', em => {
const strong = document.createElement('strong');
// eslint-disable-next-line no-unsanitized/property
strong.innerHTML = em.innerHTML;
return strong;
});
const result = markdownRenderer.render('**bold *bold and italic***').innerHTML;
expect(result).to.be.equal('<p><strong>bold <strong>bold and italic</strong></strong></p>\n');
});

it('Should modify descendants of children after previous modification', () => {
const markdownRenderer = new MarkdownRenderer().modify('em', em => {
const strong = document.createElement('strong');
// eslint-disable-next-line no-unsanitized/property
strong.innerHTML = em.innerHTML;
return strong;
}).modify('strong', strong => { // Will pick up both the original and modified strong element
const textNode = strong.childNodes.item(0);
textNode.textContent = `changed_${textNode.textContent}`;
});
const result = markdownRenderer.render('**bold *bold and italic***').innerHTML;
expect(result).to.be.equal('<p><strong>changed_bold <strong>changed_bold and italic</strong></strong></p>\n');
});
});
76 changes: 76 additions & 0 deletions packages/core/src/browser/markdown-renderer.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,76 @@
/********************************************************************************
* Copyright (C) 2021 TypeFox and others.
*
* This program and the accompanying materials are made available under the
* terms of the Eclipse Public License v. 2.0 which is available at
* http://www.eclipse.org/legal/epl-2.0.
*
* This Source Code may also be made available under the following Secondary
* Licenses when the conditions for such availability set forth in the Eclipse
* Public License v. 2.0 are satisfied: GNU General Public License, version 2
* with the GNU Classpath Exception which is available at
* https://www.gnu.org/software/classpath/license.html.
*
* SPDX-License-Identifier: EPL-2.0 OR GPL-2.0 WITH Classpath-exception-2.0
********************************************************************************/

import * as DOMPurify from 'dompurify';
import * as markdownit from 'markdown-it';

export class MarkdownRenderer {

protected engine: markdownit;
protected callbacks = new Map<string, ((element: Element) => Element | void)[]>();

constructor(engine?: markdownit) {
this.engine = engine ?? markdownit();
}

/**
* Adds a modification callback that is applied to every element with the specified tag after rendering to HTML.
*
* @param tag The tag that this modification applies to.
* @param callback The modification to apply on every selected rendered element. Can either modify the element in place or return a new element.
*/
modify<K extends keyof HTMLElementTagNameMap>(tag: K, callback: (element: HTMLElementTagNameMap[K]) => Element | void): MarkdownRenderer {
if (this.callbacks.has(tag)) {
this.callbacks.get(tag)!.push(callback);
} else {
this.callbacks.set(tag, [callback]);
}
return this;
}

render(markdown: string): HTMLElement {
return this.renderInternal(this.engine.render(markdown));
}

renderInline(markdown: string): HTMLElement {
return this.renderInternal(this.engine.renderInline(markdown));
}

protected renderInternal(renderedHtml: string): HTMLElement {
const div = this.sanitizeHtml(renderedHtml);
for (const [tag, calls] of this.callbacks) {
for (const callback of calls) {
const elements = Array.from(div.getElementsByTagName(tag));
for (const element of elements) {
const result = callback(element);
if (result) {
const parent = element.parentElement;
if (parent) {
parent.replaceChild(result, element);
}
}
}
}
}
return div;
}

protected sanitizeHtml(html: string): HTMLElement {
const div = document.createElement('div');
div.innerHTML = DOMPurify.sanitize(html);
return div;
}
}
12 changes: 6 additions & 6 deletions packages/editor/src/browser/editor-preferences.ts
Original file line number Diff line number Diff line change
Expand Up @@ -122,7 +122,7 @@ const codeEditorPreferenceProperties = {
},
'editor.semanticHighlighting.enabled': {
'enum': [true, false, 'configuredByTheme'],
'enumDescriptions': [
'markdownEnumDescriptions': [
nls.localizeByDefault('Semantic highlighting enabled for all color themes.'),
nls.localizeByDefault('Semantic highlighting disabled for all color themes.'),
nls.localizeByDefault('Semantic highlighting is configured by the current color theme\'s `semanticHighlighting` setting.')
Expand Down Expand Up @@ -327,7 +327,7 @@ const codeEditorPreferenceProperties = {
'default': 0,
'minimum': 0,
'maximum': 100,
'description': nls.localizeByDefault('Controls the font size in pixels for CodeLens. When set to `0`, the 90% of `#editor.fontSize#` is used.')
'markdownDescription': nls.localizeByDefault('Controls the font size in pixels for CodeLens. When set to `0`, the 90% of `#editor.fontSize#` is used.')
},
'editor.colorDecorators': {
'description': nls.localizeByDefault('Controls whether the editor should render the inline color decorators and color picker.'),
Expand Down Expand Up @@ -392,11 +392,11 @@ const codeEditorPreferenceProperties = {
'maximum': 1073741824
},
'editor.cursorSurroundingLinesStyle': {
'enumDescriptions': [
'markdownEnumDescriptions': [
nls.localizeByDefault('`cursorSurroundingLines` is enforced only when triggered via the keyboard or API.'),
nls.localizeByDefault('`cursorSurroundingLines` is enforced always.')
],
'description': nls.localizeByDefault('Controls when `cursorSurroundingLines` should be enforced.'),
'markdownDescription': nls.localizeByDefault('Controls when `cursorSurroundingLines` should be enforced.'),
'type': 'string',
'enum': [
'default',
Expand Down Expand Up @@ -1111,7 +1111,7 @@ const codeEditorPreferenceProperties = {
'editor.inlineHints.fontSize': {
'type': 'number',
'default': EDITOR_FONT_DEFAULTS.fontSize,
description: nls.localizeByDefault('Controls font size of inline hints in the editor. When set to `0`, the 90% of `#editor.fontSize#` is used.')
markdownDescription: nls.localizeByDefault('Controls font size of inline hints in the editor. When set to `0`, the 90% of `#editor.fontSize#` is used.')
},
'editor.inlineHints.fontFamily': {
'type': 'string',
Expand Down Expand Up @@ -1173,7 +1173,7 @@ const codeEditorPreferenceProperties = {
'editor.suggest.insertHighlight': {
'type': 'boolean',
'default': false,
'description': nls.localize('theia/editor/suggest.insertHighlight', 'Controls whether unexpected text modifications while accepting completions should be highlighted, e.g `insertMode` is `replace` but the completion only supports `insert`.')
'markdownDescription': nls.localize('theia/editor/suggest.insertHighlight', 'Controls whether unexpected text modifications while accepting completions should be highlighted, e.g `insertMode` is `replace` but the completion only supports `insert`.')
},
'editor.suggest.filterGraceful': {
'type': 'boolean',
Expand Down
4 changes: 2 additions & 2 deletions packages/filesystem/src/browser/filesystem-preferences.ts
Original file line number Diff line number Diff line change
Expand Up @@ -55,7 +55,7 @@ export const filesystemPreferenceSchema: PreferenceSchema = {
type: 'object',
default: { '**/.git': true, '**/.svn': true, '**/.hg': true, '**/CVS': true, '**/.DS_Store': true },
// eslint-disable-next-line max-len
description: nls.localize('theia/filesystem/filesExclude', 'Configure glob patterns for excluding files and folders. For example, the file Explorer decides which files and folders to show or hide based on this setting. Refer to the `#search.exclude#` setting to define search specific excludes.'),
markdownDescription: nls.localize('theia/filesystem/filesExclude', 'Configure glob patterns for excluding files and folders. For example, the file Explorer decides which files and folders to show or hide based on this setting.'),
scope: 'resource'
},
'files.enableTrash': {
Expand All @@ -65,7 +65,7 @@ export const filesystemPreferenceSchema: PreferenceSchema = {
},
'files.associations': {
type: 'object',
description: nls.localizeByDefault(
markdownDescription: nls.localizeByDefault(
'Configure file associations to languages (e.g. `\"*.extension\": \"html\"`). These have precedence over the default associations of the languages installed.'
)
},
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -37,7 +37,7 @@ export const HostedPluginConfigSchema: PreferenceSchema = {
items: {
type: 'string'
},
description: nls.localize(
markdownDescription: nls.localize(
'theia/plugin-dev/launchOutFiles',
'Array of glob patterns for locating generated JavaScript files (`${pluginPath}` will be replaced by plugin actual path).'
),
Expand Down
5 changes: 5 additions & 0 deletions packages/preferences/src/browser/preference-tree-model.ts
Original file line number Diff line number Diff line change
Expand Up @@ -227,6 +227,11 @@ export class PreferenceTreeModel extends TreeModelImpl {
}
}

getNodeFromPreferenceId(id: string): Preference.TreeNode | undefined {
const node = this.getNode(this.treeGenerator.getNodeId(id));
return node && Preference.TreeNode.is(node) ? node : undefined;
}

/**
* @returns true if selection changed, false otherwise
*/
Expand Down
5 changes: 5 additions & 0 deletions packages/preferences/src/browser/style/index.css
Original file line number Diff line number Diff line change
Expand Up @@ -301,6 +301,11 @@
line-height: 18px;
}

.theia-settings-container .pref-description a {
text-decoration-line: none;
cursor: pointer;
}

.theia-settings-container .theia-select:focus {
outline-width: 1px;
outline-style: solid;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -126,6 +126,12 @@ export class PreferenceTreeGenerator {
return root;
};

getNodeId(preferenceId: string): string {
const expectedGroup = this.getGroupName(preferenceId.split('.'));
const expectedId = `${expectedGroup}@${preferenceId}`;
return expectedId;
}

protected getGroupName(labels: string[]): string {
const defaultGroup = labels[0];
if (this.topLevelCategories.has(defaultGroup)) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -100,6 +100,7 @@ describe('preference-tree-label-provider', () => {
visible: true,
selected: false,
depth: 2,
preferenceId: property,
preference: { data: {} }
};

Expand Down
1 change: 1 addition & 0 deletions packages/preferences/src/browser/util/preference-types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -64,6 +64,7 @@ export namespace Preference {
export interface LeafNode extends BaseTreeNode {
depth: number;
preference: { data: PreferenceDataProperty };
preferenceId: string;
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'm Ok with adding this, but there is also a utility in the Preference.TreeNode namespace (getGroupAndIdFromNodeId) to get the group and id (i.e. preferenceId) from the ID of the node.

}

export namespace LeafNode {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,7 @@

import { PreferenceLeafNodeRenderer } from './preference-node-renderer';
import { injectable, inject } from '@theia/core/shared/inversify';
import { CommandService } from '@theia/core/lib/common';
import { CommandService, nls } from '@theia/core/lib/common';
import { PreferencesCommands } from '../../util/preference-types';
import { JSONValue } from '@theia/core/shared/@phosphor/coreutils';

Expand All @@ -25,7 +25,7 @@ export class PreferenceJSONLinkRenderer extends PreferenceLeafNodeRenderer<JSONV
@inject(CommandService) protected readonly commandService: CommandService;

protected createInteractable(parent: HTMLElement): void {
const message = 'Edit in settings.json';
const message = nls.localizeByDefault('Edit in settings.json');
const interactable = document.createElement('a');
this.interactable = interactable;
interactable.classList.add('theia-json-input');
Expand Down
Loading