diff --git a/package-lock.json b/package-lock.json index 65b39cd..b612429 100644 --- a/package-lock.json +++ b/package-lock.json @@ -22,6 +22,7 @@ "electron-updater": "^6.6.2", "fastify": "^5.8.2", "get-port": "^7.1.0", + "marked": "^17.0.4", "minimatch": "^10.2.4", "minimist": "^1.2.8", "mysql2": "^3.15.3", @@ -48,6 +49,7 @@ "drizzle-kit": "^0.28.1", "electron": "^41.0.2", "electron-builder": "^26.0.12", + "jsdom": "^29.0.1", "mermaid": "^11.12.3", "react": "^18.3.1", "react-dom": "^18.3.1", @@ -349,6 +351,67 @@ "node": ">=18" } }, + "node_modules/@asamuzakjp/css-color": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/@asamuzakjp/css-color/-/css-color-5.0.1.tgz", + "integrity": "sha512-2SZFvqMyvboVV1d15lMf7XiI3m7SDqXUuKaTymJYLN6dSGadqp+fVojqJlVoMlbZnlTmu3S0TLwLTJpvBMO1Aw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@csstools/css-calc": "^3.1.1", + "@csstools/css-color-parser": "^4.0.2", + "@csstools/css-parser-algorithms": "^4.0.0", + "@csstools/css-tokenizer": "^4.0.0", + "lru-cache": "^11.2.6" + }, + "engines": { + "node": "^20.19.0 || ^22.12.0 || >=24.0.0" + } + }, + "node_modules/@asamuzakjp/css-color/node_modules/lru-cache": { + "version": "11.2.7", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.2.7.tgz", + "integrity": "sha512-aY/R+aEsRelme17KGQa/1ZSIpLpNYYrhcrepKTZgE+W3WM16YMCaPwOHLHsmopZHELU0Ojin1lPVxKR0MihncA==", + "dev": true, + "license": "BlueOak-1.0.0", + "engines": { + "node": "20 || >=22" + } + }, + "node_modules/@asamuzakjp/dom-selector": { + "version": "7.0.3", + "resolved": "https://registry.npmjs.org/@asamuzakjp/dom-selector/-/dom-selector-7.0.3.tgz", + "integrity": "sha512-Q6mU0Z6bfj6YvnX2k9n0JxiIwrCFN59x/nWmYQnAqP000ruX/yV+5bp/GRcF5T8ncvfwJQ7fgfP74DlpKExILA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@asamuzakjp/nwsapi": "^2.3.9", + "bidi-js": "^1.0.3", + "css-tree": "^3.2.1", + "is-potential-custom-element-name": "^1.0.1", + "lru-cache": "^11.2.7" + }, + "engines": { + "node": "^20.19.0 || ^22.12.0 || >=24.0.0" + } + }, + "node_modules/@asamuzakjp/dom-selector/node_modules/lru-cache": { + "version": "11.2.7", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.2.7.tgz", + "integrity": "sha512-aY/R+aEsRelme17KGQa/1ZSIpLpNYYrhcrepKTZgE+W3WM16YMCaPwOHLHsmopZHELU0Ojin1lPVxKR0MihncA==", + "dev": true, + "license": "BlueOak-1.0.0", + "engines": { + "node": "20 || >=22" + } + }, + "node_modules/@asamuzakjp/nwsapi": { + "version": "2.3.9", + "resolved": "https://registry.npmjs.org/@asamuzakjp/nwsapi/-/nwsapi-2.3.9.tgz", + "integrity": "sha512-n8GuYSrI9bF7FFZ/SjhwevlHc8xaVlb/7HmHelnc/PZXBD2ZR49NnN9sMMuDdEGPeeRQ5d0hqlSlEpgCX3Wl0Q==", + "dev": true, + "license": "MIT" + }, "node_modules/@babel/code-frame": { "version": "7.29.0", "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.29.0.tgz", @@ -638,6 +701,19 @@ "dev": true, "license": "MIT" }, + "node_modules/@bramus/specificity": { + "version": "2.4.2", + "resolved": "https://registry.npmjs.org/@bramus/specificity/-/specificity-2.4.2.tgz", + "integrity": "sha512-ctxtJ/eA+t+6q2++vj5j7FYX3nRu311q1wfYH3xjlLOsczhlhxAg2FWNUXhpGvAw3BWo1xBcvOV6/YLc2r5FJw==", + "dev": true, + "license": "MIT", + "dependencies": { + "css-tree": "^3.0.0" + }, + "bin": { + "specificity": "bin/cli.js" + } + }, "node_modules/@chevrotain/cst-dts-gen": { "version": "11.1.2", "resolved": "https://registry.npmjs.org/@chevrotain/cst-dts-gen/-/cst-dts-gen-11.1.2.tgz", @@ -682,6 +758,146 @@ "dev": true, "license": "Apache-2.0" }, + "node_modules/@csstools/color-helpers": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/@csstools/color-helpers/-/color-helpers-6.0.2.tgz", + "integrity": "sha512-LMGQLS9EuADloEFkcTBR3BwV/CGHV7zyDxVRtVDTwdI2Ca4it0CCVTT9wCkxSgokjE5Ho41hEPgb8OEUwoXr6Q==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT-0", + "engines": { + "node": ">=20.19.0" + } + }, + "node_modules/@csstools/css-calc": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/@csstools/css-calc/-/css-calc-3.1.1.tgz", + "integrity": "sha512-HJ26Z/vmsZQqs/o3a6bgKslXGFAungXGbinULZO3eMsOyNJHeBBZfup5FiZInOghgoM4Hwnmw+OgbJCNg1wwUQ==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT", + "engines": { + "node": ">=20.19.0" + }, + "peerDependencies": { + "@csstools/css-parser-algorithms": "^4.0.0", + "@csstools/css-tokenizer": "^4.0.0" + } + }, + "node_modules/@csstools/css-color-parser": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/@csstools/css-color-parser/-/css-color-parser-4.0.2.tgz", + "integrity": "sha512-0GEfbBLmTFf0dJlpsNU7zwxRIH0/BGEMuXLTCvFYxuL1tNhqzTbtnFICyJLTNK4a+RechKP75e7w42ClXSnJQw==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT", + "dependencies": { + "@csstools/color-helpers": "^6.0.2", + "@csstools/css-calc": "^3.1.1" + }, + "engines": { + "node": ">=20.19.0" + }, + "peerDependencies": { + "@csstools/css-parser-algorithms": "^4.0.0", + "@csstools/css-tokenizer": "^4.0.0" + } + }, + "node_modules/@csstools/css-parser-algorithms": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/@csstools/css-parser-algorithms/-/css-parser-algorithms-4.0.0.tgz", + "integrity": "sha512-+B87qS7fIG3L5h3qwJ/IFbjoVoOe/bpOdh9hAjXbvx0o8ImEmUsGXN0inFOnk2ChCFgqkkGFQ+TpM5rbhkKe4w==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT", + "engines": { + "node": ">=20.19.0" + }, + "peerDependencies": { + "@csstools/css-tokenizer": "^4.0.0" + } + }, + "node_modules/@csstools/css-syntax-patches-for-csstree": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@csstools/css-syntax-patches-for-csstree/-/css-syntax-patches-for-csstree-1.1.1.tgz", + "integrity": "sha512-BvqN0AMWNAnLk9G8jnUT77D+mUbY/H2b3uDTvg2isJkHaOufUE2R3AOwxWo7VBQKT1lOdwdvorddo2B/lk64+w==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT-0", + "peerDependencies": { + "css-tree": "^3.2.1" + }, + "peerDependenciesMeta": { + "css-tree": { + "optional": true + } + } + }, + "node_modules/@csstools/css-tokenizer": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/@csstools/css-tokenizer/-/css-tokenizer-4.0.0.tgz", + "integrity": "sha512-QxULHAm7cNu72w97JUNCBFODFaXpbDg+dP8b/oWFAZ2MTRppA3U00Y2L1HqaS4J6yBqxwa/Y3nMBaxVKbB/NsA==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT", + "engines": { + "node": ">=20.19.0" + } + }, "node_modules/@develar/schema-utils": { "version": "2.6.5", "resolved": "https://registry.npmjs.org/@develar/schema-utils/-/schema-utils-2.6.5.tgz", @@ -2188,6 +2404,24 @@ "node": ">=12" } }, + "node_modules/@exodus/bytes": { + "version": "1.15.0", + "resolved": "https://registry.npmjs.org/@exodus/bytes/-/bytes-1.15.0.tgz", + "integrity": "sha512-UY0nlA+feH81UGSHv92sLEPLCeZFjXOuHhrIo0HQydScuQc8s0A7kL/UdgwgDq8g8ilksmuoF35YVTNphV2aBQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^20.19.0 || ^22.12.0 || >=24.0.0" + }, + "peerDependencies": { + "@noble/hashes": "^1.8.0 || ^2.0.0" + }, + "peerDependenciesMeta": { + "@noble/hashes": { + "optional": true + } + } + }, "node_modules/@fastify/accept-negotiator": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/@fastify/accept-negotiator/-/accept-negotiator-2.0.1.tgz", @@ -6134,6 +6368,16 @@ "node": "20.x || 22.x || 23.x || 24.x || 25.x" } }, + "node_modules/bidi-js": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/bidi-js/-/bidi-js-1.0.3.tgz", + "integrity": "sha512-RKshQI1R3YQ+n9YJz2QQ147P66ELpa1FQEg20Dk8oW9t2KgLbpDLLp9aGZ7y8WHSshDknG0bknqGw5/tyCs5tw==", + "dev": true, + "license": "MIT", + "dependencies": { + "require-from-string": "^2.0.2" + } + }, "node_modules/bindings": { "version": "1.5.0", "resolved": "https://registry.npmjs.org/bindings/-/bindings-1.5.0.tgz", @@ -7000,6 +7244,20 @@ "node": ">= 8" } }, + "node_modules/css-tree": { + "version": "3.2.1", + "resolved": "https://registry.npmjs.org/css-tree/-/css-tree-3.2.1.tgz", + "integrity": "sha512-X7sjQzceUhu1u7Y/ylrRZFU2FS6LRiFVp6rKLPg23y3x3c3DOKAwuXGDp+PAGjh6CSnCjYeAul8pcT8bAl+lSA==", + "dev": true, + "license": "MIT", + "dependencies": { + "mdn-data": "2.27.1", + "source-map-js": "^1.2.1" + }, + "engines": { + "node": "^10 || ^12.20.0 || ^14.13.0 || >=15.0.0" + } + }, "node_modules/csstype": { "version": "3.2.3", "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz", @@ -7635,6 +7893,20 @@ "lodash-es": "^4.17.21" } }, + "node_modules/data-urls": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/data-urls/-/data-urls-7.0.0.tgz", + "integrity": "sha512-23XHcCF+coGYevirZceTVD7NdJOqVn+49IHyxgszm+JIiHLoB2TkmPtsYkNWT1pvRSGkc35L6NHs0yHkN2SumA==", + "dev": true, + "license": "MIT", + "dependencies": { + "whatwg-mimetype": "^5.0.0", + "whatwg-url": "^16.0.0" + }, + "engines": { + "node": "^20.19.0 || ^22.12.0 || >=24.0.0" + } + }, "node_modules/dayjs": { "version": "1.11.19", "resolved": "https://registry.npmjs.org/dayjs/-/dayjs-1.11.19.tgz", @@ -7659,6 +7931,13 @@ } } }, + "node_modules/decimal.js": { + "version": "10.6.0", + "resolved": "https://registry.npmjs.org/decimal.js/-/decimal.js-10.6.0.tgz", + "integrity": "sha512-YpgQiITW3JXGntzdUmyUR1V812Hn8T1YVXhCu+wO3OpS4eU9l4YdD3qjyiKdV6mvV29zapkMeD390UVEf2lkUg==", + "dev": true, + "license": "MIT" + }, "node_modules/decompress-response": { "version": "6.0.0", "resolved": "https://registry.npmjs.org/decompress-response/-/decompress-response-6.0.0.tgz", @@ -9664,6 +9943,19 @@ "dev": true, "license": "ISC" }, + "node_modules/html-encoding-sniffer": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/html-encoding-sniffer/-/html-encoding-sniffer-6.0.0.tgz", + "integrity": "sha512-CV9TW3Y3f8/wT0BRFc1/KAVQ3TUHiXmaAb6VW9vtiMFf7SLoMd1PdAc4W3KFOFETBJUb90KatHqlsZMWV+R9Gg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@exodus/bytes": "^1.6.0" + }, + "engines": { + "node": "^20.19.0 || ^22.12.0 || >=24.0.0" + } + }, "node_modules/html-void-elements": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/html-void-elements/-/html-void-elements-3.0.0.tgz", @@ -9876,6 +10168,13 @@ "node": ">=8" } }, + "node_modules/is-potential-custom-element-name": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/is-potential-custom-element-name/-/is-potential-custom-element-name-1.0.1.tgz", + "integrity": "sha512-bCYeRA2rVibKZd+s2625gGnGF/t7DSqDs4dP7CrLA1m7jKWz6pps0LpYLJN8Q64HtmPKJ1hrN3nzPNKFEKOUiQ==", + "dev": true, + "license": "MIT" + }, "node_modules/is-property": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/is-property/-/is-property-1.0.2.tgz", @@ -10021,6 +10320,67 @@ "js-yaml": "bin/js-yaml.js" } }, + "node_modules/jsdom": { + "version": "29.0.1", + "resolved": "https://registry.npmjs.org/jsdom/-/jsdom-29.0.1.tgz", + "integrity": "sha512-z6JOK5gRO7aMybVq/y/MlIpKh8JIi68FBKMUtKkK2KH/wMSRlCxQ682d08LB9fYXplyY/UXG8P4XXTScmdjApg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@asamuzakjp/css-color": "^5.0.1", + "@asamuzakjp/dom-selector": "^7.0.3", + "@bramus/specificity": "^2.4.2", + "@csstools/css-syntax-patches-for-csstree": "^1.1.1", + "@exodus/bytes": "^1.15.0", + "css-tree": "^3.2.1", + "data-urls": "^7.0.0", + "decimal.js": "^10.6.0", + "html-encoding-sniffer": "^6.0.0", + "is-potential-custom-element-name": "^1.0.1", + "lru-cache": "^11.2.7", + "parse5": "^8.0.0", + "saxes": "^6.0.0", + "symbol-tree": "^3.2.4", + "tough-cookie": "^6.0.1", + "undici": "^7.24.5", + "w3c-xmlserializer": "^5.0.0", + "webidl-conversions": "^8.0.1", + "whatwg-mimetype": "^5.0.0", + "whatwg-url": "^16.0.1", + "xml-name-validator": "^5.0.0" + }, + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24.0.0" + }, + "peerDependencies": { + "canvas": "^3.0.0" + }, + "peerDependenciesMeta": { + "canvas": { + "optional": true + } + } + }, + "node_modules/jsdom/node_modules/lru-cache": { + "version": "11.2.7", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.2.7.tgz", + "integrity": "sha512-aY/R+aEsRelme17KGQa/1ZSIpLpNYYrhcrepKTZgE+W3WM16YMCaPwOHLHsmopZHELU0Ojin1lPVxKR0MihncA==", + "dev": true, + "license": "BlueOak-1.0.0", + "engines": { + "node": "20 || >=22" + } + }, + "node_modules/jsdom/node_modules/undici": { + "version": "7.24.5", + "resolved": "https://registry.npmjs.org/undici/-/undici-7.24.5.tgz", + "integrity": "sha512-3IWdCpjgxp15CbJnsi/Y9TCDE7HWVN19j1hmzVhoAkY/+CJx449tVxT5wZc1Gwg8J+P0LWvzlBzxYRnHJ+1i7Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=20.18.1" + } + }, "node_modules/jsesc": { "version": "3.1.0", "resolved": "https://registry.npmjs.org/jsesc/-/jsesc-3.1.0.tgz", @@ -10620,10 +10980,9 @@ "license": "MIT" }, "node_modules/marked": { - "version": "16.4.2", - "resolved": "https://registry.npmjs.org/marked/-/marked-16.4.2.tgz", - "integrity": "sha512-TI3V8YYWvkVf3KJe1dRkpnjs68JUPyEa5vjKrp1XEEJUAOaQc+Qj+L1qWbPd0SJuAdQkFU0h73sXXqwDYxsiDA==", - "dev": true, + "version": "17.0.4", + "resolved": "https://registry.npmjs.org/marked/-/marked-17.0.4.tgz", + "integrity": "sha512-NOmVMM+KAokHMvjWmC5N/ZOvgmSWuqJB8FoYI019j4ogb/PeRMKoKIjReZ2w3376kkA8dSJIP8uD993Kxc0iRQ==", "license": "MIT", "bin": { "marked": "bin/marked.js" @@ -10678,6 +11037,13 @@ "url": "https://opencollective.com/unified" } }, + "node_modules/mdn-data": { + "version": "2.27.1", + "resolved": "https://registry.npmjs.org/mdn-data/-/mdn-data-2.27.1.tgz", + "integrity": "sha512-9Yubnt3e8A0OKwxYSXyhLymGW4sCufcLG6VdiDdUGVkPhpqLxlvP5vl1983gQjJl3tqbrM731mjaZaP68AgosQ==", + "dev": true, + "license": "CC0-1.0" + }, "node_modules/mermaid": { "version": "11.12.3", "resolved": "https://registry.npmjs.org/mermaid/-/mermaid-11.12.3.tgz", @@ -10707,6 +11073,19 @@ "uuid": "^11.1.0" } }, + "node_modules/mermaid/node_modules/marked": { + "version": "16.4.2", + "resolved": "https://registry.npmjs.org/marked/-/marked-16.4.2.tgz", + "integrity": "sha512-TI3V8YYWvkVf3KJe1dRkpnjs68JUPyEa5vjKrp1XEEJUAOaQc+Qj+L1qWbPd0SJuAdQkFU0h73sXXqwDYxsiDA==", + "dev": true, + "license": "MIT", + "bin": { + "marked": "bin/marked.js" + }, + "engines": { + "node": ">= 20" + } + }, "node_modules/mermaid/node_modules/uuid": { "version": "11.1.0", "resolved": "https://registry.npmjs.org/uuid/-/uuid-11.1.0.tgz", @@ -11524,6 +11903,32 @@ "integrity": "sha512-JyPSBnkTJ0AI8GGJLfMXvKq42cj5c006fnLz6fXy6zfoVjJizi8BNTpu8on8ziI1cKy9d9DGNuY17Ce7wuejpQ==", "license": "MIT" }, + "node_modules/parse5": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/parse5/-/parse5-8.0.0.tgz", + "integrity": "sha512-9m4m5GSgXjL4AjumKzq1Fgfp3Z8rsvjRNbnkVwfu2ImRqE5D0LnY2QfDen18FSY9C573YU5XxSapdHZTZ2WolA==", + "dev": true, + "license": "MIT", + "dependencies": { + "entities": "^6.0.0" + }, + "funding": { + "url": "https://github.com/inikulin/parse5?sponsor=1" + } + }, + "node_modules/parse5/node_modules/entities": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/entities/-/entities-6.0.1.tgz", + "integrity": "sha512-aN97NXWF6AWBTahfVOIrB/NShkzi5H7F9r1s9mD3cDj4Ko5f2qhhVoYMibXF7GlLveb/D2ioWay8lxI97Ven3g==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=0.12" + }, + "funding": { + "url": "https://github.com/fb55/entities?sponsor=1" + } + }, "node_modules/path-browserify": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/path-browserify/-/path-browserify-1.0.1.tgz", @@ -12675,6 +13080,19 @@ "node": ">=11.0.0" } }, + "node_modules/saxes": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/saxes/-/saxes-6.0.0.tgz", + "integrity": "sha512-xAg7SOnEhrm5zI3puOOKyy1OMcMlIJZYNJY7xLBwSze0UjhPLnWfj2GF2EpT0jmzaJKIWKHLsaSSajf35bcYnA==", + "dev": true, + "license": "ISC", + "dependencies": { + "xmlchars": "^2.2.0" + }, + "engines": { + "node": ">=v12.22.7" + } + }, "node_modules/scheduler": { "version": "0.23.2", "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.23.2.tgz", @@ -13376,6 +13794,13 @@ "url": "https://github.com/chalk/supports-color?sponsor=1" } }, + "node_modules/symbol-tree": { + "version": "3.2.4", + "resolved": "https://registry.npmjs.org/symbol-tree/-/symbol-tree-3.2.4.tgz", + "integrity": "sha512-9QNk5KwDF+Bvz+PyObkmSYjI5ksVUYtjW7AU22r2NKcfLJcXp96hkDWU3+XndOsUb+AQ9QhfzfCT2O+CNWT5Tw==", + "dev": true, + "license": "MIT" + }, "node_modules/tabbable": { "version": "6.4.0", "resolved": "https://registry.npmjs.org/tabbable/-/tabbable-6.4.0.tgz", @@ -13639,6 +14064,26 @@ "node": ">=14.0.0" } }, + "node_modules/tldts": { + "version": "7.0.26", + "resolved": "https://registry.npmjs.org/tldts/-/tldts-7.0.26.tgz", + "integrity": "sha512-WiGwQjr0qYdNNG8KpMKlSvpxz652lqa3Rd+/hSaDcY4Uo6SKWZq2LAF+hsAhUewTtYhXlorBKgNF3Kk8hnjGoQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "tldts-core": "^7.0.26" + }, + "bin": { + "tldts": "bin/cli.js" + } + }, + "node_modules/tldts-core": { + "version": "7.0.26", + "resolved": "https://registry.npmjs.org/tldts-core/-/tldts-core-7.0.26.tgz", + "integrity": "sha512-5WJ2SqFsv4G2Dwi7ZFVRnz6b2H1od39QME1lc2y5Ew3eWiZMAeqOAfWpRP9jHvhUl881406QtZTODvjttJs+ew==", + "dev": true, + "license": "MIT" + }, "node_modules/tmp": { "version": "0.2.5", "resolved": "https://registry.npmjs.org/tmp/-/tmp-0.2.5.tgz", @@ -13703,6 +14148,32 @@ "geo2topo": "bin/geo2topo" } }, + "node_modules/tough-cookie": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/tough-cookie/-/tough-cookie-6.0.1.tgz", + "integrity": "sha512-LktZQb3IeoUWB9lqR5EWTHgW/VTITCXg4D21M+lvybRVdylLrRMnqaIONLVb5mav8vM19m44HIcGq4qASeu2Qw==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "tldts": "^7.0.5" + }, + "engines": { + "node": ">=16" + } + }, + "node_modules/tr46": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/tr46/-/tr46-6.0.0.tgz", + "integrity": "sha512-bLVMLPtstlZ4iMQHpFHTR7GAGj2jxi8Dg0s2h2MafAE4uSWF98FC/3MomU51iQAMf8/qDUbKWf5GxuvvVcXEhw==", + "dev": true, + "license": "MIT", + "dependencies": { + "punycode": "^2.3.1" + }, + "engines": { + "node": ">=20" + } + }, "node_modules/tree-kill": { "version": "1.2.2", "resolved": "https://registry.npmjs.org/tree-kill/-/tree-kill-1.2.2.tgz", @@ -16766,6 +17237,19 @@ } } }, + "node_modules/w3c-xmlserializer": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/w3c-xmlserializer/-/w3c-xmlserializer-5.0.0.tgz", + "integrity": "sha512-o8qghlI8NZHU1lLPrpi2+Uq7abh4GGPpYANlalzWxyWteJOCsr/P+oPBA49TOLu5FTZO4d3F9MnWJfiMo4BkmA==", + "dev": true, + "license": "MIT", + "dependencies": { + "xml-name-validator": "^5.0.0" + }, + "engines": { + "node": ">=18" + } + }, "node_modules/wait-on": { "version": "9.0.4", "resolved": "https://registry.npmjs.org/wait-on/-/wait-on-9.0.4.tgz", @@ -16796,6 +17280,41 @@ "defaults": "^1.0.3" } }, + "node_modules/webidl-conversions": { + "version": "8.0.1", + "resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-8.0.1.tgz", + "integrity": "sha512-BMhLD/Sw+GbJC21C/UgyaZX41nPt8bUTg+jWyDeg7e7YN4xOM05YPSIXceACnXVtqyEw/LMClUQMtMZ+PGGpqQ==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=20" + } + }, + "node_modules/whatwg-mimetype": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/whatwg-mimetype/-/whatwg-mimetype-5.0.0.tgz", + "integrity": "sha512-sXcNcHOC51uPGF0P/D4NVtrkjSU2fNsm9iog4ZvZJsL3rjoDAzXZhkm2MWt1y+PUdggKAYVoMAIYcs78wJ51Cw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=20" + } + }, + "node_modules/whatwg-url": { + "version": "16.0.1", + "resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-16.0.1.tgz", + "integrity": "sha512-1to4zXBxmXHV3IiSSEInrreIlu02vUOvrhxJJH5vcxYTBDAx51cqZiKdyTxlecdKNSjj8EcxGBxNf6Vg+945gw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@exodus/bytes": "^1.11.0", + "tr46": "^6.0.0", + "webidl-conversions": "^8.0.1" + }, + "engines": { + "node": "^20.19.0 || ^22.12.0 || >=24.0.0" + } + }, "node_modules/which": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", @@ -16960,6 +17479,16 @@ } } }, + "node_modules/xml-name-validator": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/xml-name-validator/-/xml-name-validator-5.0.0.tgz", + "integrity": "sha512-EvGK8EJ3DhaHfbRlETOWAS5pO9MZITeauHKJyb8wyajUfQUenkIg2MvLDTZ4T/TgIcm3HU0TFBgWWboAZ30UHg==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=18" + } + }, "node_modules/xmlbuilder": { "version": "15.1.1", "resolved": "https://registry.npmjs.org/xmlbuilder/-/xmlbuilder-15.1.1.tgz", @@ -16970,6 +17499,13 @@ "node": ">=8.0" } }, + "node_modules/xmlchars": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/xmlchars/-/xmlchars-2.2.0.tgz", + "integrity": "sha512-JZnDKK8B0RCDw84FNdDAIpZK+JuJw+s7Lz8nksI7SIuU3UXJJslUthsi+uWBUYOwPFwW7W7PRLRfUKpxjtjFCw==", + "dev": true, + "license": "MIT" + }, "node_modules/xtend": { "version": "4.0.2", "resolved": "https://registry.npmjs.org/xtend/-/xtend-4.0.2.tgz", diff --git a/package.json b/package.json index 49a73e6..68d3539 100644 --- a/package.json +++ b/package.json @@ -72,6 +72,7 @@ "electron-updater": "^6.6.2", "fastify": "^5.8.2", "get-port": "^7.1.0", + "marked": "^17.0.4", "minimatch": "^10.2.4", "minimist": "^1.2.8", "mysql2": "^3.15.3", @@ -98,6 +99,7 @@ "drizzle-kit": "^0.28.1", "electron": "^41.0.2", "electron-builder": "^26.0.12", + "jsdom": "^29.0.1", "mermaid": "^11.12.3", "react": "^18.3.1", "react-dom": "^18.3.1", diff --git a/src/web/index.css b/src/web/index.css index d30401a..37a5dc5 100644 --- a/src/web/index.css +++ b/src/web/index.css @@ -1629,6 +1629,107 @@ body { animation: row-focus-glow 2.2s ease; } +.announcement-rich-content { + font-size: 13px; + color: var(--color-text-secondary); + line-height: 1.7; + word-break: break-word; +} + +.announcement-rich-content > :first-child { + margin-top: 0; +} + +.announcement-rich-content > :last-child { + margin-bottom: 0; +} + +.announcement-rich-content p, +.announcement-rich-content ul, +.announcement-rich-content ol, +.announcement-rich-content blockquote, +.announcement-rich-content pre, +.announcement-rich-content table, +.announcement-rich-content h1, +.announcement-rich-content h2, +.announcement-rich-content h3, +.announcement-rich-content h4, +.announcement-rich-content h5, +.announcement-rich-content h6 { + margin: 0 0 10px 0; +} + +.announcement-rich-content h1, +.announcement-rich-content h2, +.announcement-rich-content h3, +.announcement-rich-content h4, +.announcement-rich-content h5, +.announcement-rich-content h6 { + color: var(--color-text-primary); + line-height: 1.45; +} + +.announcement-rich-content ul, +.announcement-rich-content ol { + padding-left: 20px; +} + +.announcement-rich-content blockquote { + padding: 10px 12px; + border-left: 3px solid var(--color-primary); + background: var(--color-surface-subtle); + border-radius: 8px; +} + +.announcement-rich-content code { + font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace; + font-size: 12px; + padding: 1px 4px; + border-radius: 6px; + background: var(--color-surface-subtle); + color: var(--color-text-primary); +} + +.announcement-rich-content pre { + overflow-x: auto; + padding: 12px 14px; + border-radius: 10px; + background: var(--color-surface-subtle); + border: 1px solid var(--color-border); +} + +.announcement-rich-content pre code { + display: block; + padding: 0; + background: transparent; +} + +.announcement-rich-content a { + color: var(--color-primary); + text-decoration: underline; + text-underline-offset: 2px; +} + +.announcement-rich-content img { + max-width: 100%; + height: auto; + border-radius: 10px; + border: 1px solid var(--color-border); +} + +.announcement-rich-content table { + width: 100%; + border-collapse: collapse; +} + +.announcement-rich-content th, +.announcement-rich-content td { + border: 1px solid var(--color-border); + padding: 8px 10px; + text-align: left; + vertical-align: top; +} + @keyframes rebind-panel-glow { 0% { box-shadow: 0 0 0 2px color-mix(in srgb, var(--color-primary) 42%, transparent); diff --git a/src/web/pages/SiteAnnouncements.tsx b/src/web/pages/SiteAnnouncements.tsx index 6b2066d..5c08b6e 100644 --- a/src/web/pages/SiteAnnouncements.tsx +++ b/src/web/pages/SiteAnnouncements.tsx @@ -2,8 +2,12 @@ import { useEffect, useMemo, useRef, useState } from 'react'; import { useLocation, useNavigate } from 'react-router-dom'; import { api } from '../api.js'; import { useToast } from '../components/Toast.js'; -import { formatDateTimeLocal } from './helpers/checkinLogTime.js'; import { clearFocusParams, readFocusAnnouncementId } from './helpers/navigationFocus.js'; +import { + formatSiteAnnouncementSeenAt, + readClientTimeZone, + SiteAnnouncementContent, +} from './helpers/siteAnnouncementPresentation.js'; import { tr } from '../i18n.js'; type SiteAnnouncementRow = { @@ -38,6 +42,7 @@ export default function SiteAnnouncements() { const [highlightAnnouncementId, setHighlightAnnouncementId] = useState(null); const rowRefs = useRef>(new Map()); const highlightTimerRef = useRef(null); + const viewerTimeZone = useMemo(() => readClientTimeZone(), []); const siteNameById = useMemo(() => { const map = new Map(); @@ -211,9 +216,12 @@ export default function SiteAnnouncements() { {row.readAt ? '已读' : '未读'} -
{row.content}
-
- 首次发现:{formatDateTimeLocal(row.firstSeenAt || row.lastSeenAt || '')} + +
+ 首次发现:{formatSiteAnnouncementSeenAt(row.firstSeenAt || row.lastSeenAt || '', viewerTimeZone)}
)) diff --git a/src/web/pages/helpers/siteAnnouncementPresentation.test.tsx b/src/web/pages/helpers/siteAnnouncementPresentation.test.tsx new file mode 100644 index 0000000..38c4486 --- /dev/null +++ b/src/web/pages/helpers/siteAnnouncementPresentation.test.tsx @@ -0,0 +1,91 @@ +import { afterEach, beforeEach, describe, expect, it } from 'vitest'; +import { JSDOM } from 'jsdom'; +import { renderToStaticMarkup } from 'react-dom/server'; +import { + SiteAnnouncementContent, + formatSiteAnnouncementSeenAt, + readClientTimeZone, +} from './siteAnnouncementPresentation.js'; + +type BrowserGlobals = { + window?: Window & typeof globalThis; + document?: Document; + DOMParser?: typeof DOMParser; + Node?: typeof Node; +}; + +const browserGlobals = globalThis as typeof globalThis & BrowserGlobals; +const originalWindow = browserGlobals.window; +const originalDocument = browserGlobals.document; +const originalDOMParser = browserGlobals.DOMParser; +const originalNode = browserGlobals.Node; + +describe('siteAnnouncementPresentation helpers', () => { + beforeEach(() => { + const dom = new JSDOM(''); + browserGlobals.window = dom.window as unknown as Window & typeof globalThis; + browserGlobals.document = dom.window.document; + browserGlobals.DOMParser = dom.window.DOMParser; + browserGlobals.Node = dom.window.Node; + }); + + afterEach(() => { + browserGlobals.window = originalWindow; + browserGlobals.document = originalDocument; + browserGlobals.DOMParser = originalDOMParser; + browserGlobals.Node = originalNode; + }); + + it('renders sanitized html notices with safe links', () => { + const markup = renderToStaticMarkup( + Notice', + '

Welcome back.

', + '', + 'bad', + 'docs', + ].join('')} + />, + ); + + expect(markup).toContain('

Notice

'); + expect(markup).toContain('back'); + expect(markup).not.toContain(' { + const markup = renderToStaticMarkup( + , + ); + + expect(markup).toContain('

README.md

'); + expect(markup).toContain('href="https://example.com/setup"'); + expect(markup).toContain('
');
+    expect(markup).toContain('"model": "gpt-5.4"');
+  });
+
+  it('formats first-seen time in the requested local timezone', () => {
+    expect(formatSiteAnnouncementSeenAt('2026-03-20 04:23:27', 'Asia/Shanghai')).toBe('2026/03/20 12:23:27');
+  });
+
+  it('reads the browser timezone when available', () => {
+    expect(readClientTimeZone()).toBeTruthy();
+  });
+});
diff --git a/src/web/pages/helpers/siteAnnouncementPresentation.tsx b/src/web/pages/helpers/siteAnnouncementPresentation.tsx
new file mode 100644
index 0000000..f82ef8b
--- /dev/null
+++ b/src/web/pages/helpers/siteAnnouncementPresentation.tsx
@@ -0,0 +1,373 @@
+import { marked } from 'marked';
+import { formatDateTimeLocal } from './checkinLogTime.js';
+
+const VOID_TAGS = new Set(['br', 'hr', 'img']);
+const DROPPED_TAGS = new Set(['script', 'style', 'iframe', 'object', 'embed', 'link', 'meta']);
+const ALLOWED_TAGS = new Set([
+  'a',
+  'b',
+  'blockquote',
+  'br',
+  'center',
+  'code',
+  'del',
+  'div',
+  'em',
+  'font',
+  'h1',
+  'h2',
+  'h3',
+  'h4',
+  'h5',
+  'h6',
+  'hr',
+  'img',
+  'li',
+  'ol',
+  'p',
+  'pre',
+  'span',
+  'strong',
+  'table',
+  'tbody',
+  'td',
+  'th',
+  'thead',
+  'tr',
+  'ul',
+]);
+
+function escapeHtml(value: string): string {
+  return value
+    .replace(/&/g, '&')
+    .replace(//g, '>')
+    .replace(/"/g, '"')
+    .replace(/'/g, ''');
+}
+
+function normalizeLineEndings(value: string): string {
+  return String(value || '').replace(/\r\n?/g, '\n');
+}
+
+function isLikelyHtml(content: string): boolean {
+  return /<([a-z][a-z0-9-]*)(?:\s[^>]*)?>/i.test(content) || /<\/([a-z][a-z0-9-]*)>/i.test(content);
+}
+
+function isLikelyMarkdown(content: string): boolean {
+  return /(^|\n)(#{1,6}\s+|>\s+|[-*+]\s+|\d+\.\s+|```|~~~)/.test(content)
+    || /!\[[^\]]*]\([^)]+\)|\[[^\]]+\]\([^)]+\)|`[^`]+`|\*\*[^*]+\*\*|~~[^~]+~~/.test(content);
+}
+
+function sanitizeUrl(raw: string | null | undefined, allowDataImage = false): string | null {
+  const value = String(raw || '').trim();
+  if (!value) return null;
+  if (value.startsWith('#') || value.startsWith('/')) return value;
+  if (allowDataImage && /^data:image\/[a-z0-9.+-]+;base64,/i.test(value)) return value;
+  if (/^https?:\/\//i.test(value)) return value;
+  if (/^mailto:/i.test(value)) return value;
+  if (/^tel:/i.test(value)) return value;
+  return null;
+}
+
+function extractVisibleTextFallback(html: string): string {
+  return normalizeLineEndings(html)
+    .replace(//gi, '')
+    .replace(//gi, '')
+    .replace(/<[^>]+>/g, ' ')
+    .replace(/[ \t]+\n/g, '\n')
+    .replace(/\n{3,}/g, '\n\n')
+    .trim();
+}
+
+function renderInlineMarkdown(text: string): string {
+  const tokens: string[] = [];
+  const pushToken = (html: string) => {
+    const key = `\u0000${tokens.length}\u0000`;
+    tokens.push(html);
+    return key;
+  };
+
+  let html = escapeHtml(text);
+
+  html = html.replace(/`([^`]+)`/g, (_, code) => pushToken(`${escapeHtml(code)}`));
+  html = html.replace(/!\[([^\]]*)\]\(([^)\s]+)(?:\s+"([^"]+)")?\)/g, (_, alt, src, title) => {
+    const safeSrc = sanitizeUrl(src, true);
+    if (!safeSrc) return escapeHtml(String(alt || ''));
+    const titleAttr = title ? ` title="${escapeHtml(String(title))}"` : '';
+    return pushToken(`${escapeHtml(String(alt || ''))}`);
+  });
+  html = html.replace(/\[([^\]]+)\]\(([^)\s]+)(?:\s+"([^"]+)")?\)/g, (_, label, href, title) => {
+    const safeHref = sanitizeUrl(href, false);
+    if (!safeHref) return escapeHtml(String(label || ''));
+    const titleAttr = title ? ` title="${escapeHtml(String(title))}"` : '';
+    return pushToken(`${escapeHtml(String(label || ''))}`);
+  });
+  html = html.replace(/\*\*([^*]+)\*\*/g, '$1');
+  html = html.replace(/~~([^~]+)~~/g, '$1');
+  html = html.replace(/(^|[^\*])\*([^*\n]+)\*(?=[^\*]|$)/g, '$1$2');
+
+  return html.replace(/\u0000(\d+)\u0000/g, (_, index) => tokens[Number(index)] || '');
+}
+
+function isMarkdownListLine(line: string, ordered: boolean): boolean {
+  return ordered ? /^\s*\d+\.\s+/.test(line) : /^\s*[-*+]\s+/.test(line);
+}
+
+function consumeMarkdownList(lines: string[], startIndex: number, ordered: boolean) {
+  const items: string[] = [];
+  let index = startIndex;
+  while (index < lines.length && isMarkdownListLine(lines[index] || '', ordered)) {
+    const line = lines[index] || '';
+    const item = ordered
+      ? line.replace(/^\s*\d+\.\s+/, '')
+      : line.replace(/^\s*[-*+]\s+/, '');
+    items.push(`
  • ${renderInlineMarkdown(item.trim())}
  • `); + index += 1; + } + const tag = ordered ? 'ol' : 'ul'; + return { + html: `<${tag}>${items.join('')}`, + nextIndex: index, + }; +} + +function consumeMarkdownQuote(lines: string[], startIndex: number) { + const parts: string[] = []; + let index = startIndex; + while (index < lines.length && /^\s*>\s?/.test(lines[index] || '')) { + parts.push((lines[index] || '').replace(/^\s*>\s?/, '')); + index += 1; + } + return { + html: `
    ${renderMarkdown(parts.join('\n'))}
    `, + nextIndex: index, + }; +} + +function consumeMarkdownFence(lines: string[], startIndex: number) { + const firstLine = String(lines[startIndex] || ''); + const marker = firstLine.startsWith('~~~') ? '~~~' : '```'; + const language = firstLine.slice(marker.length).trim().toLowerCase(); + const codeLines: string[] = []; + let index = startIndex + 1; + while (index < lines.length && !String(lines[index] || '').startsWith(marker)) { + codeLines.push(String(lines[index] || '')); + index += 1; + } + if (index < lines.length) index += 1; + const classAttr = language && /^[a-z0-9_-]+$/i.test(language) ? ` class="language-${escapeHtml(language)}"` : ''; + return { + html: `
    ${escapeHtml(codeLines.join('\n'))}
    `, + nextIndex: index, + }; +} + +function consumeMarkdownParagraph(lines: string[], startIndex: number) { + const parts: string[] = []; + let index = startIndex; + while (index < lines.length) { + const line = String(lines[index] || ''); + const trimmed = line.trim(); + if (!trimmed) break; + if (/^#{1,6}\s+/.test(trimmed)) break; + if (/^\s*>\s?/.test(line)) break; + if (isMarkdownListLine(line, false) || isMarkdownListLine(line, true)) break; + if (trimmed.startsWith('```') || trimmed.startsWith('~~~')) break; + if (/^(-{3,}|\*{3,})$/.test(trimmed)) break; + parts.push(line); + index += 1; + } + + const content = parts.map((line) => renderInlineMarkdown(line.trim())).join('
    '); + return { + html: `

    ${content}

    `, + nextIndex: index, + }; +} + +function renderMarkdown(content: string): string { + const lines = normalizeLineEndings(content).split('\n'); + const blocks: string[] = []; + let index = 0; + + while (index < lines.length) { + const rawLine = String(lines[index] || ''); + const line = rawLine.trim(); + if (!line) { + index += 1; + continue; + } + + if (line.startsWith('```') || line.startsWith('~~~')) { + const fenced = consumeMarkdownFence(lines, index); + blocks.push(fenced.html); + index = fenced.nextIndex; + continue; + } + + const headingMatch = line.match(/^(#{1,6})\s+(.+)$/); + if (headingMatch) { + const level = headingMatch[1].length; + blocks.push(`${renderInlineMarkdown(headingMatch[2])}`); + index += 1; + continue; + } + + if (/^\s*>\s?/.test(rawLine)) { + const quoted = consumeMarkdownQuote(lines, index); + blocks.push(quoted.html); + index = quoted.nextIndex; + continue; + } + + if (isMarkdownListLine(rawLine, false)) { + const list = consumeMarkdownList(lines, index, false); + blocks.push(list.html); + index = list.nextIndex; + continue; + } + + if (isMarkdownListLine(rawLine, true)) { + const list = consumeMarkdownList(lines, index, true); + blocks.push(list.html); + index = list.nextIndex; + continue; + } + + if (/^(-{3,}|\*{3,})$/.test(line)) { + blocks.push('
    '); + index += 1; + continue; + } + + const paragraph = consumeMarkdownParagraph(lines, index); + blocks.push(paragraph.html); + index = paragraph.nextIndex; + } + + return blocks.join(''); +} + +function sanitizeNode(node: Node): string { + if (node.nodeType === Node.TEXT_NODE) { + return escapeHtml(node.textContent || ''); + } + + if (node.nodeType !== Node.ELEMENT_NODE) { + return ''; + } + + const element = node as Element; + const tag = element.tagName.toLowerCase(); + if (DROPPED_TAGS.has(tag)) { + return ''; + } + + const childrenHtml = Array.from(element.childNodes).map((child) => sanitizeNode(child)).join(''); + if (!ALLOWED_TAGS.has(tag)) { + return childrenHtml; + } + + const attrs: string[] = []; + if (tag === 'a') { + const safeHref = sanitizeUrl(element.getAttribute('href')); + if (safeHref) { + attrs.push(` href="${escapeHtml(safeHref)}"`); + attrs.push(' target="_blank"'); + attrs.push(' rel="noopener noreferrer"'); + } + const title = element.getAttribute('title'); + if (title) { + attrs.push(` title="${escapeHtml(title)}"`); + } + } else if (tag === 'img') { + const safeSrc = sanitizeUrl(element.getAttribute('src'), true); + if (!safeSrc) return ''; + attrs.push(` src="${escapeHtml(safeSrc)}"`); + const alt = element.getAttribute('alt'); + if (alt) attrs.push(` alt="${escapeHtml(alt)}"`); + const title = element.getAttribute('title'); + if (title) attrs.push(` title="${escapeHtml(title)}"`); + } else if (tag === 'code') { + const className = String(element.getAttribute('class') || '').trim(); + if (/^language-[a-z0-9_-]+$/i.test(className)) { + attrs.push(` class="${escapeHtml(className)}"`); + } + } else if (tag === 'td' || tag === 'th') { + const colspan = Number.parseInt(String(element.getAttribute('colspan') || ''), 10); + const rowspan = Number.parseInt(String(element.getAttribute('rowspan') || ''), 10); + if (Number.isFinite(colspan) && colspan > 1) attrs.push(` colspan="${colspan}"`); + if (Number.isFinite(rowspan) && rowspan > 1) attrs.push(` rowspan="${rowspan}"`); + } + + if (VOID_TAGS.has(tag)) { + return `<${tag}${attrs.join('')}>`; + } + return `<${tag}${attrs.join('')}>${childrenHtml}`; +} + +function sanitizeAnnouncementHtml(html: string): string { + if (typeof DOMParser !== 'function' || typeof Node === 'undefined') { + return renderPlainText(extractVisibleTextFallback(html)); + } + + const parser = new DOMParser(); + const doc = parser.parseFromString(`${html}`, 'text/html'); + return Array.from(doc.body.childNodes).map((node) => sanitizeNode(node)).join(''); +} + +function renderPlainText(content: string): string { + const paragraphs = normalizeLineEndings(content) + .split(/\n{2,}/) + .map((part) => part.trim()) + .filter(Boolean); + + if (paragraphs.length === 0) { + return `

    ${escapeHtml(String(content || '').trim())}

    `; + } + + return paragraphs + .map((part) => `

    ${escapeHtml(part).replace(/\n/g, '
    ')}

    `) + .join(''); +} + +export function readClientTimeZone(): string | undefined { + try { + return Intl.DateTimeFormat().resolvedOptions().timeZone || undefined; + } catch { + return undefined; + } +} + +export function formatSiteAnnouncementSeenAt( + value: string | null | undefined, + timeZone = readClientTimeZone(), +): string { + return formatDateTimeLocal(value, 'zh-CN', timeZone); +} + +export function renderSiteAnnouncementHtml(content: string): string { + const raw = String(content || '').trim(); + if (!raw) return '

    -

    '; + + if (isLikelyHtml(raw)) { + return sanitizeAnnouncementHtml(raw); + } + + if (isLikelyMarkdown(raw)) { + const rendered = String(marked.parse(raw, { gfm: true, breaks: true })); + return sanitizeAnnouncementHtml(rendered || renderMarkdown(raw)); + } + + return renderPlainText(raw); +} + +export function SiteAnnouncementContent({ content }: { content: string }) { + return ( +
    + ); +}