1
0
mirror of https://github.com/twirl/The-API-Book.git synced 2025-06-30 22:43:38 +02:00

Examples for 'Decomposing Components' chapter - first draft

This commit is contained in:
Sergey Konstantinov
2023-07-23 13:48:09 +03:00
parent c3dba5c1bc
commit 15ca4b3417
21 changed files with 1163 additions and 1 deletions

View File

@ -0,0 +1,121 @@
import { attrValue, html, raw } from './util/html';
import {
IOfferListComponent,
IOfferListComponentOptions,
IOfferListEvents
} from './interfaces/IOfferListComponent';
import { IDisposer, IEventEmitter } from './interfaces/common';
import {
IOfferPreview,
ISearchBoxComposer
} from './interfaces/ISearchBoxComposer';
import { EventEmitter } from './util/EventEmitter';
export class OfferListComponent implements IOfferListComponent {
public events: IEventEmitter<IOfferListEvents> = new EventEmitter();
protected listenerDisposers: IDisposer[] = [];
constructor(
protected readonly context: ISearchBoxComposer,
protected readonly container: HTMLElement,
protected offerList: IOfferPreview[] | null,
protected readonly options: IOfferListComponentOptions
) {
this.render();
this.setupListeners();
}
protected onOfferListChange({ offerList }) {
this.offerList = offerList;
this.render();
}
public getOfferList() {
return this.offerList;
}
public destroy() {
this.teardownListeners();
this.teardownDomListeners();
this.offerList = null;
this.container.innerHTML = '';
}
public selectOffer(offerId: string) {
this.events.emit('offerSelect', {
offerId
});
}
protected render() {
this.teardownDomListeners();
this.container.innerHTML = html`<ul class="our-coffee-api-offer-list">
${this.offerList === null
? ''
: this.offerList.map((offer) =>
raw(this.generateOfferHtml(offer))
)}
</ul>`.toString();
this.setupDomListeners();
}
protected generateOfferHtml(offer: IOfferPreview): string {
return html`<li
class="our-coffee-api-offer-list-offer"
data-offer-id="${attrValue(offer.offerId)}"
>
${offer.imageUrl !== undefined
? `<img src="${attrValue(offer.imageUrl)}">`
: ''}
<h3>${offer.title}</h3>
<p>${offer.subtitle}</p>
<p>${offer.bottomLine}</p>
<aside>${offer.price.formattedValue}</aside>
</li>`.toString();
}
protected onClickListener = (e: MouseEvent) => {
let target = e.target;
while (target) {
const offerId = (<HTMLElement>target).dataset?.offerId;
if (offerId !== undefined) {
this.onOfferClick(offerId);
break;
}
target = (<HTMLElement>target).parentNode;
}
};
protected onOfferClick(offerId: string) {
this.selectOffer(offerId);
}
protected setupListeners() {
this.listenerDisposers.push(
this.context.events.on(
'offerPreviewListChange',
this.onOfferListChange
)
);
}
protected setupDomListeners() {
this.container.addEventListener('click', this.onClickListener, false);
}
protected teardownListeners() {
for (const disposer of this.listenerDisposers) {
disposer.off();
}
this.listenerDisposers = [];
}
protected teardownDomListeners() {
this.container.removeEventListener(
'click',
this.onClickListener,
false
);
}
}

View File

