diff --git a/.gitignore b/.gitignore index 56a9c1a..d5906a5 100644 --- a/.gitignore +++ b/.gitignore @@ -38,3 +38,5 @@ compose.yml yarn.lock package-lock.json pnpm-lock.yaml + +!web/dist/.gitkeep \ No newline at end of file diff --git a/web/.gitignore b/web/.gitignore index 1b55e1f..92254ae 100644 --- a/web/.gitignore +++ b/web/.gitignore @@ -51,7 +51,4 @@ pnpm-lock.yaml # Contentlayer -.contentlayer - -# commit directory dist with content for github pages -/dist \ No newline at end of file +.contentlayer \ No newline at end of file diff --git a/web/README.md b/web/README.md index b8ce60c..798db34 100644 --- a/web/README.md +++ b/web/README.md @@ -20,14 +20,42 @@ cd compozify/web ### DEVELOPMENT -Install dependencies: +#### Install dependencies ```bash pnpm install ``` -Start the development server: +#### Start the backend server + +##### Prerequisites + +- [Go 1.18+](https://golang.org/dl/) + +##### Run the server + +Open a new terminal and route to `cmd/compozify-web` and run: + +```bash +go run main.go +``` + +#### Start the development server + +In `compozify/web/next.config.js` file + +1. Enable comment the `async rewrites()` function. + +2. Disable comment the `output: 'export'` function. + +Then run the following command: ```bash pnpm dev ``` + +**NB**: + +- Make sure you have the backend server running in another terminal in order to make requests to the API endpoints. + +- Return the `async rewrites()` function and the `output: 'export'` function to its initial state before building the app for production or making commits. diff --git a/web/content/docs/commands/compozify-add-service.mdx b/web/content/docs/commands/compozify-add-service.mdx new file mode 100644 index 0000000..b44a6bb --- /dev/null +++ b/web/content/docs/commands/compozify-add-service.mdx @@ -0,0 +1,71 @@ +--- +title: compozify add-service +description: Add a service to an existing docker-compose file +--- + +# compozify add-service + +Add a service to an existing docker-compose file + +## Synopsis + +Converts the docker run command to docker compose and adds as a new service to an existing docker-compose file. +If no file is specified, compozify will look for a docker compose file in the current directory. +If no file is found, compozify will create one in the current directory. +Expected file names are docker-compose.[yml,yaml], compose.[yml,yaml] + +```bash +compozify add-service [flags] DOCKER_RUN_COMMAND +``` + +## Examples + +- Add service to existing docker-compose file in current directory + +```bash +compozify add-service "docker run -i -t --rm alpine" +``` + +- Add service to existing docker-compose file + +```bash +compozify add-service -f /path/to/docker-compose.yml "docker run -i -t --rm alpine" +``` + +- Write to file + +```bash +compozify add-service -w -f /path/to/docker-compose.yml "docker run -i -t --rm alpine" +``` + +- Alternative usage specifying beginning of docker run command without quotes + +```bash +compozify add-service -w -f /path/to/docker-compose.yml -- docker run -i -t --rm alpine +``` + +- Add service with custom name + +```bash +compozify add-service -w -f /path/to/docker-compose.yml -n my-service "docker run -i -t --rm alpine" +``` + +## Options + +```bash + -f, --file string Compose file path + -h, --help help for add-service + -n, --service-name string Name of the service + -w, --write write to file +``` + +### Options inherited from parent commands + +```bash + -v, --verbose verbose output +``` + +### SEE ALSO + +- compozify - compozify is a tool mainly for converting docker run + commands to docker compose files diff --git a/web/content/docs/installation.mdx b/web/content/docs/installation.mdx index 6604ad1..b7eeaf7 100644 --- a/web/content/docs/installation.mdx +++ b/web/content/docs/installation.mdx @@ -7,6 +7,24 @@ description: Helps with the installation of the tool Download a binary suitable for your OS at the [releases page](https://github.com/profclems/compozify/releases/latest). +## Quick install + +### Linux and macOS + +```sh +curl -sfL https://raw.githubusercontent.com/profclems/compozify/main/install.sh | sh +``` + +### Windows (PowerShell) + +Open a PowerShell terminal (version 5.1 or later) and run: + +```powershell +Set-ExecutionPolicy RemoteSigned -Scope CurrentUser # Optional: Needed to run a remote script the first time + +irm https://raw.githubusercontent.com/profclems/compozify/main/install.ps1 | iex +``` + ## From source ### Prerequisites for building from source diff --git a/web/contentlayer.config.ts b/web/contentlayer.config.ts index ae572e6..5635af0 100644 --- a/web/contentlayer.config.ts +++ b/web/contentlayer.config.ts @@ -9,7 +9,7 @@ import { getHighlighter, loadTheme } from 'shiki' import { visit } from 'unist-util-visit' import { rehypeComponent } from './lib/rehype-component' -import { UnistNode, UnistTree } from './lib/unist' +import { UnistNode, UnistTree } from './lib/toc' /** @type {import('contentlayer/source-files').ComputedFields} */ const computedFields: import('contentlayer/source-files').ComputedFields = { diff --git a/web/lib/rehype-component.ts b/web/lib/rehype-component.ts index 13b11a7..32a5105 100644 --- a/web/lib/rehype-component.ts +++ b/web/lib/rehype-component.ts @@ -4,7 +4,7 @@ import { u } from 'unist-builder' import { visit } from 'unist-util-visit' import { languages } from './rehype-languages' -import { UnistNode, UnistTree } from './unist' +import { UnistNode, UnistTree } from './toc' export function rehypeComponent() { return async (tree: UnistTree) => { diff --git a/web/lib/shiki.ts b/web/lib/shiki.ts deleted file mode 100644 index b0b0cd0..0000000 --- a/web/lib/shiki.ts +++ /dev/null @@ -1,13 +0,0 @@ -import { getHighlighter, Lang } from 'shiki' -import { languages } from '~/lib/rehype-languages' - -const highlighterPromise = getHighlighter({ - theme: 'nord', - langs: languages as Lang[] -}) - -export async function hightlight(code: string, language: string) { - const highlighter = await highlighterPromise - const output = highlighter.codeToHtml(code, { lang: language }) - return output -} diff --git a/web/lib/toc.ts b/web/lib/toc.ts index cbb960e..152b053 100644 --- a/web/lib/toc.ts +++ b/web/lib/toc.ts @@ -2,7 +2,31 @@ import { toc } from 'mdast-util-toc' import { remark } from 'remark' import { visit } from 'unist-util-visit' -import { UnistNode } from './unist' +type Node = import('unist').Node + +export interface UnistNode extends Node { + url?: string + type: string + name?: string + tagName?: string + value?: string + properties?: { + __rawString__?: string + __className__?: string + className?: string[] + [key: string]: unknown + } + attributes?: { + name: string + value: unknown + type?: string + }[] + children?: UnistNode[] +} + +export interface UnistTree extends Node { + children: UnistNode[] +} const textTypes = ['text', 'emphasis', 'strong', 'inlineCode'] diff --git a/web/lib/unist.ts b/web/lib/unist.ts deleted file mode 100644 index dc0ffbb..0000000 --- a/web/lib/unist.ts +++ /dev/null @@ -1,25 +0,0 @@ -type Node = import('unist').Node - -export interface UnistNode extends Node { - url?: string - type: string - name?: string - tagName?: string - value?: string - properties?: { - __rawString__?: string - __className__?: string - className?: string[] - [key: string]: unknown - } - attributes?: { - name: string - value: unknown - type?: string - }[] - children?: UnistNode[] -} - -export interface UnistTree extends Node { - children: UnistNode[] -} diff --git a/web/next.config.js b/web/next.config.js index 5dc64c0..db7b573 100644 --- a/web/next.config.js +++ b/web/next.config.js @@ -13,9 +13,6 @@ const nextConfig = { output: 'export', distDir: process.env.NODE_ENV === 'development' ? 'out' : 'dist', images: { unoptimized: true }, - experimental: { - serverComponentsExternalPackages: ['vscode-oniguruma', 'shiki'] - }, webpack: (config, { defaultLoaders }) => { // clear cache defaultLoaders.babel.options.cache = false @@ -25,6 +22,10 @@ const nextConfig = { return config } + // for local development - (\\d{1,}) is for port number + // async rewrites() { + // return [{ source: '/api/parse', destination: 'http://localhost:8080/api/parse' }] + // } } const withALL = (nextConfig = {}) => withContentlayer(withPWA(nextConfig)) diff --git a/web/package.json b/web/package.json index 88d336c..9af56de 100644 --- a/web/package.json +++ b/web/package.json @@ -17,26 +17,27 @@ "@radix-ui/react-dropdown-menu": "^2.0.5", "@radix-ui/react-scroll-area": "^1.0.4", "@radix-ui/react-separator": "^1.0.3", - "@types/node": "20.4.1", - "@types/react": "18.2.14", - "@types/react-dom": "18.2.6", - "autoprefixer": "10.4.14", - "class-variance-authority": "^0.6.1", - "clsx": "^1.2.1", + "@types/node": "20.5.1", + "@types/react": "18.2.20", + "@types/react-dom": "18.2.7", + "autoprefixer": "10.4.15", + "class-variance-authority": "^0.7.0", + "clsx": "^2.0.0", "concurrently": "^8.2.0", "contentlayer": "^0.3.4", - "framer-motion": "^10.12.18", + "framer-motion": "^10.16.0", + "highlight.js": "^11.8.0", "mdast-util-toc": "^7.0.0", - "next": "13.4.8", + "next": "13.4.19", "next-contentlayer": "^0.3.4", "next-pwa": "^5.6.0", "next-themes": "^0.2.1", - "postcss": "8.4.25", + "postcss": "8.4.28", "postcss-focus-visible": "^9.0.0", "postcss-import": "^15.1.0", "react": "18.2.0", "react-dom": "18.2.0", - "react-hook-form": "^7.45.1", + "react-hook-form": "^7.45.4", "react-icons": "^4.10.1", "react-intersection-observer": "^9.5.2", "rehype-autolink-headings": "^6.1.1", @@ -46,28 +47,28 @@ "remark-code-import": "^1.2.0", "remark-gfm": "^3.0.1", "shiki": "^0.14.3", - "tailwind-merge": "^1.13.2", - "tailwindcss": "3.3.2", + "tailwind-merge": "^1.14.0", + "tailwindcss": "3.3.3", + "typeit-react": "^2.6.4", "typewriter-react": "^1.0.1", "unist-builder": "^4.0.0", "unist-util-visit": "^5.0.0", - "vscode-oniguruma": "^1.7.0", "yaml": "^2.3.1", - "zustand": "^4.3.9" + "zustand": "^4.4.1" }, "devDependencies": { "@types/unist": "^3.0.0", - "@typescript-eslint/eslint-plugin": "^6.0.0", - "@typescript-eslint/parser": "^6.0.0", - "eslint": "8.44.0", - "eslint-config-next": "13.4.9", - "eslint-config-prettier": "^8.8.0", + "@typescript-eslint/eslint-plugin": "^6.4.0", + "@typescript-eslint/parser": "^6.4.0", + "eslint": "8.47.0", + "eslint-config-next": "13.4.19", + "eslint-config-prettier": "^9.0.0", "eslint-plugin-jsx-a11y": "^6.7.1", - "eslint-plugin-prettier": "^4.2.1", - "eslint-plugin-react": "^7.32.2", + "eslint-plugin-prettier": "^5.0.0", + "eslint-plugin-react": "^7.33.2", "eslint-plugin-react-hooks": "^4.6.0", - "prettier": "^2.8.8", - "prettier-plugin-tailwindcss": "^0.3.0", + "prettier": "^3.0.2", + "prettier-plugin-tailwindcss": "^0.5.3", "typescript": "5.1.6" } } diff --git a/web/src/app/page.tsx b/web/src/app/page.tsx index 4b73999..4014a6b 100644 --- a/web/src/app/page.tsx +++ b/web/src/app/page.tsx @@ -1,22 +1,27 @@ 'use client' import { Fragment, useEffect, useRef, useState } from 'react' -import Spinner from '~/components/Spinner' -import { siteConfig } from '~/config/site' -import useStore from '~/context/useStore' -import { cn } from '~/utils/classNames' import { useInView } from 'framer-motion' import { SubmitHandler, useForm } from 'react-hook-form' import { FaDocker } from 'react-icons/fa' +import CustomLink from '~/components/custom-link' import CopyButton from '~/components/copy-button' import { LiaFileInvoiceSolid } from 'react-icons/lia' -import CustomLink from '~/components/custom-link' +import hljs from 'highlight.js' +import Typeit from 'typeit-react' +import Spinner from '~/components/spinner' +import { siteConfig } from '~/config/site' +import useStore from '~/context/useStore' +import { cn } from '~/utils/classNames' +import 'highlight.js/styles/atom-one-dark.css' +import { stripHtml } from '~/utils/stripHtml' -export default function Home() { +export default function Home(): JSX.Element { const { titleInView: t, setTitleInView, compose, code, error } = useStore() const titleRef = useRef(null) const titleInView = useInView(titleRef, { margin: '0px 0px -100px 0px' }) const [loading, setLoading] = useState(false) + const highlightedCode = hljs.highlight(code || '', { language: 'yaml' }).value useEffect(() => { if (titleInView !== t) setTitleInView(titleInView) @@ -153,20 +158,21 @@ export default function Home() {
{code && ( -
+
{/* header */} -
+

docker-compose.yaml

+ {/* code */}
-                {code.replace(/^\s*\|/, '')}
+                
               
)} diff --git a/web/src/components/Navbar.tsx b/web/src/components/Navbar.tsx index b82be76..7153636 100644 --- a/web/src/components/Navbar.tsx +++ b/web/src/components/Navbar.tsx @@ -45,11 +45,8 @@ export default function Navbar({ className }: { className?: string }) { Docs diff --git a/web/src/components/mdx.tsx b/web/src/components/mdx.tsx index 11c8308..3bf29ee 100644 --- a/web/src/components/mdx.tsx +++ b/web/src/components/mdx.tsx @@ -25,7 +25,7 @@ const components = { h2: ({ className, ...props }: React.HTMLAttributes) => (

) => ( ), @@ -63,18 +63,15 @@ const components = { ), blockquote: ({ className, ...props }: React.HTMLAttributes) => (
*]:text-neutral-600', - className - )} + className={cn('mt-6 border-l-2 border-zinc-300 pl-6 italic text-zinc-800 [&>*]:text-zinc-600', className)} {...props} /> ), img: ({ className, alt, ...props }: React.ImgHTMLAttributes) => ( - {alt} + {alt} ), hr: ({ ...props }: React.HTMLAttributes) => ( -
+
), table: ({ className, ...props }: React.HTMLAttributes) => (
@@ -82,12 +79,12 @@ const components = {
), tr: ({ className, ...props }: React.HTMLAttributes) => ( - + ), th: ({ className, ...props }: React.HTMLAttributes) => ( ) => ( { return ( -
+
{/* header */} -
+

{/* icon */} {__withMeta__ && __filename__ ? ( <> - {__filename__} + {__filename__} ) : ( <> - Terminal + Terminal )}

@@ -139,13 +136,13 @@ const components = { )}
{/* code */}
       
@@ -154,7 +151,7 @@ const components = { code: ({ className, ...props }: React.HTMLAttributes) => ( route === pathname && 'hidden'), disableOnLayouts && disableOnLayouts.map(layout => pathname.startsWith(layout) && 'hidden'), top < 100 && 'hidden' diff --git a/web/src/components/separator.tsx b/web/src/components/separator.tsx deleted file mode 100644 index 6d20a9b..0000000 --- a/web/src/components/separator.tsx +++ /dev/null @@ -1,25 +0,0 @@ -'use client' - -import * as React from 'react' -import * as SeparatorPrimitive from '@radix-ui/react-separator' -import clsx from 'clsx' - -const Separator = React.forwardRef< - React.ElementRef, - React.ComponentPropsWithoutRef ->(({ className, orientation = 'horizontal', decorative = true, ...props }, ref) => ( - -)) -Separator.displayName = SeparatorPrimitive.Root.displayName - -export { Separator } diff --git a/web/src/components/Spinner.tsx b/web/src/components/spinner.tsx similarity index 87% rename from web/src/components/Spinner.tsx rename to web/src/components/spinner.tsx index 28d7031..0c85052 100644 --- a/web/src/components/Spinner.tsx +++ b/web/src/components/spinner.tsx @@ -3,7 +3,7 @@ import { cn } from '~/utils/classNames' export default function Spinner({ className }: { className?: string }) { return ( { if (toc && toc.items) { return toc.items - .flatMap(item => [item.url, item?.items?.map(item => item.url)]) + .flatMap(item => [item.url, item?.items?.map(i => i.url)] || []) .flat() .filter(Boolean) .map(id => id?.split('#')[1]) @@ -36,22 +34,8 @@ export function DocsTableOfContents({ toc, className }: TocProps) { {/* Table of content */}

On This Page

- - {/* Docss */} - - - Back to Docs - + {/* scroll to top */} +
@@ -75,17 +59,13 @@ function useActiveItem(itemIds: string[]) { itemIds?.forEach(id => { const element = document.getElementById(id) - if (element) { - observer.observe(element) - } + if (element) observer.observe(element) }) return () => { itemIds?.forEach(id => { const element = document.getElementById(id) - if (element) { - observer.unobserve(element) - } + if (element) observer.unobserve(element) }) } }, [itemIds]) @@ -100,9 +80,9 @@ interface TreeProps { } function Tree({ tree, level = 1, activeItem }: TreeProps) { - return tree?.items?.length && level < 4 ? ( + return tree?.items?.length && level < 3 ? (
    , - React.ComponentPropsWithoutRef & { - inset?: boolean - } ->(({ className, inset, children, ...props }, ref) => ( - - {children} - - -)) -DropdownMenuSubTrigger.displayName = DropdownMenuPrimitive.SubTrigger.displayName - -const DropdownMenuSubContent = React.forwardRef< - React.ElementRef, - React.ComponentPropsWithoutRef ->(({ className, ...props }, ref) => ( - -)) -DropdownMenuSubContent.displayName = DropdownMenuPrimitive.SubContent.displayName - -const DropdownMenuContent = React.forwardRef< - React.ElementRef, - React.ComponentPropsWithoutRef ->(({ className, sideOffset = 4, ...props }, ref) => ( - - - -)) -DropdownMenuContent.displayName = DropdownMenuPrimitive.Content.displayName - -const DropdownMenuItem = React.forwardRef< - React.ElementRef, - React.ComponentPropsWithoutRef & { - inset?: boolean - } ->(({ className, inset, ...props }, ref) => ( - -)) -DropdownMenuItem.displayName = DropdownMenuPrimitive.Item.displayName - -const DropdownMenuCheckboxItem = React.forwardRef< - React.ElementRef, - React.ComponentPropsWithoutRef ->(({ className, children, checked, ...props }, ref) => ( - - - - - - - {children} - -)) -DropdownMenuCheckboxItem.displayName = DropdownMenuPrimitive.CheckboxItem.displayName - -const DropdownMenuRadioItem = React.forwardRef< - React.ElementRef, - React.ComponentPropsWithoutRef ->(({ className, children, ...props }, ref) => ( - - - - - - - {children} - -)) -DropdownMenuRadioItem.displayName = DropdownMenuPrimitive.RadioItem.displayName - -const DropdownMenuLabel = React.forwardRef< - React.ElementRef, - React.ComponentPropsWithoutRef & { - inset?: boolean - } ->(({ className, inset, ...props }, ref) => ( - -)) -DropdownMenuLabel.displayName = DropdownMenuPrimitive.Label.displayName - -const DropdownMenuSeparator = React.forwardRef< - React.ElementRef, - React.ComponentPropsWithoutRef ->(({ className, ...props }, ref) => ( - -)) -DropdownMenuSeparator.displayName = DropdownMenuPrimitive.Separator.displayName - -const DropdownMenuShortcut = ({ className, ...props }: React.HTMLAttributes) => { - return -} -DropdownMenuShortcut.displayName = 'DropdownMenuShortcut' - -export { - DropdownMenu, - DropdownMenuTrigger, - DropdownMenuContent, - DropdownMenuItem, - DropdownMenuCheckboxItem, - DropdownMenuRadioItem, - DropdownMenuLabel, - DropdownMenuSeparator, - DropdownMenuShortcut, - DropdownMenuGroup, - DropdownMenuPortal, - DropdownMenuSub, - DropdownMenuSubContent, - DropdownMenuSubTrigger, - DropdownMenuRadioGroup -} diff --git a/web/src/components/ui/theme-toggler.tsx b/web/src/components/ui/theme-toggler.tsx deleted file mode 100644 index aa7066d..0000000 --- a/web/src/components/ui/theme-toggler.tsx +++ /dev/null @@ -1,28 +0,0 @@ -'use client' - -import * as React from 'react' -import { Button } from '~/components/ui/button' -import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger } from '~/components/ui/dropdown-menu' -import { useTheme } from 'next-themes' -import { HiMoon, HiSun } from 'react-icons/hi' - -export default function ThemeToggle() { - const { setTheme } = useTheme() - - return ( - - - - - - setTheme('light')}>Light - setTheme('dark')}>Dark - setTheme('system')}>System - - - ) -} diff --git a/web/src/config/docs.ts b/web/src/config/docs.ts index a9718f7..96dd659 100644 --- a/web/src/config/docs.ts +++ b/web/src/config/docs.ts @@ -18,11 +18,15 @@ export const docsConfig: DocsConfig = { ], Commands: [ { - title: 'Compozify', + title: 'compozify', href: '/docs/commands/compozify' }, { - title: 'Convert', + title: 'add-service', + href: '/docs/commands/compozify-add-service' + }, + { + title: 'convert', href: '/docs/commands/compozify-convert' } ] diff --git a/web/src/context/useStore.tsx b/web/src/context/useStore.tsx index a310dc3..76d088b 100644 --- a/web/src/context/useStore.tsx +++ b/web/src/context/useStore.tsx @@ -2,7 +2,6 @@ import { createContext, ReactNode, useCallback, useContext, useEffect, useMemo, useState } from 'react' import { ThemeProvider as Theme } from 'next-themes' -import { stringify } from 'yaml' import useMounted from '~/hooks/useMounted' import { ErrorCause } from '~/types/nav' @@ -11,7 +10,10 @@ interface Store { setTitleInView: (value: boolean) => void compose: (command: string) => Promise code?: string + previousCode?: string menu: boolean + // eslint-disable-next-line @typescript-eslint/no-explicit-any + setTypingInstance: (value: any) => void setMenu: (value: boolean) => void error?: Err } @@ -26,6 +28,8 @@ const StoreContext = createContext({ setTitleInView: () => {}, compose: async () => {}, code: undefined, + previousCode: undefined, + setTypingInstance: () => {}, menu: false, setMenu: () => {} }) @@ -36,6 +40,8 @@ export function StoreProvider({ children }: { children: ReactNode }) { const [code, setCode] = useState(undefined) const [menu, setMenu] = useState(false) const [error, setError] = useState(undefined) + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const [_typingInstace, setTypingInstance] = useState(null) useEffect(() => { const e = setTimeout(() => setError(undefined), 5000) @@ -48,7 +54,7 @@ export function StoreProvider({ children }: { children: ReactNode }) { const response = await fetch('/api/parse', { mode: 'cors', method: 'POST', - headers: { 'Content-Type': 'application/json' }, + headers: { 'Content-Type': 'application/json', 'Access-Control-Allow-Origin': '*' }, body: JSON.stringify({ command: command }) }) @@ -58,8 +64,8 @@ export function StoreProvider({ children }: { children: ReactNode }) { type Res = undefined | { output?: string } // get response body and handle it here const body: Res = await response.json() - const str = body && typeof body?.output === 'string' ? body.output.replace(/^\s*\|/, '') : undefined - setCode(body && body.output ? stringify(str) : undefined) + const str = body && typeof body?.output === 'string' ? body.output : undefined + setCode(str) } catch (error) { let err: ErrorCause if (error instanceof Error) err = error as ErrorCause @@ -92,9 +98,10 @@ export function StoreProvider({ children }: { children: ReactNode }) { compose, code, menu, - setMenu + setMenu, + setTypingInstance }), - [code, compose, titleInView, menu] + [titleInView, compose, code, menu] ) if (!mounted) return null diff --git a/web/src/utils/stripHtml.ts b/web/src/utils/stripHtml.ts new file mode 100644 index 0000000..5ea8ba5 --- /dev/null +++ b/web/src/utils/stripHtml.ts @@ -0,0 +1,24 @@ +/** + * stripHtml - strips html tags from a string + * @param {string} html - html string + * @returns {string} - string without html tags + */ +export function stripHtml(html: string): string { + let text = html.replace(/(<([^>]+)>)/gi, '') + + // Create a map of special characters to their text equivalent. + const specialCharacters = { + '"': '"', + "'": ''', + '<': '<', + '>': '>', + '&': '&' + } + + // Replace all special characters in the text with their text equivalent. + for (const [character, replacement] of Object.entries(specialCharacters)) { + text = text.replaceAll(replacement, character) + } + + return text +}