Compare commits

..

No commits in common. "1d0f2f7c3e5e44b005e3860354363396f2fcf5ba" and "d56e8ca23dcb199d821acaa3e2175dfa5147f4bf" have entirely different histories.

33 changed files with 122 additions and 4000 deletions

677
package-lock.json generated
View File

@ -13,7 +13,6 @@
"@solidjs/router": "^0.15.0",
"@thisbeyond/solid-dnd": "^0.7.5",
"@types/three": "^0.183.1",
"aedes": "^1.1.1",
"chokidar": "^5.0.0",
"commander": "^14.0.3",
"csv-parse": "^6.1.0",
@ -24,14 +23,11 @@
"marked-directive": "^1.0.7",
"marked-gfm-heading-id": "^4.1.3",
"mermaid": "^11.0.0",
"mqtt": "^5.15.1",
"solid-element": "^1.9.1",
"solid-js": "^1.9.3",
"three": "^0.183.2",
"three-3mf-exporter": "^45.1.0",
"ws": "^8.21.0",
"yarn-spinner-loader": "^0.1.0",
"zod": "^4.4.3"
"yarn-spinner-loader": "^0.1.0"
},
"bin": {
"ttrpg": "dist/cli/index.js"
@ -47,7 +43,6 @@
"@types/jest": "^30.0.0",
"@types/js-yaml": "^4.0.9",
"@types/node": "^22.19.13",
"@types/ws": "^8.18.1",
"husky": "^9.1.7",
"jest": "^29.7.0",
"jest-environment-jsdom": "^29.7.0",
@ -726,15 +721,6 @@
"@babel/core": "^7.0.0-0"
}
},
"node_modules/@babel/runtime": {
"version": "7.29.7",
"resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.29.7.tgz",
"integrity": "sha512-Nq8OhGWiZIZGV6hLHoyAKLLcJihP/xFeBMGJoUrxTX2psI8dCifzLhZISFb+VWS3wFMRDmCGw5R+dOySCqPLhw==",
"license": "MIT",
"engines": {
"node": ">=6.9.0"
}
},
"node_modules/@babel/template": {
"version": "7.28.6",
"resolved": "https://registry.npmjs.org/@babel/template/-/template-7.28.6.tgz",
@ -1885,15 +1871,6 @@
"zod": "^3.23.8"
}
},
"node_modules/@modelcontextprotocol/sdk/node_modules/zod": {
"version": "3.25.76",
"resolved": "https://registry.npmjs.org/zod/-/zod-3.25.76.tgz",
"integrity": "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ==",
"license": "MIT",
"funding": {
"url": "https://github.com/sponsors/colinhacks"
}
},
"node_modules/@module-federation/error-codes": {
"version": "0.22.0",
"resolved": "https://registry.npmjs.org/@module-federation/error-codes/-/error-codes-0.22.0.tgz",
@ -3333,20 +3310,12 @@
"version": "22.19.17",
"resolved": "https://registry.npmjs.org/@types/node/-/node-22.19.17.tgz",
"integrity": "sha512-wGdMcf+vPYM6jikpS/qhg6WiqSV/OhG+jeeHT/KlVqxYfD40iYJf9/AE1uQxVWFvU7MipKRkRv8NSHiCGgPr8Q==",
"devOptional": true,
"license": "MIT",
"dependencies": {
"undici-types": "~6.21.0"
}
},
"node_modules/@types/readable-stream": {
"version": "4.0.24",
"resolved": "https://registry.npmjs.org/@types/readable-stream/-/readable-stream-4.0.24.tgz",
"integrity": "sha512-NRvUNC/JFGPJvqdAfEve8oginbM6V08u5NzLWpG8MwA2kTPOLnqk+wpwuPT+mp3aUsxyuT6m2gnrPuHYCruzEg==",
"license": "MIT",
"dependencies": {
"@types/node": "*"
}
},
"node_modules/@types/stack-utils": {
"version": "2.0.3",
"resolved": "https://registry.npmjs.org/@types/stack-utils/-/stack-utils-2.0.3.tgz",
@ -3395,15 +3364,6 @@
"integrity": "sha512-h8fgEd/DpoS9CBrjEQXR+dIDraopAEfu4wYVNY2tEPwk60stPWhvZMf4Foo5FakuQ7HFZoa8WceaWFervK2Ovg==",
"license": "MIT"
},
"node_modules/@types/ws": {
"version": "8.18.1",
"resolved": "https://registry.npmjs.org/@types/ws/-/ws-8.18.1.tgz",
"integrity": "sha512-ThVF6DCVhA8kUGy+aazFQ4kXQ7E1Ty7A3ypFOe0IcJV8O/M511G99AW24irKrW56Wt44yG9+ij8FaqoBGkuBXg==",
"license": "MIT",
"dependencies": {
"@types/node": "*"
}
},
"node_modules/@types/yargs": {
"version": "17.0.35",
"resolved": "https://registry.npmjs.org/@types/yargs/-/yargs-17.0.35.tgz",
@ -3445,18 +3405,6 @@
"dev": true,
"license": "BSD-3-Clause"
},
"node_modules/abort-controller": {
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/abort-controller/-/abort-controller-3.0.0.tgz",
"integrity": "sha512-h8lQ8tacZYnR3vNQTgibj+tODHI5/+l06Au2Pcriv/Gmet0eaj4TwWH41sO9wnHDiQsEj19q0drzdWdeAHtweg==",
"license": "MIT",
"dependencies": {
"event-target-shim": "^5.0.0"
},
"engines": {
"node": ">=6.5"
}
},
"node_modules/acorn": {
"version": "8.16.0",
"resolved": "https://registry.npmjs.org/acorn/-/acorn-8.16.0.tgz",
@ -3493,138 +3441,6 @@
"node": ">=0.4.0"
}
},
"node_modules/aedes": {
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/aedes/-/aedes-1.1.1.tgz",
"integrity": "sha512-kmKFRgQrjGyv9HuVqpNGM/aPD2K4bf20aF1UbrxEvh3j8BJ1OD0rHi3E3u3xfrgc3ydj2+9UiPdW0sHiP6d+Sg==",
"license": "MIT",
"dependencies": {
"aedes-packet": "^3.0.0",
"aedes-persistence": "^10.2.2",
"mqemitter": "^7.1.0",
"mqtt-packet": "^9.0.2"
},
"engines": {
"node": ">=20"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/aedes"
},
"peerDependencies": {
"aedes-persistence-level": "^9.1.2",
"aedes-persistence-mongodb": "^9.3.1",
"aedes-persistence-redis": "^11.2.1"
},
"peerDependenciesMeta": {
"aedes-persistence-level": {
"optional": true
},
"aedes-persistence-mongodb": {
"optional": true
},
"aedes-persistence-redis": {
"optional": true
}
}
},
"node_modules/aedes-packet": {
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/aedes-packet/-/aedes-packet-3.0.0.tgz",
"integrity": "sha512-swASey0BxGs4/npZGWoiVDmnEyPvVFIRY6l2LVKL4rbiW8IhcIGDLfnb20Qo8U20itXlitAKPQ3MVTEbOGG5ZA==",
"license": "MIT",
"dependencies": {
"mqtt-packet": "^7.0.0"
},
"engines": {
"node": ">=14"
}
},
"node_modules/aedes-packet/node_modules/bl": {
"version": "4.1.0",
"resolved": "https://registry.npmjs.org/bl/-/bl-4.1.0.tgz",
"integrity": "sha512-1W07cM9gS6DcLperZfFSj+bWLtaPGSOHWhPiGzXmvVJbRLdG82sH/Kn8EtW1VqWVA54AKf2h5k5BbnIbwF3h6w==",
"license": "MIT",
"dependencies": {
"buffer": "^5.5.0",
"inherits": "^2.0.4",
"readable-stream": "^3.4.0"
}
},
"node_modules/aedes-packet/node_modules/buffer": {
"version": "5.7.1",
"resolved": "https://registry.npmjs.org/buffer/-/buffer-5.7.1.tgz",
"integrity": "sha512-EHcyIPBQ4BSGlvjB16k5KgAJ27CIsHY/2JBmCRReo48y9rQ3MaUzWX3KVlBa4U7MyX02HdVj0K7C3WaB3ju7FQ==",
"funding": [
{
"type": "github",
"url": "https://github.com/sponsors/feross"
},
{
"type": "patreon",
"url": "https://www.patreon.com/feross"
},
{
"type": "consulting",
"url": "https://feross.org/support"
}
],
"license": "MIT",
"dependencies": {
"base64-js": "^1.3.1",
"ieee754": "^1.1.13"
}
},
"node_modules/aedes-packet/node_modules/mqtt-packet": {
"version": "7.1.2",
"resolved": "https://registry.npmjs.org/mqtt-packet/-/mqtt-packet-7.1.2.tgz",
"integrity": "sha512-FFZbcZ2omsf4c5TxEQfcX9hI+JzDpDKPT46OmeIBpVA7+t32ey25UNqlqNXTmeZOr5BLsSIERpQQLsFWJS94SQ==",
"license": "MIT",
"dependencies": {
"bl": "^4.0.2",
"debug": "^4.1.1",
"process-nextick-args": "^2.0.1"
}
},
"node_modules/aedes-packet/node_modules/readable-stream": {
"version": "3.6.2",
"resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-3.6.2.tgz",
"integrity": "sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA==",
"license": "MIT",
"dependencies": {
"inherits": "^2.0.3",
"string_decoder": "^1.1.1",
"util-deprecate": "^1.0.1"
},
"engines": {
"node": ">= 6"
}
},
"node_modules/aedes-persistence": {
"version": "10.3.1",
"resolved": "https://registry.npmjs.org/aedes-persistence/-/aedes-persistence-10.3.1.tgz",
"integrity": "sha512-C/z0zLEXY9PkUH+7pz31H5gGzlQZxqhFONK1lt5VON0hiHXQq6tr4cSIAtFUVQ1kyUdd7SsfkeKLAECp3gjjpw==",
"license": "MIT",
"dependencies": {
"aedes-packet": "^4.0.1",
"qlobber": "^8.0.1"
},
"engines": {
"node": ">=20"
}
},
"node_modules/aedes-persistence/node_modules/aedes-packet": {
"version": "4.0.1",
"resolved": "https://registry.npmjs.org/aedes-packet/-/aedes-packet-4.0.1.tgz",
"integrity": "sha512-PLjKJElXEYB66RzHhJCw0SrRLfSmGRbbg2mtO5t/6FAbFp8vC0cG5ErhvPvfa9iKBrKZwoluWLgn8doBU8sRAw==",
"license": "MIT",
"dependencies": {
"mqtt-packet": "^9.0.0"
},
"engines": {
"node": ">=20"
}
},
"node_modules/agent-base": {
"version": "6.0.2",
"resolved": "https://registry.npmjs.org/agent-base/-/agent-base-6.0.2.tgz",
@ -3911,26 +3727,6 @@
"dev": true,
"license": "MIT"
},
"node_modules/base64-js": {
"version": "1.5.1",
"resolved": "https://registry.npmjs.org/base64-js/-/base64-js-1.5.1.tgz",
"integrity": "sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==",
"funding": [
{
"type": "github",
"url": "https://github.com/sponsors/feross"
},
{
"type": "patreon",
"url": "https://www.patreon.com/feross"
},
{
"type": "consulting",
"url": "https://feross.org/support"
}
],
"license": "MIT"
},
"node_modules/baseline-browser-mapping": {
"version": "2.10.15",
"resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.10.15.tgz",
@ -3944,63 +3740,6 @@
"node": ">=6.0.0"
}
},
"node_modules/bl": {
"version": "6.1.6",
"resolved": "https://registry.npmjs.org/bl/-/bl-6.1.6.tgz",
"integrity": "sha512-jLsPgN/YSvPUg9UX0Kd73CXpm2Psg9FxMeCSXnk3WBO3CMT10JMwijubhGfHCnFu6TPn1ei3b975dxv7K2pWVg==",
"license": "MIT",
"dependencies": {
"@types/readable-stream": "^4.0.0",
"buffer": "^6.0.3",
"inherits": "^2.0.4",
"readable-stream": "^4.2.0"
}
},
"node_modules/bl/node_modules/readable-stream": {
"version": "4.7.0",
"resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-4.7.0.tgz",
"integrity": "sha512-oIGGmcpTLwPga8Bn6/Z75SVaH1z5dUut2ibSyAMVhmUggWpmDn2dapB0n7f8nwaSiRtepAsfJyfXIO5DCVAODg==",
"license": "MIT",
"dependencies": {
"abort-controller": "^3.0.0",
"buffer": "^6.0.3",
"events": "^3.3.0",
"process": "^0.11.10",
"string_decoder": "^1.3.0"
},
"engines": {
"node": "^12.22.0 || ^14.17.0 || >=16.0.0"
}
},
"node_modules/bl/node_modules/safe-buffer": {
"version": "5.2.1",
"resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz",
"integrity": "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==",
"funding": [
{
"type": "github",
"url": "https://github.com/sponsors/feross"
},
{
"type": "patreon",
"url": "https://www.patreon.com/feross"
},
{
"type": "consulting",
"url": "https://feross.org/support"
}
],
"license": "MIT"
},
"node_modules/bl/node_modules/string_decoder": {
"version": "1.3.0",
"resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.3.0.tgz",
"integrity": "sha512-hkRX8U1WjJFd8LsDJ2yQ/wWWxaopEsABU1XfkM8A+j0+85JAGppt16cr1Whg6KIbb4okU6Mql6BOj+uup/wKeA==",
"license": "MIT",
"dependencies": {
"safe-buffer": "~5.2.0"
}
},
"node_modules/brace-expansion": {
"version": "1.1.13",
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.13.tgz",
@ -4024,18 +3763,6 @@
"node": ">=8"
}
},
"node_modules/broker-factory": {
"version": "3.1.15",
"resolved": "https://registry.npmjs.org/broker-factory/-/broker-factory-3.1.15.tgz",
"integrity": "sha512-ko+aWvgNuP49meGrdjUu7rC+Y+Wai3cCPxP3xWwHsHfehFjOh5ZQM2yC4gEB2UddeZ/YXhm0K1eG/L6fxym2Og==",
"license": "MIT",
"dependencies": {
"@babel/runtime": "^7.29.7",
"fast-unique-numbers": "^9.0.27",
"tslib": "^2.8.1",
"worker-factory": "^7.0.50"
}
},
"node_modules/browserslist": {
"version": "4.28.2",
"resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.28.2.tgz",
@ -4093,34 +3820,11 @@
"node-int64": "^0.4.0"
}
},
"node_modules/buffer": {
"version": "6.0.3",
"resolved": "https://registry.npmjs.org/buffer/-/buffer-6.0.3.tgz",
"integrity": "sha512-FTiCpNxtwiZZHEZbcbTIcZjERVICn9yq/pDFkTl95/AxzD1naBctN7YO68riM/gLSDY7sdrMby8hofADYuuqOA==",
"funding": [
{
"type": "github",
"url": "https://github.com/sponsors/feross"
},
{
"type": "patreon",
"url": "https://www.patreon.com/feross"
},
{
"type": "consulting",
"url": "https://feross.org/support"
}
],
"license": "MIT",
"dependencies": {
"base64-js": "^1.3.1",
"ieee754": "^1.2.1"
}
},
"node_modules/buffer-from": {
"version": "1.1.2",
"resolved": "https://registry.npmjs.org/buffer-from/-/buffer-from-1.1.2.tgz",
"integrity": "sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ==",
"dev": true,
"license": "MIT"
},
"node_modules/bytes": {
@ -4359,12 +4063,6 @@
"node": ">=20"
}
},
"node_modules/commist": {
"version": "3.2.0",
"resolved": "https://registry.npmjs.org/commist/-/commist-3.2.0.tgz",
"integrity": "sha512-4PIMoPniho+LqXmpS5d3NuGYncG6XWlkBSVGiWycL22dd42OYdUGil2CWuzklaJoNxyxUSpO4MKIBU94viWNAw==",
"license": "MIT"
},
"node_modules/component-register": {
"version": "0.8.8",
"resolved": "https://registry.npmjs.org/component-register/-/component-register-0.8.8.tgz",
@ -4378,35 +4076,6 @@
"dev": true,
"license": "MIT"
},
"node_modules/concat-stream": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/concat-stream/-/concat-stream-2.0.0.tgz",
"integrity": "sha512-MWufYdFw53ccGjCA+Ol7XJYpAlW6/prSMzuPOTRnJGcGzuhLn4Scrz7qf6o8bROZ514ltazcIFJZevcfbo0x7A==",
"engines": [
"node >= 6.0"
],
"license": "MIT",
"dependencies": {
"buffer-from": "^1.0.0",
"inherits": "^2.0.3",
"readable-stream": "^3.0.2",
"typedarray": "^0.0.6"
}
},
"node_modules/concat-stream/node_modules/readable-stream": {
"version": "3.6.2",
"resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-3.6.2.tgz",
"integrity": "sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA==",
"license": "MIT",
"dependencies": {
"inherits": "^2.0.3",
"string_decoder": "^1.1.1",
"util-deprecate": "^1.0.1"
},
"engines": {
"node": ">= 6"
}
},
"node_modules/confbox": {
"version": "0.1.8",
"resolved": "https://registry.npmjs.org/confbox/-/confbox-0.1.8.tgz",
@ -5109,6 +4778,7 @@
"version": "4.4.3",
"resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz",
"integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==",
"dev": true,
"license": "MIT",
"dependencies": {
"ms": "^2.1.3"
@ -5449,24 +5119,6 @@
"node": ">=0.10.0"
}
},
"node_modules/event-target-shim": {
"version": "5.0.1",
"resolved": "https://registry.npmjs.org/event-target-shim/-/event-target-shim-5.0.1.tgz",
"integrity": "sha512-i/2XbnSz/uxRCU6+NdVJgKWDTM427+MqYbkQzD321DuCQJUqOuJKIA0IM2+W2xtYHdKOmZ4dR6fExsd4SXL+WQ==",
"license": "MIT",
"engines": {
"node": ">=6"
}
},
"node_modules/events": {
"version": "3.3.0",
"resolved": "https://registry.npmjs.org/events/-/events-3.3.0.tgz",
"integrity": "sha512-mQw+2fkQbALzQ7V0MY0IqdnXNOeTtP4r0lN9z7AAawCXgqea7bDii20AYrIBrFd/Hx0M2Ocz6S111CaFkUcb0Q==",
"license": "MIT",
"engines": {
"node": ">=0.8.x"
}
},
"node_modules/execa": {
"version": "5.1.1",
"resolved": "https://registry.npmjs.org/execa/-/execa-5.1.1.tgz",
@ -5547,19 +5199,6 @@
"dev": true,
"license": "MIT"
},
"node_modules/fast-unique-numbers": {
"version": "9.0.27",
"resolved": "https://registry.npmjs.org/fast-unique-numbers/-/fast-unique-numbers-9.0.27.tgz",
"integrity": "sha512-nDA9ADeINN8SA2u2wCtU+siWFTTDqQR37XvgPIDDmboWQeExz7X0mImxuaN+kJddliIqy2FpVRmnvRZ+j8i1/A==",
"license": "MIT",
"dependencies": {
"@babel/runtime": "^7.29.2",
"tslib": "^2.8.1"
},
"engines": {
"node": ">=18.2.0"
}
},
"node_modules/fast-uri": {
"version": "3.1.0",
"resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.0.tgz",
@ -5611,16 +5250,6 @@
"fxparser": "src/cli/cli.js"
}
},
"node_modules/fastparallel": {
"version": "2.4.1",
"resolved": "https://registry.npmjs.org/fastparallel/-/fastparallel-2.4.1.tgz",
"integrity": "sha512-qUmhxPgNHmvRjZKBFUNI0oZuuH9OlSIOXmJ98lhKPxMZZ7zS/Fi0wRHOihDSz0R1YiIOjxzOY4bq65YTcdBi2Q==",
"license": "ISC",
"dependencies": {
"reusify": "^1.0.4",
"xtend": "^4.0.2"
}
},
"node_modules/fastq": {
"version": "1.20.1",
"resolved": "https://registry.npmjs.org/fastq/-/fastq-1.20.1.tgz",
@ -5942,12 +5571,6 @@
"node": ">= 0.4"
}
},
"node_modules/help-me": {
"version": "5.0.0",
"resolved": "https://registry.npmjs.org/help-me/-/help-me-5.0.0.tgz",
"integrity": "sha512-7xgomUX6ADmcYzFik0HzAxh/73YlKR9bmFzf51CZwR+b6YtzU2m0u49hQCqV6SvlqIqsaxovfwdvbnsw3b/zpg==",
"license": "MIT"
},
"node_modules/html-encoding-sniffer": {
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/html-encoding-sniffer/-/html-encoding-sniffer-3.0.0.tgz",
@ -6062,26 +5685,6 @@
"node": ">=0.10.0"
}
},
"node_modules/ieee754": {
"version": "1.2.1",
"resolved": "https://registry.npmjs.org/ieee754/-/ieee754-1.2.1.tgz",
"integrity": "sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA==",
"funding": [
{
"type": "github",
"url": "https://github.com/sponsors/feross"
},
{
"type": "patreon",
"url": "https://www.patreon.com/feross"
},
{
"type": "consulting",
"url": "https://feross.org/support"
}
],
"license": "BSD-3-Clause"
},
"node_modules/immediate": {
"version": "3.0.6",
"resolved": "https://registry.npmjs.org/immediate/-/immediate-3.0.6.tgz",
@ -6145,15 +5748,6 @@
"node": ">=12"
}
},
"node_modules/ip-address": {
"version": "10.2.0",
"resolved": "https://registry.npmjs.org/ip-address/-/ip-address-10.2.0.tgz",
"integrity": "sha512-/+S6j4E9AHvW9SWMSEY9Xfy66O5PWvVEJ08O0y5JGyEKQpojb0K0GKpz/v5HJ/G0vi3D2sjGK78119oXZeE0qA==",
"license": "MIT",
"engines": {
"node": ">= 12"
}
},
"node_modules/is-arrayish": {
"version": "0.2.1",
"resolved": "https://registry.npmjs.org/is-arrayish/-/is-arrayish-0.2.1.tgz",
@ -7836,16 +7430,6 @@
"jiti": "lib/jiti-cli.mjs"
}
},
"node_modules/js-sdsl": {
"version": "4.3.0",
"resolved": "https://registry.npmjs.org/js-sdsl/-/js-sdsl-4.3.0.tgz",
"integrity": "sha512-mifzlm2+5nZ+lEcLJMoBK0/IH/bDg8XnJfd/Wq6IP+xoCjLZsTOnV2QpxlVbX9bMnkl5PdEjNtBJ9Cj1NjifhQ==",
"license": "MIT",
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/js-sdsl"
}
},
"node_modules/js-tokens": {
"version": "4.0.0",
"resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz",
@ -8583,6 +8167,7 @@
"version": "1.2.8",
"resolved": "https://registry.npmjs.org/minimist/-/minimist-1.2.8.tgz",
"integrity": "sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA==",
"dev": true,
"license": "MIT",
"funding": {
"url": "https://github.com/sponsors/ljharb"
@ -8606,117 +8191,11 @@
"integrity": "sha512-m2fmM2dDm7GZQsY7KK2cme8agi+AAljILjQnof7p1ZMDe6dQ4bdnSMx0cPppudoeNv5hEFQirN6u+O4fDE0IWA==",
"license": "BSD-3-Clause"
},
"node_modules/mqemitter": {
"version": "7.1.0",
"resolved": "https://registry.npmjs.org/mqemitter/-/mqemitter-7.1.0.tgz",
"integrity": "sha512-GnBDNz3lxmllW201ne0mrmdy5tPOTnc79jjVcsfUa2LG2pUGeyGWVeiae6ZysfC/64XrYOqCKRAQYrB7pGyBVQ==",
"license": "ISC",
"dependencies": {
"fastparallel": "^2.4.1",
"qlobber": "^8.0.1"
},
"engines": {
"node": ">=20"
}
},
"node_modules/mqtt": {
"version": "5.15.1",
"resolved": "https://registry.npmjs.org/mqtt/-/mqtt-5.15.1.tgz",
"integrity": "sha512-V1WnkGuJh3ec9QXzy5Iylw8OOBK+Xu1WhxcQ9mMpLThG+/JZIMV1PgLNRgIiqXhZnvnVLsuyxHl5A/3bHHbcAA==",
"license": "MIT",
"dependencies": {
"@types/readable-stream": "^4.0.21",
"@types/ws": "^8.18.1",
"commist": "^3.2.0",
"concat-stream": "^2.0.0",
"debug": "^4.4.1",
"help-me": "^5.0.0",
"lru-cache": "^10.4.3",
"minimist": "^1.2.8",
"mqtt-packet": "^9.0.2",
"number-allocator": "^1.0.14",
"readable-stream": "^4.7.0",
"rfdc": "^1.4.1",
"socks": "^2.8.6",
"split2": "^4.2.0",
"worker-timers": "^8.0.23",
"ws": "^8.18.3"
},
"bin": {
"mqtt": "build/bin/mqtt.js",
"mqtt_pub": "build/bin/pub.js",
"mqtt_sub": "build/bin/sub.js"
},
"engines": {
"node": ">=16.0.0"
}
},
"node_modules/mqtt-packet": {
"version": "9.0.2",
"resolved": "https://registry.npmjs.org/mqtt-packet/-/mqtt-packet-9.0.2.tgz",
"integrity": "sha512-MvIY0B8/qjq7bKxdN1eD+nrljoeaai+qjLJgfRn3TiMuz0pamsIWY2bFODPZMSNmabsLANXsLl4EMoWvlaTZWA==",
"license": "MIT",
"dependencies": {
"bl": "^6.0.8",
"debug": "^4.3.4",
"process-nextick-args": "^2.0.1"
}
},
"node_modules/mqtt/node_modules/lru-cache": {
"version": "10.4.3",
"resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-10.4.3.tgz",
"integrity": "sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ==",
"license": "ISC"
},
"node_modules/mqtt/node_modules/readable-stream": {
"version": "4.7.0",
"resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-4.7.0.tgz",
"integrity": "sha512-oIGGmcpTLwPga8Bn6/Z75SVaH1z5dUut2ibSyAMVhmUggWpmDn2dapB0n7f8nwaSiRtepAsfJyfXIO5DCVAODg==",
"license": "MIT",
"dependencies": {
"abort-controller": "^3.0.0",
"buffer": "^6.0.3",
"events": "^3.3.0",
"process": "^0.11.10",
"string_decoder": "^1.3.0"
},
"engines": {
"node": "^12.22.0 || ^14.17.0 || >=16.0.0"
}
},
"node_modules/mqtt/node_modules/safe-buffer": {
"version": "5.2.1",
"resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz",
"integrity": "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==",
"funding": [
{
"type": "github",
"url": "https://github.com/sponsors/feross"
},
{
"type": "patreon",
"url": "https://www.patreon.com/feross"
},
{
"type": "consulting",
"url": "https://feross.org/support"
}
],
"license": "MIT"
},
"node_modules/mqtt/node_modules/string_decoder": {
"version": "1.3.0",
"resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.3.0.tgz",
"integrity": "sha512-hkRX8U1WjJFd8LsDJ2yQ/wWWxaopEsABU1XfkM8A+j0+85JAGppt16cr1Whg6KIbb4okU6Mql6BOj+uup/wKeA==",
"license": "MIT",
"dependencies": {
"safe-buffer": "~5.2.0"
}
},
"node_modules/ms": {
"version": "2.1.3",
"resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz",
"integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==",
"dev": true,
"license": "MIT"
},
"node_modules/nanoid": {
@ -8789,16 +8268,6 @@
"node": ">=8"
}
},
"node_modules/number-allocator": {
"version": "1.0.14",
"resolved": "https://registry.npmjs.org/number-allocator/-/number-allocator-1.0.14.tgz",
"integrity": "sha512-OrL44UTVAvkKdOdRQZIJpLkAdjXGTRda052sN4sO77bKEzYYqWKMBjQvrJFzqygI99gL6Z4u2xctPW1tB8ErvA==",
"license": "MIT",
"dependencies": {
"debug": "^4.3.1",
"js-sdsl": "4.3.0"
}
},
"node_modules/nwsapi": {
"version": "2.2.23",
"resolved": "https://registry.npmjs.org/nwsapi/-/nwsapi-2.2.23.tgz",
@ -9155,15 +8624,6 @@
"url": "https://github.com/chalk/ansi-styles?sponsor=1"
}
},
"node_modules/process": {
"version": "0.11.10",
"resolved": "https://registry.npmjs.org/process/-/process-0.11.10.tgz",
"integrity": "sha512-cdGef/drWFoydD1JsMzuFf8100nZl+GT+yacc2bEced5f9Rjk4z+WtFUTBu9PhOi9j/jfmBPu0mMEY4wIdAF8A==",
"license": "MIT",
"engines": {
"node": ">= 0.6.0"
}
},
"node_modules/process-nextick-args": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/process-nextick-args/-/process-nextick-args-2.0.1.tgz",
@ -9224,15 +8684,6 @@
],
"license": "MIT"
},
"node_modules/qlobber": {
"version": "8.0.1",
"resolved": "https://registry.npmjs.org/qlobber/-/qlobber-8.0.1.tgz",
"integrity": "sha512-O+Wd1chXj5YE1DwmD+ae0bXiSLehmnS3czlC1R9FL/Nt/3q8uMS1bIHmg2lJfCoiimCxClWM8AAuJrF0EvNiog==",
"license": "MIT",
"engines": {
"node": ">= 16"
}
},
"node_modules/querystringify": {
"version": "2.2.0",
"resolved": "https://registry.npmjs.org/querystringify/-/querystringify-2.2.0.tgz",
@ -9423,12 +8874,6 @@
"node": ">=0.10.0"
}
},
"node_modules/rfdc": {
"version": "1.4.1",
"resolved": "https://registry.npmjs.org/rfdc/-/rfdc-1.4.1.tgz",
"integrity": "sha512-q1b3N5QkRUWUl7iyylaaj3kOpIT0N2i9MqIEQXP73GVsN9cw3fdx8X63cEmWhJGi2PPCF23Ijp7ktmd39rawIA==",
"license": "MIT"
},
"node_modules/robust-predicates": {
"version": "3.0.3",
"resolved": "https://registry.npmjs.org/robust-predicates/-/robust-predicates-3.0.3.tgz",
@ -9626,30 +9071,6 @@
"node": ">=8"
}
},
"node_modules/smart-buffer": {
"version": "4.2.0",
"resolved": "https://registry.npmjs.org/smart-buffer/-/smart-buffer-4.2.0.tgz",
"integrity": "sha512-94hK0Hh8rPqQl2xXc3HsaBoOXKV20MToPkcXvwbISWLEs+64sBq5kFgn2kJDHb1Pry9yrP0dxrCI9RRci7RXKg==",
"license": "MIT",
"engines": {
"node": ">= 6.0.0",
"npm": ">= 3.0.0"
}
},
"node_modules/socks": {
"version": "2.8.9",
"resolved": "https://registry.npmjs.org/socks/-/socks-2.8.9.tgz",
"integrity": "sha512-LJhUYUvItdQ0LkJTmPeaEObWXAqFyfmP85x0tch/ez9cahmhlBBLbIqDFnvBnUJGagb0JbIQrkBs1wJ+yRYpEw==",
"license": "MIT",
"dependencies": {
"ip-address": "^10.1.1",
"smart-buffer": "^4.2.0"
},
"engines": {
"node": ">= 10.0.0",
"npm": ">= 3.0.0"
}
},
"node_modules/solid-element": {
"version": "1.9.1",
"resolved": "https://registry.npmjs.org/solid-element/-/solid-element-1.9.1.tgz",
@ -9718,15 +9139,6 @@
"source-map": "^0.6.0"
}
},
"node_modules/split2": {
"version": "4.2.0",
"resolved": "https://registry.npmjs.org/split2/-/split2-4.2.0.tgz",
"integrity": "sha512-UcjcJOWknrNkF6PLX83qcHM6KHgVKNkV62Y8a5uYDVv9ydGQVwAHMKqHdJje1VTWpljG0WYpCDhrCdAOYH4TWg==",
"license": "ISC",
"engines": {
"node": ">= 10.x"
}
},
"node_modules/sprintf-js": {
"version": "1.0.3",
"resolved": "https://registry.npmjs.org/sprintf-js/-/sprintf-js-1.0.3.tgz",
@ -10199,6 +9611,7 @@
"version": "2.8.1",
"resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz",
"integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==",
"devOptional": true,
"license": "0BSD"
},
"node_modules/type-detect": {
@ -10224,12 +9637,6 @@
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/typedarray": {
"version": "0.0.6",
"resolved": "https://registry.npmjs.org/typedarray/-/typedarray-0.0.6.tgz",
"integrity": "sha512-/aCDEGatGvZ2BIk+HmLf4ifCJFwvKFNb9/JeZPMulfgFracn9QFcAf5GO8B/mweUjSoblS5In0cWhqpfs/5PQA==",
"license": "MIT"
},
"node_modules/typescript": {
"version": "5.9.3",
"resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz",
@ -10268,6 +9675,7 @@
"version": "6.21.0",
"resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz",
"integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==",
"devOptional": true,
"license": "MIT"
},
"node_modules/universalify": {
@ -10608,53 +10016,6 @@
"dev": true,
"license": "MIT"
},
"node_modules/worker-factory": {
"version": "7.0.50",
"resolved": "https://registry.npmjs.org/worker-factory/-/worker-factory-7.0.50.tgz",
"integrity": "sha512-hhwc0G+sFwM4qBuhJIUBn2p1Jf8v/FwmLUANBf/Q+Lt2uI8mfIZQhXaZQACodQD4R7Zp6cn/6702bIvNn2puJQ==",
"license": "MIT",
"dependencies": {
"@babel/runtime": "^7.29.7",
"fast-unique-numbers": "^9.0.27",
"tslib": "^2.8.1"
}
},
"node_modules/worker-timers": {
"version": "8.0.33",
"resolved": "https://registry.npmjs.org/worker-timers/-/worker-timers-8.0.33.tgz",
"integrity": "sha512-RQVlKkek80v8M6SHvdMKiywaXFmX3XTpYTXTw0r62PdXB06t74XNxcTuG8wsQwnjorAQymNQyyQ0rzv7PykEMQ==",
"license": "MIT",
"dependencies": {
"@babel/runtime": "^7.29.7",
"tslib": "^2.8.1",
"worker-timers-broker": "^8.0.18",
"worker-timers-worker": "^9.0.15"
}
},
"node_modules/worker-timers-broker": {
"version": "8.0.18",
"resolved": "https://registry.npmjs.org/worker-timers-broker/-/worker-timers-broker-8.0.18.tgz",
"integrity": "sha512-FrjzDVX1wKfZN0gRbCFqv8VHuTncG4sbI/WGEg4tSSQeIsnwqg4YBYWMAHYLJtUDEYYmiK65UKFrVMVk2irDSg==",
"license": "MIT",
"dependencies": {
"@babel/runtime": "^7.29.7",
"broker-factory": "^3.1.15",
"fast-unique-numbers": "^9.0.27",
"tslib": "^2.8.1",
"worker-timers-worker": "^9.0.15"
}
},
"node_modules/worker-timers-worker": {
"version": "9.0.15",
"resolved": "https://registry.npmjs.org/worker-timers-worker/-/worker-timers-worker-9.0.15.tgz",
"integrity": "sha512-KKUe7lZ/Aignr51H6hOUik8LwTnIgojH/1lwhli8A8qIEIyewogZTpNpMW5B6BF7nmwBOkUoTYgf1H3QShcjSA==",
"license": "MIT",
"dependencies": {
"@babel/runtime": "^7.29.7",
"tslib": "^2.8.1",
"worker-factory": "^7.0.50"
}
},
"node_modules/wrap-ansi": {
"version": "7.0.0",
"resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-7.0.0.tgz",
@ -10695,9 +10056,10 @@
}
},
"node_modules/ws": {
"version": "8.21.0",
"resolved": "https://registry.npmjs.org/ws/-/ws-8.21.0.tgz",
"integrity": "sha512-Vsp28b7DRcimFQvrqu2Wek3z1iYxDCWqHYB8Qsnk/S4RfaCQzPGPyBNuVjJV3cd6UiKtUtp6sNM77gWvzcCH+g==",
"version": "8.20.0",
"resolved": "https://registry.npmjs.org/ws/-/ws-8.20.0.tgz",
"integrity": "sha512-sAt8BhgNbzCtgGbt2OxmpuryO63ZoDk/sqaB/znQm94T4fCEsy/yV+7CdC1kJhOU9lboAEU7R3kquuycDoibVA==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=10.0.0"
@ -10732,15 +10094,6 @@
"dev": true,
"license": "MIT"
},
"node_modules/xtend": {
"version": "4.0.2",
"resolved": "https://registry.npmjs.org/xtend/-/xtend-4.0.2.tgz",
"integrity": "sha512-LKYU1iAXJXUgAXn9URjiu+MWhyUXHsvfp7mcuYm9dSUKK0/CjtrUwFAxD82/mCWbtLsGjFIad0wIsod4zrTAEQ==",
"license": "MIT",
"engines": {
"node": ">=0.4"
}
},
"node_modules/y18n": {
"version": "5.0.8",
"resolved": "https://registry.npmjs.org/y18n/-/y18n-5.0.8.tgz",
@ -10841,9 +10194,9 @@
}
},
"node_modules/zod": {
"version": "4.4.3",
"resolved": "https://registry.npmjs.org/zod/-/zod-4.4.3.tgz",
"integrity": "sha512-ytENFjIJFl2UwYglde2jchW2Hwm4GJFLDiSXWdTrJQBIN9Fcyp7n4DhxJEiWNAJMV1/BqWfW/kkg71UDcHJyTQ==",
"version": "3.25.76",
"resolved": "https://registry.npmjs.org/zod/-/zod-3.25.76.tgz",
"integrity": "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ==",
"license": "MIT",
"funding": {
"url": "https://github.com/sponsors/colinhacks"

View File

@ -36,7 +36,6 @@
"@solidjs/router": "^0.15.0",
"@thisbeyond/solid-dnd": "^0.7.5",
"@types/three": "^0.183.1",
"aedes": "^1.1.1",
"chokidar": "^5.0.0",
"commander": "^14.0.3",
"csv-parse": "^6.1.0",
@ -47,14 +46,11 @@
"marked-directive": "^1.0.7",
"marked-gfm-heading-id": "^4.1.3",
"mermaid": "^11.0.0",
"mqtt": "^5.15.1",
"solid-element": "^1.9.1",
"solid-js": "^1.9.3",
"three": "^0.183.2",
"three-3mf-exporter": "^45.1.0",
"ws": "^8.21.0",
"yarn-spinner-loader": "^0.1.0",
"zod": "^4.4.3"
"yarn-spinner-loader": "^0.1.0"
},
"devDependencies": {
"@image-tracer-ts/core": "^1.0.2",
@ -67,7 +63,6 @@
"@types/jest": "^30.0.0",
"@types/js-yaml": "^4.0.9",
"@types/node": "^22.19.13",
"@types/ws": "^8.18.1",
"husky": "^9.1.7",
"jest": "^29.7.0",
"jest-environment-jsdom": "^29.7.0",

View File

@ -1,6 +1,5 @@
import { Component, createEffect, createMemo, createSignal } from "solid-js";
import { useLocation } from "@solidjs/router";
import { useJournalStream } from "./components/stores/journalStream";
// 导入组件以注册自定义元素
import "./components";
@ -12,15 +11,12 @@ import {
DataSourceDialog,
} from "./components";
import { generateToc, type FileNode, type TocNode } from "./data-loader";
import { JournalPanel } from "./components/journal";
const App: Component = () => {
const location = useLocation();
const stream = useJournalStream();
const [isSidebarOpen, setIsSidebarOpen] = createSignal(false);
const [isDocOpen, setIsDocOpen] = createSignal(false);
const [isDataSourceOpen, setIsDataSourceOpen] = createSignal(false);
const [isJournalOpen, setIsJournalOpen] = createSignal(false);
// Sidebar and TOC data — reload when source changes
const [fileTree, setFileTree] = createSignal<FileNode[]>([]);
@ -59,18 +55,13 @@ const App: Component = () => {
return (
<div class="min-h-screen bg-gray-50">
{/* 桌面端固定侧边栏 */}
<DesktopSidebar
fileTree={fileTree()}
pathHeadings={pathHeadings()}
onDataSourceOpen={() => setIsDataSourceOpen(true)}
/>
<DesktopSidebar fileTree={fileTree()} pathHeadings={pathHeadings()} />
{/* 移动端抽屉式侧边栏 */}
<MobileSidebar
isOpen={isSidebarOpen()}
onClose={() => setIsSidebarOpen(false)}
fileTree={fileTree()}
pathHeadings={pathHeadings()}
onDataSourceOpen={() => setIsDataSourceOpen(true)}
/>
<header class="fixed top-0 left-0 right-0 bg-white shadow z-30">
<div class="max-w-4xl mx-auto px-4 py-4 flex items-center gap-4">
@ -85,25 +76,11 @@ const App: Component = () => {
<h1 class="text-2xl font-bold text-gray-900">TTRPG Tools</h1>
<div class="flex-1" />
<button
onClick={() => setIsJournalOpen((v) => !v)}
class={`relative w-8 h-8 rounded flex items-center justify-center text-sm ${
isJournalOpen()
? "bg-blue-100 text-blue-600"
: "text-gray-500 hover:bg-gray-100"
}`}
title={`Journal (${stream.connectionStatus})`}
onClick={() => setIsDataSourceOpen(true)}
class="text-sm text-gray-500 hover:text-gray-700 px-2 py-1 rounded hover:bg-gray-100"
title="Content Source"
>
<span
class="absolute top-0.5 right-0.5 w-2 h-2 rounded-full border border-white"
classList={{
"bg-gray-400": stream.connectionStatus === "disconnected",
"bg-yellow-400 animate-pulse":
stream.connectionStatus === "connecting",
"bg-green-500": stream.connectionStatus === "connected",
"bg-red-500": stream.connectionStatus === "error",
}}
/>
📋
📂
</button>
<button
onClick={() => setIsDocOpen(true)}
@ -114,11 +91,8 @@ const App: Component = () => {
</button>
</div>
</header>
{/* fill the rest of the space */}
<div
class="fixed top-16 left-0 right-0 bottom-0 overflow-auto transition-all duration-300"
classList={{ "md:pr-[420px]": isJournalOpen() }}
>
{/* fill th rest of the space*/}
<div class="fixed top-16 left-0 right-0 bottom-0 overflow-auto">
<main class="max-w-4xl mx-auto px-4 py-8 pt-4 md:ml-64 2xl:ml-auto flex justify-center items-center">
<Article
class="prose text-black prose-sm max-w-full flex-1"
@ -132,10 +106,6 @@ const App: Component = () => {
onClose={() => setIsDataSourceOpen(false)}
onSourceChanged={handleSourceChanged}
/>
<JournalPanel
open={isJournalOpen()}
onClose={() => setIsJournalOpen(false)}
/>
</div>
);
};

View File

@ -5,11 +5,6 @@ import { createReadStream } from "fs";
import { join, resolve, extname, sep, relative, dirname } from "path";
import { watch } from "chokidar";
import { fileURLToPath } from "url";
import { createJournalServer } from "../journal.js";
import {
scanCompletions,
type CompletionsPayload,
} from "../completions/index.js";
interface ContentIndex {
[path: string]: string;
@ -68,11 +63,7 @@ export function scanDirectory(dir: string): ContentIndex {
const stats = statSync(fullPath);
if (stats.isDirectory()) {
scan(fullPath, relPath);
} else if (
entry.endsWith(".md") ||
entry.endsWith(".csv") ||
entry.endsWith(".yarn")
) {
} else if (entry.endsWith(".md") || entry.endsWith(".csv") || entry.endsWith(".yarn")) {
try {
const content = readFileSync(fullPath, "utf-8");
index[normalizedRelPath] = content;
@ -148,7 +139,6 @@ function createRequestHandler(
contentDir: string,
distDir: string,
getIndex: () => ContentIndex,
getCompletions: () => CompletionsPayload,
) {
return (req: IncomingMessage, res: ServerResponse) => {
const url = req.url || "/";
@ -160,12 +150,6 @@ function createRequestHandler(
return;
}
// 1b. 处理 /__COMPLETIONS.json
if (filePath === "/__COMPLETIONS.json") {
sendJson(res, getCompletions());
return;
}
// 2. 处理 /static/ 目录(从 dist/web
if (filePath.startsWith("/static/")) {
if (tryServeStatic(res, filePath, distDir)) {
@ -207,10 +191,6 @@ export interface ContentServer {
*
*/
index: ContentIndex;
/**
*
*/
completions: CompletionsPayload;
/**
*
*/
@ -227,112 +207,79 @@ export function createContentServer(
host: string = "0.0.0.0",
): ContentServer {
let contentIndex: ContentIndex = {};
let completionsIndex: CompletionsPayload = { dice: [], links: [] };
/** Re-scan completions from current content index (cached) */
function recomputeCompletions(): void {
completionsIndex = scanCompletions(contentIndex);
console.log(
`[completions] dice=${completionsIndex.dice.length} links=${completionsIndex.links.length}`,
);
}
// 扫描内容目录生成索引
console.log("扫描内容目录...");
contentIndex = scanDirectory(contentDir);
console.log(`已索引 ${Object.keys(contentIndex).length} 个文件`);
recomputeCompletions();
// 监听文件变化
console.log("监听文件变化...");
const watcher = watch(contentDir, {
ignored: /(^|[\/\\])\./,
ignored: /(^|[\/\\])\../,
persistent: true,
ignoreInitial: true,
});
watcher
.on("add", (path) => {
if (
path.endsWith(".md") ||
path.endsWith(".csv") ||
path.endsWith(".yarn")
) {
if (path.endsWith(".md") || path.endsWith(".csv") || path.endsWith(".yarn")) {
try {
const content = readFileSync(path, "utf-8");
const relPath = "/" + relative(contentDir, path).split(sep).join("/");
contentIndex[relPath] = content;
console.log(`[新增] ${relPath}`);
if (relPath.endsWith(".md")) recomputeCompletions();
} catch (e) {
console.error(`读取新增文件失败:${path}`, e);
}
}
})
.on("change", (path) => {
if (
path.endsWith(".md") ||
path.endsWith(".csv") ||
path.endsWith(".yarn")
) {
if (path.endsWith(".md") || path.endsWith(".csv") || path.endsWith(".yarn")) {
try {
const content = readFileSync(path, "utf-8");
const relPath = "/" + relative(contentDir, path).split(sep).join("/");
contentIndex[relPath] = content;
console.log(`[更新] ${relPath}`);
if (relPath.endsWith(".md")) recomputeCompletions();
} catch (e) {
console.error(`读取更新文件失败:${path}`, e);
}
}
})
.on("unlink", (path) => {
if (
path.endsWith(".md") ||
path.endsWith(".csv") ||
path.endsWith(".yarn")
) {
if (path.endsWith(".md") || path.endsWith(".csv") || path.endsWith(".yarn")) {
const relPath = "/" + relative(contentDir, path).split(sep).join("/");
delete contentIndex[relPath];
console.log(`[删除] ${relPath}`);
if (relPath.endsWith(".md")) recomputeCompletions();
}
});
// ---- Journal / MQTT broker ----
let journal: Awaited<ReturnType<typeof createJournalServer>> | null = null;
// 创建 HTTP 服务器 BEFORE journal so we can pass it in
// 创建请求处理器
const handleRequest = createRequestHandler(
contentDir,
distPath,
() => contentIndex,
() => completionsIndex,
);
const server = createServer(handleRequest);
createJournalServer(contentDir, server).then((j) => {
journal = j;
});
// 创建 HTTP 服务器
const server = createServer(handleRequest);
server.listen(port, host, () => {
const displayHost = host === "0.0.0.0" ? "localhost" : host;
console.log(`\n开发服务器已启动http://${displayHost}:${port}`);
console.log(`内容目录:${contentDir}`);
console.log(`静态资源目录:${distPath}`);
console.log(`Journal 连接ws://${displayHost}:${port}\n`);
console.log(`索引文件http://${displayHost}:${port}/__CONTENT_INDEX.json\n`);
});
return {
server,
watcher,
index: contentIndex,
completions: completionsIndex,
close() {
console.log("关闭内容服务器...");
server.close();
watcher.close();
if (journal) journal.close();
},
};
}

View File

@ -1,32 +0,0 @@
/**
* Completion index orchestrates all registered completion sources.
*/
import { diceSource } from "./sources/dice.js";
import { linksSource } from "./sources/links.js";
import type { CompletionSource, CompletionsPayload } from "./types.js";
export type {
CompletionsPayload,
DiceCompletion,
LinkCompletion,
} from "./types.js";
/** Registered sources — open for extension */
const sources: CompletionSource[] = [diceSource, linksSource];
/**
* Scan the full content index and return structured completion data.
* Called at server startup and on any file change.
*/
export function scanCompletions(
index: Record<string, string>,
): CompletionsPayload {
const payload: Record<string, unknown[]> = {};
for (const source of sources) {
payload[source.key] = source.scan(index);
}
return payload as unknown as CompletionsPayload;
}

View File

@ -1,38 +0,0 @@
/**
* Dice completion source extracts `<md-dice>` text content from markdown files.
*/
import type { CompletionSource, DiceCompletion } from "../types.js";
function looksLikeDice(raw: string): boolean {
if (raw.length > 80) return false;
return /^\d*d\d+/i.test(raw) || /^[+-]/.test(raw);
}
export const diceSource: CompletionSource = {
key: "dice",
scan(index) {
const items: DiceCompletion[] = [];
const tagRegex = /:md-dice\[([^[]+)\]/gi;
for (const [path, content] of Object.entries(index)) {
if (!path.endsWith(".md")) continue;
let match: RegExpExecArray | null;
tagRegex.lastIndex = 0;
while ((match = tagRegex.exec(content)) !== null) {
const raw = match[1].trim();
if (!raw || !looksLikeDice(raw)) continue;
items.push({
label: raw,
notation: raw,
source: path,
});
}
}
return items;
},
};

View File

@ -1,55 +0,0 @@
/**
* Link completion source extracts markdown headings from all .md files.
*
* Produces two entries per heading section, plus one for the file itself.
* Uses github-slugger to match marked-gfm-heading-id's generated IDs.
*/
import Slugger from "github-slugger";
import type { CompletionSource, LinkCompletion } from "../types.js";
export const linksSource: CompletionSource = {
key: "links",
scan(index) {
const items: LinkCompletion[] = [];
for (const [filePath, content] of Object.entries(index)) {
if (!filePath.endsWith(".md")) continue;
// Strip .md extension for the router-friendly path
const basePath = filePath.replace(/\.md$/, "");
const fileName = fileNameFromPath(basePath);
const slugger = new Slugger();
// Add the file itself as a link (whole article)
items.push({
path: basePath,
label: fileName,
section: null,
});
// Parse headings for section-scoped links
const headingRegex = /^(#{1,6})\s+(.+)$/gm;
let match: RegExpExecArray | null;
while ((match = headingRegex.exec(content)) !== null) {
const title = match[2].trim();
const id = slugger.slug(title.toLowerCase());
items.push({
path: basePath,
label: `${fileName} § ${title}`,
section: id,
});
}
}
return items;
},
};
function fileNameFromPath(path: string): string {
const parts = path.split("/").filter(Boolean);
return parts[parts.length - 1] || path;
}

View File

@ -1,40 +0,0 @@
/**
* Completion source types shared between CLI scanner and frontend consumer.
*/
/** A single dice expression found in a markdown file */
export interface DiceCompletion {
/** Display text shown in the dropdown */
label: string;
/** Dice notation, e.g. "3d6kh1" or "1d20+5" */
notation: string;
/** Source file for attribution */
source: string;
}
/** A single link target (article path, optionally scoped to a heading) */
export interface LinkCompletion {
/** Route-relative path without .md extension, e.g. "/rules/combat" */
path: string;
/** Human-readable label */
label: string;
/** Heading slug anchor (without #), or null for the whole file */
section: string | null;
}
/** Top-level payload served at /__COMPLETIONS.json */
export interface CompletionsPayload {
dice: DiceCompletion[];
links: LinkCompletion[];
}
/**
* A completion source plugin. Add new sources by implementing this
* interface and registering in the barrel.
*/
export interface CompletionSource {
/** Unique key — becomes the top-level key in CompletionsPayload */
key: string;
/** Scan the content index and return structured completion items */
scan(index: Record<string, string>): unknown[];
}

View File

@ -1,32 +1,33 @@
#!/usr/bin/env node
import { Command } from "commander";
import { serveCommand } from "./commands/serve.js";
import { compileCommand } from "./commands/compile.js";
import { mcpCommand } from "./commands/mcp.js";
import type { ServeOptions, CompileOptions } from "./types.js";
import { Command } from 'commander';
import { serveCommand } from './commands/serve.js';
import { compileCommand } from './commands/compile.js';
import { mcpCommand } from './commands/mcp.js';
import type { ServeOptions, CompileOptions } from './types.js';
const program = new Command();
program
.name("ttrpg")
.description("TTRPG 工具箱 - 用于编译和预览 TTRPG 文档")
.version("0.0.1");
.name('ttrpg')
.description('TTRPG 工具箱 - 用于编译和预览 TTRPG 文档')
.version('0.0.1');
program
.command("serve")
.description("运行一个 web 服务器预览目录中的内容,并实时监听更新")
.argument("[dir]", "要预览的目录", ".")
.option("-p, --port <port>", "HTTP 端口号", "3000")
.option("-h, --host <host>", "主机地址", "0.0.0.0")
.command('serve')
.description('运行一个 web 服务器预览目录中的内容,并实时监听更新')
.argument('[dir]', '要预览的目录', '.')
.option('-p, --port <port>', '端口号', '3000')
.option('-h, --host <host>', '主机地址', '0.0.0.0')
.action(serveCommand);
program
.command("compile")
.description("将目录中的内容输出为带 hash 路由、单个 html 入口的 web 应用")
.argument("[dir]", "要编译的目录", ".")
.option("-o, --output <dir>", "输出目录", "./dist/output")
.command('compile')
.description('将目录中的内容输出为带 hash 路由、单个 html 入口的 web 应用')
.argument('[dir]', '要编译的目录', '.')
.option('-o, --output <dir>', '输出目录', './dist/output')
.action(compileCommand);
program.addCommand(mcpCommand);
program
.addCommand(mcpCommand);
program.parse();

View File

@ -1,227 +0,0 @@
/**
* Journal stream persistence MQTT broker + JSONL append + session manifest
*
* Runs an aedes MQTT broker over WebSocket, attached to the existing
* HTTP server. Uses ws v8's built-in createWebSocketStream.
*
* Pattern from aedes docs:
* https://github.com/moscajs/aedes#mqtt-server-over-websocket
*
* Persistence uses aedes's internal subscribe/publish API, so the
* server process doesn't need to connect to itself as an MQTT client.
*/
import { join, resolve } from "path";
import {
appendFileSync,
existsSync,
mkdirSync,
readFileSync,
rmdirSync,
unlinkSync,
writeFileSync,
} from "fs";
import type { Server as HttpServer } from "http";
import type { AedesPublishPacket } from "aedes";
import { WebSocketServer, createWebSocketStream } from "ws";
// ---------------------------------------------------------------------------
// Types
// ---------------------------------------------------------------------------
interface SessionMeta {
name: string;
created: number;
players: string[];
}
interface SessionManifest {
sessions: Record<string, SessionMeta>;
}
export interface JournalServer {
broker: import("aedes").Aedes;
wsServer: import("ws").WebSocketServer;
close(): Promise<void>;
}
// ---------------------------------------------------------------------------
// Path helpers
// ---------------------------------------------------------------------------
function dataDir(contentDir: string): string {
const d = resolve(contentDir, ".ttrpg");
mkdirSync(d, { recursive: true });
return d;
}
function manifestPath(dataRoot: string): string {
return join(dataRoot, "manifest.json");
}
function sessionDir(dataRoot: string, id: string): string {
return join(dataRoot, "sessions", id);
}
function streamPath(dataRoot: string, id: string): string {
return join(sessionDir(dataRoot, id), "stream.jsonl");
}
function loadManifest(dataRoot: string): SessionManifest {
const p = manifestPath(dataRoot);
if (!existsSync(p)) return { sessions: {} };
try {
return JSON.parse(readFileSync(p, "utf-8"));
} catch {
return { sessions: {} };
}
}
function saveManifest(dataRoot: string, m: SessionManifest): void {
writeFileSync(manifestPath(dataRoot), JSON.stringify(m, null, 2), "utf-8");
}
function appendStream(dataRoot: string, id: string, line: string): void {
const sd = sessionDir(dataRoot, id);
mkdirSync(sd, { recursive: true });
appendFileSync(streamPath(dataRoot, id), line + "\n", "utf-8");
}
// ---------------------------------------------------------------------------
// Server factory
// ---------------------------------------------------------------------------
const $SESSIONS = "ttrpg/$SESSIONS";
export async function createJournalServer(
contentDir: string,
httpServer: HttpServer,
): Promise<JournalServer> {
const root = dataDir(contentDir);
console.log(`[journal] data dir: ${root}`);
// ---- MQTT Broker ----
const { Aedes: AedesFactory } = await import("aedes");
// aedes requires listen() to initialize persistence before handling connections.
const broker = await AedesFactory.createBroker();
// ---- WebSocket server (attached to the existing HTTP server) ----
// Pattern from aedes docs: pass { server } to WSS, use 'connection' event
const wss = new WebSocketServer({ server: httpServer });
wss.on("connection", (ws, req) => {
const stream = createWebSocketStream(ws);
broker.handle(stream, req);
});
broker.on("client", (client: import("aedes").Client) => {
console.log(`[journal] client ready: ${client.id}`);
});
broker.on("clientDisconnect", (client: import("aedes").Client) => {
console.log(`[journal] client disconnected: ${client.id}`);
});
// ---- Persistence (internal subscribe, no separate MQTT client) ----
broker.subscribe(
"ttrpg/+/stream",
(packet, cb) => {
const sessionId = extractSessionId(packet.topic);
if (sessionId) {
try {
appendStream(root, sessionId, packet.payload.toString());
} catch (e) {
console.error(`[journal] stream append err for ${sessionId}:`, e);
}
}
cb();
},
() => {},
);
broker.subscribe(
"ttrpg/+/meta",
(packet, cb) => {
const sessionId = extractSessionId(packet.topic);
if (sessionId) {
handleMetaChange(root, sessionId, broker, packet.payload.toString());
}
cb();
},
() => {},
);
console.log("[journal] persistence listener active");
console.log("[journal] broker available at ws://<host>:<port>");
return {
broker,
wsServer: wss,
async close() {
console.log("[journal] shutting down...");
await new Promise<void>((r) => wss.close(() => r()));
await new Promise<void>((r) => broker.close(() => r()));
},
};
}
// ---------------------------------------------------------------------------
// Helpers
// ---------------------------------------------------------------------------
function extractSessionId(topic: string): string | null {
const parts = topic.split("/");
return parts.length >= 3 ? parts[1] : null;
}
// ---------------------------------------------------------------------------
// Session lifecycle
// ---------------------------------------------------------------------------
function handleMetaChange(
root: string,
sessionId: string,
broker: import("aedes").Aedes,
rawPayload: string,
): void {
const manifest = loadManifest(root);
try {
const meta = JSON.parse(rawPayload) as SessionMeta | null;
if (!meta || !meta.name) {
if (manifest.sessions[sessionId]) {
delete manifest.sessions[sessionId];
const sp = streamPath(root, sessionId);
if (existsSync(sp)) unlinkSync(sp);
const sd = sessionDir(root, sessionId);
try {
rmdirSync(sd);
} catch {
/* */
}
console.log(`[journal] session deleted: ${sessionId}`);
}
} else {
manifest.sessions[sessionId] = {
name: meta.name,
created: meta.created || Date.now(),
players: meta.players || [],
};
console.log(`[journal] session updated: ${sessionId} (${meta.name})`);
}
saveManifest(root, manifest);
broker.publish(
{
topic: $SESSIONS,
payload: Buffer.from(JSON.stringify(manifest)),
qos: 1,
retain: true,
cmd: "publish" as const,
dup: false,
},
() => {},
);
} catch (e) {
console.error(`[journal] meta parse err for ${sessionId}:`, e);
}
}

View File

@ -7,14 +7,8 @@ export interface CompileOptions {
output: string;
}
export type ServeCommandHandler = (
dir: string,
options: ServeOptions,
) => Promise<void>;
export type CompileCommandHandler = (
dir: string,
options: CompileOptions,
) => Promise<void>;
export type ServeCommandHandler = (dir: string, options: ServeOptions) => Promise<void>;
export type CompileCommandHandler = (dir: string, options: CompileOptions) => Promise<void>;
export interface MarkdownFile {
path: string;

View File

@ -8,7 +8,6 @@ export interface SidebarProps {
onClose: () => void;
fileTree?: FileNode[];
pathHeadings?: Record<string, TocNode[]>;
onDataSourceOpen?: () => void;
}
interface SidebarContentProps {
@ -17,7 +16,6 @@ interface SidebarContentProps {
currentPath: string;
onClose: () => void;
isDesktop?: boolean;
onDataSourceOpen?: () => void;
}
/**
@ -39,24 +37,15 @@ const SidebarContent: Component<SidebarContentProps> = (props) => {
<div class="p-4 border-b border-b-gray-200">
<div class="flex items-center justify-between">
<h2 class="text-lg font-bold text-gray-900"></h2>
<div class="flex items-center gap-1">
<Show when={!props.isDesktop}>
<button
onClick={props.onDataSourceOpen}
class="text-gray-400 hover:text-gray-600 p-1 rounded hover:bg-gray-100"
title="Content Source"
onClick={props.onClose}
class="text-gray-500 hover:text-gray-700"
title="关闭"
>
📂
</button>
<Show when={!props.isDesktop}>
<button
onClick={props.onClose}
class="text-gray-500 hover:text-gray-700"
title="关闭"
>
</button>
</Show>
</div>
</Show>
</div>
</div>
@ -132,7 +121,6 @@ export const MobileSidebar: Component<SidebarProps> = (props) => {
pathHeadings={pathHeadings()}
currentPath={location.pathname}
onClose={props.onClose}
onDataSourceOpen={props.onDataSourceOpen}
/>
</aside>
</>
@ -145,7 +133,6 @@ export const MobileSidebar: Component<SidebarProps> = (props) => {
export const DesktopSidebar: Component<{
fileTree?: FileNode[];
pathHeadings?: Record<string, TocNode[]>;
onDataSourceOpen?: () => void;
}> = (props) => {
const location = useLocation();
const [selfFileTree, setSelfFileTree] = createSignal<FileNode[]>([]);
@ -171,7 +158,6 @@ export const DesktopSidebar: Component<{
currentPath={location.pathname}
onClose={() => {}}
isDesktop
onDataSourceOpen={props.onDataSourceOpen}
/>
</aside>
);

View File

@ -1,87 +0,0 @@
/**
* ComposePanel type picker + dynamic form + send button
*/
import { Component, createSignal, For, Show, createMemo } from "solid-js";
import { registeredTypes, canEmit } from "./registry";
import { sendMessage, useJournalStream } from "../stores/journalStream";
import type { MessageTypeDef } from "./registry";
import { DynamicForm } from "./DynamicForm";
export const ComposePanel: Component = () => {
const stream = useJournalStream();
const [selectedType, setSelectedType] = createSignal<string>("chat");
const [formData, setFormData] = createSignal<Record<string, unknown>>({});
const [error, setError] = createSignal<string | null>(null);
const [sending, setSending] = createSignal(false);
const role = () => stream.myRole;
// Available types filtered by role
const availableTypes = createMemo(() => {
const types: string[] = [];
for (const type of registeredTypes()) {
if (canEmit(type, role())) {
types.push(type);
}
}
// Default to chat if available, otherwise first
if (types.includes("chat")) {
return types;
}
return types;
});
const handleSend = () => {
setError(null);
setSending(true);
const result = sendMessage(selectedType(), formData());
if (!result.success) {
setError(result.error);
} else {
setFormData({}); // Reset on success
}
setSending(false);
};
return (
<div class="border-t border-gray-200 p-2 space-y-2 bg-white">
{/* Type picker */}
<div class="flex items-center gap-2">
<select
value={selectedType()}
onChange={(e) => {
setSelectedType(e.currentTarget.value);
setFormData({});
}}
class="text-xs border border-gray-300 rounded px-2 py-1 bg-white"
>
<For each={availableTypes()}>
{(type) => <option value={type}>{type}</option>}
</For>
</select>
<button
onClick={handleSend}
disabled={sending()}
class="ml-auto bg-blue-600 text-white rounded px-3 py-1 text-xs hover:bg-blue-700 disabled:opacity-50"
>
{sending() ? "..." : "Send"}
</button>
</div>
{/* Dynamic form */}
<DynamicForm
type={selectedType()}
value={formData()}
onChange={setFormData}
/>
<Show when={error()}>
<p class="text-red-500 text-xs">{error()}</p>
</Show>
</div>
);
};

View File

@ -1,144 +0,0 @@
/**
* ConnectionBar session picker, player info (connected state only)
*/
import { Component, createSignal, Show, For } from "solid-js";
import {
hydrateFromServer,
useJournalStream,
sessions,
createSession,
deleteSession,
disconnectStream,
setSessionId,
} from "../stores/journalStream";
import { journalSetState } from "../stores/journalStream";
export const ConnectionBar: Component = () => {
const stream = useJournalStream();
const [newSessionName, setNewSessionName] = createSignal("");
const [showNewSession, setShowNewSession] = createSignal(false);
const [error, setError] = createSignal<string | null>(null);
const manifest = sessions;
const handleCreateSession = () => {
const name = newSessionName().trim();
if (!name) return;
createSession(name);
setNewSessionName("");
setShowNewSession(false);
};
const handleDeleteSession = (id: string) => {
if (confirm(`Delete session "${id}"? This cannot be undone.`)) {
deleteSession(id);
}
};
const handleSessionSelect = async (id: string) => {
try {
await hydrateFromServer(id);
setSessionId(id);
} catch (e) {
setError("Failed to load session");
}
};
const handleDisconnect = () => {
disconnectStream();
};
return (
<div class="border-b border-gray-200 px-3 py-2 space-y-2 text-sm">
{/* Status row */}
<div class="flex items-center gap-2">
<span class="w-2 h-2 rounded-full bg-green-500" />
<span class="text-xs text-gray-500">connected</span>
<Show when={stream.sessionId}>
<span class="text-xs font-mono bg-gray-100 px-1.5 py-0.5 rounded">
{stream.sessionName || stream.sessionId}
</span>
</Show>
<div class="flex-1" />
<Show when={stream.myName !== "gm"}>
<span class="text-xs text-gray-400">Playing as {stream.myName}</span>
</Show>
<button
onClick={handleDisconnect}
class="text-xs text-gray-400 hover:text-red-500"
title="Disconnect"
>
</button>
</div>
{/* GM session management */}
<Show when={stream.myName === "gm"}>
<div class="space-y-1">
<div class="flex items-center gap-1">
<span class="text-xs text-gray-500">Sessions:</span>
<button
onClick={() => setShowNewSession((v) => !v)}
class="text-xs text-blue-600 hover:underline"
>
+ new
</button>
</div>
<Show when={showNewSession()}>
<div class="flex gap-1">
<input
type="text"
value={newSessionName()}
onInput={(e) => setNewSessionName(e.currentTarget.value)}
placeholder="Session name"
class="flex-1 border border-gray-300 rounded px-2 py-0.5 text-xs"
onKeyDown={(e) => e.key === "Enter" && handleCreateSession()}
/>
<button
onClick={handleCreateSession}
class="bg-green-600 text-white rounded px-2 py-0.5 text-xs hover:bg-green-700"
>
Create
</button>
</div>
</Show>
<For each={Object.entries(manifest().sessions)}>
{([id, meta]) => (
<div
class={`flex items-center gap-1 px-1 py-0.5 rounded cursor-pointer text-xs ${
id === stream.sessionId
? "bg-blue-100 text-blue-800"
: "hover:bg-gray-100"
}`}
>
<span
class="flex-1 truncate"
onClick={() => handleSessionSelect(id)}
>
{meta.name || id}
</span>
<button
onClick={(e) => {
e.stopPropagation();
handleDeleteSession(id);
}}
class="text-gray-400 hover:text-red-500 text-xs"
title="Delete session"
>
</button>
</div>
)}
</For>
</div>
</Show>
<Show when={error()}>
<p class="text-red-500 text-xs">{error()}</p>
</Show>
</div>
);
};

View File

@ -1,299 +0,0 @@
/**
* DynamicForm renders form inputs from a Zod schema
*
* Walks the schema's shape and renders appropriate inputs for each field.
* Supports: string, number, boolean, enum, array, and nested objects.
* Optional fields get a checkbox to toggle inclusion.
*/
import { Component, For, Show, createMemo } from "solid-js";
import { z } from "zod";
import { getMessageType } from "./registry";
import type { MessageTypeDef } from "./registry";
type FormData = Record<string, unknown>;
interface DynamicFormProps {
type: string;
value: FormData;
onChange: (data: FormData) => void;
}
export const DynamicForm: Component<DynamicFormProps> = (props) => {
const def = createMemo(() => getMessageType(props.type));
// Generate default on type change
const schema = createMemo(() => def()?.schema);
return (
<div class="space-y-1.5">
<Show when={def()}>
{(d) => (
<SchemaFields
schema={d().schema}
value={props.value}
onChange={props.onChange}
/>
)}
</Show>
</div>
);
};
// ---------------------------------------------------------------------------
// SchemaFields — recursive
// ---------------------------------------------------------------------------
const SchemaFields: Component<{
schema: z.ZodTypeAny;
value: FormData;
onChange: (data: FormData) => void;
prefix?: string;
}> = (props) => {
const shape = unwrapShape(props.schema);
if (!shape) {
return <p class="text-xs text-gray-400">No fields</p>;
}
return (
<For each={Object.entries(shape)}>
{([key, fieldSchema]) => {
const def = (fieldSchema as z.ZodDefault<any>)._def;
const isOptional = fieldSchema.isOptional?.() ?? false;
const fieldType = getFieldType(fieldSchema);
// Nested object
if (fieldType === "object" && fieldSchema instanceof z.ZodObject) {
const nestedValue = (props.value[key] as FormData) ?? {};
return (
<fieldset class="border border-gray-200 rounded px-2 py-1">
<legend class="text-xs text-gray-500 px-1">{key}</legend>
<SchemaFields
schema={fieldSchema}
value={nestedValue}
onChange={(v) => updateField(props, key, v)}
/>
</fieldset>
);
}
// Optional toggle
const isSet = () => props.value[key] !== undefined;
const toggleOptional = () => {
if (isSet()) {
const next = { ...props.value };
delete next[key];
props.onChange(next);
} else {
props.onChange({ ...props.value, [key]: getDefault(fieldSchema) });
}
};
return (
<div class="flex items-start gap-1.5">
<Show when={isOptional}>
<input
type="checkbox"
checked={isSet()}
onChange={toggleOptional}
class="mt-1.5"
title={`Include ${key}`}
/>
</Show>
<div class={`flex-1 ${isOptional && !isSet() ? "opacity-40" : ""}`}>
<FieldInput
label={key}
schema={fieldSchema}
value={isSet() ? props.value[key] : getDefault(fieldSchema)}
onChange={(v) => updateField(props, key, v)}
disabled={isOptional && !isSet()}
/>
</div>
</div>
);
}}
</For>
);
};
// ---------------------------------------------------------------------------
// FieldInput
// ---------------------------------------------------------------------------
const FieldInput: Component<{
label: string;
schema: z.ZodTypeAny;
value: unknown;
onChange: (v: unknown) => void;
disabled?: boolean;
}> = (props) => {
const fieldType = createMemo(() => getFieldType(props.schema));
const inputClass =
"w-full border border-gray-300 rounded px-2 py-1 text-xs disabled:bg-gray-100 disabled:text-gray-400";
return (
<div class="flex items-center gap-1">
<label
class="text-xs text-gray-500 w-20 flex-shrink-0 truncate"
title={props.label}
>
{props.label}
</label>
<Show when={fieldType() === "string"}>
<input
type="text"
value={(props.value as string) ?? ""}
onInput={(e) => props.onChange(e.currentTarget.value)}
disabled={props.disabled}
class={inputClass}
/>
</Show>
<Show when={fieldType() === "number"}>
<input
type="number"
value={(props.value as number) ?? ""}
onInput={(e) =>
props.onChange(parseFloat(e.currentTarget.value) || 0)
}
disabled={props.disabled}
class={inputClass}
/>
</Show>
<Show when={fieldType() === "boolean"}>
<input
type="checkbox"
checked={(props.value as boolean) ?? false}
onChange={(e) => props.onChange(e.currentTarget.checked)}
disabled={props.disabled}
class="mt-1"
/>
</Show>
<Show when={fieldType() === "enum"}>
<select
value={(props.value as string) ?? ""}
onChange={(e) => props.onChange(e.currentTarget.value)}
disabled={props.disabled}
class={inputClass}
>
<For each={getEnumValues(props.schema)}>
{(v) => <option value={v}>{v}</option>}
</For>
</select>
</Show>
<Show when={fieldType() === "array"}>
<input
type="text"
value={
Array.isArray(props.value) ? (props.value as any[]).join(", ") : ""
}
onInput={(e) => {
const raw = e.currentTarget.value;
const parsed = raw
.split(",")
.map((s) => s.trim())
.filter(Boolean)
.map((s) => (isNaN(Number(s)) ? s : Number(s)));
props.onChange(parsed);
}}
disabled={props.disabled}
class={inputClass}
placeholder="comma-separated"
/>
</Show>
</div>
);
};
// ---------------------------------------------------------------------------
// Helpers
// ---------------------------------------------------------------------------
function unwrapShape(
schema: z.ZodTypeAny,
): Record<string, z.ZodTypeAny> | null {
try {
if (schema instanceof z.ZodObject) {
return schema.shape as Record<string, z.ZodTypeAny>;
}
// Unwrap optional/branded/etc
const inner = (schema as any)?._def?.innerType;
if (inner) return unwrapShape(inner);
return null;
} catch {
return null;
}
}
type FieldType =
"string" | "number" | "boolean" | "enum" | "array" | "object" | "unknown";
function getFieldType(schema: z.ZodTypeAny): FieldType {
if (schema instanceof z.ZodString) return "string";
if (schema instanceof z.ZodNumber) return "number";
if (schema instanceof z.ZodBoolean) return "boolean";
if (schema instanceof z.ZodEnum) return "enum";
// Zod v4: nativeEnum returns ZodEnum, catch with duck-type check
if (isZodEnum(schema)) return "enum";
if (schema instanceof z.ZodArray) return "array";
if (schema instanceof z.ZodObject) return "object";
// Unwrap optional
const inner = (schema as any)?._def?.innerType;
if (inner) return getFieldType(inner);
return "unknown";
}
/** Duck-type check: Zod v4's nativeEnum returns a ZodEnum instance */
function isZodEnum(schema: z.ZodTypeAny): boolean {
return (schema as any)?._def?.typeName === "ZodEnum";
}
function getEnumValues(schema: z.ZodTypeAny): string[] {
try {
if (schema instanceof z.ZodEnum) return schema.options as string[];
if (isZodEnum(schema) && (schema as any).enum) {
return Object.values((schema as any).enum).filter(
(v: unknown) => typeof v === "string",
) as string[];
}
const inner = (schema as any)?._def?.innerType;
if (inner) return getEnumValues(inner);
} catch {
/* ignore */
}
return [];
}
function getDefault(schema: z.ZodTypeAny): unknown {
const ft = getFieldType(schema);
switch (ft) {
case "string":
return "";
case "number":
return 0;
case "boolean":
return false;
case "array":
return [];
case "object":
return {};
case "enum":
return getEnumValues(schema)[0] ?? "";
default:
return "";
}
}
function updateField(
parent: { value: FormData; onChange: (data: FormData) => void },
key: string,
value: unknown,
): void {
parent.onChange({ ...parent.value, [key]: value });
}

View File

@ -1,461 +0,0 @@
/**
* JournalInput chat-style textarea with command dispatch and autocomplete.
*
* - Enter sends, Shift+Enter inserts newline
* - Plain text "chat" type
* - `/roll 3d6kh1` "roll" type (result resolved client-side by GM)
* - `/link path#section` "link" type
* - `/` alone opens completions dropdown (populated from /__COMPLETIONS.json
* in CLI mode, or client-side scan of the in-memory file index in dev mode)
*/
import {
Component,
createSignal,
createEffect,
onMount,
onCleanup,
Show,
For,
Switch,
Match,
} from "solid-js";
import { sendMessage, useJournalStream } from "../stores/journalStream";
import { useJournalCompletions, ensureCompletions } from "./completions";
import { resolveRollPayload } from "./types/roll";
// ---- Helpers ----
interface CompletionItem {
label: string;
kind: "command" | "value" | "no-results";
insertText: string;
}
interface ParsedInput {
type: "chat" | "roll" | "link";
payload: Record<string, unknown>;
error?: string;
}
function parseInput(raw: string): ParsedInput {
if (raw.startsWith("/roll ")) {
const notation = raw.slice("/roll ".length).trim();
if (!notation)
return { type: "roll", payload: {}, error: "Dice notation required" };
return { type: "roll", payload: { notation, label: notation } };
}
if (raw.startsWith("/link ")) {
const arg = raw.slice("/link ".length).trim();
if (!arg) return { type: "link", payload: {}, error: "Path required" };
const hashIdx = arg.indexOf("#");
const path = hashIdx === -1 ? arg : arg.slice(0, hashIdx);
const section =
hashIdx === -1 ? undefined : arg.slice(hashIdx + 1) || undefined;
return { type: "link", payload: { path, section } };
}
// /roll or /link with no space — need to complete, don't send
if (raw === "/roll" || raw === "/link") {
return { type: "chat", payload: {}, error: "Complete the command" };
}
return { type: "chat", payload: { text: raw } };
}
// ---- Component ----
export const JournalInput: Component = () => {
const stream = useJournalStream();
const comp = useJournalCompletions();
const isObserver = () => stream.myRole === "observer";
const isPlayer = () => stream.myRole === "player";
const isGm = () => stream.myRole === "gm";
const [text, setText] = createSignal("");
const [error, setError] = createSignal<string | null>(null);
const [sending, setSending] = createSignal(false);
const [showCompletions, setShowCompletions] = createSignal(false);
const [selectedIdx, setSelectedIdx] = createSignal(0);
let textareaRef!: HTMLTextAreaElement;
let completionsRef!: HTMLDivElement;
// Ensure completions are loading on mount
onMount(() => {
void ensureCompletions();
});
// ---- Send ----
function handleSend() {
const raw = text().trim();
if (!raw) return;
// Players / observers: everything is plain chat
if (isPlayer() || isObserver()) {
const result = sendMessage("chat", { text: raw });
if (!result.success) {
setError(result.error);
} else {
setText("");
}
textareaRef?.focus();
return;
}
const parsed = parseInput(raw);
if (parsed.error) {
setError(parsed.error);
return;
}
setError(null);
setSending(true);
// GM roll: resolve the dice result locally
if (parsed.type === "roll") {
const p = resolveRollPayload(
parsed.payload as { notation: string; label?: string },
);
const result = sendMessage("roll", p);
if (!result.success) {
setError(result.error);
} else {
setText("");
}
setSending(false);
textareaRef?.focus();
return;
}
const result = sendMessage(parsed.type, parsed.payload);
if (!result.success) {
setError(result.error);
} else {
setText("");
}
setSending(false);
textareaRef?.focus();
}
// ---- Completions ----
// ---- Scroll selected completion into view ----
createEffect(() => {
const idx = selectedIdx();
if (!showCompletions() || !completionsRef) return;
const el = completionsRef.querySelector(`[data-comp-idx="${idx}"]`);
if (el) el.scrollIntoView({ block: "nearest" });
});
// ---- Completions ----
function buildCompletions(): CompletionItem[] {
const raw = text().trim();
// Only GM gets completions
if (!isGm()) return [];
const data = comp.data;
const commands = [
{ label: "/roll", kind: "command" as const, insertText: "/roll " },
{ label: "/link", kind: "command" as const, insertText: "/link " },
];
// Show commands when user types / or starts typing a command name
if (raw.startsWith("/") && !raw.includes(" ")) {
const prefix = raw.toLowerCase();
const matches = commands.filter((c) =>
c.label.toLowerCase().startsWith(prefix),
);
return matches.length > 0
? matches
: [{ label: "Unknown command", kind: "no-results", insertText: "" }];
}
// After /roll — show dice suggestions
if (raw.startsWith("/roll ")) {
const prefix = raw.slice("/roll ".length).toLowerCase();
const matches = data.dice
.filter(
(d) =>
d.notation.toLowerCase().includes(prefix) ||
d.label.toLowerCase().includes(prefix),
)
.slice(0, 8);
if (matches.length === 0) {
return [{ label: "No dice found", kind: "no-results", insertText: "" }];
}
return matches.map((d) => ({
label: d.notation,
kind: "value" as const,
insertText: "/roll " + d.notation,
}));
}
// After /link — show article and heading suggestions
if (raw.startsWith("/link ")) {
const prefix = raw.slice("/link ".length).toLowerCase();
const matches = data.links
.filter(
(l) =>
l.path.toLowerCase().includes(prefix) ||
l.label.toLowerCase().includes(prefix),
)
.slice(0, 8);
if (matches.length === 0) {
return [
{ label: "No links found", kind: "no-results", insertText: "" },
];
}
return matches.map((l) => {
const insert = l.section
? `/link ${l.path}#${l.section}`
: `/link ${l.path}`;
return {
label: l.label,
kind: "value" as const,
insertText: insert,
};
});
}
return [];
}
function currentCompletions(): CompletionItem[] {
return buildCompletions();
}
function acceptCompletion(item: CompletionItem) {
if (item.kind === "no-results") return;
setText(item.insertText);
setShowCompletions(false);
textareaRef?.focus();
}
function selectCompletion(dir: "up" | "down") {
const comps = currentCompletions();
if (comps.length === 0) return;
setSelectedIdx((prev) => {
if (dir === "down") return (prev + 1) % comps.length;
return (prev - 1 + comps.length) % comps.length;
});
}
// ---- Keyboard ----
function handleKeyDown(e: KeyboardEvent) {
const comps = currentCompletions();
const open = showCompletions();
// When opening completions with Tab, if the text matches a command prefix
// and there are options, accept the first. Otherwise just show.
if (e.key === "Tab") {
if (open) {
e.preventDefault();
if (comps.length > 0 && comps[selectedIdx()].kind !== "no-results") {
acceptCompletion(comps[selectedIdx()]);
}
return;
}
// If not open and starts with /, open completions (GM only)
const raw = text();
if (isGm() && raw.startsWith("/")) {
e.preventDefault();
setShowCompletions(true);
setSelectedIdx(0);
return;
}
return;
}
if (open && comps.length > 0) {
if (e.key === "ArrowDown") {
e.preventDefault();
selectCompletion("down");
return;
}
if (e.key === "ArrowUp") {
e.preventDefault();
selectCompletion("up");
return;
}
if (e.key === "Enter" && comps[selectedIdx()].kind !== "no-results") {
e.preventDefault();
acceptCompletion(comps[selectedIdx()]);
return;
}
if (e.key === "Escape") {
setShowCompletions(false);
return;
}
}
// Send
if (e.key === "Enter" && !e.shiftKey) {
e.preventDefault();
handleSend();
}
}
function handleInput(e: InputEvent) {
const input = e.currentTarget as HTMLTextAreaElement;
const raw = input.value;
setText(raw);
// Completions visibility — keep open while user types any / (GM only)
if (isGm() && raw.startsWith("/")) {
setShowCompletions(true);
setSelectedIdx(0);
} else {
setShowCompletions(false);
}
// Auto-resize
textareaRef.style.height = "auto";
textareaRef.style.height = Math.min(textareaRef.scrollHeight, 150) + "px";
}
// ---- Click outside ----
onMount(() => {
const handler = (e: MouseEvent) => {
if (completionsRef && !completionsRef.contains(e.target as Node)) {
setShowCompletions(false);
}
};
document.addEventListener("mousedown", handler);
onCleanup(() => document.removeEventListener("mousedown", handler));
});
// Completions placeholder — shown in the dropdown area when no matches
function renderCompletionsDropdown() {
const comps = currentCompletions();
if (comps.length === 0) return null;
return (
<div
ref={completionsRef}
class="absolute bottom-full left-0 right-0 bg-white border border-gray-200 rounded-t shadow-lg mb-0.5 max-h-40 overflow-y-auto z-50"
>
<For each={comps}>
{(item, idx) => (
<div
data-comp-idx={idx()}
class={`flex items-center gap-2 px-3 py-1.5 cursor-pointer text-sm ${
item.kind === "no-results"
? "text-gray-400 italic cursor-default"
: idx() === selectedIdx()
? "bg-blue-50 text-blue-700"
: "hover:bg-gray-100 text-gray-700"
}`}
onClick={() => acceptCompletion(item)}
onMouseEnter={() =>
item.kind !== "no-results" && setSelectedIdx(idx())
}
>
<Show
when={item.kind !== "no-results"}
fallback={<span class="text-xs text-gray-300 shrink-0"></span>}
>
<span
class={`text-xs px-1 rounded shrink-0 ${
item.kind === "command"
? "bg-purple-100 text-purple-700"
: "bg-gray-100 text-gray-500"
}`}
>
{item.kind === "command" ? "cmd" : "val"}
</span>
</Show>
<span class="font-mono truncate flex-1">{item.label}</span>
</div>
)}
</For>
</div>
);
}
return (
<div class="border-t border-gray-200 bg-white relative">
{/* Completions loading / error / empty teaser */}
<Show when={showCompletions()}>
<Switch>
<Match when={comp.state.status === "loading"}>
<div
ref={completionsRef}
class="absolute bottom-full left-0 right-0 bg-white border border-gray-200 rounded-t shadow-lg mb-0.5 z-50 px-3 py-2 text-xs text-gray-400 italic"
>
Loading completions
</div>
</Match>
<Match when={comp.state.status === "error"}>
<div
ref={completionsRef}
class="absolute bottom-full left-0 right-0 bg-white border border-red-200 rounded-t shadow-lg mb-0.5 z-50 px-3 py-2 text-xs text-red-500"
>
{(comp.state as any).message || "Failed to load completions"}
</div>
</Match>
<Match
when={
comp.state.status === "empty" || comp.state.status === "loaded"
}
>
{renderCompletionsDropdown()}
</Match>
</Switch>
</Show>
{/* Textarea + actions */}
<div class="flex flex-col">
<Show
when={!isObserver()}
fallback={
<div class="px-3 py-4 text-xs text-gray-400 italic text-center">
You are observing. Open this panel on another device to join as GM
or player.
</div>
}
>
<textarea
ref={textareaRef}
value={text()}
onInput={handleInput}
onKeyDown={handleKeyDown}
placeholder={
isGm()
? "Type a message, or /roll or /link..."
: "Type a message..."
}
rows={2}
class="w-full resize-none border-0 px-3 pt-2.5 pb-1 text-sm
text-gray-800 placeholder-gray-400 focus:outline-none
bg-transparent min-h-[60px]"
/>
{/* Bottom row: error + buttons */}
<div class="flex items-center justify-between px-2 pb-2">
<Show when={error()}>
<p class="text-red-500 text-xs truncate max-w-[60%]">{error()}</p>
</Show>
<div class="flex items-center gap-1 ml-auto">
<button
onClick={handleSend}
disabled={sending() || !text().trim()}
class="bg-blue-600 text-white rounded px-3 py-1 text-xs
hover:bg-blue-700 disabled:opacity-40 disabled:cursor-not-allowed
transition-colors"
>
Send
</button>
</div>
</div>
</Show>
</div>
</div>
);
};

View File

@ -1,347 +0,0 @@
/**
* JournalPanel right panel container for the journal stream
*
* Fixed overlay. Shows stream when connected, connect dialog when not.
* Auto-connects to ws://{current host}:{current port} — no URL input needed.
*/
import { Component, Show, createSignal, onMount, For } from "solid-js";
import {
connectStream,
hydrateFromServer,
useJournalStream,
setMyName,
setMyRole,
setSessionId,
sessions,
createSession,
disconnectStream,
} from "../stores/journalStream";
import { StreamView } from "./StreamView";
import { JournalInput } from "./JournalInput";
export interface JournalPanelProps {
open: boolean;
onClose: () => void;
}
export const JournalPanel: Component<JournalPanelProps> = (props) => {
const stream = useJournalStream();
// Player list (exclude gm and observer)
const playerEntries = () =>
Object.entries(stream.players).filter(
([_, p]) => p.role !== "gm" && p.role !== "observer",
);
return (
<>
{/* Backdrop — only on mobile, starts below header */}
<div
class="fixed top-16 inset-x-0 bottom-0 bg-black/30 z-40 md:hidden"
classList={{ hidden: !props.open }}
onClick={props.onClose}
/>
{/* Panel — always mounted for exit animation, visibility toggled */}
<aside
class={`fixed top-16 right-0 bottom-0 z-50 bg-white border-l border-gray-200
flex flex-col w-full max-w-md md:w-[420px] shadow-lg
transition-transform duration-300 ease-in-out ${
props.open ? "translate-x-0" : "translate-x-full"
}`}
aria-hidden={!props.open}
>
{/* Header */}
<JournalHeader onClose={props.onClose} />
{/* Player list tabs */}
<Show when={stream.connected && playerEntries().length > 0}>
<div class="flex items-center gap-1 px-3 py-1.5 border-b border-gray-100 bg-gray-50 overflow-x-auto shrink-0">
<For each={playerEntries()}>
{([name]) => (
<span class="text-xs bg-blue-100 text-blue-700 px-2 py-0.5 rounded-full whitespace-nowrap">
{name}
</span>
)}
</For>
</div>
</Show>
<Show
when={stream.connected}
fallback={
<div class="flex-1 flex items-center justify-center p-4 overflow-y-auto">
<ConnectDialog />
</div>
}
>
<div class="flex-1 min-h-0">
<StreamView />
</div>
<JournalInput />
</Show>
</aside>
</>
);
};
// ---------------------------------------------------------------------------
// Journal Header — status dot, player name, session dropdown, disconnect & close
// ---------------------------------------------------------------------------
interface JournalHeaderProps {
onClose: () => void;
}
const JournalHeader: Component<JournalHeaderProps> = (props) => {
const stream = useJournalStream();
const manifest = sessions;
const [dropdownOpen, setDropdownOpen] = createSignal(false);
const [newSessionName, setNewSessionName] = createSignal("");
const [showNewInput, setShowNewInput] = createSignal(false);
let dropdownRef!: HTMLDivElement;
const handleSessionSelect = async (id: string) => {
try {
await hydrateFromServer(id);
setSessionId(id);
} catch {
/* */
}
setDropdownOpen(false);
};
const handleCreate = () => {
const name = newSessionName().trim();
if (!name) return;
createSession(name);
setNewSessionName("");
setShowNewInput(false);
};
const handleDisconnect = () => disconnectStream();
// Close dropdown on outside click
if (typeof document !== "undefined") {
document.addEventListener("click", (e) => {
if (dropdownRef && !dropdownRef.contains(e.target as Node)) {
setDropdownOpen(false);
setShowNewInput(false);
}
});
}
return (
<div class="flex items-center justify-between px-3 py-2 border-b border-gray-200 shrink-0">
<div class="flex items-center gap-2 min-w-0">
<span
class="w-2 h-2 rounded-full shrink-0"
classList={{
"bg-gray-400": stream.connectionStatus === "disconnected",
"bg-yellow-400 animate-pulse":
stream.connectionStatus === "connecting",
"bg-green-500": stream.connectionStatus === "connected",
"bg-red-500": stream.connectionStatus === "error",
}}
title={stream.connectionStatus}
/>
<Show
when={stream.connected && stream.sessionId}
fallback={
<h2 class="text-sm font-semibold text-gray-700 truncate">
Disconnected
</h2>
}
>
<div class="min-w-0">
<p class="text-sm font-semibold text-gray-700 truncate leading-tight flex items-center gap-1">
{stream.myName}
<Show when={stream.myRole !== "gm"}>
<span
class={`text-[10px] px-1 rounded ${
stream.myRole === "observer"
? "bg-gray-200 text-gray-500"
: "bg-blue-100 text-blue-600"
}`}
>
{stream.myRole}
</span>
</Show>
</p>
{/* Clickable subtitle — session dropdown (GM only) */}
<Show when={stream.myRole === "gm"}>
<div ref={dropdownRef} class="relative">
<button
onClick={() => setDropdownOpen((v) => !v)}
class="text-[10px] text-gray-400 hover:text-gray-600 truncate leading-tight cursor-pointer text-left"
>
{stream.sessionName || stream.sessionId}{" "}
<span class="text-gray-300"></span>
</button>
<Show when={dropdownOpen()}>
<div class="absolute top-full left-0 mt-1 w-48 bg-white border border-gray-200 rounded shadow-lg z-50 py-1 max-h-48 overflow-y-auto">
<For each={Object.entries(manifest().sessions)}>
{([id, meta]) => (
<button
onClick={() => handleSessionSelect(id)}
class={`w-full text-left px-3 py-1 text-xs hover:bg-gray-100 flex items-center gap-1 ${
id === stream.sessionId
? "bg-blue-50 text-blue-700"
: "text-gray-700"
}`}
>
<span class="truncate flex-1">{meta.name || id}</span>
<Show when={id === stream.sessionId}>
<span class="text-blue-500 shrink-0"></span>
</Show>
</button>
)}
</For>
<div class="border-t border-gray-100 my-0.5" />
<Show
when={!showNewInput()}
fallback={
<div class="px-3 py-1 flex gap-1">
<input
type="text"
value={newSessionName()}
onInput={(e) =>
setNewSessionName(e.currentTarget.value)
}
placeholder="Session name"
class="flex-1 border border-gray-300 rounded px-1.5 py-0.5 text-xs"
autofocus
onKeyDown={(e) => {
if (e.key === "Enter") handleCreate();
if (e.key === "Escape") setShowNewInput(false);
}}
/>
<button
onClick={handleCreate}
class="bg-blue-600 text-white rounded px-2 py-0.5 text-xs hover:bg-blue-700"
>
+
</button>
</div>
}
>
<button
onClick={() => setShowNewInput(true)}
class="w-full text-left px-3 py-1 text-xs text-blue-600 hover:bg-gray-100"
>
+ New session
</button>
</Show>
</div>
</Show>
</div>
</Show>
</div>
</Show>
</div>
<div class="flex items-center gap-1 shrink-0">
<Show when={stream.connected}>
<button
onClick={handleDisconnect}
class="text-xs text-gray-400 hover:text-red-500 px-1"
title="Disconnect"
>
</button>
</Show>
<button
onClick={props.onClose}
class="text-gray-400 hover:text-gray-600 text-sm px-1"
title="Hide panel"
>
</button>
</div>
</div>
);
};
// ---------------------------------------------------------------------------
// Connect dialog — one input: player name. Broker URL is always current host.
// ---------------------------------------------------------------------------
const ConnectDialog: Component = () => {
const stream = useJournalStream();
const [playerName, setPlayerName] = createSignal(stream.myName);
const [role, setRole] = createSignal<"gm" | "player" | "observer">(
stream.myRole,
);
const [connecting, setConnecting] = createSignal(false);
const [error, setError] = createSignal<string | null>(null);
const handleConnect = async () => {
const name = playerName().trim() || stream.myName;
if (!name) return;
const brokerUrl = `ws://${window.location.host}`;
setConnecting(true);
setError(null);
try {
setMyName(name);
setMyRole(role());
const sessionId = stream.sessionId || "default";
setSessionId(sessionId);
await hydrateFromServer(sessionId);
await connectStream(sessionId, brokerUrl);
} catch (e) {
const msg = e instanceof Error ? e.message : "Connection failed";
setError(msg);
} finally {
setConnecting(false);
}
};
return (
<div class="w-full max-w-sm space-y-3">
<p class="text-sm text-gray-600 text-center">
Enter your name and role to join the session.
<br />
<span class="text-xs text-gray-400">
Connecting to {window.location.host}
</span>
</p>
<input
type="text"
value={playerName()}
onInput={(e) => setPlayerName(e.currentTarget.value)}
onKeyDown={(e) => e.key === "Enter" && handleConnect()}
placeholder="Your name"
class="w-full border border-gray-300 rounded px-3 py-1.5 text-sm"
autofocus
/>
{/* Role selector */}
<div class="flex gap-1">
{(["gm", "player", "observer"] as const).map((r) => (
<button
onClick={() => setRole(r)}
class={`flex-1 rounded px-2 py-1.5 text-xs font-medium border transition-colors ${
role() === r
? "bg-blue-600 text-white border-blue-600"
: "bg-white text-gray-600 border-gray-300 hover:border-gray-400"
}`}
>
{r === "gm" ? "GM" : r === "player" ? "Player" : "Observer"}
</button>
))}
</div>
<button
onClick={handleConnect}
disabled={connecting() || !playerName().trim()}
class="w-full bg-blue-600 text-white rounded px-3 py-2 text-sm font-medium
hover:bg-blue-700 disabled:opacity-50"
>
{connecting() ? "Connecting..." : "Join"}
</button>
<Show when={error()}>
<p class="text-red-500 text-sm text-center">{error()}</p>
</Show>
</div>
);
};

View File

@ -1,94 +0,0 @@
/**
* StreamMessage single message card in the stream
*/
import { Component, Show, createMemo } from "solid-js";
import type { StreamMessage as StreamMessageType } from "./registry";
import { getMessageType } from "./registry";
import { revertLatest, useJournalStream } from "../stores/journalStream";
export const StreamMessageCard: Component<{
message: StreamMessageType;
}> = (props) => {
const stream = useJournalStream();
const def = createMemo(() => getMessageType(props.message.type));
const rendered = createMemo(() =>
def()?.render?.(props.message.payload, props.message as any),
);
const time = createMemo(() => {
const d = new Date(props.message.timestamp);
return d.toLocaleTimeString([], { hour: "2-digit", minute: "2-digit" });
});
const isMine = () => props.message.sender === stream.myName;
const latestSeq = () => stream.senderSeq[stream.myName] ?? 0;
const canRevertThis = () =>
isMine() && props.message.seq === latestSeq() && !props.message.reverted;
const senderClass = () =>
props.message.sender === "gm"
? "text-purple-600 font-semibold"
: "text-blue-600 font-semibold";
return (
<Show
when={!props.message.reverted}
fallback={<RevealedCard msg={props.message} />}
>
<div class="bg-white rounded-lg border border-gray-200 shadow-sm overflow-hidden">
{/* Header */}
<div class="flex items-center gap-1.5 px-2.5 py-1 bg-gray-50 border-b border-gray-100">
<span class={`text-xs ${senderClass()}`}>{props.message.sender}</span>
<span class="text-xs text-gray-400">{time()}</span>
<Show when={canRevertThis()}>
<button
onClick={() => revertLatest()}
class="text-xs text-gray-300 hover:text-red-500 ml-auto px-0.5 leading-none"
title="Revert this message"
>
×
</button>
</Show>
<span
class="text-xs text-gray-300 bg-gray-100 px-1 rounded"
classList={{ "ml-auto": !canRevertThis() }}
>
{props.message.type}
</span>
</div>
{/* Body */}
<div class="px-2.5 py-1.5 text-sm text-gray-800">
<Show
when={rendered()}
fallback={<JsonBody payload={props.message.payload} />}
>
{rendered()}
</Show>
</div>
</div>
</Show>
);
};
/** Fallback: render payload as formatted JSON */
const JsonBody: Component<{ payload: unknown }> = (props) => {
const text = () => JSON.stringify(props.payload, null, 2);
return (
<pre class="text-xs text-gray-600 whitespace-pre-wrap font-mono">
{text()}
</pre>
);
};
/** Reverted card: struck-through, grey */
const RevealedCard: Component<{ msg: StreamMessageType }> = (props) => {
return (
<div class="bg-gray-100 rounded-lg border border-gray-200 opacity-50 px-2.5 py-1.5">
<span class="text-xs text-gray-400 line-through">
{props.msg.sender} reverted
</span>
</div>
);
};

View File

@ -1,41 +0,0 @@
/**
* StreamView scrollable message log
*/
import { Component, For, createEffect, onMount } from "solid-js";
import { visibleMessages, useJournalStream } from "../stores/journalStream";
import { StreamMessageCard } from "./StreamMessage";
export const StreamView: Component = () => {
const stream = useJournalStream();
let containerRef: HTMLDivElement | undefined;
// Auto-scroll to bottom on new messages
const messages = () => visibleMessages();
createEffect(() => {
void messages().length; // track
if (!containerRef) return;
containerRef.scrollTop = containerRef.scrollHeight;
});
return (
<div
ref={containerRef}
class="h-full overflow-y-auto px-3 py-2 space-y-2 bg-gray-50"
>
<For each={messages()}>
{(msg) => <StreamMessageCard message={msg} />}
</For>
<Show when={messages().length === 0}>
<p class="text-center text-gray-400 text-xs py-8">
{stream.connected
? "No messages yet. Start by posting something."
: "Connect to a session to see messages."}
</p>
</Show>
</div>
);
};
import { Show } from "solid-js";

View File

@ -1,161 +0,0 @@
/**
* Journal completions client-side loader for /__COMPLETIONS.json
*
* In CLI mode, fetches the pre-computed index. In dev/browser mode, falls
* back to scanning the in-memory file index for dice expressions and headings.
*
* The fetch runs eagerly on module import. Call `useJournalCompletions()`
* from any Solid component to reactively read the state.
*/
import { createSignal } from "solid-js";
import { extractHeadings } from "../../data-loader/toc";
import {
getPathsByExtension,
getIndexedData,
} from "../../data-loader/file-index";
// ------------------- Types (mirrors CLI) -------------------
export interface DiceCompletion {
label: string;
notation: string;
source: string;
}
export interface LinkCompletion {
path: string;
label: string;
section: string | null;
}
export interface JournalCompletions {
dice: DiceCompletion[];
links: LinkCompletion[];
}
export type CompletionsState =
| { status: "loading" }
| { status: "loaded"; data: JournalCompletions }
| { status: "empty" }
| { status: "error"; message: string };
// ------------------- Reactive state (module-level signal) -------------------
const [completionsState, setCompletionsState] = createSignal<CompletionsState>({
status: "loading",
});
// ------------------- Fetch (CLI mode) -------------------
async function tryServer(): Promise<JournalCompletions | null> {
try {
const resp = await fetch("/__COMPLETIONS.json");
if (!resp.ok) return null;
const data = await resp.json();
return {
dice: Array.isArray(data.dice) ? data.dice : [],
links: Array.isArray(data.links) ? data.links : [],
};
} catch {
return null;
}
}
// ------------------- Client-side fallback scan -------------------
async function scanClientSide(): Promise<JournalCompletions> {
const paths = await getPathsByExtension("md");
const dice: DiceCompletion[] = [];
const links: LinkCompletion[] = [];
const tagRegex = /<md-dice[^>]*>\s*([\s\S]*?)\s*<\/md-dice>/gi;
for (const filePath of paths) {
const content = await getIndexedData(filePath);
if (!content) continue;
// Dice scan
let match: RegExpExecArray | null;
tagRegex.lastIndex = 0;
while ((match = tagRegex.exec(content)) !== null) {
const raw = match[1].trim();
if (!raw || raw.length > 80) continue;
if (!/^\d*d\d+/i.test(raw) && !/^[+-]/.test(raw)) continue;
dice.push({ label: raw, notation: raw, source: filePath });
}
// Link scan (headings)
const basePath = filePath.replace(/\.md$/, "");
const fileName = basePath.split("/").filter(Boolean).pop() || basePath;
links.push({ path: basePath, label: fileName, section: null });
for (const heading of extractHeadings(content)) {
links.push({
path: basePath,
label: `${fileName} § ${heading.title}`,
section: heading.id ?? null,
});
}
}
return { dice, links };
}
// ------------------- Init (runs eagerly at import time) -------------------
// Using a top-level IIFE so the promise starts immediately
const _initPromise: Promise<void> = (async () => {
// 1. Try server first (CLI mode)
const serverData = await tryServer();
if (serverData) {
setCompletionsState({ status: "loaded", data: serverData });
return;
}
// 2. Fall back to client-side scan (dev/browser mode)
try {
const data = await scanClientSide();
if (data.dice.length > 0 || data.links.length > 0) {
setCompletionsState({ status: "loaded", data });
} else {
setCompletionsState({ status: "empty" });
}
} catch (e) {
setCompletionsState({
status: "error",
message: e instanceof Error ? e.message : "Failed to scan",
});
}
})();
// ------------------- Public API -------------------
/**
* Returns a Promise that resolves once completions are loaded (or failed).
* Useful for waiting before showing the completions dropdown.
*/
export function ensureCompletions(): Promise<void> {
return _initPromise;
}
/** Force a re-fetch on the next page load. For runtime, call before invalidate triggers. */
let _invalidated = false;
export function invalidateCompletions(): void {
_invalidated = true;
// On next import (page reload), the module will re-init.
// For a runtime invalidation, you could call init again.
}
/**
* Reactive hooks for the journal input.
* Returns the current completions state + a convenience `data` extractor.
*/
export function useJournalCompletions(): {
state: CompletionsState;
data: JournalCompletions;
} {
const s = completionsState();
if (s.status === "loaded") {
return { state: s, data: s.data };
}
return { state: s, data: { dice: [], links: [] } };
}

View File

@ -1,46 +0,0 @@
/**
* Journal module barrel export
*/
// Trigger message type registration
import "./types";
export {
registerMessageType,
getMessageType,
registeredTypes,
validatePayload,
canEmit,
} from "./registry";
export type { MessageTypeDef, StreamMessage } from "./registry";
export {
connectStream,
disconnectStream,
hydrateFromServer,
sendMessage,
revertLatest,
canRevert,
visibleMessages,
useJournalStream,
journalStreamState,
createSession,
deleteSession,
sessions,
setMyName,
} from "../stores/journalStream";
export type {
JournalStreamState,
SessionMeta,
SessionManifest,
} from "../stores/journalStream";
// UI components
export { JournalPanel } from "./JournalPanel";
export type { JournalPanelProps } from "./JournalPanel";
export { ConnectionBar } from "./ConnectionBar";
export { StreamView } from "./StreamView";
export { StreamMessageCard } from "./StreamMessage";
export { ComposePanel } from "./ComposePanel";
export { JournalInput } from "./JournalInput";
export { DynamicForm } from "./DynamicForm";
export { useJournalCompletions, invalidateCompletions } from "./completions";

View File

@ -1,128 +0,0 @@
/**
* Journal Stream Message Type Registry
*
* An open, add-only registry of message types. Game-specific widgets
* call `registerMessageType()` at module import time. The stream core
* uses this to validate, render, and reduce messages.
*/
import type { ZodType } from "zod";
import type { JSX } from "solid-js";
// ---------------------------------------------------------------------------
// StreamMessage
// ---------------------------------------------------------------------------
/**
* The typed envelope carried on the stream topic.
* `P` is narrowed by the `type` key at the receiving side.
*/
export interface StreamMessage<P = unknown> {
/** Deterministic id: `{sender}-{seq}` */
id: string;
/** Sender identifier — "gm" or a player name */
sender: string;
/** Monotonic sequence number, per sender */
seq: number;
/** Registered type key — determines which payload schema & widget are used */
type: string;
/** Type-specific data, validated against the registry's Zod schema */
payload: P;
/** Epoch millis, set by the sender at publish time */
timestamp: number;
/**
* Soft-delete. Only the sender's latest (highest seq) message can be
* reverted. Sending a new message locks the previous one permanently.
*/
reverted: boolean;
}
// ---------------------------------------------------------------------------
// MessageTypeDef
// ---------------------------------------------------------------------------
export interface MessageTypeDef<P = unknown> {
/** Unique type key, e.g. "roll", "link" */
type: string;
/** Human-readable label shown in the compose panel dropdown */
label: string;
/** Which roles can emit this type. Empty array = all roles (including observer) allowed. */
emitters?: ("gm" | "player" | "observer")[];
/** Zod schema used to validate payload before publish */
schema: ZodType<P>;
/** Returns a default payload for compose panel initialization */
defaultPayload?: () => P;
/**
* Render the message body in the stream.
* Receives the full message for context (sender, timestamp, reverted).
* Return undefined to suppress inline rendering (e.g. pure state mutations).
*/
render?: (payload: P, msg: StreamMessage<P>) => JSX.Element | undefined;
/**
* Apply side-effects to local state when this message is received.
* Runs once per message, at initial hydration and on live receipt.
*/
reducer?: (payload: P, msg: StreamMessage<P>) => void;
}
// ---------------------------------------------------------------------------
// Registry
// ---------------------------------------------------------------------------
const registry = new Map<string, MessageTypeDef<any>>();
/**
* Register a message type. Calling module should import and call this at
* top-level so the registry is populated before any stream operations.
*/
export function registerMessageType<P>(def: MessageTypeDef<P>): void {
if (registry.has(def.type)) {
throw new Error(`Message type "${def.type}" is already registered.`);
}
registry.set(def.type, def);
}
/** Retrieve a registered type definition. */
export function getMessageType(type: string): MessageTypeDef | undefined {
return registry.get(type);
}
/** Iterate over all registered type keys. */
export function registeredTypes(): IterableIterator<string> {
return registry.keys();
}
/**
* Validate a payload against the registered schema for `type`.
* Returns the parsed (narrowed) payload on success, or a ZodError.
*/
export function validatePayload<P>(
type: string,
payload: P,
): { success: true; data: P } | { success: false; error: string } {
const def = registry.get(type);
if (!def) {
return { success: false, error: `Unknown message type: "${type}"` };
}
const result = def.schema.safeParse(payload);
if (result.success) {
return { success: true, data: result.data as P };
}
return {
success: false,
error: result.error.issues
.map((i) => `${i.path.join(".")}: ${i.message}`)
.join("; "),
};
}
/**
* Check whether a role (sender) is allowed to emit a given message type.
*/
export function canEmit(type: string, role: string): boolean {
const def = registry.get(type);
if (!def) return false;
const emitters = def.emitters;
if (!emitters || emitters.length === 0) return true;
return emitters.includes(role as "gm" | "player" | "observer");
}

View File

@ -1,27 +0,0 @@
/**
* Built-in message type: chat
*
* Emitters: gm, player
* Command: plain text (or press Enter)
*/
import { z } from "zod";
import { registerMessageType } from "../registry";
const schema = z.object({
text: z
.string()
.min(1, "Message cannot be empty")
.max(2000, "Message too long"),
});
export type ChatPayload = z.infer<typeof schema>;
registerMessageType<ChatPayload>({
type: "chat",
label: "Chat",
emitters: ["gm", "player"],
schema,
defaultPayload: () => ({ text: "" }),
render: (p) => <p class="text-sm whitespace-pre-wrap">{p.text}</p>,
});

View File

@ -1,11 +0,0 @@
/**
* Journal message types barrel export
*
* Importing this module registers all built-in message types.
* Game-specific widgets should create their own barrel and import it.
*/
import "./chat";
import "./roll";
import "./link";
import "./intent";

View File

@ -1,77 +0,0 @@
/**
* Built-in message types: intent / resolution
*/
import { z } from "zod";
import { registerMessageType } from "../registry";
// ---------------------------------------------------------------------------
// intent
// ---------------------------------------------------------------------------
const intentSchema = z.object({
description: z.string().min(1),
context: z.record(z.string(), z.unknown()).optional(),
});
export type IntentPayload = z.infer<typeof intentSchema>;
registerMessageType<IntentPayload>({
type: "intent",
label: "Declare Intent",
emitters: ["player"],
schema: intentSchema,
defaultPayload: () => ({ description: "" }),
render: (p) => (
<div class="space-y-1">
<div class="flex items-center gap-2">
<span class="text-lg">🙋</span>
<span class="text-sm font-medium text-yellow-700">Intent</span>
</div>
<p class="text-sm">{p.description}</p>
{p.context && Object.keys(p.context).length > 0 && (
<pre class="text-xs bg-gray-100 rounded px-2 py-1 text-gray-600">
{JSON.stringify(p.context, null, 2)}
</pre>
)}
</div>
),
});
// ---------------------------------------------------------------------------
// resolution
// ---------------------------------------------------------------------------
const resolutionSchema = z.object({
intentId: z.string().min(1),
outcome: z.string().min(1),
statChanges: z.record(z.string(), z.unknown()).optional(),
});
export type ResolutionPayload = z.infer<typeof resolutionSchema>;
registerMessageType<ResolutionPayload>({
type: "resolution",
label: "Resolve Intent",
emitters: ["gm"],
schema: resolutionSchema,
render: (p) => (
<div class="space-y-1">
<div class="flex items-center gap-2">
<span class="text-lg"></span>
<span class="text-sm font-medium text-green-700">Resolution</span>
<span class="text-xs text-gray-400">re: {p.intentId}</span>
</div>
<p class="text-sm">{p.outcome}</p>
{p.statChanges && Object.keys(p.statChanges).length > 0 && (
<div class="flex flex-wrap gap-1">
{Object.entries(p.statChanges).map(([k, v]) => (
<span class="text-xs bg-green-50 text-green-800 px-1.5 py-0.5 rounded border border-green-200">
{k}: {String(v)}
</span>
))}
</div>
)}
</div>
),
});

View File

@ -1,90 +0,0 @@
/**
* Built-in message type: link (article reveal)
*
* Emitters: gm
* Command: /link path#section
*/
import { Component } from "solid-js";
import { useNavigate } from "@solidjs/router";
import { z } from "zod";
import { registerMessageType } from "../registry";
import { journalSetState } from "../../stores/journalStream";
import { produce } from "solid-js/store";
const schema = z.object({
path: z.string().min(1),
label: z.string().optional(),
section: z.string().optional(),
});
export type LinkPayload = z.infer<typeof schema>;
function fileNameFromPath(path: string): string {
const parts = path.split("/").filter(Boolean);
return parts[parts.length - 1] || path;
}
/** Section slug → human-readable title */
function slugToTitle(slug: string): string {
return slug
.split("-")
.map((w) => w.charAt(0).toUpperCase() + w.slice(1))
.join(" ");
}
const RevealLink: Component<LinkPayload> = (p) => {
const navigate = useNavigate();
const target = () => {
let t = p.path;
if (p.section) t += `#${p.section}`;
return t;
};
const displayLabel = () => {
if (p.label) return p.label;
return fileNameFromPath(p.path);
};
const handleClick = (e: MouseEvent) => {
e.preventDefault();
navigate(target());
};
return (
<div class="flex items-center gap-2">
<span class="text-lg">📄</span>
<div>
<a
href={target()}
onClick={handleClick}
class="text-blue-600 underline text-sm"
>
{displayLabel()}
</a>
{p.section && (
<span class="text-xs text-gray-400 ml-1">
§ {slugToTitle(p.section)}
</span>
)}
</div>
</div>
);
};
registerMessageType<LinkPayload>({
type: "link",
label: "Link Article",
emitters: ["gm"],
schema,
defaultPayload: () => ({ path: "/" }),
render: (p) => <RevealLink {...p} />,
reducer: (p) => {
journalSetState(
produce((s) => {
s.revealedPaths.add(p.path);
}),
);
},
});

View File

@ -1,70 +0,0 @@
/**
* Built-in message type: roll
*
* Emitters: gm
* Command: /roll formula
* The GM's browser rolls the formula and publishes the resolved result.
*/
import { z } from "zod";
import { registerMessageType } from "../registry";
import { rollFormula } from "../../md-commander/hooks";
const schema = z.object({
notation: z.string().min(1),
label: z.string().optional(),
result: z.object({
total: z.number(),
detail: z.string(),
plainDetail: z.string(),
pools: z.array(
z.object({
rolls: z.array(z.number()),
subtotal: z.number(),
}),
),
}),
});
export type RollPayload = z.infer<typeof schema>;
/** Compute the dice result on the GM's machine before send */
export function resolveRollPayload(raw: {
notation: string;
label?: string;
}): RollPayload {
const roll = rollFormula(raw.notation);
return {
notation: raw.notation,
label: raw.label,
result: roll.result,
};
}
registerMessageType<RollPayload>({
type: "roll",
label: "Roll Dice",
emitters: ["gm"],
schema,
defaultPayload: () => ({
notation: "1d20",
result: { total: 0, detail: "", plainDetail: "", pools: [] },
}),
render: (p) => {
const { result } = p;
return (
<div class="space-y-0.5">
<div class="flex items-center gap-1.5">
<span class="text-lg">🎲</span>
<span class="font-mono text-sm">
{p.notation} {" "}
<span class="font-bold text-base">{result.total}</span>
</span>
</div>
{result.detail && (
<p class="text-xs text-gray-400" innerHTML={result.detail} />
)}
</div>
);
},
});

View File

@ -38,10 +38,7 @@ export function rollDicePool(pool: DicePool): DicePoolResult {
const { keptRolls, droppedRolls } = applyModifier(rolls, pool.modifier);
// 固定数字总是保留
const finalKeptRolls = [
...keptRolls,
...allRolls.filter((r) => r.sides === 0),
];
const finalKeptRolls = [...keptRolls, ...allRolls.filter((r) => r.sides === 0)];
// 计算小计
const subtotal = finalKeptRolls.reduce((sum, roll) => {
@ -62,7 +59,7 @@ export function rollDicePool(pool: DicePool): DicePoolResult {
*/
export function applyModifier(
rolls: DieResult[],
modifier: DicePool["modifier"],
modifier: DicePool["modifier"]
): { keptRolls: DieResult[]; droppedRolls: DieResult[] } {
if (!modifier) {
return { keptRolls: rolls, droppedRolls: [] };
@ -73,8 +70,7 @@ export function applyModifier(
switch (modifier.type) {
case "kh": // Keep Highest - 保留最大的 N 个
case "dh": {
// Drop Highest - 丢弃最大的 N 个
case "dh": { // Drop Highest - 丢弃最大的 N 个
const count = modifier.count;
const higher = sorted.slice(-count);
const rest = sorted.slice(0, -count);
@ -85,8 +81,7 @@ export function applyModifier(
}
}
case "kl": // Keep Lowest - 保留最小的 N 个
case "dl": {
// Drop Lowest - 丢弃最小的 N 个
case "dl": { // Drop Lowest - 丢弃最小的 N 个
const count = modifier.count;
const lower = sorted.slice(0, count);
const rest = sorted.slice(count);
@ -110,35 +105,29 @@ export function roll(formula: string): RollResult {
const total = poolResults.reduce((sum, result) => sum + result.subtotal, 0);
// Generate detail expressions (both HTML and plain text)
const htmlSpans: string[] = [];
const textSpans: string[] = [];
// 生成详细表达式 - 使用 HTML 格式
const rollSpans: string[] = [];
for (const result of poolResults) {
// 构建骰子结果 HTML
for (const roll of result.rolls) {
const isKept = result.keptRolls.some((kept) => kept === roll);
if (roll.sides === 0) {
const sign = roll.isNegative ? "-" : "";
htmlSpans.push(`<strong>${sign}${roll.value}</strong>`);
textSpans.push(`${sign}${roll.value}`);
// 固定数字,不显示括号
rollSpans.push(`<strong>${roll.isNegative ? '-' : ''}${roll.value}</strong>`);
} else if (isKept) {
htmlSpans.push(`<strong>[${roll.value}]</strong>`);
textSpans.push(`[${roll.value}]`);
rollSpans.push(`<strong>[${roll.value}]</strong>`);
} else {
htmlSpans.push(`<span class="text-gray-400">[${roll.value}]</span>`);
textSpans.push(`[${roll.value}]`);
rollSpans.push(`<span class="text-gray-400">[${roll.value}]</span>`);
}
}
}
const rollHtml = htmlSpans.join(" + ");
const rollText = textSpans.join(" + ");
const detail = `<strong>${total}</strong> = ${rollHtml}`;
const plainDetail = `${total} = ${rollText}`;
const rollsStr = rollSpans.join(" + ");
const detail = `<strong>${total}</strong> = ${rollsStr}`;
return {
pools: poolResults,
total,
detail,
plainDetail,
};
}

View File

@ -1,6 +1,6 @@
/**
*
*
*
* :
* - 3d6: 标准骰子表示
* - {d4, d8}:
@ -14,8 +14,8 @@
*
*/
export interface DieResult {
sides: number; // 骰子面数0 表示固定数字
value: number; // 掷骰结果
sides: number; // 骰子面数0 表示固定数字
value: number; // 掷骰结果
isNegative: boolean; // 是否为负数
}
@ -23,9 +23,9 @@ export interface DieResult {
*
*/
export interface DicePool {
dice: DieResult[]; // 骰子列表(未掷骰前为模板)
dice: DieResult[]; // 骰子列表(未掷骰前为模板)
modifier?: {
type: "kh" | "kl" | "dh" | "dl";
type: 'kh' | 'kl' | 'dh' | 'dl';
count: number;
};
isNegative: boolean; // 整个骰池是否为负
@ -35,32 +35,31 @@ export interface DicePool {
*
*/
export interface RollResult {
pools: DicePoolResult[]; // 各骰池结果
total: number; // 总和
detail: string; // 详细表达式 (HTML)
plainDetail: string; // 详细表达式 (plain text)
pools: DicePoolResult[]; // 各骰池结果
total: number; // 总和
detail: string; // 详细表达式
}
/**
*
*/
export interface DicePoolResult {
pool: DicePool; // 原始骰池定义
rolls: DieResult[]; // 掷骰结果
keptRolls: DieResult[]; // 保留的骰子(应用修饰符后)
pool: DicePool; // 原始骰池定义
rolls: DieResult[]; // 掷骰结果
keptRolls: DieResult[]; // 保留的骰子(应用修饰符后)
droppedRolls: DieResult[]; // 丢弃的骰子
subtotal: number; // 该骰池小计
subtotal: number; // 该骰池小计
}
/**
*
*/
export type Token =
| { type: "number"; value: number }
| { type: "dice"; count: number; sides: number }
| { type: "pool_start" }
| { type: "pool_end" }
| { type: "comma" }
| { type: "plus" }
| { type: "minus" }
| { type: "modifier"; modType: "kh" | "kl" | "dh" | "dl"; count: number };
| { type: 'number'; value: number }
| { type: 'dice'; count: number; sides: number }
| { type: 'pool_start' }
| { type: 'pool_end' }
| { type: 'comma' }
| { type: 'plus' }
| { type: 'minus' }
| { type: 'modifier'; modType: 'kh' | 'kl' | 'dh' | 'dl'; count: number };

View File

@ -5,7 +5,6 @@ export interface DiceRollerResult {
result: {
total: number;
detail: string;
plainDetail: string;
pools: Array<{
rolls: number[];
keptRolls: number[];
@ -39,7 +38,6 @@ export function rollFormula(formula: string): DiceRollerResult {
result: {
total: result.total,
detail: result.detail,
plainDetail: result.plainDetail,
pools: result.pools.map((pool) => ({
rolls: pool.rolls.map((r) => r.value),
keptRolls: pool.keptRolls.map((r) => r.value),
@ -54,7 +52,6 @@ export function rollFormula(formula: string): DiceRollerResult {
result: {
total: 0,
detail: "",
plainDetail: "",
pools: [],
},
success: false,
@ -67,10 +64,7 @@ export function rollFormula(formula: string): DiceRollerResult {
* HTML
* [1] [2] [3] = <strong>6</strong>
*/
export function rollSimple(formula: string): {
text: string;
isHtml?: boolean;
} {
export function rollSimple(formula: string): { text: string; isHtml?: boolean } {
try {
const result = rollDice(formula);
return {

View File

@ -1,6 +1,6 @@
import { customElement, noShadowDOM } from "solid-element";
import { createSignal, onMount } from "solid-js";
import { rollFormula } from "./md-commander/hooks";
import {rollFormula} from "./md-commander/hooks";
export interface DiceProps {
key?: string;
@ -34,7 +34,7 @@ customElement("md-dice", { key: "" }, (props, { element }) => {
// 从 element 的 textContent 获取骰子公式
const formula = element?.textContent?.trim() || "";
// 隐藏原始文本内容
if (element) {
element.textContent = "";
@ -57,9 +57,9 @@ customElement("md-dice", { key: "" }, (props, { element }) => {
const handleRoll = (e: MouseEvent) => {
e.preventDefault();
// 点击骰子图标:总是重 roll
const rollResult = rollFormula(formula);
setResult(rollResult.result.total);
setRollDetail(rollResult.result.plainDetail);
const rollResult = rollFormula(formula).result;
setResult(rollResult.total);
setRollDetail(rollResult.detail);
setIsRolled(true);
if (effectiveKey()) {
setDiceResultToUrl(effectiveKey(), rollResult.total);

View File

@ -1,608 +0,0 @@
/**
* Journal Stream Client Store
*
* Reactive state for a single session's message stream. Manages MQTT
* connection, local message log, per-sender sequence tracking, and the
* revealed-paths set (populated by the link reducer).
*
* Session lifecycle (create/list/delete) is handled via MQTT retained
* topics ttrpg/$SESSIONS for the manifest and ttrpg/{id}/meta per session.
*
* No persistence here that's the CLI server's job via JSONL append.
*/
import { createStore, produce } from "solid-js/store";
import { createSignal } from "solid-js";
import type { StreamMessage } from "../journal/registry";
import { getMessageType, validatePayload } from "../journal/registry";
// ---------------------------------------------------------------------------
// Types
// ---------------------------------------------------------------------------
export interface JournalStreamState {
sessionId: string | null;
/** Human-readable name for the current session (from manifest) */
sessionName: string | null;
/** Full message log, oldest-first */
messages: StreamMessage[];
/** Last sequence number per sender */
senderSeq: Record<string, number>;
/**
* Paths revealed by link messages.
* Populated during hydration and live receipt via the type's reducer.
*/
revealedPaths: Set<string>;
/** MQTT connection status */
connected: boolean;
/** Granular connection state for UI indicators */
connectionStatus: "disconnected" | "connecting" | "connected" | "error";
/** Last connection error message, if any */
connectionError: string | null;
/** This client's identity */
myName: string;
/** Role: gm | player | observer. Immutable while connected. */
myRole: "gm" | "player" | "observer";
/** Broker URL, set after connect */
brokerUrl: string | null;
/** Active player list (keyed by player name) */
players: Record<string, { role: string }>;
}
export interface SessionMeta {
name: string;
created: number;
players: string[];
}
export interface SessionManifest {
sessions: Record<string, SessionMeta>;
}
// ---------------------------------------------------------------------------
// localStorage keys
// ---------------------------------------------------------------------------
const LS_PLAYER_NAME = "ttrpg.playerName";
const LS_BROKER_URL = "ttrpg.brokerUrl";
const LS_LAST_SESSION = "ttrpg.lastSessionId";
const LS_PLAYER_ROLE = "ttrpg.playerRole";
function loadPersisted(): {
myName: string;
brokerUrl: string | null;
lastSessionId: string | null;
myRole: string;
} {
if (typeof localStorage === "undefined") {
return { myName: "gm", brokerUrl: null, lastSessionId: null, myRole: "gm" };
}
return {
myName: localStorage.getItem(LS_PLAYER_NAME) || "gm",
brokerUrl: localStorage.getItem(LS_BROKER_URL),
lastSessionId: localStorage.getItem(LS_LAST_SESSION),
myRole: localStorage.getItem(LS_PLAYER_ROLE) || "gm",
};
}
// ---------------------------------------------------------------------------
// Store
// ---------------------------------------------------------------------------
const persisted = loadPersisted();
const urlParams = readUrlParams();
// URL params override localStorage if present
const initialName = urlParams.playerName ?? persisted.myName;
const initialSession = urlParams.sessionId ?? persisted.lastSessionId;
const [state, setState] = createStore<JournalStreamState>({
sessionId: initialSession,
sessionName: null,
messages: [],
senderSeq: {},
revealedPaths: new Set(),
connected: false,
connectionStatus: "disconnected",
connectionError: null,
myName: initialName,
myRole: (persisted.myRole as "gm" | "player" | "observer") || "gm",
brokerUrl: persisted.brokerUrl,
players: {},
});
// Sync initial URL params if they came from localStorage (not URL)
if (initialName && !urlParams.playerName) syncUrlParam("player", initialName);
if (initialSession && !urlParams.sessionId)
syncUrlParam("session", initialSession);
export { setState as journalSetState };
const [sessionList, setSessionList] = createSignal<SessionManifest>({
sessions: {},
});
export { sessionList as sessions };
/**
* Change the current player's name. Persisted to localStorage so it
* survives page reloads. Also syncs to URL search param.
*/
export function setMyName(name: string): void {
setState("myName", name);
if (typeof localStorage !== "undefined") {
localStorage.setItem(LS_PLAYER_NAME, name);
}
syncUrlParam("player", name);
}
/**
* Change the current player's role. Persisted to localStorage.
* Only callable when disconnected.
*/
export function setMyRole(role: "gm" | "player" | "observer"): void {
setState("myRole", role);
if (typeof localStorage !== "undefined") {
localStorage.setItem(LS_PLAYER_ROLE, role);
}
}
/**
* Set the active session ID and sync to URL. Also resolves the human-readable
* session name from the cached manifest.
*/
export function setSessionId(id: string | null): void {
setState("sessionId", id);
if (typeof localStorage !== "undefined" && id) {
localStorage.setItem(LS_LAST_SESSION, id);
}
if (id) {
syncUrlParam("session", id);
// Resolve session name from current manifest
const manifest = sessionList();
const name = manifest.sessions[id]?.name ?? null;
setState("sessionName", name);
} else {
removeUrlParam("session");
setState("sessionName", null);
}
}
// ---------------------------------------------------------------------------
// URL param sync (session & player for bookmarkable links)
// ---------------------------------------------------------------------------
function syncUrlParam(key: string, value: string): void {
if (typeof window === "undefined") return;
const url = new URL(window.location.href);
url.searchParams.set(key, value);
window.history.replaceState(null, "", url.toString());
}
function removeUrlParam(key: string): void {
if (typeof window === "undefined") return;
const url = new URL(window.location.href);
url.searchParams.delete(key);
window.history.replaceState(null, "", url.toString());
}
/**
* Read initial session/player from URL search params on store init.
* Called once at module load time.
*/
function readUrlParams(): {
sessionId: string | null;
playerName: string | null;
} {
if (typeof window === "undefined")
return { sessionId: null, playerName: null };
const params = new URL(window.location.href).searchParams;
return {
sessionId: params.get("session"),
playerName: params.get("player"),
};
}
// Will hold the MQTT client instance after connect()
let _mqttClient: import("mqtt").MqttClient | null = null;
let _mqttConnected = false;
// ---------------------------------------------------------------------------
// Helpers
// ---------------------------------------------------------------------------
function makeMessageId(sender: string, seq: number): string {
return `${sender}-${seq}`;
}
function runReducer(msg: StreamMessage): void {
const def = getMessageType(msg.type);
if (def?.reducer) {
def.reducer(msg.payload, msg);
}
}
const $SESSIONS = "ttrpg/$SESSIONS";
// ---------------------------------------------------------------------------
// Hydration (initial load from server)
// ---------------------------------------------------------------------------
/**
* Load the full message history from the static server's JSONL file.
* The file is served from the same HTTP origin as the web app.
* Runs all reducers in order.
*/
export async function hydrateFromServer(sessionId: string): Promise<void> {
const response = await fetch(
`/.ttrpg/sessions/${encodeURIComponent(sessionId)}/stream.jsonl`,
);
if (!response.ok) {
if (response.status === 404) {
return; // fresh session, no file yet
}
throw new Error(`Failed to load session: ${response.statusText}`);
}
const text = await response.text();
const lines = text.split("\n").filter((l) => l.trim());
const messages: StreamMessage[] = [];
const senderSeq: Record<string, number> = {};
for (const line of lines) {
try {
const msg: StreamMessage = JSON.parse(line);
messages.push(msg);
senderSeq[msg.sender] = Math.max(senderSeq[msg.sender] ?? 0, msg.seq);
runReducer(msg);
} catch {
/* skip corrupt */
}
}
setState(
produce((s) => {
s.messages = messages;
s.senderSeq = senderSeq;
s.sessionId = sessionId;
}),
);
}
// ---------------------------------------------------------------------------
// MQTT Connect
// ---------------------------------------------------------------------------
/**
* Connect to the MQTT broker, subscribe to the session stream, session
* list, and session meta. Must be called after `hydrateFromServer`.
*/
export async function connectStream(
sessionId: string,
brokerUrl: string,
): Promise<void> {
const { default: mqtt } = await import("mqtt");
setState("connectionStatus", "connecting");
setState("connectionError", null);
return new Promise<void>((resolve, reject) => {
const client = mqtt.connect(brokerUrl, {
clientId: `${state.myName}-${state.myRole}-${Date.now()}`,
protocol: brokerUrl.startsWith("wss") ? "wss" : "ws",
reconnectPeriod: 2000,
});
_mqttClient = client;
client.on("connect", () => {
_mqttConnected = true;
setState("connected", true);
setState("connectionStatus", "connected");
setState("connectionError", null);
setState("brokerUrl", brokerUrl);
// Persist connection info for next time
if (typeof localStorage !== "undefined") {
localStorage.setItem(LS_BROKER_URL, brokerUrl);
localStorage.setItem(LS_LAST_SESSION, sessionId);
}
client.subscribe(`ttrpg/${sessionId}/stream`, { qos: 1 }, (err) => {
if (err) console.error("[stream] stream sub err:", err);
});
client.subscribe($SESSIONS, { qos: 1 }, (err) => {
if (err) console.error("[stream] sessions sub err:", err);
});
client.subscribe(`ttrpg/${sessionId}/meta`, { qos: 1 });
// Presence tracking
client.subscribe(`ttrpg/${sessionId}/presence/+`, { qos: 1 });
// Publish own presence (retained)
const presenceData = JSON.stringify({
name: state.myName,
role: state.myRole,
});
client.publish(
`ttrpg/${sessionId}/presence/${state.myName}`,
presenceData,
{ qos: 1, retain: true },
);
resolve();
});
client.on("error", (err) => {
console.error("[stream] mqtt error:", err);
setState("connectionStatus", "error");
setState("connectionError", err.message);
reject(err);
});
client.on("close", () => {
_mqttConnected = false;
setState("connected", false);
setState("connectionStatus", "disconnected");
});
client.on("message", (topic, payload) => {
const raw = payload.toString();
const parts = topic.split("/");
if (topic === $SESSIONS) {
// Session manifest update
try {
const manifest: SessionManifest = JSON.parse(raw);
setSessionList(manifest);
// Refresh the sessionName if we're in a session now
const currentId = state.sessionId;
if (currentId && manifest.sessions[currentId]) {
setState("sessionName", manifest.sessions[currentId].name);
}
} catch (e) {
console.error("[stream] manifest parse err:", e);
}
return;
}
if (parts.length >= 3 && parts[0] === "ttrpg" && parts[2] === "meta") {
// Session meta update — manifest will be republished by server,
// handled via $SESSIONS subscription above.
return;
}
// Presence: ttrpg/{sessionId}/presence/{playerName}
if (
parts.length >= 4 &&
parts[0] === "ttrpg" &&
parts[2] === "presence"
) {
const playerName = parts[3];
if (raw) {
try {
const presence = JSON.parse(raw);
setState("players", playerName, {
role: presence.role || "player",
});
} catch {
setState("players", playerName, { role: "player" });
}
} else {
// Tombstone — player disconnected
setState(
produce((s) => {
delete s.players[playerName];
}),
);
}
return;
}
if (parts.length >= 3 && parts[0] === "ttrpg" && parts[2] === "stream") {
try {
const msg: StreamMessage = JSON.parse(raw);
receiveMessage(msg);
} catch (e) {
console.error("[stream] malformed message:", e);
}
}
});
});
}
// ---------------------------------------------------------------------------
// Session lifecycle
// ---------------------------------------------------------------------------
/**
* Create a new session by publishing retained metadata to its meta topic.
* The server picks it up and adds it to the $SESSIONS manifest.
*/
export function createSession(name: string, players: string[] = []): void {
if (!_mqttClient || !_mqttConnected) return;
const id =
name
.toLowerCase()
.replace(/[^a-z0-9]+/g, "-")
.replace(/^-|-$/g, "") || "session";
const meta: SessionMeta = { name, created: Date.now(), players };
_mqttClient.publish(`ttrpg/${id}/meta`, JSON.stringify(meta), {
qos: 1,
retain: true,
});
}
/**
* Delete a session: publish tombstone (empty payload) to its meta topic.
*/
export function deleteSession(sessionId: string): void {
if (!_mqttClient || !_mqttConnected) return;
_mqttClient.publish(`ttrpg/${sessionId}/meta`, "", { qos: 1, retain: true });
}
// ---------------------------------------------------------------------------
// Send
// ---------------------------------------------------------------------------
/**
* Publish a message to the stream. Validates the payload against the
* registered Zod schema before sending. Auto-increments the sender's seq.
*/
export function sendMessage<T>(
type: string,
payload: T,
):
{ success: true; msg: StreamMessage<T> } | { success: false; error: string } {
if (!_mqttClient || !_mqttConnected) {
return { success: false, error: "Not connected to stream" };
}
const sessionId = state.sessionId;
if (!sessionId) {
return { success: false, error: "No active session" };
}
const validation = validatePayload(type, payload);
if (!validation.success) {
return { success: false, error: validation.error };
}
const sender = state.myName;
const seq = (state.senderSeq[sender] ?? 0) + 1;
const id = makeMessageId(sender, seq);
const msg: StreamMessage<T> = {
id,
sender,
seq,
type,
payload: validation.data,
timestamp: Date.now(),
reverted: false,
};
const topic = `ttrpg/${sessionId}/stream`;
_mqttClient.publish(topic, JSON.stringify(msg), { qos: 1 }, (err) => {
if (err) console.error("[stream] publish error:", err);
});
// Optimistic local insert
receiveMessage(msg as StreamMessage);
return { success: true, msg };
}
// ---------------------------------------------------------------------------
// Receive
// ---------------------------------------------------------------------------
function receiveMessage(msg: StreamMessage): void {
const existing = state.messages.find((m) => m.id === msg.id);
if (existing) {
setState(
produce((s) => {
const idx = s.messages.findIndex((m) => m.id === msg.id);
if (idx !== -1) s.messages[idx] = msg;
}),
);
return;
}
setState(
produce((s) => {
s.messages.push(msg);
s.senderSeq[msg.sender] = Math.max(s.senderSeq[msg.sender] ?? 0, msg.seq);
}),
);
runReducer(msg);
}
// ---------------------------------------------------------------------------
// Revert
// ---------------------------------------------------------------------------
/**
* Revert the current sender's latest (highest seq) message.
* Only works if it's still the latest a subsequent message locks it.
*/
export function revertLatest():
{ success: true } | { success: false; error: string } {
if (!_mqttClient || !_mqttConnected) {
return { success: false, error: "Not connected to stream" };
}
const sessionId = state.sessionId;
if (!sessionId) return { success: false, error: "No active session" };
const sender = state.myName;
const latestSeq = state.senderSeq[sender];
if (!latestSeq) return { success: false, error: "No messages to revert" };
const id = makeMessageId(sender, latestSeq);
const original = state.messages.find((m) => m.id === id);
if (!original) return { success: false, error: "Message not found" };
if (original.reverted) return { success: false, error: "Already reverted" };
const reverted: StreamMessage = { ...original, reverted: true };
const topic = `ttrpg/${sessionId}/stream`;
_mqttClient.publish(topic, JSON.stringify(reverted), { qos: 1 }, (err) => {
if (err) console.error("[stream] revert publish error:", err);
});
return { success: true };
}
// ---------------------------------------------------------------------------
// Disconnect
// ---------------------------------------------------------------------------
export function disconnectStream(): void {
if (_mqttClient) {
// Clear our presence before disconnecting (tombstone)
const sessionId = state.sessionId;
if (sessionId) {
_mqttClient.publish(`ttrpg/${sessionId}/presence/${state.myName}`, "", {
qos: 1,
retain: true,
});
}
// Force disconnect without reconnect
_mqttClient.end(true, void 0, () => {
// noop
});
_mqttClient = null;
_mqttConnected = false;
}
setState("connected", false);
setState("connectionStatus", "disconnected");
setState("players", {});
}
// ---------------------------------------------------------------------------
// Derived / helpers
// ---------------------------------------------------------------------------
export function canRevert(): boolean {
const sender = state.myName;
const seq = state.senderSeq[sender];
if (!seq) return false;
const msg = state.messages.find((m) => m.id === makeMessageId(sender, seq));
return msg !== undefined && !msg.reverted;
}
export function visibleMessages(): StreamMessage[] {
return state.messages.filter((m) => !m.reverted);
}
export function useJournalStream() {
return state;
}
export { state as journalStreamState };

View File

@ -1,74 +1,62 @@
@import "tailwindcss";
@plugin "@tailwindcss/typography";
@source inline("bg-{slate,gray,zinc,neutral,stone,red,orange,amber,yellow,lime,green,emerald,teal,cyan,sky,blue,indigo,violet,purple,fuchsia,pink,rose}-{50,{100..900..100},950}");
/* icon */
icon,
pull,
pull-2 {
icon, pull, pull-2{
@apply inline-block;
width: 1.5em;
height: 1.28em;
vertical-align: text-bottom;
--icon-src: "";
--icon-src: '';
background-image: var(--icon-src);
background-size: contain;
background-position: center;
background-repeat: no-repeat;
position: relative;
}
icon.masked {
mask-image: var(--icon-src);
mask-size: contain;
mask-position: center;
mask-repeat: no-repeat;
background-image: unset;
}
icon.big {
icon.big{
width: 2em;
height: 2em;
}
pull {
margin-right: -0.5em;
pull{
margin-right: -.5em;
width: 0;
}
pull-2 {
pull-2{
margin-right: -1em;
width: 0;
}
icon.big .icon-label,
icon.big .icon-label-stroke {
icon.big .icon-label, icon.big .icon-label-stroke{
line-height: 2;
}
.icon-label,
.icon-label-stroke {
.icon-label, .icon-label-stroke{
position: absolute;
font-weight: bold;
text-align: center;
line-height: 1.28;
left: 0;
right: 0;
top: 0;
bottom: 0;
left: 0; right: 0; top: 0; bottom: 0;
}
.icon-label {
.icon-label{
color: white;
}
.icon-label-stroke {
.icon-label-stroke{
color: black;
-webkit-text-stroke: 0.14em black;
-webkit-text-stroke: .14em black;
}
/* prose */
@utility prose {
@apply prose-h2:first:mt-0 prose-h3:first:mt-0 prose-h4:first:mt-0 prose-h5:first:mt-0;
code {
code{
background: #0001;
@apply rounded;
padding: 0.2em 0.4em;
margin: 0 0.2em;
padding: .2em .4em;
margin: 0 .2em;
&::before,
&::after {
content: none;
@ -86,11 +74,12 @@ icon.big .icon-label-stroke {
/*alert*/
.markdown-alert-title {
.markdown-alert-title{
@apply hidden;
}
.markdown-alert-title + * {
.markdown-alert-title + *
{
@apply mt-0;
}
@ -120,21 +109,9 @@ icon.big .icon-label-stroke {
/* cols */
.cols {
@apply lg:flex lg:gap-4;
}
.col {
@apply lg:flex-1;
}
.col-2 {
@apply lg:flex-2;
}
.col-3 {
@apply lg:flex-3;
}
.col-4 {
@apply lg:flex-4;
}
.col-5 {
@apply lg:flex-5;
}
.cols{ @apply lg:flex lg:gap-4; }
.col{ @apply lg:flex-1; }
.col-2{ @apply lg:flex-2; }
.col-3{ @apply lg:flex-3; }
.col-4{ @apply lg:flex-4; }
.col-5{ @apply lg:flex-5; }