@ -0,0 +1,188 @@
import {
IButton,
IButtonEvents,
IButtonOptions,
IButtonPressEvent
} from './interfaces/IButton';
import {
IOfferPanelComponent,
IOfferPanelComponentEvents,
IOfferPanelComponentOptions
} from './interfaces/IOfferPanelComponent';
import {
IOfferFullView,
ISearchBoxComposer
} from './interfaces/ISearchBoxComposer';
import { IDisposer, IEventEmitter } from './interfaces/common';
import { EventEmitter } from './util/EventEmitter';
import { $, attrValue, html } from './util/html';
export class OfferPanelComponent implements IOfferPanelComponent {
public events: IEventEmitter<IOfferPanelComponentEvents> =
new EventEmitter();
protected buttons: Array<{
button: IButton;
listenerDisposer: IDisposer;
}>;
protected listenerDisposers: IDisposer[] = [];
constructor(
protected readonly context: ISearchBoxComposer,
protected readonly container: HTMLElement,
protected currentOffer: IOfferFullView | null = null,
protected readonly options: IOfferPanelComponentOptions,
protected readonly buttonBuilders: ButtonBuilder[] = [
OfferPanelComponent.buildCreateOrderButton,
OfferPanelComponent.buildCloseButton
]
) {
this.render();
this.setupButtons();
this.listenerDisposers.push(
this.context.events.on('offerFullViewToggle', ({ offer }) => {
this.currentOffer = offer;
this.render();
})
);
}
public static buildCreateOrderButton: ButtonBuilder = (
container,
options
) =>
new CreateOrderButton(container, {
iconUrl: options.createOrderButtonUrl,
text: options.createOrderButtonText
});
public static buildCloseButton: ButtonBuilder = (container, options) =>
new CloseButton(container, {
iconUrl: options.closeButtonUrl,
text: options.closeButtonText
});
public destroy() {
this.currentOffer = null;
for (const disposer of this.listenerDisposers) {
disposer.off();
}
this.destroyButtons();
this.container.innerHTML = '';
}
protected render() {
this.destroyButtons();
this.container.innerHTML =
this.currentOffer === null
? ''
: html`<h1>${this.currentOffer.title}</h1>
${this.currentOffer.description.map(
(description) => html`<p>${description}</p>`
)}
<ul
class="out-coffee-sdk-offer-panel-buttons"
></ul>`.toString();
this.setupButtons();
}
protected setupButtons() {
const buttonsContainer = $(
this.container,
'.out-coffee-sdk-offer-panel-buttons'
);
for (const buttonBuilder of this.buttonBuilders) {
const buttonContainer = document.createElement('li');
buttonsContainer.appendChild(buttonContainer);
const button = buttonBuilder(buttonContainer, this.options);
const listenerDisposer = button.events.on(
'press',
this.onButtonPress
);
this.buttons.push({ button, listenerDisposer });
}
}
protected destroyButtons() {
for (const { button, listenerDisposer } of this.buttons) {
listenerDisposer.off();
button.destroy();
}
this.buttons = [];
$(this.container, '.out-coffee-sdk-offer-panel-buttons').innerHTML = '';
}
protected onButtonPress = ({ target: { action } }: IButtonPressEvent) => {
if (this.currentOffer !== null) {
this.events.emit('action', {
action,
offerId: this.currentOffer.offerId
});
} else {
// TBD
}
};
}
export type ButtonBuilder = (
container: HTMLElement,
options: IOfferPanelComponentOptions
) => IButton;
export class OfferPanelButton implements IButton {
public events: IEventEmitter<IButtonEvents> = new EventEmitter();
protected onClickListener = () => {
this.events.emit('press', { target: this });
};
protected button: HTMLButtonElement;
constructor(
public readonly action: string,
protected readonly container: HTMLElement,
protected readonly options: IButtonOptions
) {
this.container.innerHTML = html`<button
class="our-coffee-api-sdk-offer-panel-button"
>
${this.options.iconUrl
? html`<img src="${attrValue(this.options.iconUrl)}" />`
: ''}
<span>${this.options.text}</span>
</button>`.toString();
this.button = $<HTMLButtonElement>(
this.container,
'.our-coffee-api-sdk-offer-panel-button'
);
this.button.addEventListener('click', this.onClickListener, false);
}
destroy() {
this.button.removeEventListener('click', this.onClickListener, false);
this.container.innerHTML = '';
}
}
export class CreateOrderButton extends OfferPanelButton {
constructor(container: HTMLElement, options: Partial<IButtonOptions>) {
super('createOrder', container, {
text: 'Place an Order',
...options
});
this.button.classList.add(
'our-coffee-sdk-offer-panel-create-order-button'
);
}
}
export class CloseButton extends OfferPanelButton {
constructor(container: HTMLElement, options: Partial<IButtonOptions>) {
super('close', container, {
text: 'Not Now',
...options
});
this.button.classList.add('our-coffee-sdk-offer-panel-close-button');
}
}

View File

