Add a tagged template function for HTML supporting HTML interpolation.

This allows better Prettier integration: Prettier recognizes and
reformats tagged template literals with a tag named ‘html’.

Signed-off-by: Anders Kaseorg <anders@zulip.com>
This commit is contained in:
Anders Kaseorg
2021-03-31 18:43:14 -07:00
parent 2c40843306
commit ce9a680333
17 changed files with 116 additions and 86 deletions

26
app/common/html.ts Normal file
View File

@@ -0,0 +1,26 @@
import {htmlEscape} from 'escape-goat';
export class HTML {
html: string;
constructor({html}: {html: string}) {
this.html = html;
}
join(htmls: readonly HTML[]): HTML {
return new HTML({html: htmls.map(html => html.html).join(this.html)});
}
}
export function html(
template: TemplateStringsArray,
...values: unknown[]
): HTML {
let html = template[0];
for (const [index, value] of values.entries()) {
html += value instanceof HTML ? value.html : htmlEscape(String(value));
html += template[index + 1];
}
return new HTML({html});
}

View File

@@ -1,7 +1,9 @@
import type {HTML} from '../../../common/html';
export default class BaseComponent {
generateNodeFromHTML(html: string): Element | null {
generateNodeFromHTML(html: HTML): Element | null {
const wrapper = document.createElement('div');
wrapper.innerHTML = html;
wrapper.innerHTML = html.html;
return wrapper.firstElementChild;
}
}

View File

@@ -1,4 +1,5 @@
import {htmlEscape} from 'escape-goat';
import type {HTML} from '../../../common/html';
import {html} from '../../../common/html';
import type {TabProps} from './tab';
import Tab from './tab';
@@ -11,8 +12,8 @@ export default class FunctionalTab extends Tab {
this.init();
}
templateHTML(): string {
return htmlEscape`
templateHTML(): HTML {
return html`
<div class="tab functional-tab" data-tab-id="${this.props.tabIndex}">
<div class="server-tab-badge close-button">
<i class="material-icons">close</i>

View File

@@ -1,7 +1,7 @@
import {ipcRenderer} from 'electron';
import {htmlEscape} from 'escape-goat';
import type {HTML} from '../../../common/html';
import {html} from '../../../common/html';
import * as SystemUtil from '../utils/system-util';
import type {TabProps} from './tab';
@@ -15,8 +15,8 @@ export default class ServerTab extends Tab {
this.init();
}
templateHTML(): string {
return htmlEscape`
templateHTML(): HTML {
return html`
<div class="tab" data-tab-id="${this.props.tabIndex}">
<div class="server-tooltip" style="display:none">${this.props.name}</div>
<div class="server-tab-badge"></div>

View File

@@ -2,9 +2,8 @@ import {ipcRenderer, remote} from 'electron';
import fs from 'fs';
import path from 'path';
import {htmlEscape} from 'escape-goat';
import * as ConfigUtil from '../../../common/config-util';
import {HTML, html} from '../../../common/html';
import * as SystemUtil from '../utils/system-util';
import BaseComponent from './base';
@@ -52,14 +51,14 @@ export default class WebView extends BaseComponent {
this.$webviewsContainer = document.querySelector('#webviews-container').classList;
}
templateHTML(): string {
return htmlEscape`
templateHTML(): HTML {
return html`
<webview
class="disabled"
data-tab-id="${this.props.tabIndex}"
src="${this.props.url}"
` + (this.props.nodeIntegration ? 'nodeIntegration' : '') + htmlEscape`
` + (this.props.preload ? 'preload="js/preload.js"' : '') + htmlEscape`
${new HTML({html: this.props.nodeIntegration ? 'nodeIntegration' : ''})}
${new HTML({html: this.props.preload ? 'preload="js/preload.js"' : ''})}
partition="persist:webviewsession"
name="${this.props.name}"
webpreferences="

View File

@@ -1,7 +1,7 @@
import {ipcRenderer} from 'electron';
import {htmlEscape} from 'escape-goat';
import type {HTML} from '../../../../common/html';
import {html} from '../../../../common/html';
import BaseComponent from '../../components/base';
interface BaseSectionProps {
@@ -25,24 +25,24 @@ export default class BaseSection extends BaseComponent {
}
}
generateOptionHTML(settingOption: boolean, disabled?: boolean): string {
const labelHTML = disabled ? '<label class="disallowed" title="Setting locked by system administrator."></label>' : '<label></label>';
generateOptionHTML(settingOption: boolean, disabled?: boolean): HTML {
const labelHTML = disabled ? html`<label class="disallowed" title="Setting locked by system administrator."></label>` : html`<label></label>`;
if (settingOption) {
return htmlEscape`
return html`
<div class="action">
<div class="switch">
<input class="toggle toggle-round" type="checkbox" checked disabled>
` + labelHTML + htmlEscape`
${labelHTML}
</div>
</div>
`;
}
return htmlEscape`
return html`
<div class="action">
<div class="switch">
<input class="toggle toggle-round" type="checkbox">
` + labelHTML + htmlEscape`
${labelHTML}
</div>
</div>
`;
@@ -51,15 +51,15 @@ export default class BaseSection extends BaseComponent {
/* A method that in future can be used to create dropdown menus using <select> <option> tags.
it needs an object which has ``key: value`` pairs and will return a string that can be appended to HTML
*/
generateSelectHTML(options: Record<string, string>, className?: string, idName?: string): string {
let html = htmlEscape`<select class="${className}" id="${idName}">\n`;
for (const key of Object.keys(options)) {
html += htmlEscape`<option name="${key}" value="${key}">${options[key]}</option>\n`;
}
html += '</select>';
return html;
generateSelectHTML(options: Record<string, string>, className?: string, idName?: string): HTML {
const optionsHTML = html``.join(Object.keys(options).map(key => html`
<option name="${key}" value="${key}">${options[key]}</option>
`));
return html`
<select class="${className}" id="${idName}">
${optionsHTML}
</select>
`;
}
reloadApp(): void {

View File

@@ -1,7 +1,7 @@
import {ipcRenderer} from 'electron';
import {htmlEscape} from 'escape-goat';
import type {HTML} from '../../../../common/html';
import {html} from '../../../../common/html';
import * as t from '../../../../common/translation-util';
import * as DomainUtil from '../../utils/domain-util';
@@ -24,8 +24,8 @@ export default class ConnectedOrgSection extends BaseSection {
this.props = props;
}
templateHTML(): string {
return htmlEscape`
templateHTML(): HTML {
return html`
<div class="settings-pane" id="server-settings-pane">
<div class="page-title">${t.__('Connected organizations')}</div>
<div class="title" id="existing-servers">${t.__('All the connected orgnizations will appear here.')}</div>
@@ -45,7 +45,7 @@ export default class ConnectedOrgSection extends BaseSection {
this.props.$root.textContent = '';
const servers = DomainUtil.getDomains();
this.props.$root.innerHTML = this.templateHTML();
this.props.$root.innerHTML = this.templateHTML().html;
this.$serverInfoContainer = document.querySelector('#server-info-container');
this.$existingServers = document.querySelector('#existing-servers');

View File

@@ -1,5 +1,5 @@
import {htmlEscape} from 'escape-goat';
import type {HTML} from '../../../../common/html';
import {html} from '../../../../common/html';
import * as t from '../../../../common/translation-util';
import BaseComponent from '../../components/base';
import * as LinkUtil from '../../utils/link-util';
@@ -18,8 +18,8 @@ export default class FindAccounts extends BaseComponent {
this.props = props;
}
templateHTML(): string {
return htmlEscape`
templateHTML(): HTML {
return html`
<div class="settings-card certificate-card">
<div class="certificate-input">
<div>${t.__('Organization URL')}</div>

View File

@@ -4,11 +4,12 @@ import fs from 'fs';
import path from 'path';
import Tagify from '@yaireo/tagify';
import {htmlEscape} from 'escape-goat';
import ISO6391 from 'iso-639-1';
import * as ConfigUtil from '../../../../common/config-util';
import * as EnterpriseUtil from '../../../../common/enterprise-util';
import type {HTML} from '../../../../common/html';
import {html} from '../../../../common/html';
import * as t from '../../../../common/translation-util';
import supportedLocales from '../../../../translations/supported-locales.json';
@@ -28,8 +29,8 @@ export default class GeneralSection extends BaseSection {
this.props = props;
}
templateHTML(): string {
return htmlEscape`
templateHTML(): HTML {
return html`
<div class="settings-pane">
<div class="title">${t.__('Appearance')}</div>
<div id="appearance-option-settings" class="settings-card">
@@ -159,7 +160,7 @@ export default class GeneralSection extends BaseSection {
}
init(): void {
this.props.$root.innerHTML = this.templateHTML();
this.props.$root.innerHTML = this.templateHTML().html;
this.updateTrayOption();
this.updateBadgeOption();
this.updateSilentOption();
@@ -402,7 +403,7 @@ export default class GeneralSection extends BaseSection {
setLocale(): void {
const langDiv: HTMLSelectElement = document.querySelector('.lang-div');
const langListHTML = this.generateSelectHTML(supportedLocales, 'lang-menu');
langDiv.innerHTML += langListHTML;
langDiv.innerHTML += langListHTML.html;
// `langMenu` is the select-option dropdown menu formed after executing the previous command
const langMenu: HTMLSelectElement = document.querySelector('.lang-menu');
@@ -520,9 +521,10 @@ export default class GeneralSection extends BaseSection {
const note: HTMLElement = document.querySelector('#note');
note.append(t.__('You can select a maximum of 3 languages for spellchecking.'));
const spellDiv: HTMLElement = document.querySelector('#spellcheck-langs');
spellDiv.innerHTML += htmlEscape`
spellDiv.innerHTML += html`
<div class="setting-description">${t.__('Spellchecker Languages')}</div>
<input name='spellcheck' placeholder='Enter Languages'>`;
<input name='spellcheck' placeholder='Enter Languages'>
`.html;
const availableLanguages = session.fromPartition('persist:webviewsession').availableSpellCheckerLanguages;
let languagePairs: Map<string, string> = new Map();

View File

@@ -1,5 +1,5 @@
import {htmlEscape} from 'escape-goat';
import type {HTML} from '../../../../common/html';
import {html} from '../../../../common/html';
import * as t from '../../../../common/translation-util';
import BaseComponent from '../../components/base';
@@ -19,16 +19,15 @@ export default class PreferenceNav extends BaseComponent {
this.init();
}
templateHTML(): string {
let navItemsHTML = '';
for (const navItem of this.navItems) {
navItemsHTML += htmlEscape`<div class="nav" id="nav-${navItem}">${t.__(navItem)}</div>`;
}
templateHTML(): HTML {
const navItemsHTML = html``.join(this.navItems.map(navItem => html`
<div class="nav" id="nav-${navItem}">${t.__(navItem)}</div>
`));
return htmlEscape`
return html`
<div>
<div id="settings-header">${t.__('Settings')}</div>
<div id="nav-container">` + navItemsHTML + htmlEscape`</div>
<div id="nav-container">${navItemsHTML}</div>
</div>
`;
}

View File

@@ -1,8 +1,8 @@
import {ipcRenderer} from 'electron';
import {htmlEscape} from 'escape-goat';
import * as ConfigUtil from '../../../../common/config-util';
import type {HTML} from '../../../../common/html';
import {html} from '../../../../common/html';
import * as t from '../../../../common/translation-util';
import BaseSection from './base-section';
@@ -23,8 +23,8 @@ export default class NetworkSection extends BaseSection {
this.props = props;
}
templateHTML(): string {
return htmlEscape`
templateHTML(): HTML {
return html`
<div class="settings-pane">
<div class="title">${t.__('Proxy')}</div>
<div id="appearance-option-settings" class="settings-card">
@@ -61,7 +61,7 @@ export default class NetworkSection extends BaseSection {
}
init(): void {
this.props.$root.innerHTML = this.templateHTML();
this.props.$root.innerHTML = this.templateHTML().html;
this.$proxyPAC = document.querySelector('#proxy-pac-option .setting-input-value');
this.$proxyRules = document.querySelector('#proxy-rules-option .setting-input-value');
this.$proxyBypass = document.querySelector('#proxy-bypass-option .setting-input-value');

View File

@@ -1,7 +1,7 @@
import {ipcRenderer, remote} from 'electron';
import {htmlEscape} from 'escape-goat';
import {html} from '../../../../common/html';
import type {HTML} from '../../../../common/html';
import * as t from '../../../../common/translation-util';
import BaseComponent from '../../components/base';
import * as DomainUtil from '../../utils/domain-util';
@@ -24,8 +24,8 @@ export default class NewServerForm extends BaseComponent {
this.props = props;
}
templateHTML(): string {
return htmlEscape`
templateHTML(): HTML {
return html`
<div class="server-input-container">
<div class="title">${t.__('Organization URL')}</div>
<div class="add-server-info-row">

View File

@@ -1,7 +1,7 @@
import {remote, ipcRenderer} from 'electron';
import {htmlEscape} from 'escape-goat';
import {html} from '../../../../common/html';
import type {HTML} from '../../../../common/html';
import * as Messages from '../../../../common/messages';
import * as t from '../../../../common/translation-util';
import type {ServerConf} from '../../../../common/types';
@@ -29,8 +29,8 @@ export default class ServerInfoForm extends BaseComponent {
this.props = props;
}
templateHTML(): string {
return htmlEscape`
templateHTML(): HTML {
return html`
<div class="settings-card">
<div class="server-info-left">
<img class="server-info-icon" src="${this.props.server.icon}"/>

View File

@@ -1,5 +1,5 @@
import {htmlEscape} from 'escape-goat';
import type {HTML} from '../../../../common/html';
import {html} from '../../../../common/html';
import * as t from '../../../../common/translation-util';
import BaseSection from './base-section';
@@ -17,8 +17,8 @@ export default class ServersSection extends BaseSection {
this.props = props;
}
templateHTML(): string {
return htmlEscape`
templateHTML(): HTML {
return html`
<div class="add-server-modal">
<div class="modal-container">
<div class="settings-pane" id="server-settings-pane">
@@ -37,7 +37,7 @@ export default class ServersSection extends BaseSection {
initServers(): void {
this.props.$root.textContent = '';
this.props.$root.innerHTML = this.templateHTML();
this.props.$root.innerHTML = this.templateHTML().html;
this.$newServerContainer = document.querySelector('#new-server-container');
this.initNewServerForm();

View File

@@ -1,5 +1,5 @@
import {htmlEscape} from 'escape-goat';
import type {HTML} from '../../../../common/html';
import {html} from '../../../../common/html';
import * as t from '../../../../common/translation-util';
import * as LinkUtil from '../../utils/link-util';
@@ -17,10 +17,10 @@ export default class ShortcutsSection extends BaseSection {
}
// eslint-disable-next-line complexity
templateHTML(): string {
templateHTML(): HTML {
const cmdOrCtrl = process.platform === 'darwin' ? '⌘' : 'Ctrl';
return htmlEscape`
return html`
<div class="settings-pane">
<div class="settings-card tip"><p><b><i class="material-icons md-14">settings</i>${t.__('Tip')}: </b>${t.__('These desktop app shortcuts extend the Zulip webapp\'s')} <span id="open-hotkeys-link"> ${t.__('keyboard shortcuts')}</span>.</p></div>
<div class="title">${t.__('Application Shortcuts')}</div>
@@ -234,7 +234,7 @@ export default class ShortcutsSection extends BaseSection {
}
init(): void {
this.props.$root.innerHTML = this.templateHTML();
this.props.$root.innerHTML = this.templateHTML().html;
this.openHotkeysExternalLink();
}
}

View File

@@ -3,7 +3,7 @@ import fs from 'fs';
import os from 'os';
import path from 'path';
import {htmlEscape} from 'escape-goat';
import {html} from '../../../common/html';
export function isUploadsUrl(server: string, url: URL): boolean {
return url.origin === server && url.pathname.startsWith('/user_uploads/');
@@ -19,7 +19,7 @@ export async function openBrowser(url: URL): Promise<void> {
path.join(os.tmpdir(), 'zulip-redirect-')
);
const file = path.join(dir, 'redirect.html');
fs.writeFileSync(file, htmlEscape`\
fs.writeFileSync(file, html`\
<!DOCTYPE html>
<html>
<head>
@@ -36,7 +36,7 @@ export async function openBrowser(url: URL): Promise<void> {
<p>Opening <a href="${url.href}">${url.href}</a>…</p>
</body>
</html>
`);
`.html);
await shell.openPath(file);
setTimeout(() => {
fs.unlinkSync(file);

View File

@@ -1,8 +1,8 @@
import {ipcRenderer} from 'electron';
import * as backoff from 'backoff';
import {htmlEscape} from 'escape-goat';
import {html} from '../../../common/html';
import Logger from '../../../common/logger-util';
import type WebView from '../components/webview';
@@ -60,9 +60,10 @@ export default class ReconnectUtil {
logger.log('There is no internet connection, try checking network cables, modem and router.');
const errorMessageHolder = document.querySelector('#description');
if (errorMessageHolder) {
errorMessageHolder.innerHTML = htmlEscape`
errorMessageHolder.innerHTML = html`
<div>Your internet connection doesn't seem to work properly!</div>
<div>Verify that it works and then click try again.</div>`;
<div>Verify that it works and then click try again.</div>
`.html;
}
return false;