diff --git a/tgui/packages/common/string.babel-plugin.cjs b/tgui/packages/common/string.babel-plugin.cjs deleted file mode 100644 index 97ca67c6ea4c..000000000000 --- a/tgui/packages/common/string.babel-plugin.cjs +++ /dev/null @@ -1,73 +0,0 @@ -/** - * This plugin saves overall about 10KB on the final bundle size, so it's - * sort of worth it. - * - * We are using a .cjs extension because: - * - * 1. Webpack CLI only supports CommonJS modules; - * 2. tgui-dev-server supports both, but we still need to signal NodeJS - * to import it as a CommonJS module, hence .cjs extension. - * - * We need to copy-paste the whole "multiline" function because we can't - * synchronously import an ES module from a CommonJS module. - * - * @file - * @copyright 2020 Aleksej Komarov - * @license MIT - */ - -/** - * Removes excess whitespace and indentation from the string. - */ -const multiline = (str) => { - const lines = str.split('\n'); - // Determine base indentation - let minIndent; - for (let line of lines) { - for (let indent = 0; indent < line.length; indent++) { - const char = line[indent]; - if (char !== ' ') { - if (minIndent === undefined || indent < minIndent) { - minIndent = indent; - } - break; - } - } - } - if (!minIndent) { - minIndent = 0; - } - // Remove this base indentation and trim the resulting string - // from both ends. - return lines - .map((line) => line.substr(minIndent).trimRight()) - .join('\n') - .trim(); -}; - -const StringPlugin = (ref) => { - return { - visitor: { - TaggedTemplateExpression: (path) => { - if (path.node.tag.name === 'multiline') { - const { quasi } = path.node; - if (quasi.expressions.length > 0) { - throw new Error('Multiline tag does not support expressions!'); - } - if (quasi.quasis.length > 1) { - throw new Error('Quasis is longer than 1'); - } - const { value } = quasi.quasis[0]; - value.raw = multiline(value.raw); - value.cooked = multiline(value.cooked); - path.replaceWith(quasi); - } - }, - }, - }; -}; - -module.exports = { - __esModule: true, - default: StringPlugin, -}; diff --git a/tgui/packages/common/string.js b/tgui/packages/common/string.js deleted file mode 100644 index 5906d83cb84f..000000000000 --- a/tgui/packages/common/string.js +++ /dev/null @@ -1,196 +0,0 @@ -/** - * @file - * @copyright 2020 Aleksej Komarov - * @license MIT - */ - -/** - * Removes excess whitespace and indentation from the string. - */ -export const multiline = (str) => { - if (Array.isArray(str)) { - // Small stub to allow usage as a template tag - return multiline(str.join('')); - } - const lines = str.split('\n'); - // Determine base indentation - let minIndent; - for (let line of lines) { - for (let indent = 0; indent < line.length; indent++) { - const char = line[indent]; - if (char !== ' ') { - if (minIndent === undefined || indent < minIndent) { - minIndent = indent; - } - break; - } - } - } - if (!minIndent) { - minIndent = 0; - } - // Remove this base indentation and trim the resulting string - // from both ends. - return lines - .map((line) => line.substr(minIndent).trimRight()) - .join('\n') - .trim(); -}; - -/** - * Creates a glob pattern matcher. - * - * Matches strings with wildcards. - * - * Example: createGlobPattern('*@domain')('user@domain') === true - */ -export const createGlobPattern = (pattern) => { - const escapeString = (str) => str.replace(/[|\\{}()[\]^$+*?.]/g, '\\$&'); - // prettier-ignore - const regex = new RegExp('^' - + pattern.split(/\*+/).map(escapeString).join('.*') - + '$'); - return (str) => regex.test(str); -}; - -/** - * Creates a search terms matcher. - * - * Returns true if given string matches the search text. - * - * @template T - * @param {string} searchText - * @param {(obj: T) => string} stringifier - * @returns {(obj: T) => boolean} - */ -export const createSearch = (searchText, stringifier) => { - const preparedSearchText = searchText.toLowerCase().trim(); - return (obj) => { - if (!preparedSearchText) { - return true; - } - const str = stringifier ? stringifier(obj) : obj; - if (!str) { - return false; - } - return str.toLowerCase().includes(preparedSearchText); - }; -}; - -/** - * Capitalizes a word and lowercases the rest. - * @param {string} str - * @returns {string} capitalized string - * - * @example capitalize('heLLo') === 'Hello' - */ -export const capitalize = (str) => { - // Handle array - if (Array.isArray(str)) { - return str.map(capitalize); - } - // Handle string - return str.charAt(0).toUpperCase() + str.slice(1).toLowerCase(); -}; - -/** - * Similar to capitalize, this takes a string and replaces all first letters - * of any words. - * - * @param {string} str - * @return {string} The string with the first letters capitalized. - * - * @example capitalizeAll('heLLo woRLd') === 'HeLLo WoRLd' - */ -export const capitalizeAll = (str) => { - return str.replace(/(^\w{1})|(\s+\w{1})/g, (letter) => letter.toUpperCase()); -}; - -/** - * Capitalizes only the first letter of the str. - * - * @param {string} str - * @return {string} capitalized string - * - * @example capitalizeFirst('heLLo woRLd') === 'HeLLo woRLd' - */ -export const capitalizeFirst = (str) => { - return str.replace(/^\w/, (letter) => letter.toUpperCase()); -}; - -export const toTitleCase = (str) => { - // Handle array - if (Array.isArray(str)) { - return str.map(toTitleCase); - } - // Pass non-string - if (typeof str !== 'string') { - return str; - } - // Handle string - const WORDS_UPPER = ['Id', 'Tv']; - // prettier-ignore - const WORDS_LOWER = [ - 'A', 'An', 'And', 'As', 'At', 'But', 'By', 'For', 'For', 'From', 'In', - 'Into', 'Near', 'Nor', 'Of', 'On', 'Onto', 'Or', 'The', 'To', 'With', - ]; - let currentStr = str.replace(/([^\W_]+[^\s-]*) */g, (str) => { - return str.charAt(0).toUpperCase() + str.substr(1).toLowerCase(); - }); - for (let word of WORDS_LOWER) { - const regex = new RegExp('\\s' + word + '\\s', 'g'); - currentStr = currentStr.replace(regex, (str) => str.toLowerCase()); - } - for (let word of WORDS_UPPER) { - const regex = new RegExp('\\b' + word + '\\b', 'g'); - currentStr = currentStr.replace(regex, (str) => str.toLowerCase()); - } - return currentStr; -}; - -/** - * Decodes HTML entities, and removes unnecessary HTML tags. - * - * @param {String} str Encoded HTML string - * @return {String} Decoded HTML string - */ -export const decodeHtmlEntities = (str) => { - if (!str) { - return str; - } - const translate_re = /&(nbsp|amp|quot|lt|gt|apos);/g; - const translate = { - nbsp: ' ', - amp: '&', - quot: '"', - lt: '<', - gt: '>', - apos: "'", - }; - // prettier-ignore - return str - // Newline tags - .replace(/
/gi, '\n') - .replace(/<\/?[a-z0-9-_]+[^>]*>/gi, '') - // Basic entities - .replace(translate_re, (match, entity) => translate[entity]) - // Decimal entities - .replace(/&#?([0-9]+);/gi, (match, numStr) => { - const num = parseInt(numStr, 10); - return String.fromCharCode(num); - }) - // Hex entities - .replace(/&#x?([0-9a-f]+);/gi, (match, numStr) => { - const num = parseInt(numStr, 16); - return String.fromCharCode(num); - }); -}; - -/** - * Converts an object into a query string, - */ -// prettier-ignore -export const buildQueryString = obj => Object.keys(obj) - .map(key => encodeURIComponent(key) - + '=' + encodeURIComponent(obj[key])) - .join('&'); diff --git a/tgui/packages/common/string.test.ts b/tgui/packages/common/string.test.ts new file mode 100644 index 000000000000..06b24da80136 --- /dev/null +++ b/tgui/packages/common/string.test.ts @@ -0,0 +1,35 @@ +import { createSearch, decodeHtmlEntities, toTitleCase } from './string'; + +describe('createSearch', () => { + it('matches search terms correctly', () => { + const search = createSearch('test', (obj: { value: string }) => obj.value); + + const obj1 = { value: 'This is a test string.' }; + const obj2 = { value: 'This is a different string.' }; + const obj3 = { value: 'This is a test string.' }; + + const objects = [obj1, obj2, obj3]; + + expect(objects.filter(search)).toEqual([obj1, obj3]); + }); +}); + +describe('toTitleCase', () => { + it('converts strings to title case correctly', () => { + expect(toTitleCase('hello world')).toBe('Hello World'); + expect(toTitleCase('HELLO WORLD')).toBe('Hello World'); + expect(toTitleCase('HeLLo wORLd')).toBe('Hello World'); + expect(toTitleCase('a tale of two cities')).toBe('A Tale of Two Cities'); + expect(toTitleCase('war and peace')).toBe('War and Peace'); + }); +}); + +describe('decodeHtmlEntities', () => { + it('decodes HTML entities and removes unnecessary HTML tags correctly', () => { + expect(decodeHtmlEntities('
')).toBe('\n'); + expect(decodeHtmlEntities('