@ -0,0 +1,133 @@
import { SearchBoxComposer } from './SearchBoxComposer';
import { $, html } from './util/html';
import { ICoffeeApi, INewOrder, ISearchResult } from './interfaces/ICoffeeApi';
import {
ISearchBox,
ISearchBoxEvents,
ISearchBoxOptions
} from './interfaces/ISearchBox';
import { ISearchBoxComposer } from './interfaces/ISearchBoxComposer';
import { IDisposer, IEventEmitter } from './interfaces/common';
import { EventEmitter } from './util/EventEmitter';
export class SearchBox implements ISearchBox {
public readonly events: IEventEmitter<ISearchBoxEvents> =
new EventEmitter();
protected readonly options: SearchBoxOptions;
protected readonly composer: ISearchBoxComposer;
protected offerList: ISearchResult[] | null = null;
protected currentRequest: Promise<ISearchResult[]> | null = null;
protected searchButton: HTMLButtonElement;
protected listenerDisposers: IDisposer[] = [];
public constructor(
protected readonly parentNode: HTMLElement,
protected readonly coffeeApi: ICoffeeApi,
options: ISearchBoxOptions
) {
this.options = {
...SearchBox.DEFAULT_OPTIONS,
...options
};
this.render();
this.composer = this.buildComposer();
this.setupListeners();
}
public getOptions() {
return this.options;
}
public getContainer() {
return this.parentNode;
}
public search(query: string): void {
const request = (this.currentRequest = this.coffeeApi.search(query));
this.setOfferList(null);
request.then((result: ISearchResult[]) => {
if (request === this.currentRequest) {
this.setOfferList(result);
this.currentRequest = null;
}
});
}
public getOfferList() {
return this.offerList;
}
public createOrder(parameters: { offerId: string }): Promise<INewOrder> {
return this.coffeeApi.createOrder(parameters);
}
public destroy() {
this.composer.destroy();
this.currentRequest = this.offerList = null;
}
public buildComposer() {
return new SearchBoxComposer(this, this.parentNode, this.options);
}
public static DEFAULT_OPTIONS: SearchBoxOptions = {
searchButtonText: 'Search'
};
protected render() {
this.parentNode.innerHTML = html`<div class="our-coffee-sdk-search-box">
<div class="our-coffee-sdk-search-box-head">
<input type="text" class="our-coffee-sdk-search-box-input" />
<button class="our-coffee-sdk-search-box-search-button">
${this.options.searchButtonText}
</button>
</div>
</div>`.toString();
this.searchButton = $(
this.parentNode,
'.our-coffee-sdk-search-box-search-button'
);
}
protected onSearchButtonClickListener = () => {
this.search(this.searchButton.value);
};
protected setupListeners() {
this.searchButton.addEventListener(
'click',
this.onSearchButtonClickListener,
false
);
this.listenerDisposers.push(
this.composer.events.on('createOrder', ({ offer: offerId }) =>
this.createOrder(offerId)
)
);
}
protected teardownListeners() {
for (const disposer of this.listenerDisposers) {
disposer.off();
}
this.listenerDisposers = [];
this.searchButton.removeEventListener(
'click',
this.onSearchButtonClickListener,
false
);
}
protected setOfferList(offerList: ISearchResult[] | null) {
this.offerList = offerList;
this.events.emit('offerListChange', { offerList });
}
}
export interface SearchBoxOptions extends ISearchBoxOptions {
searchButtonText: string;
}
export type SearchBoxComposerBuilder = (
context: SearchBox
) => ISearchBoxComposer;

View File

