improve site announcement display
This commit is contained in:
Generated
+540
-4
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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<number | null>(null);
|
||||
const rowRefs = useRef<Map<number, HTMLDivElement>>(new Map());
|
||||
const highlightTimerRef = useRef<number | null>(null);
|
||||
const viewerTimeZone = useMemo(() => readClientTimeZone(), []);
|
||||
|
||||
const siteNameById = useMemo(() => {
|
||||
const map = new Map<number, string>();
|
||||
@@ -211,9 +216,12 @@ export default function SiteAnnouncements() {
|
||||
<span className={`badge ${row.readAt ? 'badge-muted' : 'badge-warning'}`}>{row.readAt ? '已读' : '未读'}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div style={{ fontSize: 13, color: 'var(--color-text-secondary)', lineHeight: 1.6, whiteSpace: 'pre-wrap' }}>{row.content}</div>
|
||||
<div style={{ marginTop: 8, fontSize: 12, color: 'var(--color-text-muted)' }}>
|
||||
首次发现:{formatDateTimeLocal(row.firstSeenAt || row.lastSeenAt || '')}
|
||||
<SiteAnnouncementContent content={row.content} />
|
||||
<div
|
||||
style={{ marginTop: 8, fontSize: 12, color: 'var(--color-text-muted)' }}
|
||||
title={viewerTimeZone ? `本地时区:${viewerTimeZone}` : undefined}
|
||||
>
|
||||
首次发现:{formatSiteAnnouncementSeenAt(row.firstSeenAt || row.lastSeenAt || '', viewerTimeZone)}
|
||||
</div>
|
||||
</div>
|
||||
))
|
||||
|
||||
@@ -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('<!doctype html><html><body></body></html>');
|
||||
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(
|
||||
<SiteAnnouncementContent
|
||||
content={[
|
||||
'<h2>Notice</h2>',
|
||||
'<p>Welcome <strong>back</strong>.</p>',
|
||||
'<script>alert(1)</script>',
|
||||
'<a href="javascript:alert(1)" onclick="alert(1)">bad</a>',
|
||||
'<a href="https://example.com/docs" target="_blank">docs</a>',
|
||||
].join('')}
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(markup).toContain('<h2>Notice</h2>');
|
||||
expect(markup).toContain('<strong>back</strong>');
|
||||
expect(markup).not.toContain('<script');
|
||||
expect(markup).not.toContain('onclick=');
|
||||
expect(markup).not.toContain('javascript:alert');
|
||||
expect(markup).toContain('href="https://example.com/docs"');
|
||||
expect(markup).toContain('rel="noopener noreferrer"');
|
||||
});
|
||||
|
||||
it('renders markdown notices as structured content', () => {
|
||||
const markup = renderToStaticMarkup(
|
||||
<SiteAnnouncementContent
|
||||
content={[
|
||||
'# README.md',
|
||||
'',
|
||||
'这是一个 [接入文档](https://example.com/setup)。',
|
||||
'',
|
||||
'```json',
|
||||
'{',
|
||||
' "model": "gpt-5.4"',
|
||||
'}',
|
||||
'```',
|
||||
].join('\n')}
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(markup).toContain('<h1>README.md</h1>');
|
||||
expect(markup).toContain('href="https://example.com/setup"');
|
||||
expect(markup).toContain('<pre><code class="language-json">');
|
||||
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();
|
||||
});
|
||||
});
|
||||
@@ -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, '"')
|
||||
.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(/<script[\s\S]*?<\/script>/gi, '')
|
||||
.replace(/<style[\s\S]*?<\/style>/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(`<code>${escapeHtml(code)}</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(`<img src="${escapeHtml(safeSrc)}" alt="${escapeHtml(String(alt || ''))}"${titleAttr}>`);
|
||||
});
|
||||
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(`<a href="${escapeHtml(safeHref)}" target="_blank" rel="noopener noreferrer"${titleAttr}>${escapeHtml(String(label || ''))}</a>`);
|
||||
});
|
||||
html = html.replace(/\*\*([^*]+)\*\*/g, '<strong>$1</strong>');
|
||||
html = html.replace(/~~([^~]+)~~/g, '<del>$1</del>');
|
||||
html = html.replace(/(^|[^\*])\*([^*\n]+)\*(?=[^\*]|$)/g, '$1<em>$2</em>');
|
||||
|
||||
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(`<li>${renderInlineMarkdown(item.trim())}</li>`);
|
||||
index += 1;
|
||||
}
|
||||
const tag = ordered ? 'ol' : 'ul';
|
||||
return {
|
||||
html: `<${tag}>${items.join('')}</${tag}>`,
|
||||
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: `<blockquote>${renderMarkdown(parts.join('\n'))}</blockquote>`,
|
||||
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: `<pre><code${classAttr}>${escapeHtml(codeLines.join('\n'))}</code></pre>`,
|
||||
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('<br />');
|
||||
return {
|
||||
html: `<p>${content}</p>`,
|
||||
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(`<h${level}>${renderInlineMarkdown(headingMatch[2])}</h${level}>`);
|
||||
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('<hr>');
|
||||
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}</${tag}>`;
|
||||
}
|
||||
|
||||
function sanitizeAnnouncementHtml(html: string): string {
|
||||
if (typeof DOMParser !== 'function' || typeof Node === 'undefined') {
|
||||
return renderPlainText(extractVisibleTextFallback(html));
|
||||
}
|
||||
|
||||
const parser = new DOMParser();
|
||||
const doc = parser.parseFromString(`<body>${html}</body>`, '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 `<p>${escapeHtml(String(content || '').trim())}</p>`;
|
||||
}
|
||||
|
||||
return paragraphs
|
||||
.map((part) => `<p>${escapeHtml(part).replace(/\n/g, '<br />')}</p>`)
|
||||
.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 '<p>-</p>';
|
||||
|
||||
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 (
|
||||
<div
|
||||
className="announcement-rich-content"
|
||||
dangerouslySetInnerHTML={{ __html: renderSiteAnnouncementHtml(content) }}
|
||||
/>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user