diff --git a/README.md b/README.md
index e4fcdac8..95ffb8a9 100644
--- a/README.md
+++ b/README.md
@@ -98,8 +98,8 @@ Application behavior can be changed by configuring the following environment var
#### Site meta
- `NEXT_PUBLIC_SITE_TITLE` (seen in browser tab)
-- `NEXT_PUBLIC_SITE_DESCRIPTION` (seen in nav, under title)
-- `NEXT_PUBLIC_SITE_ABOUT` (e.g., seen in grid sidebar)
+- `NEXT_PUBLIC_SITE_DESCRIPTION` (seen in nav, beneath title)
+- `NEXT_PUBLIC_SITE_ABOUT` (seen in grid sidebar—accepted rich formatting tags: ``, ``, ``, ``, ``, ` TEXT
`)
#### Site behavior
- `NEXT_PUBLIC_GRID_HOMEPAGE = 1` shows grid layout on homepage
diff --git a/__tests__/html.test.ts b/__tests__/html.test.ts
new file mode 100644
index 00000000..9468d976
--- /dev/null
+++ b/__tests__/html.test.ts
@@ -0,0 +1,17 @@
+import { htmlHasBrParagraphBreaks, safelyParseFormattedHtml } from '@/utility/html';
+import { parameterize } from '@/utility/string';
+
+describe('HTML', () => {
+ it('safely parses', () => {
+ expect(safelyParseFormattedHtml('
')).toBeTruthy();
+ expect(htmlHasBrParagraphBreaks('TEXT
')).toBeTruthy();
+ expect(htmlHasBrParagraphBreaks('TEXT
')).toBeTruthy();
+ expect(htmlHasBrParagraphBreaks('TEXT')).toBeFalsy();
+ expect(htmlHasBrParagraphBreaks('TEXT
')).toBeFalsy();
+ expect(htmlHasBrParagraphBreaks('TEXT
')).toBeFalsy();
+ });
+});
diff --git a/package.json b/package.json
index 77ff067b..5778cf25 100644
--- a/package.json
+++ b/package.json
@@ -23,6 +23,7 @@
"@types/pg": "^8.11.10",
"@types/react": "18.3.8",
"@types/react-dom": "18.3.0",
+ "@types/sanitize-html": "^2.13.0",
"@typescript-eslint/eslint-plugin": "^7.17.0",
"@typescript-eslint/parser": "^7.17.0",
"@upstash/ratelimit": "^2.0.3",
@@ -51,6 +52,7 @@
"react": "18.3.1",
"react-dom": "18.3.1",
"react-icons": "^5.3.0",
+ "sanitize-html": "^2.13.0",
"sharp": "^0.33.5",
"sonner": "^1.5.0",
"swr": "^2.2.5",
diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml
index 17fecaa5..fdc8ef00 100644
--- a/pnpm-lock.yaml
+++ b/pnpm-lock.yaml
@@ -50,6 +50,9 @@ importers:
'@types/react-dom':
specifier: 18.3.0
version: 18.3.0
+ '@types/sanitize-html':
+ specifier: ^2.13.0
+ version: 2.13.0
'@typescript-eslint/eslint-plugin':
specifier: ^7.17.0
version: 7.17.0(@typescript-eslint/parser@7.17.0(eslint@8.57.0)(typescript@5.6.2))(eslint@8.57.0)(typescript@5.6.2)
@@ -134,6 +137,9 @@ importers:
react-icons:
specifier: ^5.3.0
version: 5.3.0(react@18.3.1)
+ sanitize-html:
+ specifier: ^2.13.0
+ version: 2.13.0
sharp:
specifier: ^0.33.5
version: 0.33.5
@@ -1677,6 +1683,9 @@ packages:
'@types/react@18.3.8':
resolution: {integrity: sha512-syBUrW3/XpnW4WJ41Pft+I+aPoDVbrBVQGEnbD7NijDGlVC+8gV/XKRY+7vMDlfPpbwYt0l1vd/Sj8bJGMbs9Q==}
+ '@types/sanitize-html@2.13.0':
+ resolution: {integrity: sha512-X31WxbvW9TjIhZZNyNBZ/p5ax4ti7qsNDBDEnH4zAgmEh35YnFD1UiS6z9Cd34kKm0LslFW0KPmTQzu/oGtsqQ==}
+
'@types/stack-utils@2.0.3':
resolution: {integrity: sha512-9aEbYZ3TbYMznPdcdr3SmIrLXwC/AKZXQeCf9Pgao5CKb8CyHuEX5jzWPTkvregvhRJHcpRO6BFoGW9ycaOkYw==}
@@ -2354,11 +2363,24 @@ packages:
dom-accessibility-api@0.6.3:
resolution: {integrity: sha512-7ZgogeTnjuHbo+ct10G9Ffp0mif17idi0IyWNVA/wcwcm7NPOD/WEHVP3n7n3MhXqxoIYm8d6MuZohYWIZ4T3w==}
+ dom-serializer@2.0.0:
+ resolution: {integrity: sha512-wIkAryiqt/nV5EQKqQpo3SToSOV9J0DnbJqwK7Wv/Trc92zIAYZ4FlMu+JPFW1DfGFt81ZTCGgDEabffXeLyJg==}
+
+ domelementtype@2.3.0:
+ resolution: {integrity: sha512-OLETBj6w0OsagBwdXnPdN0cnMfF9opN69co+7ZrbfPGrdpPVNBUj02spi6B1N7wChLQiPn4CSH/zJvXw56gmHw==}
+
domexception@4.0.0:
resolution: {integrity: sha512-A2is4PLG+eeSfoTMA95/s4pvAoSo2mKtiM5jlHkAVewmiO8ISFTFKZjH7UAM1Atli/OT/7JHOrJRJiMKUZKYBw==}
engines: {node: '>=12'}
deprecated: Use your platform's native DOMException instead
+ domhandler@5.0.3:
+ resolution: {integrity: sha512-cgwlv/1iFQiFnU96XXgROh8xTeetsnJiDsTc7TYCLFd9+/WNkIqPTxiM/8pSd8VIrhXGTf1Ny1q1hquVqDJB5w==}
+ engines: {node: '>= 4'}
+
+ domutils@3.1.0:
+ resolution: {integrity: sha512-H78uMmQtI2AhgDJjWeQmHwJJ2bLPD3GMmO7Zja/ZZh84wkm+4ut+IUnUdRa8uCGX88DiVx1j6FRe1XfxEgjEZA==}
+
duplexer@0.1.2:
resolution: {integrity: sha512-jtD6YG370ZCIi/9GTaJKQxWTZD045+4R4hTk/x1UyoqadyJ9x9CgSi1RlVDQF8U2sxLLSnFkCaMihqljHIWgMg==}
@@ -2785,6 +2807,9 @@ packages:
html-escaper@2.0.2:
resolution: {integrity: sha512-H2iMtd0I4Mt5eYiapRdIDjp+XzelXQ0tFE4JS7YFwFevXXMmOp9myNrUvCg0D6ws8iqkRPBfKHgbwig1SmlLfg==}
+ htmlparser2@8.0.2:
+ resolution: {integrity: sha512-GYdjWKDkbRLkZ5geuHs5NY1puJ+PXwP7+fHPRz06Eirsb9ugf6d8kkXav6ADhcODhFFPMIXyxkxSuMf3D6NCFA==}
+
http-proxy-agent@5.0.0:
resolution: {integrity: sha512-n2hY8YdoRE1i7r6M0w9DIw5GgZN0G25P8zLCRQ8rjXtTU3vsNFBI/vWK/UIeE6g5MUUz6avwAPXmL6Fy9D/90w==}
engines: {node: '>= 6'}
@@ -3521,6 +3546,9 @@ packages:
resolution: {integrity: sha512-ayCKvm/phCGxOkYRSCM82iDwct8/EonSEgCSxWxD7ve6jHggsFl4fZVQBPRNgQoKiuV/odhFrGzQXZwbifC8Rg==}
engines: {node: '>=8'}
+ parse-srcset@1.0.2:
+ resolution: {integrity: sha512-/2qh0lav6CmI15FzA3i/2Bzk2zCgQhGMkvhOhKNcBVQ1ldgpbfiNTVslmooUmWJcADi1f1kIeynbDRVzNlfR6Q==}
+
parse5@7.1.2:
resolution: {integrity: sha512-Czj1WaSVpaoj0wbhMzLmWD69anp2WH7FXMB9n1Sy8/ZFF9jolSQVMu1Ij5WIyGmcBmhk7EOndpO4mIpihVqAXw==}
@@ -3897,6 +3925,9 @@ packages:
safer-buffer@2.1.2:
resolution: {integrity: sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==}
+ sanitize-html@2.13.0:
+ resolution: {integrity: sha512-Xff91Z+4Mz5QiNSLdLWwjgBDm5b1RU6xBT0+12rapjiaR7SwfRdjw8f+6Rir2MXKLrDicRFHdb51hGOAxmsUIA==}
+
sax@1.2.4:
resolution: {integrity: sha512-NqVDv9TpANUjFm0N8uM5GxL36UgKi9/atZw+x7YFnQ8ckwFGKrl4xX4yWtrey3UJm5nP1kUbnYgLopqWNSRhWw==}
@@ -6514,6 +6545,10 @@ snapshots:
'@types/prop-types': 15.7.12
csstype: 3.1.3
+ '@types/sanitize-html@2.13.0':
+ dependencies:
+ htmlparser2: 8.0.2
+
'@types/stack-utils@2.0.3': {}
'@types/tough-cookie@4.0.5': {}
@@ -7262,10 +7297,28 @@ snapshots:
dom-accessibility-api@0.6.3: {}
+ dom-serializer@2.0.0:
+ dependencies:
+ domelementtype: 2.3.0
+ domhandler: 5.0.3
+ entities: 4.5.0
+
+ domelementtype@2.3.0: {}
+
domexception@4.0.0:
dependencies:
webidl-conversions: 7.0.0
+ domhandler@5.0.3:
+ dependencies:
+ domelementtype: 2.3.0
+
+ domutils@3.1.0:
+ dependencies:
+ dom-serializer: 2.0.0
+ domelementtype: 2.3.0
+ domhandler: 5.0.3
+
duplexer@0.1.2: {}
eastasianwidth@0.2.0: {}
@@ -7842,6 +7895,13 @@ snapshots:
html-escaper@2.0.2: {}
+ htmlparser2@8.0.2:
+ dependencies:
+ domelementtype: 2.3.0
+ domhandler: 5.0.3
+ domutils: 3.1.0
+ entities: 4.5.0
+
http-proxy-agent@5.0.0:
dependencies:
'@tootallnate/once': 2.0.0
@@ -8744,6 +8804,8 @@ snapshots:
json-parse-even-better-errors: 2.3.1
lines-and-columns: 1.2.4
+ parse-srcset@1.0.2: {}
+
parse5@7.1.2:
dependencies:
entities: 4.5.0
@@ -9091,6 +9153,15 @@ snapshots:
safer-buffer@2.1.2: {}
+ sanitize-html@2.13.0:
+ dependencies:
+ deepmerge: 4.3.1
+ escape-string-regexp: 4.0.0
+ htmlparser2: 8.0.2
+ is-plain-object: 5.0.0
+ parse-srcset: 1.0.2
+ postcss: 8.4.47
+
sax@1.2.4: {}
saxes@6.0.0:
diff --git a/src/photo/PhotoGridSidebar.tsx b/src/photo/PhotoGridSidebar.tsx
index ed795e15..b45dbfac 100644
--- a/src/photo/PhotoGridSidebar.tsx
+++ b/src/photo/PhotoGridSidebar.tsx
@@ -16,6 +16,8 @@ import { useAppState } from '@/state/AppState';
import { useMemo } from 'react';
import HiddenTag from '@/tag/HiddenTag';
import { SITE_ABOUT } from '@/site/config';
+import { htmlHasBrParagraphBreaks, safelyParseFormattedHtml } from '@/utility/html';
+import { clsx } from 'clsx/lite';
export default function PhotoGridSidebar({
tags,
@@ -43,10 +45,14 @@ export default function PhotoGridSidebar({
{SITE_ABOUT &&