@ -0,0 +1,211 @@
import { ISearchResult } from './interfaces/ICoffeeApi';
import {
ISearchBox,
ISearchBoxOfferListChangeEvent,
ISearchBoxOptions
} from './interfaces/ISearchBox';
import {
IOfferFullView,
IOfferPreview,
ISearchBoxComposer,
ISearchBoxComposerEvents
} from './interfaces/ISearchBoxComposer';
import {
IOfferListComponent,
IOfferListComponentOptions,
IOfferSelectedEvent
} from './interfaces/IOfferListComponent';
import {
IOfferPanelActionEvent,
IOfferPanelComponent,
IOfferPanelComponentOptions
} from './interfaces/IOfferPanelComponent';
import { IDisposer, IEventEmitter } from './interfaces/common';
import { OfferListComponent } from './OfferListComponent';
import { OfferPanelComponent } from './OfferPanelComponent';
import { EventEmitter } from './util/EventEmitter';
export class SearchBoxComposer implements ISearchBoxComposer {
public events: IEventEmitter<ISearchBoxComposerEvents> =
new EventEmitter<ISearchBoxComposerEvents>();
protected offerListContainer: HTMLElement | null = null;
protected offerListComponent: IOfferListComponent | null = null;
protected offerPanelContainer: HTMLElement | null = null;
protected offerPanelComponent: IOfferPanelComponent | null = null;
protected offerList: ISearchResult[] | null;
protected currentOffer: ISearchResult | null;
protected listenerDisposers: IDisposer[];
constructor(
protected readonly context: ISearchBox,
protected readonly container: HTMLElement,
protected readonly contextOptions: ISearchBoxOptions
) {
this.offerListContainer = document.createElement('div');
container.appendChild(this.offerListContainer);
this.offerListComponent = this.buildOfferListComponent();
this.offerPanelContainer = document.createElement('div');
container.appendChild(this.offerPanelContainer);
this.offerPanelComponent = this.buildOfferPanelComponent();
this.listenerDisposers = [
context.events.on('offerListChange', this.onContextOfferListChange),
this.offerListComponent.events.on(
'offerSelect',
this.onOfferListOfferSelect
),
this.offerPanelComponent.events.on(
'action',
this.onOfferPanelAction
)
];
}
public destroy() {
for (const disposer of this.listenerDisposers) {
disposer.off();
}
this.listenerDisposers = [];
this.offerListComponent.destroy();
this.offerPanelComponent.destroy();
this.offerListContainer.parentNode.removeChild(this.offerListContainer);
this.offerPanelContainer.parentNode.removeChild(
this.offerPanelContainer
);
}
protected onContextOfferListChange = ({
offerList
}: ISearchBoxOfferListChangeEvent) => {
if (this.currentOffer !== null) {
this.currentOffer = null;
this.events.emit('offerFullViewToggle', { offer: null });
}
this.offerList = offerList;
this.events.emit('offerPreviewListChange', {
offerList: this.buildOfferPreviews()
});
};
protected onOfferPanelAction({ action, offerId }: IOfferPanelActionEvent) {
switch (action) {
case 'createOrder':
const offer = this.findOfferById(offerId);
// Offer may be missing if `OfferPanelComponent`
// renders offers asynchronously
if (offer !== null) {
this.events.emit('createOrder', { offer });
}
break;
case 'close':
if (this.currentOffer !== null) {
this.currentOffer = null;
this.events.emit('offerFullViewToggle', { offer: null });
}
break;
}
}
protected onOfferListOfferSelect({ offerId }: IOfferSelectedEvent) {
const offer = this.findOfferById(offerId);
// Offer may be missing for a variety of reasons,
// most notably of `OfferListComponent` renders
// offers asynchronously
if (offer !== null) {
this.currentOffer = offer;
this.events.emit('offerFullViewToggle', {
offer: this.generateCurrentOfferFullView()
});
} else {
// TDB
}
}
private findOfferById(offerIdToFind: string): ISearchResult | null {
// Theoretically, we could have built a `Map`
// for quickly searching offers by their id
// Practically, as all responses in an API must be
// paginated, it makes little sense to optimize this.
return (
this.offerList?.find(({ offerId }) => offerId === offerIdToFind) ??
null
);
}
protected buildOfferListComponent() {
return new OfferListComponent(
this,
this.offerListContainer,
this.buildOfferPreviews(),
this.generateOfferListComponentOptions()
);
}
protected buildOfferPreviews(): IOfferPreview[] | null {
return this.offerList === null
? null
: this.offerList.map((offer) => ({
offerId: offer.offerId,
title: offer.place.title,
subtitle: offer.recipe.title,
price: offer.price,
bottomLine: this.generateOfferBottomLine(offer)
}));
}
protected generateOfferListComponentOptions(): IOfferListComponentOptions {
return {};
}
protected buildOfferPanelComponent() {
return new OfferPanelComponent(
this,
this.offerPanelContainer,
this.generateCurrentOfferFullView(),
this.buildOfferPanelComponentOptions()
);
}
protected generateCurrentOfferFullView(): IOfferFullView | null {
const offer = this.currentOffer;
return offer === null
? null
: {
offerId: offer.offerId,
title: offer.place.title,
description: [
offer.recipe.mediumDescription,
this.generateOfferBottomLine(offer)
],
price: offer.price
};
}
protected buildOfferPanelComponentOptions(): IOfferPanelComponentOptions {
return {};
}
protected generateOfferBottomLine(offer: ISearchResult): string {
return `${offer.place.walkTime.formattedValue} · ${offer.place.walkingDistance.formattedValue}`;
}
}
export type OfferListComponentBuilder = (
context: ISearchBoxComposer,
container: HTMLElement,
offerList: IOfferPreview[] | null,
options: IOfferListComponentOptions
) => IOfferListComponent;
export type OfferPanelComponentBuilder = (
context: ISearchBoxComposer,
container: HTMLElement,
offer: IOfferFullView | null,
options: IOfferPanelComponentOptions
) => IOfferPanelComponent;