Hello World

')).toBe('Hello World'); + expect(decodeHtmlEntities('&')).toBe('&'); + expect(decodeHtmlEntities('&')).toBe('&'); + expect(decodeHtmlEntities('&')).toBe('&'); + }); +}); diff --git a/tgui/packages/common/string.ts b/tgui/packages/common/string.ts new file mode 100644 index 000000000000..4c93d155e662 --- /dev/null +++ b/tgui/packages/common/string.ts @@ -0,0 +1,174 @@ +/* eslint-disable func-style */ +/** + * @file + * @copyright 2020 Aleksej Komarov + * @license MIT + */ + +/** + * Creates a search terms matcher. Returns true if given string matches the search text. + * + * @example + * ```tsx + * type Thing = { id: string; name: string }; + * + * const objects = [ + * { id: '123', name: 'Test' }, + * { id: '456', name: 'Test' }, + * ]; + * + * const search = createSearch('123', (obj: Thing) => obj.id); + * + * objects.filter(search); // returns [{ id: '123', name: 'Test' }] + * ``` + */ +export function createSearch( + searchText: string, + stringifier = (obj: TObj) => JSON.stringify(obj), +): (obj: TObj) => boolean { + const preparedSearchText = searchText.toLowerCase().trim(); + + return (obj) => { + if (!preparedSearchText) { + return true; + } + const str = stringifier(obj); + if (!str) { + return false; + } + return str.toLowerCase().includes(preparedSearchText); + }; +} + +/** + * Capitalizes a word and lowercases the rest. + * + * @example + * ```tsx + * capitalize('heLLo') // Hello + * ``` + */ +export function capitalize(str: string): string { + return str.charAt(0).toUpperCase() + str.slice(1).toLowerCase(); +} + +/** + * Similar to capitalize, this takes a string and replaces all first letters + * of any words. + * + * @example + * ```tsx + * capitalizeAll('heLLo woRLd') // 'HeLLo WoRLd' + * ``` + */ +export function capitalizeAll(str: string): string { + return str.replace(/(^\w{1})|(\s+\w{1})/g, (letter) => letter.toUpperCase()); +} + +/** + * Capitalizes only the first letter of the str, leaving others untouched. + * + * @example + * ```tsx + * capitalizeFirst('heLLo woRLd') // 'HeLLo woRLd' + * ``` + */ +export function capitalizeFirst(str: string): string { + return str.replace(/^\w/, (letter) => letter.toUpperCase()); +} + +const WORDS_UPPER = ['Id', 'Tv'] as const; + +const WORDS_LOWER = [ + 'A', + 'An', + 'And', + 'As', + 'At', + 'But', + 'By', + 'For', + 'For', + 'From', + 'In', + 'Into', + 'Near', + 'Nor', + 'Of', + 'On', + 'Onto', + 'Or', + 'The', + 'To', + 'With', +] as const; + +/** + * Converts a string to title case. + * + * @example + * ```tsx + * toTitleCase('a tale of two cities') // 'A Tale of Two Cities' + * ``` + */ +export function toTitleCase(str: string): string { + if (!str) return str; + + let currentStr = str.replace(/([^\W_]+[^\s-]*) */g, (str) => { + return capitalize(str); + }); + + for (let word of WORDS_LOWER) { + const regex = new RegExp('\\s' + word + '\\s', 'g'); + currentStr = currentStr.replace(regex, (str) => str.toLowerCase()); + } + + for (let word of WORDS_UPPER) { + const regex = new RegExp('\\b' + word + '\\b', 'g'); + currentStr = currentStr.replace(regex, (str) => str.toLowerCase()); + } + + return currentStr; +} + +const TRANSLATE_REGEX = /&(nbsp|amp|quot|lt|gt|apos);/g; +const TRANSLATIONS = { + amp: '&', + apos: "'", + gt: '>', + lt: '<', + nbsp: ' ', + quot: '"', +} as const; + +/** + * Decodes HTML entities and removes unnecessary HTML tags. + * + * @example + * ```tsx + * decodeHtmlEntities('&') // returns '&' + * decodeHtmlEntities('<') // returns '<' + * ``` + */ +export function decodeHtmlEntities(str: string): string { + if (!str) return str; + + return ( + str + // Newline tags + .replace(/
/gi, '\n') + .replace(/<\/?[a-z0-9-_]+[^>]*>/gi, '') + // Basic entities + .replace(TRANSLATE_REGEX, (match, entity) => TRANSLATIONS[entity]) + // Decimal entities + .replace(/&#?([0-9]+);/gi, (match, numStr) => { + const num = parseInt(numStr, 10); + return String.fromCharCode(num); + }) + // Hex entities + .replace(/&#x?([0-9a-f]+);/gi, (match, numStr) => { + const num = parseInt(numStr, 16); + return String.fromCharCode(num); + }) + ); +} diff --git a/tgui/packages/tgui/interfaces/Orbit/index.tsx b/tgui/packages/tgui/interfaces/Orbit/index.tsx index 7e0d956e2cd3..76a2ce874497 100644 --- a/tgui/packages/tgui/interfaces/Orbit/index.tsx +++ b/tgui/packages/tgui/interfaces/Orbit/index.tsx @@ -1,5 +1,5 @@ import { filter, sortBy } from 'common/collections'; -import { capitalizeFirst, multiline } from 'common/string'; +import { capitalizeFirst } from 'common/string'; import { useState } from 'react'; import { useBackend } from 'tgui/backend'; import { @@ -96,8 +96,7 @@ const ObservableSearch = (props: { color={auto_observe ? 'good' : 'transparent'} icon={auto_observe ? 'toggle-on' : 'toggle-off'} onClick={() => act('toggle_auto_observe')} - tooltip={multiline`Toggle Full Observe. When active, you'll - see the UI / full inventory of whoever you're orbiting. Neat!`} + tooltip={`Toggle Full Observe. When active, you'll see the UI / full inventory of whoever you're orbiting. Neat!`} tooltipPosition="bottom-start" /> diff --git a/tgui/packages/tgui/interfaces/PodLauncher.jsx b/tgui/packages/tgui/interfaces/PodLauncher.jsx index a44c92e045aa..abb0ae20d6d8 100644 --- a/tgui/packages/tgui/interfaces/PodLauncher.jsx +++ b/tgui/packages/tgui/interfaces/PodLauncher.jsx @@ -1,6 +1,5 @@ import { toFixed } from 'common/math'; import { storage } from 'common/storage'; -import { multiline } from 'common/string'; import { createUuid } from 'common/uuid'; import { Component, Fragment, useState } from 'react'; @@ -330,10 +329,7 @@ const ViewTabHolder = (props) => {