View File

@ -0,0 +1,20 @@
import { IEventEmitter } from './common';
export interface IButton {
action: string;
events: IEventEmitter<IButtonEvents>;
destroy: () => void;
}
export interface IButtonOptions {
iconUrl?: string;
text: string;
}
export interface IButtonEvents {
press: IButtonPressEvent;
}
export interface IButtonPressEvent {
target: IButton;
}

View File

@ -0,0 +1,29 @@
import {
IFormattedDistance,
IFormattedDuration,
IFormattedPrice
} from './common';
export interface ICoffeeApi {
search: (query: string) => Promise<ISearchResult[]>;
createOrder: (parameters: { offerId: string }) => Promise<INewOrder>;
}
export interface ISearchResult {
offerId: string;
recipe: {
title: string;
shortDescription: string;
mediumDescription: string;
};
place: {
title: string;
walkingDistance: IFormattedDistance;
walkTime: IFormattedDuration;
};
price: IFormattedPrice;
}
export interface INewOrder {
orderId: string;
}

View File

@ -0,0 +1,16 @@
import { IEventEmitter } from './common';
export interface IOfferListComponent {
events: IEventEmitter<IOfferListEvents>;
destroy: () => void;
}
export interface IOfferListComponentOptions {}
export interface IOfferListEvents {
offerSelect: IOfferSelectedEvent;
}
export interface IOfferSelectedEvent {
offerId: string;
}

View File

@ -0,0 +1,22 @@
import { IEventEmitter } from './common';
export interface IOfferPanelComponent {
events: IEventEmitter<IOfferPanelComponentEvents>;
destroy: () => void;
}
export interface IOfferPanelComponentOptions {
createOrderButtonUrl?: string;
createOrderButtonText?: string;
closeButtonUrl?: string;
closeButtonText?: string;
}
export interface IOfferPanelComponentEvents {
action: IOfferPanelActionEvent;
}
export interface IOfferPanelActionEvent {
action: string;
offerId: string;
}

View File

@ -0,0 +1,20 @@
import { INewOrder, ISearchResult } from './ICoffeeApi';
import { IEventEmitter } from './common';
export interface ISearchBox {
events: IEventEmitter<ISearchBoxEvents>;
search: (query: string) => void;
getOfferList: () => ISearchResult[] | null;
createOrder: (parameters: { offerId: string }) => Promise<INewOrder>;
destroy: () => void;
}
export interface ISearchBoxOptions {}
export interface ISearchBoxEvents {
offerListChange: ISearchBoxOfferListChangeEvent;
}
export interface ISearchBoxOfferListChangeEvent {
offerList: ISearchResult[] | null;
}

View File

@ -0,0 +1,41 @@
import { ISearchResult } from './ICoffeeApi';
import { IEventEmitter, IFormattedPrice } from './common';
export interface ISearchBoxComposer {
events: IEventEmitter<ISearchBoxComposerEvents>;
destroy: () => void;
}
export interface ISearchBoxComposerEvents {
offerPreviewListChange: IOfferPreviewListChangeEvent;
offerFullViewToggle: IOfferFullViewToggleEvent;
createOrder: ICreateOrderEvent;
}
export interface IOfferPreviewListChangeEvent {
offerList: IOfferPreview[] | null;
}
export interface IOfferFullViewToggleEvent {
offer: IOfferFullView | null;
}
export interface ICreateOrderEvent {
offer: ISearchResult;
}
export interface IOfferPreview {
offerId: string;
title: string;
subtitle: string;
bottomLine: string;
imageUrl?: string;
price: IFormattedPrice;
}
export interface IOfferFullView {
offerId: string;
title: string;
description: string[];
price: IFormattedPrice;
}

View File

@ -0,0 +1,31 @@
export interface IFormattedPrice {
decimalValue: string;
formattedValue: string;
currencyCode: string;
}
export interface IFormattedDistance {
numericValue: number;
formattedValue: string;
}
export interface IFormattedDuration {
intervalValueSeconds: number;
formattedValue: string;
}
export interface IEventEmitter<EventList extends Record<string, any>> {
on: <Type extends Extract<keyof EventList, string>>(
type: Type,
callback: (event: EventList[Type]) => void
) => IDisposer;
emit: <Type extends Extract<keyof EventList, string>>(
type: Type,
event: EventList[Type]
) => void;
}
export interface IDisposer {
off: () => void;
}

View File

@ -0,0 +1,70 @@
import { IDisposer, IEventEmitter } from '../interfaces/common';
export class EventEmitter<EventList extends Record<string, any>>
implements IEventEmitter<EventList>
{
constructor() {}
protected readonly listenersByType = new Map<
string,
Map<Disposer, Function>
>();
protected disposeCallback = (type: string, disposer: Disposer) => {
const listeners = this.listenersByType.get(type);
if (listeners !== undefined) {
const callback = listeners.get(disposer);
if (callback !== undefined) {
listeners.delete(disposer);
if (listeners.size === 0) {
this.listenersByType.delete(type);
}
}
}
};
public on<Type extends Extract<keyof EventList, string>>(
type: Type,
callback: (event: EventList[Type]) => void
): Disposer {
const disposer = new Disposer(type, this.disposeCallback);
const listeners = this.listenersByType.get(type);
if (listeners === undefined) {
this.listenersByType.set(
type,
new Map<Disposer, Function>([[disposer, callback]])
);
} else {
listeners.set(disposer, callback);
}
return disposer;
}
public emit<Type extends Extract<keyof EventList, string>>(
type: Type,
event: EventList[Type]
) {
const listeners = this.listenersByType.get(type);
if (listeners !== undefined) {
for (const callback of listeners.values()) {
callback(event);
}
}
}
}
export class Disposer implements IDisposer {
protected active = true;
constructor(
protected readonly type: string,
protected readonly callback: (type: string, disposer: Disposer) => void
) {}
public off() {
if (this.active) {
this.callback(this.type, this);
this.active = false;
}
}
}

View File

@ -0,0 +1,95 @@
export function $<T extends Element>(
...args: [HTMLElement, string] | [string]
): T {
const [node, selector] = args.length === 2 ? args : [document, args[0]];
const result = node.querySelector(selector);
if (!result) {
throw new Error(`Cannot select a node with "${selector}" selector`);
}
return <T>result;
}
export function makeTemplate(e: (str: string) => string) {
return (
texts: TemplateStringsArray,
...substitutes: Array<
string | HtmlSerializable | Array<string | HtmlSerializable>
>
): HtmlSerializable => {
const parts: string[] = [];
for (let i = 0; i < texts.length; i++) {
parts.push(texts[i]);
if (substitutes[i]) {
parts.push(getSubstituteValue(substitutes[i], e));
}
}
return new HtmlSerializable(parts.join(''));
};
}
export class HtmlSerializable {
public __isHtmlSerializable: boolean;
constructor(private readonly __html: string) {
this.__isHtmlSerializable = true;
}
toString(): string {
return this.__html;
}
}
export function getSubstituteValue(
value: string | HtmlSerializable | Array<string | HtmlSerializable>,
escapeFunction: (str: string) => string
): string {
if (typeof value == 'string') {
return escapeFunction(value);
} else if ((<HtmlSerializable>value).__isHtmlSerializable) {
return value.toString();
} else {
return (<Array<string | HtmlSerializable>>value)
.map((v) => getSubstituteValue(v, escapeFunction))
.join('');
}
}
export function htmlEscape(str: string): string {
return str
.replace(/\&/g, '&amp;')
.replace(/\</g, '&lt;')
.replace(/\>/g, '&gt;');
}
export function attrEscape(str: string): string {
return htmlEscape(str).replace(/\'/g, '&#39;').replace(/\"/g, '&quot;');
}
export function hrefEscapeBuilder(
allowedProtocols = ['http:', 'https:', null]
) {
return (raw: string) => {
const str = raw.trim();
const protocol = str.match(/$[a-z0-9\+\-\.]+\:/i)[0] ?? null;
if (allowedProtocols.includes(protocol)) {
return attrEscape(str);
} else {
return '';
}
};
}
export const httpHrefEscape = hrefEscapeBuilder();
export const html = makeTemplate(htmlEscape);
export const raw = (str: string) => new HtmlSerializable(str);
export const attr = makeTemplate(attrEscape);
export const attrValue = (str: string) => new HtmlSerializable(attrEscape(str));
export const href = makeTemplate(httpHrefEscape);
export const hrefValue = (
str: string,
allowedProtocols?: Array<string | null>
) =>
new HtmlSerializable(
allowedProtocols === undefined
? httpHrefEscape(str)
: hrefEscapeBuilder(allowedProtocols)(str)
);