14 Commits

Author SHA1 Message Date
281c3d02f2 scope codemirror-helix
All checks were successful
Publish codemirror-helix / publish (push) Successful in 2m19s
2026-06-19 17:15:51 +02:00
781e03c50c publish codemirrot helix package flow
Some checks failed
Publish codemirror-helix / publish (push) Failing after 2m16s
2026-06-19 17:07:18 +02:00
81c60feed9 mdx layout components 2026-06-19 14:48:38 +02:00
0fe62fcce5 music desc order 2026-06-18 05:55:37 +02:00
b5f40a4007 remove tacky red circles 2026-06-18 05:50:15 +02:00
1100e35091 Merge branch 'additional-ai-tools' 2026-06-18 05:37:16 +02:00
246d7339fb homepage, cv page layout 2026-06-18 05:37:11 +02:00
85af4aec77 make meeting creation user level 2026-06-18 04:55:25 +02:00
fb379b912a Merge branch 'additional-ai-tools' 2026-06-18 03:38:06 +02:00
05740e122e Add AI assistant tools for site search, project details, experience matching, and calendar availability 2026-06-18 03:37:22 +02:00
ffa475e876 Merge branch 'blog-scroll' 2026-06-18 02:57:44 +02:00
d85cc205de blog slug scroll 2026-06-18 02:57:36 +02:00
95666e20e9 Merge branch 'chat-scroll-behaiviour' 2026-06-18 02:53:20 +02:00
993137068e chat scroll to bottom by default 2026-06-18 02:53:06 +02:00
41 changed files with 3459 additions and 265 deletions

View File

@@ -0,0 +1,48 @@
name: Publish codemirror-helix
# Releases the packages/codemirror-helix workspace package to npm.
# Trigger by pushing a tag like `codemirror-helix-v0.1.0` whose version
# matches packages/codemirror-helix/package.json.
on:
push:
tags:
- "codemirror-helix-v*"
jobs:
publish:
runs-on: x86
defaults:
run:
working-directory: packages/codemirror-helix
steps:
- uses: actions/checkout@v4
- uses: oven-sh/setup-bun@v2
with:
bun-version: latest
- uses: actions/setup-node@v4
with:
node-version: 24
registry-url: "https://registry.npmjs.org"
# Workspace install must happen at the monorepo root.
- name: Install dependencies
working-directory: .
run: bun install --frozen-lockfile
- name: Check tag matches package version
run: |
PACKAGE_VERSION="$(node -p "require('./package.json').version")"
TAG_NAME="${GITHUB_REF_NAME:-${GITHUB_REF#refs/tags/}}"
test "codemirror-helix-v${PACKAGE_VERSION}" = "${TAG_NAME}"
- name: Build
run: bun run build
- name: Publish
run: npm publish --access public
env:
NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }}

3
.gitignore vendored
View File

@@ -48,3 +48,6 @@ yarn-error.log*
/.clerk/ /.clerk/
.worktrees .worktrees
.claudesession .claudesession
# built workspace packages (rebuilt by helix:build / package CI)
/packages/*/dist

View File

@@ -8,12 +8,20 @@
"@ai-sdk/openai": "^3.0.67", "@ai-sdk/openai": "^3.0.67",
"@ai-sdk/react": "^3.0.195", "@ai-sdk/react": "^3.0.195",
"@clerk/nextjs": "^7.4.2", "@clerk/nextjs": "^7.4.2",
"@codemirror/autocomplete": "^6.20.3",
"@codemirror/commands": "^6.10.3",
"@codemirror/lang-markdown": "^6.5.0",
"@codemirror/language": "^6.12.3",
"@codemirror/search": "^6.5.6",
"@codemirror/state": "^6.6.0",
"@codemirror/view": "^6.43.1",
"@electric-sql/pglite": "^0.4.6", "@electric-sql/pglite": "^0.4.6",
"@ffmpeg/ffmpeg": "^0.12.15", "@ffmpeg/ffmpeg": "^0.12.15",
"@ffmpeg/util": "^0.12.2", "@ffmpeg/util": "^0.12.2",
"@fortawesome/fontawesome-svg-core": "^7.2.0", "@fortawesome/fontawesome-svg-core": "^7.2.0",
"@fortawesome/free-solid-svg-icons": "^7.2.0", "@fortawesome/free-solid-svg-icons": "^7.2.0",
"@fortawesome/react-fontawesome": "^3.3.1", "@fortawesome/react-fontawesome": "^3.3.1",
"@gregorlohaus/codemirror-helix": "workspace:*",
"@gsap/react": "^2.1.2", "@gsap/react": "^2.1.2",
"@hookform/resolvers": "^5.4.0", "@hookform/resolvers": "^5.4.0",
"@mdx-js/loader": "^3.1.1", "@mdx-js/loader": "^3.1.1",
@@ -57,6 +65,7 @@
"@trpc/react-query": "^11.17.0", "@trpc/react-query": "^11.17.0",
"@trpc/server": "^11.17.0", "@trpc/server": "^11.17.0",
"@types/mdx": "^2.0.14", "@types/mdx": "^2.0.14",
"@uiw/react-codemirror": "^4.25.10",
"@uiw/react-md-editor": "^4.1.1", "@uiw/react-md-editor": "^4.1.1",
"@uploadthing/react": "^7.3.3", "@uploadthing/react": "^7.3.3",
"@vercel/speed-insights": "^2.0.0", "@vercel/speed-insights": "^2.0.0",
@@ -130,6 +139,25 @@
"vitest": "^4.1.8", "vitest": "^4.1.8",
}, },
}, },
"packages/codemirror-helix": {
"name": "@gregorlohaus/codemirror-helix",
"version": "0.1.0",
"devDependencies": {
"@codemirror/commands": "^6.10.3",
"@codemirror/language": "^6.12.3",
"@codemirror/search": "^6.5.6",
"@codemirror/state": "^6.6.0",
"@codemirror/view": "^6.43.1",
"typescript": "^5.6.0",
},
"peerDependencies": {
"@codemirror/commands": "^6.0.0",
"@codemirror/language": "^6.0.0",
"@codemirror/search": "^6.0.0",
"@codemirror/state": "^6.0.0",
"@codemirror/view": "^6.0.0",
},
},
}, },
"packages": { "packages": {
"@adobe/css-tools": ["@adobe/css-tools@4.5.0", "", {}, "sha512-6OzddxPio9UiWTCemp4N8cYLV2ZN1ncRnV1cVGtve7dhPOtRkleRyx32GQCYSwDYgaHU3USMm84tNsvKzRCa1Q=="], "@adobe/css-tools": ["@adobe/css-tools@4.5.0", "", {}, "sha512-6OzddxPio9UiWTCemp4N8cYLV2ZN1ncRnV1cVGtve7dhPOtRkleRyx32GQCYSwDYgaHU3USMm84tNsvKzRCa1Q=="],
@@ -272,6 +300,30 @@
"@clerk/shared": ["@clerk/shared@4.19.0", "", { "dependencies": { "@tanstack/query-core": "^5.100.6", "dequal": "2.0.3", "glob-to-regexp": "0.4.1", "js-cookie": "3.0.7" }, "peerDependencies": { "react": "^18.0.0 || ~19.0.3 || ~19.1.4 || ~19.2.3 || ~19.3.0-0", "react-dom": "^18.0.0 || ~19.0.3 || ~19.1.4 || ~19.2.3 || ~19.3.0-0" }, "optionalPeers": ["react", "react-dom"] }, "sha512-27tZG3/PAuuOQAKFpKGzFlio2FC/t/0TcSRl6UNlYap85BJRPnnYdo3PaVunNbKYKOpaFwJlwgcD2cKB7l/qWw=="], "@clerk/shared": ["@clerk/shared@4.19.0", "", { "dependencies": { "@tanstack/query-core": "^5.100.6", "dequal": "2.0.3", "glob-to-regexp": "0.4.1", "js-cookie": "3.0.7" }, "peerDependencies": { "react": "^18.0.0 || ~19.0.3 || ~19.1.4 || ~19.2.3 || ~19.3.0-0", "react-dom": "^18.0.0 || ~19.0.3 || ~19.1.4 || ~19.2.3 || ~19.3.0-0" }, "optionalPeers": ["react", "react-dom"] }, "sha512-27tZG3/PAuuOQAKFpKGzFlio2FC/t/0TcSRl6UNlYap85BJRPnnYdo3PaVunNbKYKOpaFwJlwgcD2cKB7l/qWw=="],
"@codemirror/autocomplete": ["@codemirror/autocomplete@6.20.3", "", { "dependencies": { "@codemirror/language": "^6.0.0", "@codemirror/state": "^6.0.0", "@codemirror/view": "^6.17.0", "@lezer/common": "^1.0.0" } }, "sha512-tlosUqb+3BbxCxZdu4tKeRghPFC+QM7q4X5YhKV2eCmPG+1r2F3f4AaSz5sCrFqUtX4Jh20VFTKecl16MgiV9g=="],
"@codemirror/commands": ["@codemirror/commands@6.10.3", "", { "dependencies": { "@codemirror/language": "^6.0.0", "@codemirror/state": "^6.6.0", "@codemirror/view": "^6.27.0", "@lezer/common": "^1.1.0" } }, "sha512-JFRiqhKu+bvSkDLI+rUhJwSxQxYb759W5GBezE8Uc8mHLqC9aV/9aTC7yJSqCtB3F00pylrLCwnyS91Ap5ej4Q=="],
"@codemirror/lang-css": ["@codemirror/lang-css@6.3.1", "", { "dependencies": { "@codemirror/autocomplete": "^6.0.0", "@codemirror/language": "^6.0.0", "@codemirror/state": "^6.0.0", "@lezer/common": "^1.0.2", "@lezer/css": "^1.1.7" } }, "sha512-kr5fwBGiGtmz6l0LSJIbno9QrifNMUusivHbnA1H6Dmqy4HZFte3UAICix1VuKo0lMPKQr2rqB+0BkKi/S3Ejg=="],
"@codemirror/lang-html": ["@codemirror/lang-html@6.4.11", "", { "dependencies": { "@codemirror/autocomplete": "^6.0.0", "@codemirror/lang-css": "^6.0.0", "@codemirror/lang-javascript": "^6.0.0", "@codemirror/language": "^6.4.0", "@codemirror/state": "^6.0.0", "@codemirror/view": "^6.17.0", "@lezer/common": "^1.0.0", "@lezer/css": "^1.1.0", "@lezer/html": "^1.3.12" } }, "sha512-9NsXp7Nwp891pQchI7gPdTwBuSuT3K65NGTHWHNJ55HjYcHLllr0rbIZNdOzas9ztc1EUVBlHou85FFZS4BNnw=="],
"@codemirror/lang-javascript": ["@codemirror/lang-javascript@6.2.5", "", { "dependencies": { "@codemirror/autocomplete": "^6.0.0", "@codemirror/language": "^6.6.0", "@codemirror/lint": "^6.0.0", "@codemirror/state": "^6.0.0", "@codemirror/view": "^6.17.0", "@lezer/common": "^1.0.0", "@lezer/javascript": "^1.0.0" } }, "sha512-zD4e5mS+50htS7F+TYjBPsiIFGanfVqg4HyUz6WNFikgOPf2BgKlx+TQedI1w6n/IqRBVBbBWmGFdLB/7uxO4A=="],
"@codemirror/lang-markdown": ["@codemirror/lang-markdown@6.5.0", "", { "dependencies": { "@codemirror/autocomplete": "^6.7.1", "@codemirror/lang-html": "^6.0.0", "@codemirror/language": "^6.3.0", "@codemirror/state": "^6.0.0", "@codemirror/view": "^6.0.0", "@lezer/common": "^1.2.1", "@lezer/markdown": "^1.0.0" } }, "sha512-0K40bZ35jpHya6FriukbgaleaqzBLZfOh7HuzqbMxBXkbYMJDxfF39c23xOgxFezR+3G+tR2/Mup+Xk865OMvw=="],
"@codemirror/language": ["@codemirror/language@6.12.3", "", { "dependencies": { "@codemirror/state": "^6.0.0", "@codemirror/view": "^6.23.0", "@lezer/common": "^1.5.0", "@lezer/highlight": "^1.0.0", "@lezer/lr": "^1.0.0", "style-mod": "^4.0.0" } }, "sha512-QwCZW6Tt1siP37Jet9Tb02Zs81TQt6qQrZR2H+eGMcFsL1zMrk2/b9CLC7/9ieP1fjIUMgviLWMmgiHoJrj+ZA=="],
"@codemirror/lint": ["@codemirror/lint@6.9.7", "", { "dependencies": { "@codemirror/state": "^6.0.0", "@codemirror/view": "^6.42.0", "crelt": "^1.0.5" } }, "sha512-28/+iWLYxKxsvGYhSYL7zaCZqLz5+FFFDq9tVsvGv9kv8RY4fFAchJ5WX9M3YrrRlTIsECjsXPqeNgnSmNP2dg=="],
"@codemirror/search": ["@codemirror/search@6.7.1", "", { "dependencies": { "@codemirror/state": "^6.0.0", "@codemirror/view": "^6.37.0", "crelt": "^1.0.5" } }, "sha512-uMe5UO6PamJtSHrXhhHOzSX3ReWtiJrva6GnPMwSOrZtiExb5X5eExhr2OUZQVvdxPsKpY3Ro2mFbQadpPWmHA=="],
"@codemirror/state": ["@codemirror/state@6.6.0", "", { "dependencies": { "@marijn/find-cluster-break": "^1.0.0" } }, "sha512-4nbvra5R5EtiCzr9BTHiTLc+MLXK2QGiAVYMyi8PkQd3SR+6ixar/Q/01Fa21TBIDOZXgeWV4WppsQolSreAPQ=="],
"@codemirror/theme-one-dark": ["@codemirror/theme-one-dark@6.1.3", "", { "dependencies": { "@codemirror/language": "^6.0.0", "@codemirror/state": "^6.0.0", "@codemirror/view": "^6.0.0", "@lezer/highlight": "^1.0.0" } }, "sha512-NzBdIvEJmx6fjeremiGp3t/okrLPYT0d9orIc7AFun8oZcRk58aejkqhv6spnz4MLAevrKNPMQYXEWMg4s+sKA=="],
"@codemirror/view": ["@codemirror/view@6.43.1", "", { "dependencies": { "@codemirror/state": "^6.6.0", "crelt": "^1.0.6", "style-mod": "^4.1.0", "w3c-keyname": "^2.2.4" } }, "sha512-+BIjw/AG3tDQ4pJgTLPYdAW25eDE66YsvM4LKyVPgGzVgZ4a9Wj1SRX8kPVKgBDdPt8oHtZ15F0qx7p0oOHdHw=="],
"@cspotcode/source-map-support": ["@cspotcode/source-map-support@0.8.1", "", { "dependencies": { "@jridgewell/trace-mapping": "0.3.9" } }, "sha512-IchNf6dN4tHoMFIn/7OE8LWZ19Y6q/67Bmf6vnGREv8RSbBVb9LPJxEcnwrcwX6ixSvaiGoomAUvu4YSxXrVgw=="], "@cspotcode/source-map-support": ["@cspotcode/source-map-support@0.8.1", "", { "dependencies": { "@jridgewell/trace-mapping": "0.3.9" } }, "sha512-IchNf6dN4tHoMFIn/7OE8LWZ19Y6q/67Bmf6vnGREv8RSbBVb9LPJxEcnwrcwX6ixSvaiGoomAUvu4YSxXrVgw=="],
"@csstools/color-helpers": ["@csstools/color-helpers@6.0.2", "", {}, "sha512-LMGQLS9EuADloEFkcTBR3BwV/CGHV7zyDxVRtVDTwdI2Ca4it0CCVTT9wCkxSgokjE5Ho41hEPgb8OEUwoXr6Q=="], "@csstools/color-helpers": ["@csstools/color-helpers@6.0.2", "", {}, "sha512-LMGQLS9EuADloEFkcTBR3BwV/CGHV7zyDxVRtVDTwdI2Ca4it0CCVTT9wCkxSgokjE5Ho41hEPgb8OEUwoXr6Q=="],
@@ -384,6 +436,8 @@
"@fortawesome/react-fontawesome": ["@fortawesome/react-fontawesome@3.3.1", "", { "peerDependencies": { "@fortawesome/fontawesome-svg-core": "~6 || ~7", "react": "^18.0.0 || ^19.0.0" } }, "sha512-wGnAPhfzivDwBWYmEG8MSrEXPruoiMMo48NnsRkj1NZkoaawgOijPNAiSHKMYEoCsqTBSgLTzL6EqTTWGaUR4w=="], "@fortawesome/react-fontawesome": ["@fortawesome/react-fontawesome@3.3.1", "", { "peerDependencies": { "@fortawesome/fontawesome-svg-core": "~6 || ~7", "react": "^18.0.0 || ^19.0.0" } }, "sha512-wGnAPhfzivDwBWYmEG8MSrEXPruoiMMo48NnsRkj1NZkoaawgOijPNAiSHKMYEoCsqTBSgLTzL6EqTTWGaUR4w=="],
"@gregorlohaus/codemirror-helix": ["@gregorlohaus/codemirror-helix@workspace:packages/codemirror-helix"],
"@gsap/react": ["@gsap/react@2.1.2", "", { "peerDependencies": { "gsap": "^3.12.5", "react": ">=17" } }, "sha512-JqliybO1837UcgH2hVOM4VO+38APk3ECNrsuSM4MuXp+rbf+/2IG2K1YJiqfTcXQHH7XlA0m3ykniFYstfq0Iw=="], "@gsap/react": ["@gsap/react@2.1.2", "", { "peerDependencies": { "gsap": "^3.12.5", "react": ">=17" } }, "sha512-JqliybO1837UcgH2hVOM4VO+38APk3ECNrsuSM4MuXp+rbf+/2IG2K1YJiqfTcXQHH7XlA0m3ykniFYstfq0Iw=="],
"@hono/node-server": ["@hono/node-server@1.19.14", "", { "peerDependencies": { "hono": "^4" } }, "sha512-GwtvgtXxnWsucXvbQXkRgqksiH2Qed37H9xHZocE5sA3N8O8O8/8FA3uclQXxXVzc9XBZuEOMK7+r02FmSpHtw=="], "@hono/node-server": ["@hono/node-server@1.19.14", "", { "peerDependencies": { "hono": "^4" } }, "sha512-GwtvgtXxnWsucXvbQXkRgqksiH2Qed37H9xHZocE5sA3N8O8O8/8FA3uclQXxXVzc9XBZuEOMK7+r02FmSpHtw=="],
@@ -496,6 +550,22 @@
"@jridgewell/trace-mapping": ["@jridgewell/trace-mapping@0.3.31", "", { "dependencies": { "@jridgewell/resolve-uri": "^3.1.0", "@jridgewell/sourcemap-codec": "^1.4.14" } }, "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw=="], "@jridgewell/trace-mapping": ["@jridgewell/trace-mapping@0.3.31", "", { "dependencies": { "@jridgewell/resolve-uri": "^3.1.0", "@jridgewell/sourcemap-codec": "^1.4.14" } }, "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw=="],
"@lezer/common": ["@lezer/common@1.5.2", "", {}, "sha512-sxQE460fPZyU3sdc8lafxiPwJHBzZRy/udNFynGQky1SePYBdhkBl1kOagA9uT3pxR8K09bOrmTUqA9wb/PjSQ=="],
"@lezer/css": ["@lezer/css@1.3.3", "", { "dependencies": { "@lezer/common": "^1.2.0", "@lezer/highlight": "^1.0.0", "@lezer/lr": "^1.3.0" } }, "sha512-RzBo8r+/6QJeow7aPHIpGVIH59xTcJXp399820gZoMo9noQDRVpJLheIBUicYwKcsbOYoBRoLZlf2720dG/4Tg=="],
"@lezer/highlight": ["@lezer/highlight@1.2.3", "", { "dependencies": { "@lezer/common": "^1.3.0" } }, "sha512-qXdH7UqTvGfdVBINrgKhDsVTJTxactNNxLk7+UMwZhU13lMHaOBlJe9Vqp907ya56Y3+ed2tlqzys7jDkTmW0g=="],
"@lezer/html": ["@lezer/html@1.3.13", "", { "dependencies": { "@lezer/common": "^1.2.0", "@lezer/highlight": "^1.0.0", "@lezer/lr": "^1.0.0" } }, "sha512-oI7n6NJml729m7pjm9lvLvmXbdoMoi2f+1pwSDJkl9d68zGr7a9Btz8NdHTGQZtW2DA25ybeuv/SyDb9D5tseg=="],
"@lezer/javascript": ["@lezer/javascript@1.5.4", "", { "dependencies": { "@lezer/common": "^1.2.0", "@lezer/highlight": "^1.1.3", "@lezer/lr": "^1.3.0" } }, "sha512-vvYx3MhWqeZtGPwDStM2dwgljd5smolYD2lR2UyFcHfxbBQebqx8yjmFmxtJ/E6nN6u1D9srOiVWm3Rb4tmcUA=="],
"@lezer/lr": ["@lezer/lr@1.4.10", "", { "dependencies": { "@lezer/common": "^1.0.0" } }, "sha512-rnCpTIBafOx4mRp43xOxDJbFipJm/c0cia/V5TiGlhmMa+wsSdoGmUN3w5Bqrks/09Q/D4tNAmWaT8p6NRi77A=="],
"@lezer/markdown": ["@lezer/markdown@1.6.4", "", { "dependencies": { "@lezer/common": "^1.5.0", "@lezer/highlight": "^1.0.0" } }, "sha512-N0SxazMj4k65DBfaf1azqtMZd6u7MqluP84/NZnB/io8Td9aleFmAhz9hcbvSfsxT5tdYlJ5qgv5aMJGY4zEtA=="],
"@marijn/find-cluster-break": ["@marijn/find-cluster-break@1.0.2", "", {}, "sha512-l0h88YhZFyKdXIFNfSWpyjStDjGHwZ/U7iobcK1cQQD8sejsONdQtTVU+1wVN1PBw40PiiHB1vA5S7VTfQiP9g=="],
"@mdx-js/loader": ["@mdx-js/loader@3.1.1", "", { "dependencies": { "@mdx-js/mdx": "^3.0.0", "source-map": "^0.7.0" }, "peerDependencies": { "webpack": ">=5" }, "optionalPeers": ["webpack"] }, "sha512-0TTacJyZ9mDmY+VefuthVshaNIyCGZHJG2fMnGaDttCt8HmjUF7SizlHJpaCDoGnN635nK1wpzfpx/Xx5S4WnQ=="], "@mdx-js/loader": ["@mdx-js/loader@3.1.1", "", { "dependencies": { "@mdx-js/mdx": "^3.0.0", "source-map": "^0.7.0" }, "peerDependencies": { "webpack": ">=5" }, "optionalPeers": ["webpack"] }, "sha512-0TTacJyZ9mDmY+VefuthVshaNIyCGZHJG2fMnGaDttCt8HmjUF7SizlHJpaCDoGnN635nK1wpzfpx/Xx5S4WnQ=="],
"@mdx-js/mdx": ["@mdx-js/mdx@3.1.1", "", { "dependencies": { "@types/estree": "^1.0.0", "@types/estree-jsx": "^1.0.0", "@types/hast": "^3.0.0", "@types/mdx": "^2.0.0", "acorn": "^8.0.0", "collapse-white-space": "^2.0.0", "devlop": "^1.0.0", "estree-util-is-identifier-name": "^3.0.0", "estree-util-scope": "^1.0.0", "estree-walker": "^3.0.0", "hast-util-to-jsx-runtime": "^2.0.0", "markdown-extensions": "^2.0.0", "recma-build-jsx": "^1.0.0", "recma-jsx": "^1.0.0", "recma-stringify": "^1.0.0", "rehype-recma": "^1.0.0", "remark-mdx": "^3.0.0", "remark-parse": "^11.0.0", "remark-rehype": "^11.0.0", "source-map": "^0.7.0", "unified": "^11.0.0", "unist-util-position-from-estree": "^2.0.0", "unist-util-stringify-position": "^4.0.0", "unist-util-visit": "^5.0.0", "vfile": "^6.0.0" } }, "sha512-f6ZO2ifpwAQIpzGWaBQT2TXxPv6z3RBzQKpVftEWN78Vl/YweF1uwussDx8ECAXVtr3Rs89fKyG9YlzUs9DyGQ=="], "@mdx-js/mdx": ["@mdx-js/mdx@3.1.1", "", { "dependencies": { "@types/estree": "^1.0.0", "@types/estree-jsx": "^1.0.0", "@types/hast": "^3.0.0", "@types/mdx": "^2.0.0", "acorn": "^8.0.0", "collapse-white-space": "^2.0.0", "devlop": "^1.0.0", "estree-util-is-identifier-name": "^3.0.0", "estree-util-scope": "^1.0.0", "estree-walker": "^3.0.0", "hast-util-to-jsx-runtime": "^2.0.0", "markdown-extensions": "^2.0.0", "recma-build-jsx": "^1.0.0", "recma-jsx": "^1.0.0", "recma-stringify": "^1.0.0", "rehype-recma": "^1.0.0", "remark-mdx": "^3.0.0", "remark-parse": "^11.0.0", "remark-rehype": "^11.0.0", "source-map": "^0.7.0", "unified": "^11.0.0", "unist-util-position-from-estree": "^2.0.0", "unist-util-stringify-position": "^4.0.0", "unist-util-visit": "^5.0.0", "vfile": "^6.0.0" } }, "sha512-f6ZO2ifpwAQIpzGWaBQT2TXxPv6z3RBzQKpVftEWN78Vl/YweF1uwussDx8ECAXVtr3Rs89fKyG9YlzUs9DyGQ=="],
@@ -914,8 +984,12 @@
"@types/yargs-parser": ["@types/yargs-parser@21.0.3", "", {}, "sha512-I4q9QU9MQv4oEOz4tAHJtNz1cwuLxn2F3xcc2iV5WdqLPpUnj30aUuxt1mAxYTG+oe8CZMV/+6rU4S4gRDzqtQ=="], "@types/yargs-parser": ["@types/yargs-parser@21.0.3", "", {}, "sha512-I4q9QU9MQv4oEOz4tAHJtNz1cwuLxn2F3xcc2iV5WdqLPpUnj30aUuxt1mAxYTG+oe8CZMV/+6rU4S4gRDzqtQ=="],
"@uiw/codemirror-extensions-basic-setup": ["@uiw/codemirror-extensions-basic-setup@4.25.10", "", { "dependencies": { "@codemirror/autocomplete": "^6.0.0", "@codemirror/commands": "^6.0.0", "@codemirror/language": "^6.0.0", "@codemirror/lint": "^6.0.0", "@codemirror/search": "^6.0.0", "@codemirror/state": "^6.0.0", "@codemirror/view": "^6.0.0" } }, "sha512-P3vytLlpE62KYSWrMUnwDCv2lvaQDuDZzyj03mHntuHo5bSl34fRZpjTY3kQTPGuXHxkGSYpoPFFj+hMTqaaMQ=="],
"@uiw/copy-to-clipboard": ["@uiw/copy-to-clipboard@1.0.21", "", {}, "sha512-apdzZJyJC/IEj21N22ry1H022pgpSA+FwNKxmvOGQ9rUMdyRbHf1nZq2UwqnfGOuTr7iOKLzNgWsCJtAwyAZpw=="], "@uiw/copy-to-clipboard": ["@uiw/copy-to-clipboard@1.0.21", "", {}, "sha512-apdzZJyJC/IEj21N22ry1H022pgpSA+FwNKxmvOGQ9rUMdyRbHf1nZq2UwqnfGOuTr7iOKLzNgWsCJtAwyAZpw=="],
"@uiw/react-codemirror": ["@uiw/react-codemirror@4.25.10", "", { "dependencies": { "@babel/runtime": "^7.18.6", "@codemirror/commands": "^6.1.0", "@codemirror/state": "^6.1.1", "@codemirror/theme-one-dark": "^6.0.0", "@uiw/codemirror-extensions-basic-setup": "4.25.10", "codemirror": "^6.0.0" }, "peerDependencies": { "@codemirror/view": ">=6.0.0", "react": ">=17.0.0", "react-dom": ">=17.0.0" } }, "sha512-DzgSMwM5qzB7v1FIb4gEeriYt67iiay756/HIOM9mAbeOVK0MO7rqefHf0O5c0269pJKMW7AH9FjclExD23V9w=="],
"@uiw/react-markdown-preview": ["@uiw/react-markdown-preview@5.2.1", "", { "dependencies": { "@babel/runtime": "^7.17.2", "@uiw/copy-to-clipboard": "~1.0.12", "react-markdown": "~10.1.0", "rehype-attr": "~4.0.0", "rehype-autolink-headings": "~7.1.0", "rehype-ignore": "^2.0.0", "rehype-prism-plus": "~2.0.0", "rehype-raw": "^7.0.0", "rehype-rewrite": "~4.0.0", "rehype-slug": "~6.0.0", "remark-gfm": "~4.0.0", "remark-github-blockquote-alert": "^1.0.0", "unist-util-visit": "^5.0.0" }, "peerDependencies": { "react": ">=16.8.0", "react-dom": ">=16.8.0" } }, "sha512-JjvcHveT6glhlJYJx1XGBZij6wkw+VwREV6Z6m/GpsjPPdLjF1x8nlPBSB/ATyUF4lD7C8ttMkCqVH9N9XMgEA=="], "@uiw/react-markdown-preview": ["@uiw/react-markdown-preview@5.2.1", "", { "dependencies": { "@babel/runtime": "^7.17.2", "@uiw/copy-to-clipboard": "~1.0.12", "react-markdown": "~10.1.0", "rehype-attr": "~4.0.0", "rehype-autolink-headings": "~7.1.0", "rehype-ignore": "^2.0.0", "rehype-prism-plus": "~2.0.0", "rehype-raw": "^7.0.0", "rehype-rewrite": "~4.0.0", "rehype-slug": "~6.0.0", "remark-gfm": "~4.0.0", "remark-github-blockquote-alert": "^1.0.0", "unist-util-visit": "^5.0.0" }, "peerDependencies": { "react": ">=16.8.0", "react-dom": ">=16.8.0" } }, "sha512-JjvcHveT6glhlJYJx1XGBZij6wkw+VwREV6Z6m/GpsjPPdLjF1x8nlPBSB/ATyUF4lD7C8ttMkCqVH9N9XMgEA=="],
"@uiw/react-md-editor": ["@uiw/react-md-editor@4.1.1", "", { "dependencies": { "@babel/runtime": "^7.14.6", "@uiw/react-markdown-preview": "^5.2.0", "rehype": "~13.0.0", "rehype-prism-plus": "~2.0.0" }, "peerDependencies": { "react": ">=16.8.0", "react-dom": ">=16.8.0" } }, "sha512-yZqV5twN/sSfpce4cO/1bqy16o7v2oW324VNh2gcnsSpzOr2jEHpchM+ElD1y+ivUmFXcDZ5Ky5TOuPZg4qL6g=="], "@uiw/react-md-editor": ["@uiw/react-md-editor@4.1.1", "", { "dependencies": { "@babel/runtime": "^7.14.6", "@uiw/react-markdown-preview": "^5.2.0", "rehype": "~13.0.0", "rehype-prism-plus": "~2.0.0" }, "peerDependencies": { "react": ">=16.8.0", "react-dom": ">=16.8.0" } }, "sha512-yZqV5twN/sSfpce4cO/1bqy16o7v2oW324VNh2gcnsSpzOr2jEHpchM+ElD1y+ivUmFXcDZ5Ky5TOuPZg4qL6g=="],
@@ -1132,6 +1206,8 @@
"code-block-writer": ["code-block-writer@13.0.3", "", {}, "sha512-Oofo0pq3IKnsFtuHqSF7TqBfr71aeyZDVJ0HpmqB7FBM2qEigL0iPONSCZSO9pE9dZTAxANe5XHG9Uy0YMv8cg=="], "code-block-writer": ["code-block-writer@13.0.3", "", {}, "sha512-Oofo0pq3IKnsFtuHqSF7TqBfr71aeyZDVJ0HpmqB7FBM2qEigL0iPONSCZSO9pE9dZTAxANe5XHG9Uy0YMv8cg=="],
"codemirror": ["codemirror@6.0.2", "", { "dependencies": { "@codemirror/autocomplete": "^6.0.0", "@codemirror/commands": "^6.0.0", "@codemirror/language": "^6.0.0", "@codemirror/lint": "^6.0.0", "@codemirror/search": "^6.0.0", "@codemirror/state": "^6.0.0", "@codemirror/view": "^6.0.0" } }, "sha512-VhydHotNW5w1UGK0Qj96BwSk/Zqbp9WbnyK2W/eVMv4QyF41INRGpjUhFJY7/uDNuudSc33a/PKr4iDqRduvHw=="],
"collapse-white-space": ["collapse-white-space@2.1.0", "", {}, "sha512-loKTxY1zCOuG4j9f6EPnuyyYkf58RnhhWTvRoZEokgB+WbdXehfjFviyOVYkqzEWz1Q5kRiZdBYS5SwxbQYwzw=="], "collapse-white-space": ["collapse-white-space@2.1.0", "", {}, "sha512-loKTxY1zCOuG4j9f6EPnuyyYkf58RnhhWTvRoZEokgB+WbdXehfjFviyOVYkqzEWz1Q5kRiZdBYS5SwxbQYwzw=="],
"collect-v8-coverage": ["collect-v8-coverage@1.0.3", "", {}, "sha512-1L5aqIkwPfiodaMgQunkF1zRhNqifHBmtbbbxcr6yVxxBnliw4TDOW6NxpO8DJLgJ16OT+Y4ztZqP6p/FtXnAw=="], "collect-v8-coverage": ["collect-v8-coverage@1.0.3", "", {}, "sha512-1L5aqIkwPfiodaMgQunkF1zRhNqifHBmtbbbxcr6yVxxBnliw4TDOW6NxpO8DJLgJ16OT+Y4ztZqP6p/FtXnAw=="],
@@ -1166,6 +1242,8 @@
"create-require": ["create-require@1.1.1", "", {}, "sha512-dcKFX3jn0MpIaXjisoRvexIJVEKzaq7z2rZKxf+MSr9TkdmHmsU4m2lcLojrj/FHl8mk5VxMmYA+ftRkP/3oKQ=="], "create-require": ["create-require@1.1.1", "", {}, "sha512-dcKFX3jn0MpIaXjisoRvexIJVEKzaq7z2rZKxf+MSr9TkdmHmsU4m2lcLojrj/FHl8mk5VxMmYA+ftRkP/3oKQ=="],
"crelt": ["crelt@1.0.6", "", {}, "sha512-VQ2MBenTq1fWZUH9DJNGti7kKv6EeAuYr3cLwxUWhIu1baTaXh4Ib5W2CqHVqib4/MqbYGJqiL3Zb8GJZr3l4g=="],
"cross-spawn": ["cross-spawn@7.0.6", "", { "dependencies": { "path-key": "^3.1.0", "shebang-command": "^2.0.0", "which": "^2.0.1" } }, "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA=="], "cross-spawn": ["cross-spawn@7.0.6", "", { "dependencies": { "path-key": "^3.1.0", "shebang-command": "^2.0.0", "which": "^2.0.1" } }, "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA=="],
"css-selector-parser": ["css-selector-parser@3.3.0", "", {}, "sha512-Y2asgMGFqJKF4fq4xHDSlFYIkeVfRsm69lQC1q9kbEsH5XtnINTMrweLkjYMeaUgiXBy/uvKeO/a1JHTNnmB2g=="], "css-selector-parser": ["css-selector-parser@3.3.0", "", {}, "sha512-Y2asgMGFqJKF4fq4xHDSlFYIkeVfRsm69lQC1q9kbEsH5XtnINTMrweLkjYMeaUgiXBy/uvKeO/a1JHTNnmB2g=="],
@@ -2268,6 +2346,8 @@
"strip-json-comments": ["strip-json-comments@3.1.1", "", {}, "sha512-6fPc+R4ihwqP6N/aIv2f1gMH8lOVtWQHoqC4yK6oSDVVocumAsfCqjkXnqiYMhmMwS/mEHLp7Vehlt3ql6lEig=="], "strip-json-comments": ["strip-json-comments@3.1.1", "", {}, "sha512-6fPc+R4ihwqP6N/aIv2f1gMH8lOVtWQHoqC4yK6oSDVVocumAsfCqjkXnqiYMhmMwS/mEHLp7Vehlt3ql6lEig=="],
"style-mod": ["style-mod@4.1.3", "", {}, "sha512-i/n8VsZydrugj3Iuzll8+x/00GH2vnYsk1eomD8QiRrSAeW6ItbCQDtfXCeJHd0iwiNagqjQkvpvREEPtW3IoQ=="],
"style-to-js": ["style-to-js@1.1.21", "", { "dependencies": { "style-to-object": "1.0.14" } }, "sha512-RjQetxJrrUJLQPHbLku6U/ocGtzyjbJMP9lCNK7Ag0CNh690nSH8woqWH9u16nMjYBAok+i7JO1NP2pOy8IsPQ=="], "style-to-js": ["style-to-js@1.1.21", "", { "dependencies": { "style-to-object": "1.0.14" } }, "sha512-RjQetxJrrUJLQPHbLku6U/ocGtzyjbJMP9lCNK7Ag0CNh690nSH8woqWH9u16nMjYBAok+i7JO1NP2pOy8IsPQ=="],
"style-to-object": ["style-to-object@1.0.14", "", { "dependencies": { "inline-style-parser": "0.2.7" } }, "sha512-LIN7rULI0jBscWQYaSswptyderlarFkjQ+t79nzty8tcIAceVomEVlLzH5VP4Cmsv6MtKhs7qaAiwlcp+Mgaxw=="], "style-to-object": ["style-to-object@1.0.14", "", { "dependencies": { "inline-style-parser": "0.2.7" } }, "sha512-LIN7rULI0jBscWQYaSswptyderlarFkjQ+t79nzty8tcIAceVomEVlLzH5VP4Cmsv6MtKhs7qaAiwlcp+Mgaxw=="],
@@ -2422,6 +2502,8 @@
"vitest": ["vitest@4.1.9", "", { "dependencies": { "@vitest/expect": "4.1.9", "@vitest/mocker": "4.1.9", "@vitest/pretty-format": "4.1.9", "@vitest/runner": "4.1.9", "@vitest/snapshot": "4.1.9", "@vitest/spy": "4.1.9", "@vitest/utils": "4.1.9", "es-module-lexer": "^2.0.0", "expect-type": "^1.3.0", "magic-string": "^0.30.21", "obug": "^2.1.1", "pathe": "^2.0.3", "picomatch": "^4.0.3", "std-env": "^4.0.0-rc.1", "tinybench": "^2.9.0", "tinyexec": "^1.0.2", "tinyglobby": "^0.2.15", "tinyrainbow": "^3.1.0", "vite": "^6.0.0 || ^7.0.0 || ^8.0.0", "why-is-node-running": "^2.3.0" }, "peerDependencies": { "@edge-runtime/vm": "*", "@opentelemetry/api": "^1.9.0", "@types/node": "^20.0.0 || ^22.0.0 || >=24.0.0", "@vitest/browser-playwright": "4.1.9", "@vitest/browser-preview": "4.1.9", "@vitest/browser-webdriverio": "4.1.9", "@vitest/coverage-istanbul": "4.1.9", "@vitest/coverage-v8": "4.1.9", "@vitest/ui": "4.1.9", "happy-dom": "*", "jsdom": "*" }, "optionalPeers": ["@edge-runtime/vm", "@opentelemetry/api", "@types/node", "@vitest/browser-playwright", "@vitest/browser-preview", "@vitest/browser-webdriverio", "@vitest/coverage-istanbul", "@vitest/coverage-v8", "@vitest/ui", "happy-dom", "jsdom"], "bin": { "vitest": "./vitest.mjs" } }, "sha512-nE3/LEyc0z87uHYLZebqCUOaJr2hdtuPp7BQ4BosVFnfltxgAvMG08NyrSGlPpOUWvR27c5flSmYFTNr78L9GQ=="], "vitest": ["vitest@4.1.9", "", { "dependencies": { "@vitest/expect": "4.1.9", "@vitest/mocker": "4.1.9", "@vitest/pretty-format": "4.1.9", "@vitest/runner": "4.1.9", "@vitest/snapshot": "4.1.9", "@vitest/spy": "4.1.9", "@vitest/utils": "4.1.9", "es-module-lexer": "^2.0.0", "expect-type": "^1.3.0", "magic-string": "^0.30.21", "obug": "^2.1.1", "pathe": "^2.0.3", "picomatch": "^4.0.3", "std-env": "^4.0.0-rc.1", "tinybench": "^2.9.0", "tinyexec": "^1.0.2", "tinyglobby": "^0.2.15", "tinyrainbow": "^3.1.0", "vite": "^6.0.0 || ^7.0.0 || ^8.0.0", "why-is-node-running": "^2.3.0" }, "peerDependencies": { "@edge-runtime/vm": "*", "@opentelemetry/api": "^1.9.0", "@types/node": "^20.0.0 || ^22.0.0 || >=24.0.0", "@vitest/browser-playwright": "4.1.9", "@vitest/browser-preview": "4.1.9", "@vitest/browser-webdriverio": "4.1.9", "@vitest/coverage-istanbul": "4.1.9", "@vitest/coverage-v8": "4.1.9", "@vitest/ui": "4.1.9", "happy-dom": "*", "jsdom": "*" }, "optionalPeers": ["@edge-runtime/vm", "@opentelemetry/api", "@types/node", "@vitest/browser-playwright", "@vitest/browser-preview", "@vitest/browser-webdriverio", "@vitest/coverage-istanbul", "@vitest/coverage-v8", "@vitest/ui", "happy-dom", "jsdom"], "bin": { "vitest": "./vitest.mjs" } }, "sha512-nE3/LEyc0z87uHYLZebqCUOaJr2hdtuPp7BQ4BosVFnfltxgAvMG08NyrSGlPpOUWvR27c5flSmYFTNr78L9GQ=="],
"w3c-keyname": ["w3c-keyname@2.2.8", "", {}, "sha512-dpojBhNsCNN7T82Tm7k26A6G9ML3NkhDsnw9n/eoxSRlVBB4CEtIQ/KTCLI2Fwf3ataSXRhYFkQi3SlnFwPvPQ=="],
"w3c-xmlserializer": ["w3c-xmlserializer@5.0.0", "", { "dependencies": { "xml-name-validator": "^5.0.0" } }, "sha512-o8qghlI8NZHU1lLPrpi2+Uq7abh4GGPpYANlalzWxyWteJOCsr/P+oPBA49TOLu5FTZO4d3F9MnWJfiMo4BkmA=="], "w3c-xmlserializer": ["w3c-xmlserializer@5.0.0", "", { "dependencies": { "xml-name-validator": "^5.0.0" } }, "sha512-o8qghlI8NZHU1lLPrpi2+Uq7abh4GGPpYANlalzWxyWteJOCsr/P+oPBA49TOLu5FTZO4d3F9MnWJfiMo4BkmA=="],
"walker": ["walker@1.0.8", "", { "dependencies": { "makeerror": "1.0.12" } }, "sha512-ts/8E8l5b7kY0vlWLewOkDXMmPdLcVV4GmOQLyxuSswIJsweeFZtAsMF7k1Nszz+TYBQrlYRmzOnr398y1JemQ=="], "walker": ["walker@1.0.8", "", { "dependencies": { "makeerror": "1.0.12" } }, "sha512-ts/8E8l5b7kY0vlWLewOkDXMmPdLcVV4GmOQLyxuSswIJsweeFZtAsMF7k1Nszz+TYBQrlYRmzOnr398y1JemQ=="],
@@ -2504,6 +2586,8 @@
"@esbuild-kit/core-utils/esbuild": ["esbuild@0.18.20", "", { "optionalDependencies": { "@esbuild/android-arm": "0.18.20", "@esbuild/android-arm64": "0.18.20", "@esbuild/android-x64": "0.18.20", "@esbuild/darwin-arm64": "0.18.20", "@esbuild/darwin-x64": "0.18.20", "@esbuild/freebsd-arm64": "0.18.20", "@esbuild/freebsd-x64": "0.18.20", "@esbuild/linux-arm": "0.18.20", "@esbuild/linux-arm64": "0.18.20", "@esbuild/linux-ia32": "0.18.20", "@esbuild/linux-loong64": "0.18.20", "@esbuild/linux-mips64el": "0.18.20", "@esbuild/linux-ppc64": "0.18.20", "@esbuild/linux-riscv64": "0.18.20", "@esbuild/linux-s390x": "0.18.20", "@esbuild/linux-x64": "0.18.20", "@esbuild/netbsd-x64": "0.18.20", "@esbuild/openbsd-x64": "0.18.20", "@esbuild/sunos-x64": "0.18.20", "@esbuild/win32-arm64": "0.18.20", "@esbuild/win32-ia32": "0.18.20", "@esbuild/win32-x64": "0.18.20" }, "bin": { "esbuild": "bin/esbuild" } }, "sha512-ceqxoedUrcayh7Y7ZX6NdbbDzGROiyVBgC4PriJThBKSVPWnnFHZAkfI1lJT8QFkOwH4qOS2SJkS4wvpGl8BpA=="], "@esbuild-kit/core-utils/esbuild": ["esbuild@0.18.20", "", { "optionalDependencies": { "@esbuild/android-arm": "0.18.20", "@esbuild/android-arm64": "0.18.20", "@esbuild/android-x64": "0.18.20", "@esbuild/darwin-arm64": "0.18.20", "@esbuild/darwin-x64": "0.18.20", "@esbuild/freebsd-arm64": "0.18.20", "@esbuild/freebsd-x64": "0.18.20", "@esbuild/linux-arm": "0.18.20", "@esbuild/linux-arm64": "0.18.20", "@esbuild/linux-ia32": "0.18.20", "@esbuild/linux-loong64": "0.18.20", "@esbuild/linux-mips64el": "0.18.20", "@esbuild/linux-ppc64": "0.18.20", "@esbuild/linux-riscv64": "0.18.20", "@esbuild/linux-s390x": "0.18.20", "@esbuild/linux-x64": "0.18.20", "@esbuild/netbsd-x64": "0.18.20", "@esbuild/openbsd-x64": "0.18.20", "@esbuild/sunos-x64": "0.18.20", "@esbuild/win32-arm64": "0.18.20", "@esbuild/win32-ia32": "0.18.20", "@esbuild/win32-x64": "0.18.20" }, "bin": { "esbuild": "bin/esbuild" } }, "sha512-ceqxoedUrcayh7Y7ZX6NdbbDzGROiyVBgC4PriJThBKSVPWnnFHZAkfI1lJT8QFkOwH4qOS2SJkS4wvpGl8BpA=="],
"@gregorlohaus/codemirror-helix/typescript": ["typescript@5.9.3", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw=="],
"@isaacs/cliui/string-width": ["string-width@5.1.2", "", { "dependencies": { "eastasianwidth": "^0.2.0", "emoji-regex": "^9.2.2", "strip-ansi": "^7.0.1" } }, "sha512-HnLOCR3vjcY8beoNLtcjZ5/nxn2afmME6lhrDrebokqMap+XbeW8n9TXpPDOqdGK5qcI3oT0GKTW6wC7EMiVqA=="], "@isaacs/cliui/string-width": ["string-width@5.1.2", "", { "dependencies": { "eastasianwidth": "^0.2.0", "emoji-regex": "^9.2.2", "strip-ansi": "^7.0.1" } }, "sha512-HnLOCR3vjcY8beoNLtcjZ5/nxn2afmME6lhrDrebokqMap+XbeW8n9TXpPDOqdGK5qcI3oT0GKTW6wC7EMiVqA=="],
"@isaacs/cliui/wrap-ansi": ["wrap-ansi@8.1.0", "", { "dependencies": { "ansi-styles": "^6.1.0", "string-width": "^5.0.1", "strip-ansi": "^7.0.1" } }, "sha512-si7QWI6zUMq56bESFvagtmzMdGOtoxfR+Sez11Mobfc7tm+VkUckk9bW2UeffTGVUbOksxmSw0AA2gs8g71NCQ=="], "@isaacs/cliui/wrap-ansi": ["wrap-ansi@8.1.0", "", { "dependencies": { "ansi-styles": "^6.1.0", "string-width": "^5.0.1", "strip-ansi": "^7.0.1" } }, "sha512-si7QWI6zUMq56bESFvagtmzMdGOtoxfR+Sez11Mobfc7tm+VkUckk9bW2UeffTGVUbOksxmSw0AA2gs8g71NCQ=="],

View File

@@ -3,8 +3,10 @@
"version": "0.1.0", "version": "0.1.0",
"private": true, "private": true,
"type": "module", "type": "module",
"workspaces": ["packages/*"],
"scripts": { "scripts": {
"build": "next build", "build": "bun run helix:build && next build",
"helix:build": "bun run --cwd packages/codemirror-helix build",
"build:ffmpeg-worker": "bun build node_modules/@ffmpeg/ffmpeg/dist/esm/worker.js --target browser --format esm --minify --outfile public/ffmpeg/worker.js", "build:ffmpeg-worker": "bun build node_modules/@ffmpeg/ffmpeg/dist/esm/worker.js --target browser --format esm --minify --outfile public/ffmpeg/worker.js",
"check": "biome check .", "check": "biome check .",
"check:unsafe": "biome check --write --unsafe .", "check:unsafe": "biome check --write --unsafe .",
@@ -13,7 +15,7 @@
"db:migrate": "drizzle-kit migrate", "db:migrate": "drizzle-kit migrate",
"db:push": "drizzle-kit push", "db:push": "drizzle-kit push",
"db:studio": "drizzle-kit studio", "db:studio": "drizzle-kit studio",
"dev": "next dev --turbo", "dev": "bun run helix:build && next dev --turbo",
"preview": "next build && next start", "preview": "next build && next start",
"start": "next start", "start": "next start",
"typecheck": "tsc --noEmit", "typecheck": "tsc --noEmit",
@@ -23,6 +25,13 @@
"@ai-sdk/openai": "^3.0.67", "@ai-sdk/openai": "^3.0.67",
"@ai-sdk/react": "^3.0.195", "@ai-sdk/react": "^3.0.195",
"@clerk/nextjs": "^7.4.2", "@clerk/nextjs": "^7.4.2",
"@codemirror/autocomplete": "^6.20.3",
"@codemirror/commands": "^6.10.3",
"@codemirror/lang-markdown": "^6.5.0",
"@codemirror/language": "^6.12.3",
"@codemirror/search": "^6.5.6",
"@codemirror/state": "^6.6.0",
"@codemirror/view": "^6.43.1",
"@electric-sql/pglite": "^0.4.6", "@electric-sql/pglite": "^0.4.6",
"@ffmpeg/ffmpeg": "^0.12.15", "@ffmpeg/ffmpeg": "^0.12.15",
"@ffmpeg/util": "^0.12.2", "@ffmpeg/util": "^0.12.2",
@@ -31,9 +40,9 @@
"@fortawesome/react-fontawesome": "^3.3.1", "@fortawesome/react-fontawesome": "^3.3.1",
"@gsap/react": "^2.1.2", "@gsap/react": "^2.1.2",
"@hookform/resolvers": "^5.4.0", "@hookform/resolvers": "^5.4.0",
"@mdx-js/loader": "^3.1.1",
"@mdx-js/mdx": "^3.1.1", "@mdx-js/mdx": "^3.1.1",
"@mdx-js/react": "^3.1.1", "@mdx-js/react": "^3.1.1",
"@mdx-js/loader": "^3.1.1",
"@neondatabase/serverless": "^1.1.0", "@neondatabase/serverless": "^1.1.0",
"@next/mdx": "^16.2.9", "@next/mdx": "^16.2.9",
"@radix-ui/react-accordion": "^1.2.12", "@radix-ui/react-accordion": "^1.2.12",
@@ -72,7 +81,9 @@
"@trpc/react-query": "^11.17.0", "@trpc/react-query": "^11.17.0",
"@trpc/server": "^11.17.0", "@trpc/server": "^11.17.0",
"@types/mdx": "^2.0.14", "@types/mdx": "^2.0.14",
"@uiw/react-codemirror": "^4.25.10",
"@uiw/react-md-editor": "^4.1.1", "@uiw/react-md-editor": "^4.1.1",
"@gregorlohaus/codemirror-helix": "workspace:*",
"@uploadthing/react": "^7.3.3", "@uploadthing/react": "^7.3.3",
"@vercel/speed-insights": "^2.0.0", "@vercel/speed-insights": "^2.0.0",
"ai": "^6.0.193", "ai": "^6.0.193",

10
packages/codemirror-helix/.gitignore vendored Normal file
View File

@@ -0,0 +1,10 @@
# build output (rebuilt by `bun run build` / helix:build / publish CI)
dist/
# dependencies
node_modules/
# logs / caches
*.log
.DS_Store
*.tsbuildinfo

View File

@@ -0,0 +1,69 @@
# @gregorlohaus/codemirror-helix
[Helix](https://helix-editor.com/)-style modal editing for [CodeMirror 6](https://codemirror.net/).
Selection-first editing with multiple selections, Normal/Insert/Select modes,
goto & match modes, textobjects, surround, registers, counts, and search.
> A **core motions subset** — broad coverage of everyday Helix keys, not full
> parity. No tree-sitter textobjects, LSP gotos, macros, or jumplist.
## Install
```sh
bun add @gregorlohaus/codemirror-helix
# peers: @codemirror/{state,view,commands,language,search}
```
## Usage
```ts
import { EditorView, basicSetup } from "codemirror";
import { helix } from "@gregorlohaus/codemirror-helix";
new EditorView({
doc: "hello world",
extensions: [basicSetup, helix()],
parent: document.body,
});
```
`helix()` needs a selection drawer for multi-cursor rendering — `basicSetup`
(or `drawSelection()`) covers it.
### Options
```ts
helix({
startInInsert: false, // start in Normal mode (default)
statusBar: true, // show the bottom mode line
// Let an open autocomplete popup eat the first Escape instead of leaving Insert:
escapeGuard: (state) => completionStatus(state) === "active",
});
```
## Keys
Starts in **Normal** mode. The status line shows the mode, pending count,
register, and selection count.
| Group | Keys |
| --- | --- |
| Modes | `i`/`a` insert before/after, `I`/`A` line start/end, `o`/`O` open line, `v` select (extend), `Esc` normal |
| Motion | `h j k l`, `w W b B e E`, `f t F T {char}`, `Alt-.` repeat find, `Home`/`End`, counts (`3w`) |
| Goto `g` | `gg`/`Ng` line, `ge` end, `gh`/`gl` line ends, `gs` first non-blank, `gt`/`gc`/`gb` view top/center/bottom |
| Select | `x` line (repeat extends), `X` line bounds, `%` all, `;` collapse, `Alt-;` flip, `Alt-:` forward, `,` keep primary, `Alt-,` remove primary, `(`/`)` rotate, `_` trim |
| Multi | `s` select regex, `S` split, `K`/`Alt-K` keep/remove, `C` copy selection below, `Alt-C` above |
| Match `m` | `mm` matching bracket, `mi{o}`/`ma{o}` inside/around (`w W p ( [ { < " ' \` m`), `ms{c}` surround, `md{c}` delete, `mr{c}{c}` replace |
| Edit | `d` delete, `c` change, `y` yank, `p`/`P` paste, `R` replace w/ register, `r{c}` replace char, `~`/`` ` ``/`Alt-`` ` `` case, `J` join, `>`/`<` indent, `u`/`U` undo/redo |
| Registers | `"{c}` select register for the next yank/delete/paste |
| Search | `/` `?` search, `n`/`N` next/prev, `*` search selection |
| View | `zz`/`zt`/`zb` center/top/bottom, `Ctrl-d`/`Ctrl-u` half page, `Ctrl-f`/`Ctrl-b` page |
| Clipboard | `space y` copy, `space p`/`space P` paste (system clipboard) |
`s`/`S`/`K`/`Alt-K` open a prompt and preview live as you type the regex;
`Enter` commits, `Esc` restores the original selection.
## License
MIT

View File

@@ -0,0 +1,51 @@
{
"name": "@gregorlohaus/codemirror-helix",
"version": "0.1.0",
"description": "Helix-editor-style modal editing for CodeMirror 6 (multiple selections, select mode, goto/match modes, textobjects, surround, registers, search).",
"type": "module",
"main": "./dist/index.js",
"module": "./dist/index.js",
"types": "./dist/index.d.ts",
"exports": {
".": {
"types": "./dist/index.d.ts",
"import": "./dist/index.js"
}
},
"files": ["dist", "README.md"],
"sideEffects": false,
"scripts": {
"build": "tsc -p tsconfig.json",
"prepublishOnly": "tsc -p tsconfig.json"
},
"keywords": [
"codemirror",
"codemirror6",
"helix",
"modal",
"vim",
"editor",
"keybindings"
],
"license": "MIT",
"repository": {
"type": "git",
"url": "https://gitea.gregorlohaus.com/gregor/gregorlohaus.com",
"directory": "packages/codemirror-helix"
},
"peerDependencies": {
"@codemirror/commands": "^6.0.0",
"@codemirror/language": "^6.0.0",
"@codemirror/search": "^6.0.0",
"@codemirror/state": "^6.0.0",
"@codemirror/view": "^6.0.0"
},
"devDependencies": {
"@codemirror/commands": "^6.10.3",
"@codemirror/language": "^6.12.3",
"@codemirror/search": "^6.5.6",
"@codemirror/state": "^6.6.0",
"@codemirror/view": "^6.43.1",
"typescript": "^5.6.0"
}
}

View File

@@ -0,0 +1,593 @@
import {
EditorSelection,
type ChangeSpec,
type EditorState,
type SelectionRange,
} from "@codemirror/state";
import { EditorView } from "@codemirror/view";
import { indentLess, indentMore, redo, undo } from "@codemirror/commands";
import { findNext, findPrevious, SearchQuery, setSearchQuery } from "@codemirror/search";
import {
clearPending,
getHelix,
helixEffect,
readRegister,
setRegister,
type HelixMode,
} from "./state";
import * as M from "./motions";
const settle = { ...clearPending() };
function clamp(n: number, state: EditorState): number {
return Math.max(0, Math.min(n, state.doc.length));
}
function rangesText(state: EditorState, ranges: readonly SelectionRange[]): string {
return ranges.map((r) => state.doc.sliceString(r.from, r.to)).join("\n");
}
function maybeClipboard(register: string | null, text: string) {
if ((register === "+" || register === "*") && typeof navigator !== "undefined" && navigator.clipboard) {
void navigator.clipboard.writeText(text).catch(() => {});
}
}
/** After leaving an edit, choose the resting mode. */
function restMode(current: HelixMode, target?: HelixMode): HelixMode {
if (target) return target;
return current === "select" ? "normal" : current;
}
// --- motion application ------------------------------------------------------
export type PointFn = (state: EditorState, pos: number) => number;
export interface MotionOpts {
extend: boolean;
span: boolean; // word-like: selects the traversed span in normal mode
count: number;
}
export function moveByPoint(view: EditorView, fn: PointFn, opts: MotionOpts) {
const { state } = view;
const selection = EditorSelection.create(
state.selection.ranges.map((r) => {
let head = r.head;
for (let i = 0; i < Math.max(1, opts.count); i++) head = fn(state, head);
head = clamp(head, state);
const anchor = opts.extend ? r.anchor : opts.span ? r.head : head;
return EditorSelection.range(clamp(anchor, state), head);
}),
state.selection.mainIndex,
);
view.dispatch({ selection, scrollIntoView: true });
}
export function moveVertical(view: EditorView, forward: boolean, opts: { extend: boolean; count: number }) {
const selection = EditorSelection.create(
view.state.selection.ranges.map((r) => {
let cur: SelectionRange = EditorSelection.cursor(r.head);
for (let i = 0; i < Math.max(1, opts.count); i++) cur = view.moveVertically(cur, forward);
return opts.extend
? EditorSelection.range(r.anchor, cur.head)
: EditorSelection.cursor(cur.head);
}),
view.state.selection.mainIndex,
);
view.dispatch({ selection, scrollIntoView: true });
}
// --- insertion entry points --------------------------------------------------
export function enterInsert(view: EditorView, posOf: (state: EditorState, r: SelectionRange) => number) {
const { state } = view;
const selection = EditorSelection.create(
state.selection.ranges.map((r) => EditorSelection.cursor(clamp(posOf(state, r), state))),
state.selection.mainIndex,
);
view.dispatch({ selection, effects: helixEffect.of({ mode: "insert", ...settle }) });
}
export function openLine(view: EditorView, above: boolean) {
const { state } = view;
const seen = new Set<number>();
const changes: ChangeSpec[] = [];
for (const r of state.selection.ranges) {
const line = state.doc.lineAt(r.head);
if (seen.has(line.number)) continue;
seen.add(line.number);
const indent = line.text.match(/^\s*/)?.[0] ?? "";
changes.push(above ? { from: line.from, insert: `${indent}\n` } : { from: line.to, insert: `\n${indent}` });
}
const changeSet = state.changes(changes);
// Place a cursor on each newly opened line.
const selection = EditorSelection.create(
[...seen].map((n) => {
const line = state.doc.line(n);
const pos = above ? line.from : line.to;
const mapped = changeSet.mapPos(pos, 1);
return EditorSelection.cursor(mapped);
}),
);
view.dispatch({
changes: changeSet,
selection,
effects: helixEffect.of({ mode: "insert", ...settle }),
scrollIntoView: true,
});
}
// --- delete / change / yank / paste -----------------------------------------
interface DeleteOpts {
insert?: boolean; // change (c)
}
export function deleteSelection(view: EditorView, opts: DeleteOpts = {}) {
const { state } = view;
const helix = getHelix(state);
const specs = state.selection.ranges.map((r) => ({
from: r.from,
to: r.empty ? Math.min(r.to + 1, state.doc.length) : r.to,
}));
const yanked = specs.map((s) => state.doc.sliceString(s.from, s.to)).join("\n");
const changeSet = state.changes(specs.map((s) => ({ from: s.from, to: s.to, insert: "" })));
const selection = EditorSelection.create(
specs.map((s) => EditorSelection.cursor(changeSet.mapPos(s.from, -1))),
state.selection.mainIndex,
);
view.dispatch({
changes: changeSet,
selection,
effects: [
setRegister.of({ name: helix.register ?? '"', text: yanked }),
helixEffect.of({ mode: opts.insert ? "insert" : restMode(helix.mode), ...settle }),
],
scrollIntoView: true,
});
maybeClipboard(helix.register, yanked);
}
export function yankSelection(view: EditorView) {
const { state } = view;
const helix = getHelix(state);
const text = rangesText(state, state.selection.ranges);
view.dispatch({
effects: [
setRegister.of({ name: helix.register ?? '"', text }),
helixEffect.of({ mode: restMode(helix.mode), ...settle }),
],
});
maybeClipboard(helix.register, text);
}
export function paste(view: EditorView, before: boolean) {
const { state } = view;
const helix = getHelix(state);
const text = readRegister(state, helix.register);
if (!text) return;
const linewise = text.endsWith("\n");
const changes: { from: number; to: number; insert: string }[] = [];
for (const r of state.selection.ranges) {
if (linewise) {
const line = state.doc.lineAt(before ? r.from : r.to);
if (before) changes.push({ from: line.from, to: line.from, insert: text });
else changes.push({ from: line.to, to: line.to, insert: `\n${text.replace(/\n$/, "")}` });
} else {
const at = before ? r.from : r.to;
changes.push({ from: at, to: at, insert: text });
}
}
const changeSet = state.changes(changes);
view.dispatch({
changes: changeSet,
selection: state.selection.map(changeSet),
effects: helixEffect.of(settle),
scrollIntoView: true,
});
}
/** R: replace selections with the register contents. */
export function replaceWithYank(view: EditorView) {
const { state } = view;
const helix = getHelix(state);
const text = readRegister(state, helix.register);
if (!text) return;
const changes = state.selection.ranges.map((r) => ({ from: r.from, to: r.to, insert: text }));
const changeSet = state.changes(changes);
view.dispatch({
changes: changeSet,
selection: state.selection.map(changeSet),
effects: helixEffect.of(settle),
scrollIntoView: true,
});
}
/** r{char}: replace every selected character with `char`. */
export function replaceChar(view: EditorView, char: string) {
if (char.length !== 1) return;
const { state } = view;
const changes = state.selection.ranges
.map((r) => {
const from = r.from;
const to = r.empty ? Math.min(r.to + 1, state.doc.length) : r.to;
const text = state.doc.sliceString(from, to).replace(/[^\n]/g, char);
return { from, to, insert: text };
})
.filter((c) => c.to > c.from);
if (!changes.length) return;
view.dispatch({ changes, effects: helixEffect.of(settle) });
}
type CaseMode = "toggle" | "lower" | "upper";
export function changeCase(view: EditorView, mode: CaseMode) {
const { state } = view;
const changes = state.selection.ranges
.map((r) => {
const from = r.from;
const to = r.empty ? Math.min(r.to + 1, state.doc.length) : r.to;
const text = state.doc.sliceString(from, to);
const next =
mode === "lower"
? text.toLowerCase()
: mode === "upper"
? text.toUpperCase()
: text.replace(/[a-zA-Z]/g, (c) => (c === c.toLowerCase() ? c.toUpperCase() : c.toLowerCase()));
return { from, to, insert: next };
})
.filter((c) => c.to > c.from);
if (!changes.length) return;
view.dispatch({ changes, effects: helixEffect.of(settle) });
}
/** J: join the lines spanned by each selection into one. */
export function joinLines(view: EditorView) {
const { state } = view;
const changes: ChangeSpec[] = [];
for (const r of state.selection.ranges) {
const startLine = state.doc.lineAt(r.from).number;
const endLine = state.doc.lineAt(r.to).number;
const last = Math.max(endLine, startLine + 1);
for (let n = startLine; n < last && n < state.doc.lines; n++) {
const line = state.doc.line(n);
const nextLine = state.doc.line(n + 1);
const trimmed = nextLine.text.match(/^\s*/)?.[0].length ?? 0;
changes.push({ from: line.to, to: nextLine.from + trimmed, insert: " " });
}
}
if (!changes.length) return;
view.dispatch({ changes, effects: helixEffect.of(settle), scrollIntoView: true });
}
export function indent(view: EditorView, less: boolean) {
(less ? indentLess : indentMore)(view);
view.dispatch({ effects: helixEffect.of(settle) });
}
export function undoCmd(view: EditorView) {
undo(view);
}
export function redoCmd(view: EditorView) {
redo(view);
}
// --- selection manipulation --------------------------------------------------
function setSelection(view: EditorView, ranges: SelectionRange[], mainIndex?: number) {
if (!ranges.length) return;
view.dispatch({
selection: EditorSelection.create(ranges, mainIndex ?? ranges.length - 1),
scrollIntoView: true,
});
}
export function collapseToCursor(view: EditorView) {
setSelection(
view,
view.state.selection.ranges.map((r) => EditorSelection.cursor(r.head)),
view.state.selection.mainIndex,
);
}
export function flipSelections(view: EditorView) {
setSelection(
view,
view.state.selection.ranges.map((r) => EditorSelection.range(r.head, r.anchor)),
view.state.selection.mainIndex,
);
}
export function ensureForward(view: EditorView) {
setSelection(
view,
view.state.selection.ranges.map((r) => EditorSelection.range(r.from, r.to)),
view.state.selection.mainIndex,
);
}
export function keepPrimary(view: EditorView) {
const main = view.state.selection.main;
setSelection(view, [main], 0);
}
export function removePrimary(view: EditorView) {
const { ranges, mainIndex } = view.state.selection;
if (ranges.length < 2) return;
const remaining = ranges.filter((_, i) => i !== mainIndex);
setSelection(view, remaining, Math.min(mainIndex, remaining.length - 1));
}
export function rotatePrimary(view: EditorView, forward: boolean) {
const { ranges, mainIndex } = view.state.selection;
if (ranges.length < 2) return;
const next = (mainIndex + (forward ? 1 : -1) + ranges.length) % ranges.length;
setSelection(view, [...ranges], next);
}
export function selectAll(view: EditorView) {
setSelection(view, [EditorSelection.range(0, view.state.doc.length)], 0);
}
export function trimSelections(view: EditorView) {
const { state } = view;
const ranges: SelectionRange[] = [];
for (const r of state.selection.ranges) {
const text = state.doc.sliceString(r.from, r.to);
const lead = text.match(/^\s*/)?.[0].length ?? 0;
const trail = text.match(/\s*$/)?.[0].length ?? 0;
const from = r.from + lead;
const to = Math.max(from, r.to - trail);
ranges.push(EditorSelection.range(from, to));
}
setSelection(view, ranges, state.selection.mainIndex);
}
/** x: extend each selection to whole line(s); repeat grows downward. */
export function selectLine(view: EditorView, count: number) {
const { state } = view;
const ranges = state.selection.ranges.map((r) => {
const startLine = state.doc.lineAt(r.from);
let endLine = state.doc.lineAt(r.to);
const from = startLine.from;
let to = Math.min(endLine.to + 1, state.doc.length);
const whole = r.from === from && r.to === to;
const steps = whole ? Math.max(1, count) : Math.max(0, count - 1);
for (let i = 0; i < steps && endLine.number < state.doc.lines; i++) {
endLine = state.doc.line(endLine.number + 1);
to = Math.min(endLine.to + 1, state.doc.length);
}
return EditorSelection.range(from, to);
});
setSelection(view, ranges, state.selection.mainIndex);
}
/** X: extend selections to cover full lines without crossing into the next. */
export function extendToLineBounds(view: EditorView) {
const { state } = view;
const ranges = state.selection.ranges.map((r) =>
EditorSelection.range(state.doc.lineAt(r.from).from, state.doc.lineAt(r.to).to),
);
setSelection(view, ranges, state.selection.mainIndex);
}
/** C / Alt-C: copy each selection onto the next/previous line at the same columns. */
export function copySelectionToLine(view: EditorView, below: boolean) {
const { state } = view;
const additions: SelectionRange[] = [];
for (const r of state.selection.ranges) {
const anchorLine = state.doc.lineAt(r.anchor);
const headLine = state.doc.lineAt(r.head);
const anchorCol = r.anchor - anchorLine.from;
const headCol = r.head - headLine.from;
const targetAnchorNo = anchorLine.number + (below ? 1 : -1);
const targetHeadNo = headLine.number + (below ? 1 : -1);
if (targetAnchorNo < 1 || targetAnchorNo > state.doc.lines) continue;
if (targetHeadNo < 1 || targetHeadNo > state.doc.lines) continue;
const ta = state.doc.line(targetAnchorNo);
const th = state.doc.line(targetHeadNo);
additions.push(
EditorSelection.range(
Math.min(ta.from + anchorCol, ta.to),
Math.min(th.from + headCol, th.to),
),
);
}
if (!additions.length) return;
const all = [...state.selection.ranges, ...additions];
setSelection(view, all, all.length - 1);
}
function restoreBase(view: EditorView, base: EditorSelection) {
view.dispatch({ selection: base, scrollIntoView: true });
}
/**
* s: keep only the regex matches found within each selection of `base`.
* `base` is the selection captured when the prompt opened, so this can be
* called live on every keystroke without compounding.
*/
export function selectRegexInSelections(
view: EditorView,
pattern: string,
base: EditorSelection = view.state.selection,
) {
if (!pattern) return restoreBase(view, base);
let re: RegExp;
try {
re = new RegExp(pattern, "g");
} catch {
return; // incomplete/invalid regex: leave the last good preview in place
}
const { state } = view;
const ranges: SelectionRange[] = [];
for (const r of base.ranges) {
const text = state.doc.sliceString(r.from, r.to);
for (const m of text.matchAll(re)) {
const from = r.from + (m.index ?? 0);
const to = from + m[0].length;
ranges.push(EditorSelection.range(from, Math.max(from, to)));
if (m[0].length === 0) re.lastIndex++;
}
}
if (ranges.length) setSelection(view, ranges, ranges.length - 1);
else restoreBase(view, base);
}
/** S: split each selection of `base` on the regex, keeping the pieces between. */
export function splitOnRegex(
view: EditorView,
pattern: string,
base: EditorSelection = view.state.selection,
) {
if (!pattern) return restoreBase(view, base);
let re: RegExp;
try {
re = new RegExp(pattern, "g");
} catch {
return;
}
const { state } = view;
const ranges: SelectionRange[] = [];
for (const r of base.ranges) {
const text = state.doc.sliceString(r.from, r.to);
let last = 0;
for (const m of text.matchAll(re)) {
const idx = m.index ?? 0;
ranges.push(EditorSelection.range(r.from + last, r.from + idx));
last = idx + m[0].length;
if (m[0].length === 0) re.lastIndex++;
}
ranges.push(EditorSelection.range(r.from + last, r.to));
}
const filtered = ranges.filter((r) => r.to >= r.from);
if (filtered.length) setSelection(view, filtered, filtered.length - 1);
else restoreBase(view, base);
}
/** Keep (or remove) selections of `base` whose text matches the regex. */
export function filterSelections(
view: EditorView,
pattern: string,
keep: boolean,
base: EditorSelection = view.state.selection,
) {
if (!pattern) return restoreBase(view, base);
let re: RegExp;
try {
re = new RegExp(pattern);
} catch {
return;
}
const { state } = view;
const ranges = base.ranges.filter((r) => {
const matches = re.test(state.doc.sliceString(r.from, r.to));
return keep ? matches : !matches;
});
if (ranges.length) setSelection(view, ranges, ranges.length - 1);
else restoreBase(view, base);
}
// --- surround ----------------------------------------------------------------
const SURROUND_PAIRS: Record<string, [string, string]> = {
"(": ["(", ")"],
")": ["(", ")"],
"[": ["[", "]"],
"]": ["[", "]"],
"{": ["{", "}"],
"}": ["{", "}"],
"<": ["<", ">"],
">": ["<", ">"],
};
function surroundPair(ch: string): [string, string] {
return SURROUND_PAIRS[ch] ?? [ch, ch];
}
/** ms{char}: wrap each selection in the chosen pair. */
export function surroundAdd(view: EditorView, ch: string) {
const [open, close] = surroundPair(ch);
const { state } = view;
const changes: ChangeSpec[] = [];
for (const r of state.selection.ranges) {
changes.push({ from: r.from, insert: open });
changes.push({ from: r.to, insert: close });
}
const changeSet = state.changes(changes);
view.dispatch({
changes: changeSet,
selection: state.selection.map(changeSet),
effects: helixEffect.of(settle),
});
}
/** md{char}: remove the surrounding pair around each selection. */
export function surroundDelete(view: EditorView, ch: string) {
const [open, close] = surroundPair(ch);
const { state } = view;
const changes: { from: number; to: number; insert: string }[] = [];
for (const r of state.selection.ranges) {
const openPos = M.findOpen(state, r.head, open, close);
const closePos = openPos === null ? null : M.findClose(state, openPos, open, close);
if (openPos === null || closePos === null) continue;
changes.push({ from: openPos, to: openPos + 1, insert: "" });
changes.push({ from: closePos, to: closePos + 1, insert: "" });
}
if (!changes.length) return;
view.dispatch({ changes, effects: helixEffect.of(settle) });
}
/** mr{from}{to}: replace the surrounding pair. */
export function surroundReplace(view: EditorView, fromCh: string, toCh: string) {
const [open, close] = surroundPair(fromCh);
const [newOpen, newClose] = surroundPair(toCh);
const { state } = view;
const changes: { from: number; to: number; insert: string }[] = [];
for (const r of state.selection.ranges) {
const openPos = M.findOpen(state, r.head, open, close);
const closePos = openPos === null ? null : M.findClose(state, openPos, open, close);
if (openPos === null || closePos === null) continue;
changes.push({ from: openPos, to: openPos + 1, insert: newOpen });
changes.push({ from: closePos, to: closePos + 1, insert: newClose });
}
if (!changes.length) return;
view.dispatch({ changes, effects: helixEffect.of(settle) });
}
// --- search ------------------------------------------------------------------
export function runSearch(view: EditorView, query: string, reverse: boolean) {
if (!query) return;
view.dispatch({ effects: setSearchQuery.of(new SearchQuery({ search: query, regexp: true })) });
(reverse ? findPrevious : findNext)(view);
}
export function searchNext(view: EditorView, reverse: boolean) {
(reverse ? findPrevious : findNext)(view);
}
export function searchSelection(view: EditorView, reverse: boolean) {
const text = view.state.sliceDoc(view.state.selection.main.from, view.state.selection.main.to);
if (!text) return;
view.dispatch({
effects: setSearchQuery.of(new SearchQuery({ search: text, regexp: false })),
});
(reverse ? findPrevious : findNext)(view);
}
// --- view scrolling ----------------------------------------------------------
export function scrollTo(view: EditorView, y: "center" | "start" | "end") {
const pos = view.state.selection.main.head;
view.dispatch({ effects: EditorView.scrollIntoView(pos, { y }) });
}
export function halfPage(view: EditorView, forward: boolean, extend: boolean) {
const lineHeight = view.defaultLineHeight || 16;
const lines = Math.max(1, Math.floor(view.dom.clientHeight / lineHeight / 2));
moveVertical(view, forward, { extend, count: lines });
}
export { restMode };

View File

@@ -0,0 +1,51 @@
import { EditorState, type Extension } from "@codemirror/state";
import { search } from "@codemirror/search";
import { helixState, registersField } from "./state";
import { blockCursor, helixStatusPanel, helixTheme, modeEditorClass } from "./view";
import { helixPrompt } from "./prompt";
import { helixKeymap, type HelixOptions } from "./keymap";
export interface HelixConfig extends HelixOptions {
/** Render the bottom status line. Default: true. */
statusBar?: boolean;
/** Start the editor in Insert mode instead of Normal. Default: false. */
startInInsert?: boolean;
}
/**
* Helix-style modal editing for CodeMirror 6.
*
* Add to a CodeMirror instance's `extensions`. Requires a selection drawer
* (CodeMirror's `basicSetup` or `drawSelection()`) for multi-cursor rendering.
*
* ```ts
* import { basicSetup } from "codemirror";
* import { helix } from "codemirror-helix";
*
* new EditorView({ extensions: [basicSetup, helix()], parent });
* ```
*/
export function helix(config: HelixConfig = {}): Extension {
return [
EditorState.allowMultipleSelections.of(true),
helixState.init(() => ({
mode: config.startInInsert ? "insert" : "normal",
pending: null,
count: 0,
register: null,
lastFind: null,
})),
registersField,
search(),
helixKeymap(config),
blockCursor,
modeEditorClass,
helixTheme,
...(config.statusBar === false ? [] : [helixStatusPanel]),
helixPrompt,
];
}
export { getMode, getHelix, helixState } from "./state";
export type { HelixMode, HelixStateValue } from "./state";
export type { HelixOptions } from "./keymap";

View File

@@ -0,0 +1,562 @@
import { EditorSelection, Prec, type EditorState, type Extension } from "@codemirror/state";
import { EditorView } from "@codemirror/view";
import {
clearPending,
getHelix,
helixEffect,
type HelixStateValue,
type Pending,
} from "./state";
import * as C from "./commands";
import * as M from "./motions";
import { textObject } from "./textobjects";
import { openPrompt } from "./prompt";
export interface HelixOptions {
/**
* Return true to let a pressed Escape fall through to other handlers instead
* of leaving Insert mode — e.g. when an autocompletion popup is open.
*/
escapeGuard?: (state: EditorState) => boolean;
}
function reset(view: EditorView, extra: Partial<HelixStateValue> = {}) {
view.dispatch({ effects: helixEffect.of({ ...clearPending(), ...extra }) });
}
function setPending(view: EditorView, pending: Pending) {
view.dispatch({ effects: helixEffect.of({ pending }) });
}
// --- find char ---------------------------------------------------------------
function doFind(view: EditorView, kind: "f" | "t" | "F" | "T", char: string, helix: HelixStateValue) {
const fn: C.PointFn = (state, pos) => {
const found = M.findCharOnLine(state, pos, char, kind);
return found === null ? pos : found;
};
C.moveByPoint(view, fn, { extend: helix.mode === "select", span: false, count: helix.count || 1 });
}
// --- goto mode ---------------------------------------------------------------
function gotoAbsolute(view: EditorView, pos: number, select: boolean) {
const anchor = select ? view.state.selection.main.anchor : pos;
view.dispatch({ selection: EditorSelection.range(anchor, pos), scrollIntoView: true });
}
function doGoto(view: EditorView, key: string, helix: HelixStateValue) {
const select = helix.mode === "select";
const { state } = view;
switch (key) {
case "h":
C.moveByPoint(view, (s, p) => s.doc.lineAt(p).from, { extend: select, span: false, count: 1 });
break;
case "l":
C.moveByPoint(view, (s, p) => s.doc.lineAt(p).to, { extend: select, span: false, count: 1 });
break;
case "s":
C.moveByPoint(view, (s, p) => M.firstNonBlank(s, s.doc.lineAt(p).number), {
extend: select,
span: false,
count: 1,
});
break;
case "g":
gotoAbsolute(view, helix.count > 0 ? state.doc.line(Math.min(helix.count, state.doc.lines)).from : 0, select);
break;
case "e":
gotoAbsolute(view, state.doc.length, select);
break;
case "t":
gotoAbsolute(view, state.doc.lineAt(view.viewport.from).from, select);
break;
case "b":
gotoAbsolute(view, state.doc.lineAt(view.viewport.to).from, select);
break;
case "c":
gotoAbsolute(view, state.doc.lineAt(Math.floor((view.viewport.from + view.viewport.to) / 2)).from, select);
break;
default:
break;
}
}
// --- match mode --------------------------------------------------------------
function doMatch(view: EditorView, key: string, helix: HelixStateValue) {
switch (key) {
case "m":
C.moveByPoint(
view,
(s, p) => {
const m = M.matchingBracket(s, p);
return m === null ? p : m;
},
{ extend: helix.mode === "select", span: false, count: 1 },
);
reset(view);
return;
case "i":
setPending(view, { type: "textobject", around: false });
return;
case "a":
setPending(view, { type: "textobject", around: true });
return;
case "s":
setPending(view, { type: "surround-add" });
return;
case "d":
setPending(view, { type: "surround-delete" });
return;
case "r":
setPending(view, { type: "surround-replace" });
return;
default:
reset(view);
}
}
function doTextObject(view: EditorView, key: string, around: boolean) {
const { state } = view;
const ranges = state.selection.ranges.map((r) => {
const span = textObject(state, r, key, around);
return span ? EditorSelection.range(span.from, span.to) : r;
});
view.dispatch({
selection: EditorSelection.create(ranges, state.selection.mainIndex),
scrollIntoView: true,
});
}
// --- view mode (z) -----------------------------------------------------------
function doView(view: EditorView, key: string) {
if (key === "z") C.scrollTo(view, "center");
else if (key === "t") C.scrollTo(view, "start");
else if (key === "b") C.scrollTo(view, "end");
}
// --- space menu (clipboard subset) ------------------------------------------
function doSpace(view: EditorView, key: string) {
const text = view.state.sliceDoc(view.state.selection.main.from, view.state.selection.main.to);
if (key === "y") {
if (typeof navigator !== "undefined" && navigator.clipboard && text) {
void navigator.clipboard.writeText(text).catch(() => {});
}
} else if (key === "p" || key === "P") {
if (typeof navigator !== "undefined" && navigator.clipboard) {
void navigator.clipboard.readText().then((clip) => {
if (!clip) return;
const changes = view.state.selection.ranges.map((r) => {
const at = key === "P" ? r.from : r.to;
return { from: at, to: at, insert: clip };
});
const set = view.state.changes(changes);
view.dispatch({ changes: set, selection: view.state.selection.map(set) });
});
}
}
}
// --- pending dispatch --------------------------------------------------------
function handlePending(view: EditorView, pending: Pending, key: string, helix: HelixStateValue) {
switch (pending.type) {
case "register":
if (key.length === 1) view.dispatch({ effects: helixEffect.of({ register: key, pending: null }) });
else reset(view);
return;
case "find":
if (key.length === 1) {
doFind(view, pending.kind, key, helix);
reset(view, { lastFind: { kind: pending.kind, char: key } });
} else reset(view);
return;
case "replace":
if (key.length === 1) C.replaceChar(view, key);
reset(view);
return;
case "g":
doGoto(view, key, helix);
reset(view);
return;
case "z":
doView(view, key);
reset(view);
return;
case "space":
doSpace(view, key);
reset(view);
return;
case "m":
doMatch(view, key, helix);
return;
case "textobject":
doTextObject(view, key, pending.around);
reset(view);
return;
case "surround-add":
if (key.length === 1) C.surroundAdd(view, key);
reset(view);
return;
case "surround-delete":
if (key.length === 1) C.surroundDelete(view, key);
reset(view);
return;
case "surround-replace":
if (pending.from === undefined) {
view.dispatch({ effects: helixEffect.of({ pending: { type: "surround-replace", from: key } }) });
} else {
C.surroundReplace(view, pending.from, key);
reset(view);
}
return;
}
}
// --- terminal commands -------------------------------------------------------
function handleAlt(view: EditorView, key: string, helix: HelixStateValue): boolean {
switch (key) {
case ";":
C.flipSelections(view);
return true;
case ":":
C.ensureForward(view);
return true;
case ",":
C.removePrimary(view);
return true;
case "c":
case "C":
C.copySelectionToLine(view, false);
return true;
case "`":
C.changeCase(view, "upper");
return true;
case ".":
if (helix.lastFind) doFind(view, helix.lastFind.kind, helix.lastFind.char, helix);
return true;
case "k":
case "K": {
const base = view.state.selection;
openPrompt(view, {
label: "remove:",
onChange: (p) => C.filterSelections(view, p, false, base),
onSubmit: (p) => C.filterSelections(view, p, false, base),
onCancel: () => view.dispatch({ selection: base }),
});
return true;
}
default:
return false;
}
}
function handleCommand(view: EditorView, key: string, event: KeyboardEvent, helix: HelixStateValue): boolean {
const extend = helix.mode === "select";
const count = helix.count || 1;
if (event.altKey) return handleAlt(view, key, helix);
switch (key) {
// movement
case "h":
case "ArrowLeft":
C.moveByPoint(view, (_s, p) => Math.max(0, p - 1), { extend, span: false, count });
return true;
case "l":
case "ArrowRight":
C.moveByPoint(view, (s, p) => Math.min(s.doc.length, p + 1), { extend, span: false, count });
return true;
case "j":
case "ArrowDown":
C.moveVertical(view, true, { extend, count });
return true;
case "k":
case "ArrowUp":
C.moveVertical(view, false, { extend, count });
return true;
case "w":
C.moveByPoint(view, (s, p) => M.nextWordStart(s, p, false), { extend, span: true, count });
return true;
case "W":
C.moveByPoint(view, (s, p) => M.nextWordStart(s, p, true), { extend, span: true, count });
return true;
case "b":
C.moveByPoint(view, (s, p) => M.prevWordStart(s, p, false), { extend, span: true, count });
return true;
case "B":
C.moveByPoint(view, (s, p) => M.prevWordStart(s, p, true), { extend, span: true, count });
return true;
case "e":
C.moveByPoint(view, (s, p) => M.nextWordEnd(s, p, false), { extend, span: true, count });
return true;
case "E":
C.moveByPoint(view, (s, p) => M.nextWordEnd(s, p, true), { extend, span: true, count });
return true;
case "Home":
C.moveByPoint(view, (s, p) => s.doc.lineAt(p).from, { extend, span: false, count: 1 });
return true;
case "End":
C.moveByPoint(view, (s, p) => s.doc.lineAt(p).to, { extend, span: false, count: 1 });
return true;
// selection manipulation
case "x":
C.selectLine(view, count);
return true;
case "X":
C.extendToLineBounds(view);
return true;
case "%":
C.selectAll(view);
return true;
case ";":
C.collapseToCursor(view);
return true;
case ",":
C.keepPrimary(view);
return true;
case "(":
C.rotatePrimary(view, false);
return true;
case ")":
C.rotatePrimary(view, true);
return true;
case "_":
C.trimSelections(view);
return true;
case "C":
C.copySelectionToLine(view, true);
return true;
// mode switches
case "v":
view.dispatch({ effects: helixEffect.of({ mode: extend ? "normal" : "select" }) });
return true;
case "i":
C.enterInsert(view, (_s, r) => r.from);
return true;
case "a":
C.enterInsert(view, (_s, r) => r.to);
return true;
case "I":
C.enterInsert(view, (s, r) => M.firstNonBlank(s, s.doc.lineAt(r.head).number));
return true;
case "A":
C.enterInsert(view, (s, r) => s.doc.lineAt(r.head).to);
return true;
case "o":
C.openLine(view, false);
return true;
case "O":
C.openLine(view, true);
return true;
// edits
case "d":
case "Delete":
C.deleteSelection(view);
return true;
case "c":
C.deleteSelection(view, { insert: true });
return true;
case "y":
C.yankSelection(view);
return true;
case "p":
C.paste(view, false);
return true;
case "P":
C.paste(view, true);
return true;
case "R":
C.replaceWithYank(view);
return true;
case "~":
C.changeCase(view, "toggle");
return true;
case "`":
C.changeCase(view, "lower");
return true;
case "J":
C.joinLines(view);
return true;
case ">":
C.indent(view, false);
return true;
case "<":
C.indent(view, true);
return true;
case "u":
C.undoCmd(view);
return true;
case "U":
C.redoCmd(view);
return true;
// search
case "/":
openPrompt(view, { label: "/", onSubmit: (q) => C.runSearch(view, q, false) });
return true;
case "?":
openPrompt(view, { label: "?", onSubmit: (q) => C.runSearch(view, q, true) });
return true;
case "n":
C.searchNext(view, false);
return true;
case "N":
C.searchNext(view, true);
return true;
case "*":
C.searchSelection(view, false);
return true;
// regex selection (live preview against the selection captured here)
case "s": {
const base = view.state.selection;
openPrompt(view, {
label: "select:",
onChange: (p) => C.selectRegexInSelections(view, p, base),
onSubmit: (p) => C.selectRegexInSelections(view, p, base),
onCancel: () => view.dispatch({ selection: base }),
});
return true;
}
case "S": {
const base = view.state.selection;
openPrompt(view, {
label: "split:",
onChange: (p) => C.splitOnRegex(view, p, base),
onSubmit: (p) => C.splitOnRegex(view, p, base),
onCancel: () => view.dispatch({ selection: base }),
});
return true;
}
case "K": {
const base = view.state.selection;
openPrompt(view, {
label: "keep:",
onChange: (p) => C.filterSelections(view, p, true, base),
onSubmit: (p) => C.filterSelections(view, p, true, base),
onCancel: () => view.dispatch({ selection: base }),
});
return true;
}
case "Escape":
C.collapseToCursor(view);
view.dispatch({ effects: helixEffect.of({ mode: "normal", ...clearPending() }) });
return true;
default:
return false;
}
}
function handleCtrl(view: EditorView, key: string, helix: HelixStateValue): boolean {
const extend = helix.mode === "select";
switch (key) {
case "d":
C.halfPage(view, true, extend);
return true;
case "u":
C.halfPage(view, false, extend);
return true;
case "f":
C.moveVertical(view, true, { extend, count: 20 });
return true;
case "b":
C.moveVertical(view, false, { extend, count: 20 });
return true;
default:
return false;
}
}
// --- top-level keydown -------------------------------------------------------
export function helixKeymap(options: HelixOptions = {}): Extension {
return Prec.highest(
EditorView.domEventHandlers({
keydown(event, view) {
if (event.isComposing) return false;
const helix = getHelix(view.state);
if (helix.mode === "insert") {
if (event.key === "Escape") {
if (options.escapeGuard?.(view.state)) return false;
view.dispatch({ effects: helixEffect.of({ mode: "normal", ...clearPending() }) });
event.preventDefault();
return true;
}
return false;
}
if (event.metaKey) return false;
const key = event.key;
if (helix.pending) {
event.preventDefault();
handlePending(view, helix.pending, key, helix);
return true;
}
if (event.ctrlKey) {
if (handleCtrl(view, key, helix)) {
event.preventDefault();
reset(view);
return true;
}
return false;
}
// count digits (0 only continues an existing count)
if (!event.altKey && /^[0-9]$/.test(key) && !(key === "0" && helix.count === 0)) {
event.preventDefault();
view.dispatch({ effects: helixEffect.of({ count: helix.count * 10 + Number(key) }) });
return true;
}
// prefixes awaiting another key
if (!event.altKey) {
const prefix: Record<string, Pending> = {
g: { type: "g" },
m: { type: "m" },
z: { type: "z" },
" ": { type: "space" },
'"': { type: "register" },
f: { type: "find", kind: "f" },
t: { type: "find", kind: "t" },
F: { type: "find", kind: "F" },
T: { type: "find", kind: "T" },
r: { type: "replace" },
};
const pending = prefix[key];
if (pending) {
setPending(view, pending);
event.preventDefault();
return true;
}
}
if (handleCommand(view, key, event, helix)) {
event.preventDefault();
reset(view);
return true;
}
// Swallow stray printable keys so Normal/Select mode never types.
if (key.length === 1 || ["Enter", "Backspace", "Tab", "Delete"].includes(key)) {
event.preventDefault();
return true;
}
return false;
},
}),
);
}

View File

@@ -0,0 +1,180 @@
import type { EditorState } from "@codemirror/state";
export type CharClass = "space" | "word" | "punct";
export function charAt(state: EditorState, i: number): string {
return i >= 0 && i < state.doc.length ? state.doc.sliceString(i, i + 1) : "";
}
export function classOf(ch: string): CharClass {
if (ch === "" || /\s/.test(ch)) return "space";
if (/[\p{L}\p{N}_]/u.test(ch)) return "word";
return "punct";
}
// For WORD (big) motions only whitespace separates tokens.
function bigClassOf(ch: string): CharClass {
return ch === "" || /\s/.test(ch) ? "space" : "word";
}
function classAt(state: EditorState, i: number, big: boolean): CharClass {
const ch = charAt(state, i);
return big ? bigClassOf(ch) : classOf(ch);
}
/** Helix `w`/`W`: move to the start of the next word. */
export function nextWordStart(state: EditorState, pos: number, big: boolean): number {
const len = state.doc.length;
let i = pos;
if (i >= len) return len;
if (classAt(state, i, big) === "space") {
while (i < len && classAt(state, i, big) === "space") i++;
} else {
const cls = classAt(state, i, big);
while (i < len && classAt(state, i, big) === cls) i++;
while (i < len && classAt(state, i, big) === "space") i++;
}
return i;
}
/** Helix `b`/`B`: move to the start of the previous word. */
export function prevWordStart(state: EditorState, pos: number, big: boolean): number {
let i = pos;
if (i <= 0) return 0;
i--;
while (i > 0 && classAt(state, i, big) === "space") i--;
const cls = classAt(state, i, big);
while (i > 0 && classAt(state, i - 1, big) === cls) i--;
return i;
}
/** Helix `e`/`E`: move to the end of the next word. */
export function nextWordEnd(state: EditorState, pos: number, big: boolean): number {
const len = state.doc.length;
let i = pos + 1;
while (i < len && classAt(state, i, big) === "space") i++;
const cls = classAt(state, i, big);
while (i < len && classAt(state, i, big) === cls) i++;
return Math.min(i, len);
}
export function firstNonBlank(state: EditorState, line: number): number {
const l = state.doc.line(line);
return l.from + (l.text.match(/^\s*/)?.[0].length ?? 0);
}
/** f/t/F/T: find `target` from `pos`. Returns null if not found on the line. */
export function findCharOnLine(
state: EditorState,
pos: number,
target: string,
kind: "f" | "t" | "F" | "T",
): number | null {
const line = state.doc.lineAt(pos);
const forward = kind === "f" || kind === "t";
const till = kind === "t" || kind === "T";
if (forward) {
let i = pos + 1;
// `t` should skip an adjacent target so repeated presses advance.
if (till && charAt(state, i) === target) i++;
for (; i <= line.to; i++) {
if (charAt(state, i) === target) return till ? i - 1 : i;
}
} else {
let i = pos - 1;
if (till && charAt(state, i) === target) i--;
for (; i >= line.from; i--) {
if (charAt(state, i) === target) return till ? i + 1 : i;
}
}
return null;
}
const PAIRS: Record<string, { open: string; close: string }> = {
"(": { open: "(", close: ")" },
")": { open: "(", close: ")" },
"[": { open: "[", close: "]" },
"]": { open: "[", close: "]" },
"{": { open: "{", close: "}" },
"}": { open: "{", close: "}" },
"<": { open: "<", close: ">" },
">": { open: "<", close: ">" },
};
export function pairFor(ch: string): { open: string; close: string } | null {
return PAIRS[ch] ?? null;
}
/** Scan forward for the close that matches an open at/after `pos` (nesting-aware). */
export function findClose(state: EditorState, from: number, open: string, close: string): number | null {
const len = state.doc.length;
let depth = 0;
for (let i = from; i < len; i++) {
const ch = charAt(state, i);
if (ch === open) depth++;
else if (ch === close) {
depth--;
if (depth === 0) return i;
}
}
return null;
}
/** Scan backward for the open that matches a close at/before `pos` (nesting-aware). */
export function findOpen(state: EditorState, from: number, open: string, close: string): number | null {
let depth = 0;
for (let i = from; i >= 0; i--) {
const ch = charAt(state, i);
if (ch === close) depth++;
else if (ch === open) {
depth--;
if (depth === 0) return i;
}
}
return null;
}
/** `mm`: position of the bracket matching the one at/under `pos`, or null. */
export function matchingBracket(state: EditorState, pos: number): number | null {
for (const probe of [pos, pos - 1]) {
const ch = charAt(state, probe);
const pair = pairFor(ch);
if (!pair) continue;
if (ch === pair.open) return findClose2(state, probe, pair.open, pair.close);
return findOpen2(state, probe, pair.open, pair.close);
}
return null;
}
function findClose2(state: EditorState, openPos: number, open: string, close: string): number | null {
const len = state.doc.length;
let depth = 0;
for (let i = openPos; i < len; i++) {
const ch = charAt(state, i);
if (ch === open) depth++;
else if (ch === close && --depth === 0) return i;
}
return null;
}
function findOpen2(state: EditorState, closePos: number, open: string, close: string): number | null {
let depth = 0;
for (let i = closePos; i >= 0; i--) {
const ch = charAt(state, i);
if (ch === close) depth++;
else if (ch === open && --depth === 0) return i;
}
return null;
}
/** Paragraph bounds (blank-line delimited) containing `pos`. */
export function paragraphAt(state: EditorState, pos: number): { from: number; to: number } {
const { doc } = state;
let startLine = doc.lineAt(pos).number;
let endLine = startLine;
const blank = (n: number) => doc.line(n).text.trim() === "";
while (startLine > 1 && !blank(startLine - 1)) startLine--;
while (endLine < doc.lines && !blank(endLine + 1)) endLine++;
return { from: doc.line(startLine).from, to: doc.line(endLine).to };
}

View File

@@ -0,0 +1,83 @@
import { StateEffect, StateField } from "@codemirror/state";
import { EditorView, showPanel, type Panel } from "@codemirror/view";
export interface PromptConfig {
label: string;
initial?: string;
onSubmit: (value: string, view: EditorView) => void;
onChange?: (value: string, view: EditorView) => void;
onCancel?: (view: EditorView) => void;
}
const openPromptEffect = StateEffect.define<PromptConfig>();
const closePromptEffect = StateEffect.define<null>();
const promptState = StateField.define<PromptConfig | null>({
create: () => null,
update(value, tr) {
for (const effect of tr.effects) {
if (effect.is(openPromptEffect)) return effect.value;
if (effect.is(closePromptEffect)) return null;
}
return value;
},
});
export function openPrompt(view: EditorView, config: PromptConfig) {
view.dispatch({ effects: openPromptEffect.of(config) });
// Focus after the panel mounts.
requestAnimationFrame(() => {
const input = view.dom.querySelector<HTMLInputElement>(".cm-helix-prompt input");
input?.focus();
input?.select();
});
}
function closePrompt(view: EditorView) {
view.dispatch({ effects: closePromptEffect.of(null) });
view.focus();
}
function promptPanel(view: EditorView): Panel {
const config = view.state.field(promptState);
const dom = document.createElement("form");
dom.className = "cm-helix-prompt";
const label = document.createElement("label");
label.textContent = config?.label ?? "";
const input = document.createElement("input");
input.type = "text";
input.value = config?.initial ?? "";
input.spellcheck = false;
input.autocomplete = "off";
dom.append(label, input);
input.addEventListener("input", () => {
view.state.field(promptState)?.onChange?.(input.value, view);
});
input.addEventListener("keydown", (event) => {
if (event.key === "Enter") {
event.preventDefault();
const current = view.state.field(promptState);
closePrompt(view);
current?.onSubmit(input.value, view);
} else if (event.key === "Escape") {
event.preventDefault();
const current = view.state.field(promptState);
closePrompt(view);
current?.onCancel?.(view);
}
});
return { dom, top: false };
}
export const helixPrompt = [
promptState,
showPanel.from(promptState, (config) => (config ? promptPanel : null)),
];
export function isPromptOpen(view: EditorView): boolean {
return view.state.field(promptState) !== null;
}

View File

@@ -0,0 +1,87 @@
import { StateEffect, StateField, type EditorState } from "@codemirror/state";
export type HelixMode = "normal" | "insert" | "select";
/** A multi-key prefix or a command waiting for one more keystroke. */
export type Pending =
| { type: "g" } // goto mode
| { type: "m" } // match mode
| { type: "z" } // view mode
| { type: "space" } // space menu (minimal)
| { type: "find"; kind: "f" | "t" | "F" | "T" } // awaiting target char
| { type: "replace" } // r{char}
| { type: "register" } // "{char}
| { type: "textobject"; around: boolean } // mi{o} / ma{o}
| { type: "surround-add" } // ms{char}
| { type: "surround-delete" } // md{char}
| { type: "surround-replace"; from?: string }; // mr{from}{to}
export interface LastFind {
kind: "f" | "t" | "F" | "T";
char: string;
}
export interface HelixStateValue {
mode: HelixMode;
pending: Pending | null;
count: number; // 0 = no explicit count
register: string | null; // selected register for next yank/paste/delete
lastFind: LastFind | null;
}
const initial: HelixStateValue = {
mode: "normal",
pending: null,
count: 0,
register: null,
lastFind: null,
};
/** Patch the transient Helix state. */
export const helixEffect = StateEffect.define<Partial<HelixStateValue>>();
export const helixState = StateField.define<HelixStateValue>({
create: () => initial,
update(value, tr) {
let next = value;
for (const effect of tr.effects) {
if (effect.is(helixEffect)) next = { ...next, ...effect.value };
}
return next;
},
});
export function getHelix(state: EditorState): HelixStateValue {
return state.field(helixState);
}
export function getMode(state: EditorState): HelixMode {
return state.field(helixState).mode;
}
// --- registers ---------------------------------------------------------------
export const setRegister = StateEffect.define<{ name: string; text: string }>();
export const registersField = StateField.define<Map<string, string>>({
create: () => new Map(),
update(value, tr) {
let next = value;
for (const effect of tr.effects) {
if (effect.is(setRegister)) {
next = new Map(next);
next.set(effect.value.name, effect.value.text);
}
}
return next;
},
});
export function readRegister(state: EditorState, name: string | null): string {
return state.field(registersField).get(name ?? '"') ?? "";
}
/** Clear any pending prefix / count / register selection. */
export function clearPending(): Partial<HelixStateValue> {
return { pending: null, count: 0, register: null };
}

View File

@@ -0,0 +1,127 @@
import type { EditorState, SelectionRange } from "@codemirror/state";
import { charAt, classOf, findClose, findOpen, pairFor, paragraphAt } from "./motions";
export interface Span {
from: number;
to: number;
}
function wordSpan(state: EditorState, pos: number, around: boolean): Span | null {
const cls = classOf(charAt(state, pos));
if (cls === "space") return null;
let from = pos;
let to = pos;
while (from > 0 && classOf(charAt(state, from - 1)) === cls) from--;
while (to < state.doc.length && classOf(charAt(state, to)) === cls) to++;
if (around) {
const start = to;
while (to < state.doc.length && classOf(charAt(state, to)) === "space") to++;
if (to === start) while (from > 0 && classOf(charAt(state, from - 1)) === "space") from--;
}
return { from, to };
}
function pairSpan(
state: EditorState,
pos: number,
open: string,
close: string,
around: boolean,
): Span | null {
// If we sit on a bracket, anchor the search to it.
const here = charAt(state, pos);
const openPos =
here === open ? pos : findOpen(state, here === close ? pos - 1 : pos, open, close);
if (openPos === null) return null;
const closePos = findClose(state, openPos, open, close);
if (closePos === null) return null;
return around ? { from: openPos, to: closePos + 1 } : { from: openPos + 1, to: closePos };
}
function quoteSpan(state: EditorState, pos: number, quote: string, around: boolean): Span | null {
const line = state.doc.lineAt(pos);
const positions: number[] = [];
for (let i = line.from; i <= line.to; i++) if (charAt(state, i) === quote) positions.push(i);
for (let i = 0; i + 1 < positions.length; i += 2) {
const a = positions[i]!;
const b = positions[i + 1]!;
if (pos >= a && pos <= b + 1) {
return around ? { from: a, to: b + 1 } : { from: a + 1, to: b };
}
}
return null;
}
function paragraphSpan(state: EditorState, pos: number, around: boolean): Span {
const span = paragraphAt(state, pos);
if (!around) return span;
let to = span.to;
while (to < state.doc.length && state.doc.lineAt(to + 1).text.trim() === "") {
to = state.doc.lineAt(to + 1).to;
}
return { from: span.from, to };
}
const QUOTES = new Set(['"', "'", "`"]);
/** Innermost enclosing bracket/quote pair (Helix `mi m` / `ma m`). */
function nearestPair(state: EditorState, pos: number, around: boolean): Span | null {
const candidates: Span[] = [];
for (const ch of ["(", "[", "{", "<"]) {
const span = pairSpan(state, pos, ch, pairFor(ch)!.close, around);
if (span) candidates.push(span);
}
for (const q of QUOTES) {
const span = quoteSpan(state, pos, q, around);
if (span) candidates.push(span);
}
if (!candidates.length) return null;
// Smallest enclosing span wins.
return candidates.sort((a, b) => b.from - a.from || a.to - b.to)[0]!;
}
/**
* Resolve a Helix textobject for `mi{obj}` / `ma{obj}`.
* Returns null when nothing sensible encloses the cursor.
*/
export function textObject(
state: EditorState,
range: SelectionRange,
obj: string,
around: boolean,
): Span | null {
const pos = range.head > range.from ? range.head - 1 : range.head;
switch (obj) {
case "w":
case "W":
return wordSpan(state, range.head, around);
case "p":
return paragraphSpan(state, range.head, around);
case "(":
case ")":
case "b":
return pairSpan(state, pos, "(", ")", around);
case "{":
case "}":
case "B":
return pairSpan(state, pos, "{", "}", around);
case "[":
case "]":
return pairSpan(state, pos, "[", "]", around);
case "<":
case ">":
return pairSpan(state, pos, "<", ">", around);
case '"':
return quoteSpan(state, range.head, '"', around);
case "'":
return quoteSpan(state, range.head, "'", around);
case "`":
return quoteSpan(state, range.head, "`", around);
case "m":
return nearestPair(state, pos, around);
default:
return null;
}
}
export { pairFor } from "./motions";

View File

@@ -0,0 +1,123 @@
import {
Decoration,
EditorView,
ViewPlugin,
WidgetType,
showPanel,
type DecorationSet,
type Panel,
type ViewUpdate,
} from "@codemirror/view";
import { getMode, helixState } from "./state";
class EolCursorWidget extends WidgetType {
toDOM() {
const span = document.createElement("span");
span.className = "cm-helix-block cm-helix-block-eol";
span.textContent = " ";
return span;
}
ignoreEvent() {
return true;
}
}
const blockMark = Decoration.mark({ class: "cm-helix-block" });
const selectionMark = Decoration.mark({ class: "cm-helix-selection" });
const eolCursor = Decoration.widget({ widget: new EolCursorWidget(), side: 1 });
// Draws the selection ranges + a block cursor at each head while in
// normal/select mode. We render the selection ourselves (rather than relying
// on drawSelection, which the host theme can leave invisible) so multi-cursor
// selections are always clearly highlighted.
export const blockCursor = ViewPlugin.fromClass(
class {
decorations: DecorationSet;
constructor(view: EditorView) {
this.decorations = this.build(view);
}
update(update: ViewUpdate) {
if (
update.docChanged ||
update.selectionSet ||
update.viewportChanged ||
update.startState.field(helixState).mode !== update.state.field(helixState).mode
) {
this.decorations = this.build(update.view);
}
}
build(view: EditorView): DecorationSet {
if (getMode(view.state) === "insert") return Decoration.none;
const deco: ReturnType<typeof blockMark.range>[] = [];
for (const range of view.state.selection.ranges) {
if (!range.empty) deco.push(selectionMark.range(range.from, range.to));
const pos = range.head;
const line = view.state.doc.lineAt(pos);
deco.push(pos < line.to ? blockMark.range(pos, pos + 1) : eolCursor.range(pos));
}
return Decoration.set(deco, true);
}
},
{ decorations: (plugin) => plugin.decorations },
);
export const modeEditorClass = EditorView.editorAttributes.compute([helixState], (state) => ({
class: `cm-helix cm-helix-${getMode(state)}`,
}));
function statusPanel(view: EditorView): Panel {
const dom = document.createElement("div");
dom.className = "cm-helix-status";
const render = () => {
const s = view.state.field(helixState);
const label = s.mode === "insert" ? "INS" : s.mode === "select" ? "SEL" : "NOR";
const count = s.count ? ` ${s.count}` : "";
const reg = s.register ? ` "${s.register}` : "";
const pend = s.pending ? ` ${s.pending.type}` : "";
const sels = view.state.selection.ranges.length;
const multi = sels > 1 ? ` ${sels} sels` : "";
dom.textContent = `${label}${count}${reg}${pend}${multi}`;
dom.dataset.mode = s.mode;
};
render();
return {
dom,
update: (u) => {
if (u.docChanged || u.selectionSet || u.transactions.length) render();
},
};
}
export const helixStatusPanel = showPanel.of(statusPanel);
export const helixTheme = EditorView.baseTheme({
"&.cm-helix-normal .cm-cursor, &.cm-helix-normal .cm-cursorLayer": { display: "none" },
"&.cm-helix-select .cm-cursor, &.cm-helix-select .cm-cursorLayer": { display: "none" },
".cm-helix-selection": { backgroundColor: "rgba(120,160,255,0.32)" },
".cm-helix-block": { backgroundColor: "rgba(125,165,255,0.7)", borderRadius: "1px" },
".cm-helix-block-eol": { display: "inline-block", width: "0.55em" },
".cm-helix-status": {
padding: "1px 10px",
font: "11px ui-monospace, SFMono-Regular, Menlo, monospace",
letterSpacing: "0.05em",
},
".cm-helix-status[data-mode=insert]": { color: "#16a34a" },
".cm-helix-status[data-mode=select]": { color: "#d97706" },
".cm-helix-status[data-mode=normal]": { color: "#2563eb" },
".cm-helix-prompt": {
display: "flex",
gap: "0.5em",
alignItems: "center",
padding: "2px 10px",
font: "12px ui-monospace, SFMono-Regular, Menlo, monospace",
},
".cm-helix-prompt label": { opacity: 0.7 },
".cm-helix-prompt input": {
flex: "1",
border: "none",
outline: "none",
background: "transparent",
color: "inherit",
font: "inherit",
},
});

View File

@@ -0,0 +1,19 @@
{
"compilerOptions": {
"target": "ES2020",
"module": "ESNext",
"moduleResolution": "Bundler",
"lib": ["ES2020", "DOM", "DOM.Iterable"],
"declaration": true,
"declarationMap": true,
"sourceMap": true,
"outDir": "dist",
"rootDir": "src",
"strict": true,
"noUncheckedIndexedAccess": true,
"skipLibCheck": true,
"esModuleInterop": true,
"forceConsistentCasingInFileNames": true
},
"include": ["src"]
}

View File

@@ -1,58 +1,72 @@
"use client"; "use client";
import MDEditor from "@uiw/react-md-editor"; import CodeMirror from "@uiw/react-codemirror";
import { markdown } from "@codemirror/lang-markdown";
import { autocompletion, completionStatus } from "@codemirror/autocomplete";
import { EditorView } from "@codemirror/view";
import { helix } from "@gregorlohaus/codemirror-helix";
import { Maximize2, Minimize2 } from "lucide-react"; import { Maximize2, Minimize2 } from "lucide-react";
import { useEffect, useState, type ReactElement, type TextareaHTMLAttributes } from "react"; import { useEffect, useMemo, useState, type ReactElement } from "react";
import { createPortal } from "react-dom"; import { createPortal } from "react-dom";
import type { Control, FieldValues, Path } from "react-hook-form"; import type { Control, FieldValues, Path } from "react-hook-form";
import { FormControl, FormField, FormItem, FormLabel } from "~/components/ui/form"; import { FormControl, FormField, FormItem, FormLabel } from "~/components/ui/form";
import { Button } from "~/components/ui/button"; import { Button } from "~/components/ui/button";
import { cn } from "~/lib/utils"; import { cn } from "~/lib/utils";
import { ClientMdx } from "~/components/ClientMdx";
import { import {
InternalLinkTextarea,
type AutocompleteTriggerConfig, type AutocompleteTriggerConfig,
type MdeAutocompleteSuggestion, type MdeAutocompleteSuggestion,
} from "./InternalLinkTextarea"; } from "./InternalLinkTextarea";
import { mdxCompletionSource } from "./codemirror/mdxAutocomplete";
export default function MdeFormField<T extends FieldValues>(params: { export default function MdeFormField<T extends FieldValues>(params: {
control: Control<T>, control: Control<T>;
name: Path<T>, name: Path<T>;
label: string, label: string;
dataColorMode: "dark"|"light", dataColorMode: "dark" | "light";
autocompleteSuggestions?: MdeAutocompleteSuggestion[], autocompleteSuggestions?: MdeAutocompleteSuggestion[];
triggerConfigs?: AutocompleteTriggerConfig[], triggerConfigs?: AutocompleteTriggerConfig[];
renderPreview?: (source: string) => ReactElement, renderPreview?: (source: string) => ReactElement;
}) { }) {
const [fullscreen, setFullscreen] = useState(false) const [fullscreen, setFullscreen] = useState(false);
const [mounted, setMounted] = useState(false) const [mounted, setMounted] = useState(false);
useEffect(() => { useEffect(() => {
setMounted(true) setMounted(true);
}, []) }, []);
useEffect(() => { useEffect(() => {
if (!fullscreen) return if (!fullscreen) return;
const originalOverflow = document.body.style.overflow;
const originalOverflow = document.body.style.overflow document.body.style.overflow = "hidden";
document.body.style.overflow = "hidden"
return () => { return () => {
document.body.style.overflow = originalOverflow document.body.style.overflow = originalOverflow;
} };
}, [fullscreen]) }, [fullscreen]);
const extensions = useMemo(
() => [
markdown(),
EditorView.lineWrapping,
helix({ escapeGuard: (state) => completionStatus(state) === "active" }),
autocompletion({
override: [mdxCompletionSource(params.autocompleteSuggestions ?? [], params.triggerConfigs)],
activateOnTyping: true,
defaultKeymap: true,
}),
],
[params.autocompleteSuggestions, params.triggerConfigs],
);
return ( return (
<FormField <FormField
control={params.control} control={params.control}
name={params.name} name={params.name}
render={({ field }) => { render={({ field }) => {
const source: string = field.value ?? "";
const editor = ( const editor = (
<FormItem className={cn(fullscreen && "mde-form-field-fullscreen")}> <FormItem className={cn(fullscreen && "mde-form-field-fullscreen")}>
<div className="flex shrink-0 items-center justify-between gap-2"> <div className="flex shrink-0 items-center justify-between gap-2">
<FormLabel> <FormLabel>{params.label}</FormLabel>
{params.label}
</FormLabel>
<Button <Button
type="button" type="button"
variant="outline" variant="outline"
@@ -64,37 +78,48 @@ export default function MdeFormField<T extends FieldValues>(params: {
</Button> </Button>
</div> </div>
<FormControl className={cn(fullscreen && "min-h-0 flex-1")}> <FormControl className={cn(fullscreen && "min-h-0 flex-1")}>
<MDEditor <div
className={cn(fullscreen && "mde-form-field-editor-fullscreen min-h-0 flex-1")} className={cn(
height={fullscreen ? "calc(100vh - 72px)" : undefined} "flex flex-wrap gap-4",
visibleDragbar={!fullscreen} fullscreen ? "min-h-0 flex-1 items-stretch" : "items-start",
value={field.value ? field.value : ""} )}
onChange={field.onChange} >
data-color-mode={params.dataColorMode} <div className="min-w-[18rem] flex-1 overflow-hidden rounded-md border">
commandsFilter={(command) => command.name === "fullscreen" ? false : command} <CodeMirror
components={{ value={source}
textarea: (props) => ( onChange={(value) => field.onChange(value)}
<InternalLinkTextarea extensions={extensions}
{...(props as TextareaHTMLAttributes<HTMLTextAreaElement>)} theme={params.dataColorMode === "dark" ? "dark" : "light"}
suggestions={params.autocompleteSuggestions ?? []} height={fullscreen ? "calc(100vh - 96px)" : "360px"}
triggerConfigs={params.triggerConfigs} basicSetup={{
/> lineNumbers: false,
), foldGutter: false,
preview: params.renderPreview highlightActiveLine: false,
? (source) => params.renderPreview?.(source) ?? <></> highlightActiveLineGutter: false,
: (source) => <ClientMdx source={source} fallback={source} />, }}
}} />
/> </div>
{params.renderPreview && (
<div
className={cn(
"prose dark:prose-invert min-w-[18rem] max-w-none flex-1 overflow-auto rounded-md border p-4",
fullscreen ? "min-h-0" : "max-h-[360px]",
)}
>
{params.renderPreview(source)}
</div>
)}
</div>
</FormControl> </FormControl>
</FormItem> </FormItem>
) );
if (fullscreen && mounted) { if (fullscreen && mounted) {
return createPortal(editor, document.body) return createPortal(editor, document.body);
} }
return editor return editor;
}} }}
/> />
) );
} }

View File

@@ -0,0 +1,85 @@
import { EditorSelection } from "@codemirror/state";
import type { Completion, CompletionSource } from "@codemirror/autocomplete";
import {
AUTOCOMPLETE_CURSOR_MARKER,
type AutocompleteTriggerConfig,
type MdeAutocompleteSuggestion,
} from "../InternalLinkTextarea";
// Re-implements the textarea autocomplete (trigger tokens like `<`, `[[`, `!`)
// as a CodeMirror completion source, driven by the exact same suggestion data
// produced by `useMdxEditorFieldProps`.
type ResolvedTrigger = { trigger: string };
function resolveTriggers(
suggestions: MdeAutocompleteSuggestion[],
triggerConfigs: AutocompleteTriggerConfig[] | undefined,
): (AutocompleteTriggerConfig & ResolvedTrigger)[] {
const map = new Map<string, AutocompleteTriggerConfig>();
for (const config of triggerConfigs ?? []) map.set(config.trigger, config);
for (const suggestion of suggestions) {
if (!map.has(suggestion.trigger)) {
map.set(suggestion.trigger, { trigger: suggestion.trigger, label: suggestion.trigger });
}
}
// Longer triggers first so `[[` wins over a hypothetical `[`.
return Array.from(map.values()).sort((a, b) => b.trigger.length - a.trigger.length);
}
function toCompletion(suggestion: MdeAutocompleteSuggestion, triggerStart: number): Completion {
const group = suggestion.group.toLowerCase();
const type = group === "component" ? "class" : group === "markdown" ? "keyword" : "variable";
return {
label: suggestion.label,
detail: suggestion.detail,
type,
apply: (view, _completion, _from, to) => {
const markerIndex = suggestion.value.indexOf(AUTOCOMPLETE_CURSOR_MARKER);
const inserted =
markerIndex === -1 ? suggestion.value : suggestion.value.replace(AUTOCOMPLETE_CURSOR_MARKER, "");
const cursor = triggerStart + (markerIndex === -1 ? inserted.length : markerIndex);
view.dispatch({
changes: { from: triggerStart, to, insert: inserted },
selection: EditorSelection.cursor(cursor),
});
},
};
}
export function mdxCompletionSource(
suggestions: MdeAutocompleteSuggestion[],
triggerConfigs?: AutocompleteTriggerConfig[],
): CompletionSource {
const triggers = resolveTriggers(suggestions, triggerConfigs);
return (context) => {
const before = context.state.sliceDoc(0, context.pos);
const active = triggers
.map((config) => ({ config, start: before.lastIndexOf(config.trigger) }))
.filter((candidate) => candidate.start !== -1)
.sort((a, b) => b.start - a.start)[0];
if (!active) return null;
const queryStart = active.start + active.config.trigger.length;
const query = before.slice(queryStart);
if (query.includes("\n")) return null;
if (active.config.isQueryValid && !active.config.isQueryValid(query)) return null;
const options = suggestions
.filter((suggestion) => suggestion.trigger === active.config.trigger)
.map((suggestion) => toCompletion(suggestion, active.start));
if (!options.length) return null;
return {
from: queryStart,
to: context.pos,
options,
// Keep the popup open while the query stays a single token.
validFor: /^[^\s>\]\)]*$/,
};
};
}

View File

@@ -0,0 +1,122 @@
"use client";
import { useGSAP } from "@gsap/react";
import gsap from "gsap";
import Link from "next/link";
import { useRef } from "react";
// The centerpiece is the site's own "G" mark (public/GLIcon.svg). GSAP draws the
// outline on with a stroke-dash trick, fades the fill in, then keeps everything
// gently alive (the logo floats). A curved arrow in the lower-right corner, with
// its label above the tail, points at the chat FAB to nudge people toward the
// assistant.
export default function HomeHero() {
const root = useRef<HTMLDivElement>(null);
const gPath = useRef<SVGPathElement>(null);
useGSAP(
() => {
const logo = gPath.current;
if (logo) {
const len = logo.getTotalLength();
gsap.set(logo, { strokeDasharray: len, strokeDashoffset: len });
}
gsap.set(".hero-fill", { fillOpacity: 0 });
const tl = gsap.timeline({ defaults: { ease: "power3.out" } });
if (logo) tl.to(logo, { strokeDashoffset: 0, duration: 1.3 }, 0.25);
tl.to(".hero-fill", { fillOpacity: 1, duration: 0.6 }, "-=0.35");
tl.from(".hero-line", { yPercent: 120, opacity: 0, duration: 0.7, stagger: 0.12 }, "-=0.25");
tl.from(".hero-arrow-label", { yPercent: 120, opacity: 0, duration: 0.6 }, "-=0.1");
tl.from(
".hero-arrow-svg",
{ opacity: 0, scale: 0.7, transformOrigin: "top left", duration: 0.6 },
"<+=0.1",
);
// Idle life — runs forever once the entrance has settled.
gsap.to(".hero-logo", { y: -12, duration: 3.2, ease: "sine.inOut", yoyo: true, repeat: -1 });
gsap.to(".hero-arrow-wrap", { y: 10, duration: 1.5, ease: "sine.inOut", yoyo: true, repeat: -1 });
},
{ scope: root },
);
return (
<div
ref={root}
className="relative flex h-full w-full flex-col items-center justify-center overflow-hidden px-6 text-center"
>
{/* Logo */}
<div className="hero-logo relative h-56 w-56 sm:h-64 sm:w-64">
<svg
className="absolute inset-0 h-full w-full p-10 text-foreground"
viewBox="0 0 74.193405 74.232162"
aria-label="Gregor Lohaus logo"
>
<g transform="translate(-24.550957,-64.437925)">
<path
ref={gPath}
className="hero-fill"
d="m 61.66652,64.437927 c -20.498425,1.81e-4 -37.115669,16.617653 -37.115564,37.116083 -1.05e-4,20.49842 16.617139,37.1159 37.115564,37.11608 16.081184,-0.0265 30.316081,-10.4061 35.258313,-25.70903 1.144195,-3.51294 1.757471,-7.1771 1.819527,-10.87117 H 87.864404 67.217603 v 10.87117 h 17.977714 c -4.361366,9.03731 -13.494221,14.79672 -23.528797,14.83786 -14.494622,0 -26.244916,-11.75029 -26.244909,-26.24491 -7e-6,-14.494627 11.750287,-26.244918 26.244909,-26.244912 z"
fill="currentColor"
stroke="currentColor"
strokeWidth="0.9"
/>
<rect
className="hero-fill"
width="31.802109"
height="11.397169"
x="-96.2453"
y="67.460899"
transform="rotate(-90)"
fill="currentColor"
/>
</g>
</svg>
</div>
{/* Headline */}
<div className="mt-10 max-w-xl">
<h1 className="overflow-hidden pb-2">
<span className="hero-line block text-4xl font-semibold leading-tight tracking-tight sm:text-6xl">
Gregor Lohaus
</span>
</h1>
<div className="mt-4 overflow-hidden">
<p className="hero-line text-lg text-muted-foreground sm:text-xl">
Full Stack Developer
</p>
</div>
</div>
{/* Lower-right arrow pointing at the chat FAB */}
<Link
href="/assistant"
aria-label="Chat with my AI assistant"
className="hero-arrow-wrap absolute bottom-10 right-8 z-40 flex flex-col items-start text-foreground transition-opacity hover:opacity-80 sm:right-16"
>
<span className="mb-1 max-w-[11rem] -translate-x-20 -translate-y-2 overflow-hidden sm:-translate-x-24">
<span className="hero-arrow-label block font-semibold leading-snug text-foreground drop-shadow-[0_2px_10px_rgba(0,0,0,0.7)] sm:text-lg">
Chat with my AI&nbsp;assistant
</span>
</span>
<svg
className="hero-arrow-svg h-28 w-32 drop-shadow-[0_2px_10px_rgba(0,0,0,0.4)] sm:h-32 sm:w-36"
viewBox="0 0 776.09175 693.66538"
fill="currentColor"
aria-hidden="true"
>
<g transform="matrix(2.7190747,0,0,3.1037754,-326.9763,-1172.9045)">
<path
fillRule="evenodd"
clipRule="evenodd"
d="m 130.838,381.118 c 1.125,28.749 5.277,54.82 12.695,78.018 7.205,22.53 18.847,40.222 36.812,53.747 52.018,39.16 153.369,16.572 153.369,16.572 l -4.632,-32.843 72.918,42.778 -58.597,58.775 -3.85,-27.303 c 0,0 -100.347,18.529 -163.905,-34.881 -37.659,-31.646 -53.293,-84.021 -51.593,-153.962 0.266,-0.247 4.728,-0.908 6.783,-0.901 z"
/>
</g>
</svg>
</Link>
</div>
);
}

View File

@@ -1,25 +0,0 @@
import MDEditor from "@uiw/react-md-editor";
import type { Control, FieldValues, Path } from "react-hook-form";
import { FormControl, FormField, FormItem, FormLabel } from "~/components/ui/form";
export default function MdeFormField<T extends FieldValues>(params: { control: Control<T>, name: Path<T>, label: string, dataColorMode: "dark"|"light" }) {
return (
<FormField
control={params.control}
name={params.name}
render={({ field }) => (
<FormItem>
<FormLabel>
{params.label}
</FormLabel>
<FormControl>
<MDEditor
value={field.value ? field.value : ""}
onChange={field.onChange}
data-color-mode={params.dataColorMode}
/>
</FormControl>
</FormItem>
)}
/>
)
}

View File

@@ -0,0 +1,25 @@
'use server'
import { getGoogleCalendarClient, getGoogleCalendarId } from '~/server/googleCalendar'
export async function cancelMeeting({ eventId }: { eventId: string }) {
try {
const calendar = getGoogleCalendarClient()
await calendar.events.delete({
calendarId: getGoogleCalendarId(),
eventId,
})
return {
success: true,
eventId,
message: 'Meeting removed from Gregor availability calendar.',
}
} catch (error) {
console.error('Failed to cancel meeting:', error)
return {
success: false,
error: 'Failed to remove the meeting from Gregor availability calendar.',
}
}
}

View File

@@ -1,7 +1,34 @@
'use server' 'use server'
import { clerkClient, auth } from '@clerk/nextjs/server'
import { google } from 'googleapis'
import { env } from '~/env' import { env } from '~/env'
import { getGoogleCalendarClient, getGoogleCalendarId } from '~/server/googleCalendar'
function googleCalendarDate(date: Date) {
return date.toISOString().replace(/[-:]|\.\d{3}/g, '')
}
function createGoogleCalendarTemplateLink({
title,
description,
startTime,
endTime,
gregorEmail,
}: {
title: string
description: string
startTime: Date
endTime: Date
gregorEmail: string
}) {
const params = new URLSearchParams({
action: 'TEMPLATE',
text: title,
dates: `${googleCalendarDate(startTime)}/${googleCalendarDate(endTime)}`,
details: description,
add: gregorEmail,
})
return `https://calendar.google.com/calendar/render?${params.toString()}`
}
export async function scheduleMeeting({ export async function scheduleMeeting({
title, title,
@@ -19,56 +46,39 @@ export async function scheduleMeeting({
attendeeName?: string attendeeName?: string
}) { }) {
try { try {
const clerk = await clerkClient() const calendar = getGoogleCalendarClient()
const userAuth = await auth()
const user = await clerk.users.getUser(userAuth.userId?userAuth.userId:"")
// Get admin's Google OAuth token to create the event on Gregor's calendar
const adminTokenResponse = await clerk.users.getUserOauthAccessToken(
env.ADMIN_USER_CLERK_ID,
'oauth_google',
)
const adminToken = adminTokenResponse.data[0]
if (!adminToken?.token) {
return { success: false, error: 'Admin Google Calendar not connected. Ensure the admin account is linked with Google and has calendar scope enabled.' }
}
// Try to resolve visitor's Google email for the invite
let visitorEmail: string | undefined = attendeeEmail
if (!visitorEmail) {
visitorEmail = user?.emailAddresses.at(0)?.emailAddress ?? undefined
}
const oAuth2Client = new google.auth.OAuth2()
oAuth2Client.setCredentials({ access_token: adminToken.token })
const calendar = google.calendar({ version: 'v3', auth: oAuth2Client })
const startTime = new Date(dateTime) const startTime = new Date(dateTime)
const endTime = new Date(startTime.getTime() + durationMinutes * 60 * 1000) const endTime = new Date(startTime.getTime() + durationMinutes * 60 * 1000)
const attendeeNote = attendeeEmail
? `\n\nVisitor: ${attendeeName ?? 'Unknown'} <${attendeeEmail}>`
: ''
const eventDescription = `${description}${attendeeNote}`
const attendees: { email: string; displayName?: string }[] = [] const eventRequest = {
if (visitorEmail) { summary: title,
attendees.push({ email: visitorEmail, displayName: attendeeName }) description: eventDescription,
start: { dateTime: startTime.toISOString(), timeZone: 'UTC' },
end: { dateTime: endTime.toISOString(), timeZone: 'UTC' },
} }
const event = await calendar.events.insert({ const event = await calendar.events.insert({
calendarId: 'primary', calendarId: getGoogleCalendarId(),
sendUpdates: 'all', requestBody: eventRequest,
requestBody: { })
summary: title,
description, const addToCalendarLink = createGoogleCalendarTemplateLink({
start: { dateTime: startTime.toISOString(), timeZone: 'UTC' }, title,
end: { dateTime: endTime.toISOString(), timeZone: 'UTC' }, description,
attendees, startTime,
}, endTime,
sendNotifications: true gregorEmail: env.GREGOR_MEETING_EMAIL,
}) })
return { return {
success: true, success: true,
eventId: event.data.id, eventId: event.data.id,
htmlLink: event.data.htmlLink, addToCalendarLink,
message: `Meeting "${title}" scheduled for ${startTime.toLocaleString()}${visitorEmail ? `. Invite sent to ${visitorEmail}.` : '.'}`, message: `Meeting "${title}" scheduled for ${startTime.toLocaleString()}.${attendeeEmail ? ` Visitor email noted: ${attendeeEmail}.` : ''} The add-to-calendar link invites ${env.GREGOR_MEETING_EMAIL}.`,
} }
} catch (error) { } catch (error) {
console.error('Failed to schedule meeting:', error) console.error('Failed to schedule meeting:', error)

View File

@@ -44,6 +44,26 @@ External resource
alt="Describe the image" alt="Describe the image"
caption="Optional caption" caption="Optional caption"
/>`, />`,
},
{
name: "Layout / Row / Column",
description: "Border-less flex layout. Columns sit side by side and wrap when narrow.",
code: `<Layout>
<Row>
<Column>
- First list item
- Second list item
</Column>
<Column>
- Another list
- Side by side
</Column>
</Row>
</Layout>`,
}, },
{ {
name: "PullQuote", name: "PullQuote",

View File

@@ -73,6 +73,27 @@ const mdxAutocompleteSuggestions: MdeAutocompleteSuggestion[] = [
group: 'Component', group: 'Component',
trigger: '<', trigger: '<',
}, },
{
label: 'Layout',
value: `<Layout>\n<Row>\n<Column>\n${AUTOCOMPLETE_CURSOR_MARKER}\n</Column>\n<Column>\n\n</Column>\n</Row>\n</Layout>`,
detail: 'Flex layout wrapper with side-by-side, wrapping columns.',
group: 'Component',
trigger: '<',
},
{
label: 'Row',
value: `<Row>\n<Column>\n${AUTOCOMPLETE_CURSOR_MARKER}\n</Column>\n<Column>\n\n</Column>\n</Row>`,
detail: 'Side-by-side columns that wrap when narrow.',
group: 'Component',
trigger: '<',
},
{
label: 'Column',
value: `<Column>\n${AUTOCOMPLETE_CURSOR_MARKER}\n</Column>`,
detail: 'Vertically stacked column within a Row.',
group: 'Component',
trigger: '<',
},
{ {
label: 'PullQuote', label: 'PullQuote',
value: `<PullQuote>\n${AUTOCOMPLETE_CURSOR_MARKER}\n</PullQuote>`, value: `<PullQuote>\n${AUTOCOMPLETE_CURSOR_MARKER}\n</PullQuote>`,

View File

@@ -1,14 +1,12 @@
import { auth } from '@clerk/nextjs/server' import { auth } from '@clerk/nextjs/server'
import { createOpenAI } from '@ai-sdk/openai' import { createOpenAI } from '@ai-sdk/openai'
import { streamText, tool, convertToModelMessages, stepCountIs, type UIMessage } from 'ai' import { streamText, convertToModelMessages, stepCountIs, type UIMessage } from 'ai'
import { success, z } from 'zod'
import { eq, and } from 'drizzle-orm' import { eq, and } from 'drizzle-orm'
import { env } from '~/env' import { env } from '~/env'
import { db } from '~/server/db' import { db } from '~/server/db'
import { chatSession, chatMessage } from '~/server/dbschema/schema' import { chatSession, chatMessage } from '~/server/dbschema/schema'
import { servTrpc } from '~/app/_trpc/ServerClient' import { servTrpc } from '~/app/_trpc/ServerClient'
import { scheduleMeeting } from '~/app/actions/scheduleMeeting' import { createChatTools } from '~/server/ai/tools'
import currentTime from '~/app/actions/currentTime';
const openai = createOpenAI({ apiKey: env.OPENAI_API_KEY }) const openai = createOpenAI({ apiKey: env.OPENAI_API_KEY })
@@ -31,7 +29,17 @@ export async function POST(req: Request) {
if (!session) return new Response('Session not found', { status: 404 }) if (!session) return new Response('Session not found', { status: 404 })
const systemPrompt = await servTrpc.chat.getSystemPrompt() || 'You are an AI recruiter assistant.' const configuredSystemPrompt = await servTrpc.chat.getSystemPrompt() || 'You are an AI recruiter assistant.'
const systemPrompt = `${configuredSystemPrompt}
Runtime context:
- Current server time: ${new Date().toISOString()}.
- Default meeting timezone: Europe/Berlin.
- For availability questions like "next open spot", call getAvailability once. It defaults to checking from now. Use nextAvailableSlot for the next opening, or the first item in availableSlots if needed. Do not call getAvailability again just to get more slots.
- After scheduleMeeting succeeds, include only the returned addToCalendarLink for the visitor. Format it as a Markdown link like [Add this meeting to your Google Calendar](URL); do not paste the raw URL. Explain briefly that this link lets them add the meeting to their own calendar and invite Gregor. Do not mention internal Google Calendar event links.
- You can remove meetings from Gregor's availability calendar with cancelMeeting only when you have the exact eventId from a previous scheduleMeeting result. If a visitor asks to reschedule and you have both the old eventId and a confirmed new slot, call cancelMeeting once for the old event and scheduleMeeting once for the new event. If you do not have the old eventId, ask for clarification instead of guessing.
- When rescheduling, make clear that cancelMeeting only removes the old slot from Gregor's availability calendar. If the visitor already added the old link to their own calendar, they may need to remove that copy themselves.
- Do not calculate or invent calendar availability yourself.`
const model = await servTrpc.chat.getModel() const model = await servTrpc.chat.getModel()
// Save the latest user message // Save the latest user message
@@ -50,42 +58,14 @@ export async function POST(req: Request) {
model: openai(model), model: openai(model),
system: systemPrompt, system: systemPrompt,
messages: await convertToModelMessages(messages), messages: await convertToModelMessages(messages),
tools: { tools: createChatTools(),
scheduleMeeting: tool({ stopWhen: stepCountIs(3),
description: 'Schedule a meeting with Gregor Lohaus and add it to his Google Calendar',
inputSchema: z.object({
title: z.string().describe('Meeting title, make something up if not provided'),
description: z.string().describe('Meeting description / agenda, make something up if not provided'),
dateTime: z
.string()
.describe(
'ISO 8601 datetime for the meeting start, e.g. 2025-04-01T10:00:00',
),
durationMinutes: z
.number()
.int()
.min(15)
.max(120)
.describe('Duration of the meeting in minutes, if none provided ask if 20 minutes is ok'),
attendeeEmail: z
.string()
.email()
.optional()
.describe('Optional Email of the visitor to invite (if provided)'),
attendeeName: z.string().optional().describe('Name of the visitor'),
}),
execute: async (input) => scheduleMeeting({ ...input }),
}),
getCurrentUnixTime: tool({
description: 'Get the current unix time to reference for meeting dates',
inputSchema: z.object({
none: z.string().optional().describe("no inputs are needed")
}),
execute: async () => currentTime()
})
},
stopWhen: stepCountIs(5),
onFinish: async ({ text, finishReason }) => { onFinish: async ({ text, finishReason }) => {
console.log('[ai:chat:onFinish]', {
finishReason,
hasText: Boolean(text),
textLength: text.length,
})
if (text && finishReason === 'stop') { if (text && finishReason === 'stop') {
await db.insert(chatMessage).values({ await db.insert(chatMessage).values({
sessionId, sessionId,

View File

@@ -32,7 +32,7 @@ export default async function BlogPostPage({ params }: Props) {
const date = typeof parsed.data.date === "string" ? parsed.data.date : post.date; const date = typeof parsed.data.date === "string" ? parsed.data.date : post.date;
return ( return (
<main className="mx-auto max-w-2xl px-4 py-12"> <main className="mx-auto h-full max-w-2xl overflow-y-auto px-4 py-12">
<header className="mb-8"> <header className="mb-8">
<h1 className="text-3xl font-bold">{title}</h1> <h1 className="text-3xl font-bold">{title}</h1>
{date && ( {date && (

View File

@@ -6,7 +6,7 @@ export default async function BlogPage() {
const posts = await servTrpc.blog.list(); const posts = await servTrpc.blog.list();
return ( return (
<main className="mx-auto max-w-2xl px-4 py-12"> <main className="mx-auto h-full max-w-2xl overflow-y-auto px-4 py-12">
<h1 className="mb-8 text-3xl font-bold">Blog</h1> <h1 className="mb-8 text-3xl font-bold">Blog</h1>
{posts.length === 0 ? ( {posts.length === 0 ? (
<p className="text-muted-foreground">No posts yet.</p> <p className="text-muted-foreground">No posts yet.</p>

View File

@@ -1,6 +1,25 @@
import type { UIMessage } from "ai"; import type { UIMessage } from "ai";
import { ClientMdx } from "~/components/ClientMdx"; import { ClientMdx } from "~/components/ClientMdx";
function toolLabel(type: string) {
switch (type) {
case "tool-searchSiteContent":
return "Searching site content";
case "tool-getRelevantExperience":
return "Finding relevant experience";
case "tool-getProjectDetails":
return "Loading project details";
case "tool-getAvailability":
return "Checking availability";
case "tool-cancelMeeting":
return "Removing meeting";
case "tool-getCurrentUnixTime":
return "Checking current time";
default:
return "Using tool";
}
}
export const AssistantMessage = (props: { message: UIMessage }) => { export const AssistantMessage = (props: { message: UIMessage }) => {
let message = props.message; let message = props.message;
return ( return (
@@ -10,7 +29,7 @@ export const AssistantMessage = (props: { message: UIMessage }) => {
> >
<div <div
className= className=
'max-w-[80%] px-4 py-2 text-sm space-y-2 bg-muted' 'max-w-[80%] min-w-0 px-4 py-2 text-sm space-y-2 bg-muted break-words [overflow-wrap:anywhere] [&_a]:break-all [&_pre]:max-w-full [&_pre]:overflow-x-auto'
> >
{message.parts.map((part, i) => { {message.parts.map((part, i) => {
if (part.type === 'text') { if (part.type === 'text') {
@@ -18,47 +37,82 @@ export const AssistantMessage = (props: { message: UIMessage }) => {
<ClientMdx key={i} source={part.text} fallback={part.text} /> <ClientMdx key={i} source={part.text} fallback={part.text} />
) )
} }
if (part.type === 'tool-scheduleMeeting') { if (part.type === 'tool-scheduleMeeting') {
const toolPart = part as unknown as { const toolPart = part as unknown as {
type: 'tool-scheduleMeeting' type: 'tool-scheduleMeeting'
state: string state: string
input: unknown input: unknown
output?: { success: boolean; message?: string; htmlLink?: string; error?: string } output?: { success: boolean; error?: string }
} }
if (toolPart.state === 'input-available' || toolPart.state === 'input-streaming') { if (toolPart.state === 'input-available' || toolPart.state === 'input-streaming') {
return ( return (
<p key={i} className="text-xs opacity-70 italic"> <p key={i} className="text-xs opacity-70 italic">
Scheduling meeting Scheduling meeting
</p> </p>
) )
} }
if (toolPart.state === 'output-available' && toolPart.output) { if (toolPart.state === 'output-available' && toolPart.output) {
const result = toolPart.output const result = toolPart.output
return ( if (result.success) return null
<div key={i} className="text-xs mt-1 p-2 bg-background/20 rounded"> return (
{result.success ? ( <div key={i} className="text-xs mt-1 p-2 bg-background/20 rounded">
<span> <span> {result.error}</span>
{result.message}{' '}
{result.htmlLink && (
<a
href={result.htmlLink}
target="_blank"
rel="noopener noreferrer"
className="underline"
>
View event
</a>
)}
</span>
) : (
<span> {result.error}</span>
)}
</div> </div>
) )
} }
} }
return null if (part.type === 'tool-cancelMeeting') {
})} const toolPart = part as unknown as {
type: 'tool-cancelMeeting'
state: string
output?: { success: boolean; error?: string }
}
if (toolPart.state === 'input-available' || toolPart.state === 'input-streaming') {
return (
<p key={i} className="text-xs opacity-70 italic">
Removing meeting
</p>
)
}
if (toolPart.state === 'output-available' && toolPart.output) {
if (toolPart.output.success) return null
return (
<div key={i} className="text-xs mt-1 p-2 bg-background/20 rounded">
<span> {toolPart.output.error}</span>
</div>
)
}
}
if (part.type.startsWith('tool-')) {
const toolPart = part as unknown as {
type: string
state: string
output?: { success?: boolean; results?: unknown[]; matches?: unknown[]; availableSlots?: unknown[]; project?: unknown; error?: string }
}
if (toolPart.state === 'input-available' || toolPart.state === 'input-streaming') {
return (
<p key={i} className="text-xs opacity-70 italic">
{toolLabel(toolPart.type)}...
</p>
)
}
if (toolPart.state === 'output-available') {
const count = toolPart.output?.results?.length
?? toolPart.output?.matches?.length
?? toolPart.output?.availableSlots?.length
return (
<p key={i} className="text-xs opacity-70 italic">
{toolPart.output?.success === false
? (toolPart.output.error ?? `${toolLabel(toolPart.type)} failed`)
: count != null
? `${toolLabel(toolPart.type)} complete (${count})`
: `${toolLabel(toolPart.type)} complete`}
</p>
)
}
}
return null
})}
</div> </div>
</div> </div>
) )

View File

@@ -1,5 +1,5 @@
'use client' 'use client'
import { useState, useEffect } from 'react' import { useState, useEffect, useRef } from 'react'
import { useChat } from '@ai-sdk/react' import { useChat } from '@ai-sdk/react'
import { DefaultChatTransport, type UIMessage } from 'ai' import { DefaultChatTransport, type UIMessage } from 'ai'
import { Button } from '~/components/ui/button' import { Button } from '~/components/ui/button'
@@ -54,7 +54,7 @@ function addInitMessage(messageArray: UIMessage[]) {
role: 'assistant', role: 'assistant',
parts: [{ parts: [{
type: 'text', type: 'text',
text: "Hi im gregors ai assistant,you can ask me to provide general information or to schedule a meeting." text: "Hi, I'm Gregor's AI assistant. Ask me about his experience, projects, blog posts, or availability for a meeting."
}], }],
}) })
} }
@@ -83,14 +83,21 @@ function AuthenticatedChatInterface({ dbMessages, sessionId }: ChatInterfaceProp
sendMessage({ text }) sendMessage({ text })
} }
const gsapContext = useGsapContext() const gsapContext = useGsapContext()
const didInitialScroll = useRef(false)
useEffect(() => { useEffect(() => {
let scroller = gsapContext?.getScroller() const scroller = gsapContext?.getScroller()
if (scroller instanceof Window) { if (!scroller || scroller instanceof Window) {
return; return
} }
console.log(scroller?.scrollHeight) // Jump instantly on first open so the chat starts pinned to the bottom;
scroller?.scrollTo({ behavior: 'smooth', top: scroller.scrollHeight }) // animate subsequent updates. Defer a frame so the messages have laid out
}, [messages]) // (and any streaming content has grown) before we measure scrollHeight.
const behavior: ScrollBehavior = didInitialScroll.current ? 'smooth' : 'auto'
didInitialScroll.current = true
requestAnimationFrame(() => {
scroller.scrollTo({ behavior, top: scroller.scrollHeight })
})
}, [messages, status])
return ( return (
<div className="flex flex-col h-full"> <div className="flex flex-col h-full">
{messages && {messages &&

View File

@@ -14,7 +14,7 @@ export const UserMessage = (props:{message: UIMessage}) => {
> >
<div <div
className= className=
'max-w-[80%] px-4 py-2 text-sm space-y-2 bg-primary' 'max-w-[80%] min-w-0 px-4 py-2 text-sm space-y-2 bg-primary break-words whitespace-pre-wrap [overflow-wrap:anywhere]'
> >
{message} {message}
</div> </div>

View File

@@ -5,7 +5,7 @@ import { cn } from "~/lib/utils"
import { AnimatedCard, CardContent, CardHeader, CardTitle } from "~/components/ui/card" import { AnimatedCard, CardContent, CardHeader, CardTitle } from "~/components/ui/card"
import type { ArrayElement } from "type-fest" import type { ArrayElement } from "type-fest"
import AnimateTextIn from "~/app/_components/Animated/AnimateIn" import AnimateTextIn from "~/app/_components/Animated/AnimateIn"
import AnimatePopUp from "~/app/_components/Animated/AnimatePopUp" import AnimatedDiv from "~/app/_components/Animated/AnimatedDiv"
import { ScrollArea } from "~/components/ui/scroll-area"; import { ScrollArea } from "~/components/ui/scroll-area";
export type CvCategoryData = ArrayElement<RouterOutputs['categoryv2']['listAllWithEntries']> export type CvCategoryData = ArrayElement<RouterOutputs['categoryv2']['listAllWithEntries']>
@@ -18,22 +18,46 @@ type CvCategoryProps = {
} }
export default function CvCategory({ category, layout, position = 0, descriptions }: CvCategoryProps) { export default function CvCategory({ category, layout, position = 0, descriptions }: CvCategoryProps) {
const entries = category.cvEntry const entries = category.cvEntry
const isRowLayout = layout === "row"
const entryStart = position + 1
const entryStagger = 1.1
const entryItems = entries.map((entry, i) => {
const entryPosition = entryStart + i * entryStagger
return (
<AnimatedDiv
className={cn(isRowLayout ? "min-w-[min(100%,18rem)] flex-1" : "w-full", "opacity-0 -translate-x-6")}
position={entryPosition}
debugId={`cv-entry-wrapper:${category.name}:${entry.title}:${entryPosition}`}
opacity={1}
x={0}
duration={0.4}
ease="power2.out"
key={entry.id}
>
<CvEntry position={entryPosition} entry={entry} description={descriptions[entry.id]} row={isRowLayout} className="w-full" />
</AnimatedDiv>
)
})
return ( return (
<AnimatedCard position={position} className={cn(layout == "row" ? "w-full" : "", "h-screen")}> <AnimatedCard position={position} className={cn(isRowLayout ? "h-fit min-w-[min(100%,18rem)] flex-1" : "lg:h-full lg:min-h-0")}>
<CardHeader> <CardHeader>
<AnimateTextIn once position={position + 0.2} animation="slide" debugId={`cv-category-title:${category.name}:${position + 0.2}`}> <AnimateTextIn once position={position + 0.35} animation="slide" debugId={`cv-category-title:${category.name}:${position + 0.35}`}>
<CardTitle>{category.name}</CardTitle> <CardTitle>{category.name}</CardTitle>
</AnimateTextIn> </AnimateTextIn>
</CardHeader> </CardHeader>
{entries.length > 0 ? {entries.length > 0 ?
<CardContent className={cn(layout == "row" ? "flex flex-row flex-wrap justify-center lg:justify-between" : "flex flex-col", "gap-4", "overflow-scroll")}> <CardContent className={cn(isRowLayout ? "flex flex-row flex-wrap items-stretch justify-center lg:justify-between" : "flex flex-col flex-1 min-h-0", "gap-4")}>
<ScrollArea> {isRowLayout ? (
{entries.map((entry, i) => ( entryItems
<AnimatePopUp position={position + 0.4 + i * 0.2} debugId={`cv-entry-wrapper:${category.name}:${entry.title}:${position + 0.4 + i * 0.2}`} key={entry.id}> ) : (
<CvEntry position={position + 0.4 + i * 0.2} entry={entry} description={descriptions[entry.id]} className={layout == "row" ? "w-full lg:w-fit" : undefined} /> <ScrollArea className="min-h-0 w-full flex-1">
</AnimatePopUp> <div className="flex flex-col gap-4 pr-2">
))} {entryItems}
</ScrollArea> </div>
</ScrollArea>
)}
</CardContent> </CardContent>
: :
<></> <></>

View File

@@ -9,30 +9,34 @@ import type { CvCategoryData } from "./CvCategory"
export type CvEntryData = ArrayElement<CvCategoryData['cvEntry']> export type CvEntryData = ArrayElement<CvCategoryData['cvEntry']>
export default function CvEntry({ entry, description, className, position = 0 }: { export default function CvEntry({ entry, description, className, position = 0, row = false }: {
entry: CvEntryData, entry: CvEntryData,
description?: ReactNode, description?: ReactNode,
className?: string, className?: string,
position?: number position?: number,
row?: boolean,
}) { }) {
const from = format(new Date(entry.fromTime), 'MMMM yyyy')
const to = format(new Date(entry.toTime), 'MMMM yyyy')
return ( return (
<Card className={className ? cn("w-fit", className) : "w-fit"}> <Card className={cn("w-full ring-0", row && "h-full", className)}>
{entry.title ? {entry.title ?
<CardHeader> <CardHeader>
<AnimateTextIn position={position} animation="slide" debugId={`cv-entry-title:${entry.title}:${position}`}> <AnimateTextIn once position={position + 0.25} animation="slide" debugId={`cv-entry-title:${entry.title}:${position + 0.25}`}>
<CardTitle> {entry.title} </CardTitle> <CardTitle> {entry.title} </CardTitle>
</AnimateTextIn> </AnimateTextIn>
</CardHeader> : </CardHeader> :
<></> <></>
} }
{entry.description ? {entry.description ?
<CardContent className="text-sm lg:text-base"> <CardContent className={cn("text-sm lg:text-base", row && "flex flex-1 items-center justify-center")}>
{/* Fade the description in place instead of collapsing its height: {/* Fade the description in place instead of collapsing its height:
the outer entry pop-up (CvCategory) measures height:auto when it the outer entry pop-up (CvCategory) measures height:auto when it
plays, so the description must stay laid out at full height or the plays, so the description must stay laid out at full height or the
entry reveals too short. */} entry reveals too short. */}
<AnimatedDiv once position={position + 0.2} className="opacity-0" opacity={1} duration={0.5} debugId={`cv-entry-description:${entry.title}:${position + 0.2}`}> <AnimatedDiv once position={position + 0.75} className={cn("opacity-0", row && "w-full")} opacity={1} duration={0.5} debugId={`cv-entry-description:${entry.title}:${position + 0.75}`}>
<article className="prose prose-zinc dark:prose-invert max-w-none"> <article className={cn("prose prose-zinc dark:prose-invert max-w-none", row && "text-center")}>
{description ?? entry.description} {description ?? entry.description}
</article> </article>
</AnimatedDiv> </AnimatedDiv>
@@ -40,9 +44,9 @@ export default function CvEntry({ entry, description, className, position = 0 }:
<></> <></>
} }
{!entry.hideDates ? {!entry.hideDates ?
<CardFooter className="text-sm"> <CardFooter className="border-t-0 text-sm">
<AnimateTextIn position={position + 0.4} debugId={`cv-entry-dates:${entry.title}:${position + 0.4}`}> <AnimateTextIn once position={position + 1.15} debugId={`cv-entry-dates:${entry.title}:${position + 1.15}`}>
{`von ${format(new Date(entry.fromTime), 'M. yyyy')} bis zum ${format(new Date(entry.toTime), 'M. yyyy')}`} {`${from} to ${to}`}
</AnimateTextIn> </AnimateTextIn>
</CardFooter> : </CardFooter> :
<></> <></>

View File

@@ -1,10 +1,12 @@
'use client' 'use client'
import type { ReactNode } from "react"; import type { ReactNode } from "react";
import { Sidebar, SidebarContent, SidebarProvider } from "~/components/ui/sidebar"; import { Sidebar, SidebarContent, SidebarProvider, useSidebar } from "~/components/ui/sidebar";
import { ScrollArea } from "~/components/ui/scroll-area";
import type { RouterOutputs } from "~/server/routers/_app" import type { RouterOutputs } from "~/server/routers/_app"
import SidebarTriggerDisappearsOnMobile from "./SidebarTriggerDisappearsOnMobile"; import SidebarTriggerDisappearsOnMobile from "./SidebarTriggerDisappearsOnMobile";
import CvCategory from "./CvCategory"; import CvCategory from "./CvCategory";
import { useTimeLine } from "~/app/_providers/GsapProvicer"; import { useTimeLine } from "~/app/_providers/GsapProvicer";
import { cn } from "~/lib/utils";
export default function CvPage(props: { export default function CvPage(props: {
cv: RouterOutputs['categoryv2']['listAllWithEntries'], cv: RouterOutputs['categoryv2']['listAllWithEntries'],
descriptions: Record<string, ReactNode>, descriptions: Record<string, ReactNode>,
@@ -17,43 +19,104 @@ export default function CvPage(props: {
const headerCategories = byPosition("header") const headerCategories = byPosition("header")
const col1Categories = byPosition("col1") const col1Categories = byPosition("col1")
const col2Categories = byPosition("col2") const col2Categories = byPosition("col2")
const hasSidebar = sidebarCategories.length > 0
const hasTwoMainColumns = col1Categories.length > 0 && col2Categories.length > 0
const mainColumnWidthClass = hasTwoMainColumns ? "lg:w-1/2 lg:h-full" : ""
const sequencePositions = <T extends { cvEntry: unknown[] }>(categories: T[], start = 0) => {
let cursor = start
const positions = categories.map((category) => {
const position = cursor
cursor += 1.8 + category.cvEntry.length * 1.2
return position
})
return { end: cursor, positions }
}
const headerSequence = sequencePositions(headerCategories)
const sidebarSequence = sequencePositions(sidebarCategories)
const contentStart = Math.max(headerSequence.end, sidebarSequence.end)
const col1Sequence = sequencePositions(col1Categories, contentStart)
const col2Sequence = sequencePositions(col2Categories, contentStart)
return ( return (
<> <>
<SidebarProvider> <SidebarProvider className="h-full min-h-0 overflow-hidden">
{sidebarCategories.length > 0 && {sidebarCategories.length > 0 &&
<> <>
<SidebarTriggerDisappearsOnMobile /> <SidebarTriggerDisappearsOnMobile />
<Sidebar> <Sidebar>
<SidebarContent className="p-2 lg:pt-[3.2rem]"> <SidebarContent className="p-2 lg:pt-[3.2rem]">
{sidebarCategories.map((cat, i) => ( {sidebarCategories.map((cat, i) => (
<CvCategory layout="col" position={i} category={cat} descriptions={descriptions} key={cat.id} /> <CvCategory layout="col" position={sidebarSequence.positions[i] ?? 0} category={cat} descriptions={descriptions} key={cat.id} />
))} ))}
</SidebarContent> </SidebarContent>
</Sidebar> </Sidebar>
</> </>
} }
<div className="h-full w-full flex flex-wrap flex-row p-4 pt-8 "> <MainContent
<div id="mainwrap" className="flex w-full flex-col gap-4 lg:px-[15vw]"> hasSidebar={hasSidebar}
<div id="header" className="flex w-full h-fit flex-row gap-4 flex-wrap"> mainColumnWidthClass={mainColumnWidthClass}
{headerCategories.map((cat, i) => ( headerCategories={headerCategories}
<CvCategory layout="row" position={i} category={cat} descriptions={descriptions} key={cat.id} /> headerSequence={headerSequence}
))} col1Categories={col1Categories}
</div> col1Sequence={col1Sequence}
<div id="colwrapper" className="flex flex-col lg:flex-row w-full h-3/4 gap-4"> col2Categories={col2Categories}
<div id="col1" className={`flex flex-col w-full ${col1Categories.length > 0 ? "lg:w-1/2" : ""} h-full gap-4`}> col2Sequence={col2Sequence}
{col1Categories.map((cat, i) => ( descriptions={descriptions}
<CvCategory layout="col" position={i} category={cat} descriptions={descriptions} key={cat.id} /> />
))}
</div>
<div id="col2" className={`flex flex-col w-full ${col2Categories.length > 0 ? "lg:w-1/2" : ""} h-full gap-4`}>
{col2Categories.map((cat, i) => (
<CvCategory layout="col" position={i} category={cat} descriptions={descriptions} key={cat.id} />
))}
</div>
</div>
</div>
</div>
</SidebarProvider> </SidebarProvider>
</> </>
) )
} }
type Sequence = { positions: number[] }
type Category = NonNullable<RouterOutputs['categoryv2']['listAllWithEntries']>[number]
function MainContent(props: {
hasSidebar: boolean,
mainColumnWidthClass: string,
headerCategories: Category[],
headerSequence: Sequence,
col1Categories: Category[],
col1Sequence: Sequence,
col2Categories: Category[],
col2Sequence: Sequence,
descriptions: Record<string, ReactNode>,
}) {
const {
hasSidebar, mainColumnWidthClass, headerCategories, headerSequence,
col1Categories, col1Sequence, col2Categories, col2Sequence, descriptions,
} = props
const { open } = useSidebar()
return (
<ScrollArea className="h-full min-h-0 w-full flex-1">
<div
id="mainwrap"
className={cn(
"flex w-full flex-col gap-4 p-4 pt-8",
!hasSidebar && "lg:px-[15vw]",
hasSidebar && !open && "lg:px-[8vw]",
)}
>
<div id="header" className="flex w-full flex-row flex-wrap items-stretch gap-4">
{headerCategories.map((cat, i) => (
<CvCategory layout="row" position={headerSequence.positions[i] ?? 0} category={cat} descriptions={descriptions} key={cat.id} />
))}
</div>
<div id="colwrapper" className="flex w-full flex-col gap-4 lg:h-[80vh] lg:flex-row">
<div id="col1" className={cn("flex min-h-0 w-full flex-col gap-4", mainColumnWidthClass)}>
{col1Categories.map((cat, i) => (
<CvCategory layout="col" position={col1Sequence.positions[i] ?? 0} category={cat} descriptions={descriptions} key={cat.id} />
))}
</div>
<div id="col2" className={cn("flex min-h-0 w-full flex-col gap-4", mainColumnWidthClass)}>
{col2Categories.map((cat, i) => (
<CvCategory layout="col" position={col2Sequence.positions[i] ?? 0} category={cat} descriptions={descriptions} key={cat.id} />
))}
</div>
</div>
</div>
</ScrollArea>
)
}

View File

@@ -24,7 +24,7 @@ export default async function CvPage() {
components={mdxComponents} components={mdxComponents}
options={{ options={{
mdxOptions: { mdxOptions: {
format: "md", format: "mdx",
remarkPlugins: [remarkGfm], remarkPlugins: [remarkGfm],
rehypePlugins: [rehypeHighlight], rehypePlugins: [rehypeHighlight],
}, },

View File

@@ -1,9 +1,9 @@
import HomeHero from "./_components/Home/HomeHero";
export default function HomePage() { export default function HomePage() {
return ( return (
<main> <main className="h-full w-full">
<div> <HomeHero />
hello world
</div>
</main> </main>
); );
} }

View File

@@ -23,7 +23,7 @@ export default async function ProjectsPage() {
components={mdxComponents} components={mdxComponents}
options={{ options={{
mdxOptions: { mdxOptions: {
format: "md", format: "mdx",
remarkPlugins: [remarkGfm], remarkPlugins: [remarkGfm],
rehypePlugins: [rehypeHighlight], rehypePlugins: [rehypeHighlight],
}, },

View File

@@ -91,6 +91,51 @@ function Figure({
); );
} }
// A bare markdown image (![alt](src)) fills the prose width. Pass a numeric
// markdown title — ![alt](src "160") — to render it as a fixed-size, circular
// avatar instead (used by the CV header). Untitled images keep the old look.
function Img({ src, alt, title }: { src: string; alt?: string; title?: string }) {
const size = title && /^\d+$/.test(title) ? Number(title) : undefined;
if (size) {
return (
<img
src={src}
alt={alt ?? ""}
width={size}
height={size}
style={{ width: size, height: size }}
className="mx-auto !my-0 shrink-0 rounded-full object-cover ring-2 ring-foreground/10"
/>
);
}
return <img src={src} alt={alt ?? ""} className="w-full rounded-md border object-cover" />;
}
// Composable, border-less flex layout primitives for MDX.
//
// <Layout> — section wrapper, stacks its children with spacing
// <Row> — lays children side by side, wraps when too narrow
// <Column> — stacks its own children vertically
//
// Row/Column nest freely. A Row sizes its direct children into equal,
// flex-growing tracks with a min width, so two lists/Columns sit side by
// side and drop to stacked once they can't keep ~16rem each.
function Layout({ children }: { children: ReactNode }) {
return <div className="my-6 flex flex-col gap-6 [&>*]:my-0">{children}</div>;
}
function Row({ children }: { children: ReactNode }) {
return (
<div className="flex flex-wrap gap-x-8 gap-y-4 [&>*]:mt-0 [&>*]:min-w-[16rem] [&>*]:flex-1">
{children}
</div>
);
}
function Column({ children }: { children: ReactNode }) {
return <div className="flex min-w-0 flex-col gap-2 [&>*]:my-0">{children}</div>;
}
function PullQuote({ children }: { children: ReactNode }) { function PullQuote({ children }: { children: ReactNode }) {
return ( return (
<blockquote className="border-primary my-8 border-l-4 pl-5 text-xl leading-8 font-medium"> <blockquote className="border-primary my-8 border-l-4 pl-5 text-xl leading-8 font-medium">
@@ -100,15 +145,30 @@ function PullQuote({ children }: { children: ReactNode }) {
} }
function ExternalLink(props: ComponentPropsWithoutRef<"a">) { function ExternalLink(props: ComponentPropsWithoutRef<"a">) {
const href = props.href ?? ""; const { className, ...rest } = props;
const isExternal = /^https?:\/\//.test(href);
if (!isExternal) return <a {...props} />; return (
<a
return <a {...props} target="_blank" rel="noreferrer" />; {...rest}
target="_blank"
rel="noreferrer"
className={cn(
"text-sky-600 underline underline-offset-4 hover:text-sky-700 dark:text-sky-400 dark:hover:text-sky-300",
className,
)}
/>
);
} }
const blockComponents = new Set<unknown>([Callout, Figure, PullQuote, TagList]); const blockComponents = new Set<unknown>([
Callout,
Column,
Figure,
Layout,
PullQuote,
Row,
TagList,
]);
function Paragraph({ children }: { children: ReactNode }) { function Paragraph({ children }: { children: ReactNode }) {
const containsBlockComponent = Children.toArray(children).some( const containsBlockComponent = Children.toArray(children).some(
@@ -126,8 +186,12 @@ export const mdxComponents = {
Badge, Badge,
ButtonLink, ButtonLink,
Callout, Callout,
Column,
Figure, Figure,
img: Img,
Layout,
Lead, Lead,
PullQuote, PullQuote,
Row,
TagList, TagList,
}; };

View File

@@ -31,6 +31,10 @@ export const env = createEnv({
CLERK_SECRET_KEY: z.string(), CLERK_SECRET_KEY: z.string(),
ADMIN_USER_CLERK_ID: z.string(), ADMIN_USER_CLERK_ID: z.string(),
OPENAI_API_KEY: z.string(), OPENAI_API_KEY: z.string(),
GOOGLE_SERVICE_ACCOUNT_EMAIL: z.string().email(),
GOOGLE_SERVICE_ACCOUNT_PRIVATE_KEY: z.string(),
GOOGLE_CALENDAR_ID: z.string(),
GREGOR_MEETING_EMAIL: z.string().email(),
NODE_ENV: z NODE_ENV: z
.enum(["development", "test", "production"]) .enum(["development", "test", "production"])
.default("development"), .default("development"),
@@ -72,6 +76,10 @@ export const env = createEnv({
POSTGRES_PRISMA_URL: process.env.POSTGRES_PRISMA_URL, POSTGRES_PRISMA_URL: process.env.POSTGRES_PRISMA_URL,
ADMIN_USER_CLERK_ID: process.env.ADMIN_USER_CLERK_ID, ADMIN_USER_CLERK_ID: process.env.ADMIN_USER_CLERK_ID,
OPENAI_API_KEY: process.env.OPENAI_API_KEY, OPENAI_API_KEY: process.env.OPENAI_API_KEY,
GOOGLE_SERVICE_ACCOUNT_EMAIL: process.env.GOOGLE_SERVICE_ACCOUNT_EMAIL,
GOOGLE_SERVICE_ACCOUNT_PRIVATE_KEY: process.env.GOOGLE_SERVICE_ACCOUNT_PRIVATE_KEY,
GOOGLE_CALENDAR_ID: process.env.GOOGLE_CALENDAR_ID,
GREGOR_MEETING_EMAIL: process.env.GREGOR_MEETING_EMAIL,
NEXT_PUBLIC_ADMIN_USER_CLERK_ID: process.env.NEXT_PUBLIC_ADMIN_USER_CLERK_ID, NEXT_PUBLIC_ADMIN_USER_CLERK_ID: process.env.NEXT_PUBLIC_ADMIN_USER_CLERK_ID,
CLERK_SECRET_KEY: process.env.CLERK_SECRET_KEY, CLERK_SECRET_KEY: process.env.CLERK_SECRET_KEY,
NEXT_PUBLIC_CLERK_PUBLISHABLE_KEY: process.env.NEXT_PUBLIC_CLERK_PUBLISHABLE_KEY, NEXT_PUBLIC_CLERK_PUBLISHABLE_KEY: process.env.NEXT_PUBLIC_CLERK_PUBLISHABLE_KEY,

586
src/server/ai/tools.ts Normal file
View File

@@ -0,0 +1,586 @@
import "server-only";
import { tool } from "ai";
import { desc } from "drizzle-orm";
import { z } from "zod";
import { cancelMeeting } from "~/app/actions/cancelMeeting";
import { scheduleMeeting } from "~/app/actions/scheduleMeeting";
import { db } from "~/server/db";
import { blogPost, music } from "~/server/dbschema/schema";
import { getGoogleCalendarClient, getGoogleCalendarId } from "~/server/googleCalendar";
const contentTypeSchema = z.enum(["cv", "project", "blog", "music"]);
type ContentType = z.infer<typeof contentTypeSchema>;
type SearchResult = {
type: ContentType;
title: string;
snippet: string;
url: string;
score: number;
metadata?: Record<string, unknown>;
};
type ProjectWithStack = Awaited<ReturnType<typeof loadProjects>>[number];
function stripMarkup(value: string | null | undefined) {
return (value ?? "")
.replace(/```[\s\S]*?```/g, " ")
.replace(/`([^`]+)`/g, "$1")
.replace(/<[^>]+>/g, " ")
.replace(/[#*_~[\]()>-]/g, " ")
.replace(/\s+/g, " ")
.trim();
}
function tokenize(value: string) {
return Array.from(new Set(value.toLowerCase().match(/[a-z0-9+#.-]+/g) ?? []));
}
function scoreText(query: string, title: string, body: string, extraTerms: string[] = []) {
const normalizedQuery = query.trim().toLowerCase();
const titleLower = title.toLowerCase();
const bodyLower = body.toLowerCase();
const tokens = tokenize(query);
let score = 0;
if (normalizedQuery && titleLower.includes(normalizedQuery)) score += 40;
if (normalizedQuery && bodyLower.includes(normalizedQuery)) score += 20;
for (const token of tokens) {
if (titleLower.includes(token)) score += 12;
if (bodyLower.includes(token)) score += 6;
if (extraTerms.some((term) => term.toLowerCase() === token)) score += 10;
}
return score;
}
const projectCatalogTerms = new Set(["project", "projects", "portfolio", "work"]);
const genericQuestionTerms = new Set([
"a",
"about",
"all",
"any",
"are",
"can",
"current",
"do",
"give",
"have",
"list",
"me",
"of",
"on",
"show",
"site",
"tell",
"the",
"there",
"these",
"this",
"what",
"which",
"you",
]);
function isProjectCatalogQuery(query: string) {
const tokens = tokenize(query);
if (!tokens.some((token) => projectCatalogTerms.has(token))) return false;
return tokens.every((token) => projectCatalogTerms.has(token) || genericQuestionTerms.has(token));
}
function snippet(value: string, query: string, maxLength = 240) {
const text = stripMarkup(value);
if (text.length <= maxLength) return text;
const tokens = tokenize(query);
const lower = text.toLowerCase();
const firstMatch = tokens
.map((token) => lower.indexOf(token))
.filter((index) => index >= 0)
.sort((a, b) => a - b)[0] ?? 0;
const start = Math.max(0, firstMatch - 60);
const excerpt = text.slice(start, start + maxLength).trim();
return `${start > 0 ? "..." : ""}${excerpt}${start + maxLength < text.length ? "..." : ""}`;
}
function uniqueByUrl(results: SearchResult[]) {
const seen = new Set<string>();
return results.filter((result) => {
const key = `${result.type}:${result.url}:${result.title}`;
if (seen.has(key)) return false;
seen.add(key);
return true;
});
}
async function loadCvEntries() {
const categories = await db.query.cvCategory.findMany({
orderBy: (fields, { asc }) => [asc(fields.layoutPosition), asc(fields.name)],
with: {
cvEntry: {
orderBy: (fields, { desc }) => [desc(fields.toTime), desc(fields.fromTime)],
},
},
});
return categories.flatMap((category) =>
category.cvEntry.map((entry) => ({
...entry,
categoryName: category.name ?? "CV",
})),
);
}
async function loadProjects() {
return db.query.project.findMany({
orderBy: (fields, { asc }) => [asc(fields.orderPos), asc(fields.title), asc(fields.id)],
with: {
techStack: true,
},
});
}
async function buildSearchResults(query: string, types: ContentType[]) {
const selected = new Set(types);
const results: SearchResult[] = [];
if (selected.has("cv")) {
const entries = await loadCvEntries();
for (const entry of entries) {
const body = stripMarkup(`${entry.categoryName} ${entry.description ?? ""}`);
const score = scoreText(query, entry.title, body);
if (score > 0 || !query.trim()) {
results.push({
type: "cv",
title: entry.title,
snippet: snippet(body, query),
url: "/cv",
score,
metadata: {
category: entry.categoryName,
fromTime: entry.fromTime,
toTime: entry.toTime,
},
});
}
}
}
if (selected.has("project")) {
const projects = await loadProjects();
const catalogQuery = isProjectCatalogQuery(query);
for (const [index, item] of projects.entries()) {
const stackItems = item.techStack?.stackItems ?? [];
const body = stripMarkup(`${item.description ?? ""} ${stackItems.join(" ")}`);
const score = catalogQuery ? 1000 - index : scoreText(query, item.title, body, stackItems);
if (score > 0 || !query.trim() || catalogQuery) {
results.push({
type: "project",
title: item.title,
snippet: snippet(body || item.title, query),
url: `/projects#${item.id}`,
score,
metadata: {
id: item.id,
stackItems,
sourceType: item.sourceType,
releaseStatus: item.releaseStatus,
sourceLink: item.sourceLink,
releaseLink: item.releaseLink,
},
});
}
}
}
if (selected.has("blog")) {
const posts = await db
.select({
slug: blogPost.slug,
title: blogPost.title,
date: blogPost.date,
description: blogPost.description,
tags: blogPost.tags,
})
.from(blogPost)
.orderBy(desc(blogPost.date), desc(blogPost.createdAt));
for (const post of posts) {
const tags = post.tags ?? [];
const body = stripMarkup(`${post.description ?? ""} ${tags.join(" ")}`);
const score = scoreText(query, post.title, body, tags);
if (score > 0 || !query.trim()) {
results.push({
type: "blog",
title: post.title,
snippet: snippet(body || post.title, query),
url: `/blog/${post.slug}`,
score,
metadata: {
slug: post.slug,
date: post.date,
tags,
},
});
}
}
}
if (selected.has("music")) {
const tracks = await db.select().from(music).orderBy(desc(music.createdAt));
for (const track of tracks) {
const body = stripMarkup(track.description);
const score = scoreText(query, track.title, body);
if (score > 0 || !query.trim()) {
results.push({
type: "music",
title: track.title,
snippet: snippet(body || track.fileName, query),
url: "/music",
score,
metadata: {
id: track.id,
fileName: track.fileName,
hasStream: Boolean(track.streamUrl),
},
});
}
}
}
return uniqueByUrl(results).sort((a, b) => b.score - a.score || a.title.localeCompare(b.title));
}
function projectMatches(projectItem: ProjectWithStack, idOrTitle: string) {
const normalized = idOrTitle.trim().toLowerCase();
const title = projectItem.title.toLowerCase();
return projectItem.id === idOrTitle || title === normalized || title.includes(normalized);
}
function matchedTerms(text: string, terms: string[]) {
const lower = text.toLowerCase();
return terms.filter((term) => lower.includes(term.toLowerCase()));
}
function parseDate(value: string | undefined, fallback: Date) {
if (!value?.trim()) return fallback;
const date = new Date(value);
return Number.isNaN(date.getTime()) ? fallback : date;
}
function safeTimeZone(value: string | undefined) {
const timeZone = value?.trim() || "Europe/Berlin";
try {
new Intl.DateTimeFormat("en-US", { timeZone }).format(new Date());
return timeZone;
} catch {
return "Europe/Berlin";
}
}
function isLikelyEmail(value: string) {
return /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(value);
}
function overlaps(
start: Date,
end: Date,
busy: Array<{ start: Date; end: Date }>,
) {
return busy.some((item) => start < item.end && end > item.start);
}
function getZonedParts(date: Date, timeZone: string) {
const formatter = new Intl.DateTimeFormat("en-US", {
timeZone,
weekday: "short",
hourCycle: "h23",
hour: "2-digit",
minute: "2-digit",
});
const parts = Object.fromEntries(formatter.formatToParts(date).map((part) => [part.type, part.value]));
return {
weekday: parts.weekday ?? "",
hour: Number(parts.hour ?? 0),
minute: Number(parts.minute ?? 0),
};
}
function isInsideWorkingHours(start: Date, end: Date, timeZone: string, workdayStartHour: number, workdayEndHour: number) {
const startParts = getZonedParts(start, timeZone);
const endParts = getZonedParts(end, timeZone);
const weekend = startParts.weekday === "Sat" || startParts.weekday === "Sun";
const startMinutes = startParts.hour * 60 + startParts.minute;
const endMinutes = endParts.hour * 60 + endParts.minute;
return !weekend && startMinutes >= workdayStartHour * 60 && endMinutes <= workdayEndHour * 60;
}
function availabilitySlots({
rangeStart,
rangeEnd,
busy,
durationMinutes,
timeZone,
workdayStartHour,
workdayEndHour,
}: {
rangeStart: Date;
rangeEnd: Date;
busy: Array<{ start: Date; end: Date }>;
durationMinutes: number;
timeZone: string;
workdayStartHour: number;
workdayEndHour: number;
}) {
const slots: Array<{ start: string; end: string }> = [];
const stepMinutes = 30;
const durationMs = durationMinutes * 60 * 1000;
const cursor = new Date(Math.ceil(rangeStart.getTime() / (stepMinutes * 60 * 1000)) * stepMinutes * 60 * 1000);
while (cursor.getTime() + durationMs <= rangeEnd.getTime() && slots.length < 10) {
const end = new Date(cursor.getTime() + durationMs);
if (
isInsideWorkingHours(cursor, end, timeZone, workdayStartHour, workdayEndHour)
&& !overlaps(cursor, end, busy)
) {
slots.push({ start: cursor.toISOString(), end: end.toISOString() });
}
cursor.setMinutes(cursor.getMinutes() + stepMinutes);
}
return slots;
}
function logAvailability(requestId: string, message: string, data?: Record<string, unknown>) {
console.log(`[ai:getAvailability:${requestId}] ${message}`, data ?? "");
}
export function createChatTools() {
return {
scheduleMeeting: tool({
description: "Schedule a meeting with Gregor Lohaus and add it to his Google Calendar. Use getAvailability first when the user asks for a meeting at a flexible or uncertain time.",
inputSchema: z.object({
title: z.string().describe("Meeting title, make something up if not provided"),
description: z.string().describe("Meeting description / agenda, make something up if not provided"),
dateTime: z
.string()
.describe("ISO 8601 datetime for the meeting start, e.g. 2026-06-18T10:00:00+02:00"),
durationMinutes: z
.number()
.int()
.min(15)
.max(120)
.describe("Duration of the meeting in minutes, if none provided ask if 20 minutes is ok"),
attendeeEmail: z
.string()
.optional()
.describe("Optional email of the visitor to invite, if provided"),
attendeeName: z.string().optional().describe("Name of the visitor"),
}),
execute: async (input) => {
if (input.attendeeEmail && !isLikelyEmail(input.attendeeEmail)) {
return {
success: false,
error: "The attendee email does not look valid. Ask the visitor to provide a valid email address before scheduling.",
};
}
return scheduleMeeting({ ...input });
},
}),
cancelMeeting: tool({
description: "Remove a previously scheduled meeting from Gregor's dedicated availability calendar. Use only when you have the exact eventId returned by scheduleMeeting.",
inputSchema: z.object({
eventId: z.string().min(1).describe("Google Calendar event id returned by a previous scheduleMeeting tool call."),
}),
execute: async ({ eventId }) => cancelMeeting({ eventId }),
}),
searchSiteContent: tool({
description: "Search Gregor Lohaus's own website content across CV entries, projects, blog posts, and music. Use this for questions about Gregor's work, skills, writing, projects, or site content. For broad questions about Gregor's projects, use types ['project'] so the tool returns the project catalog.",
inputSchema: z.object({
query: z.string().describe("Search query, skill, technology, topic, or phrase."),
types: z.array(contentTypeSchema).optional().describe("Optional content types to search. Omit to search all site content."),
limit: z.number().int().min(1).max(12).optional().describe("Maximum number of results to return."),
}),
execute: async ({ query, types, limit }) => {
const results = await buildSearchResults(query, types?.length ? types : ["cv", "project", "blog", "music"]);
return {
success: true,
query,
results: results.slice(0, limit ?? 8).map(({ score, ...result }) => result),
};
},
}),
getRelevantExperience: tool({
description: "Find Gregor's most relevant CV entries and projects for a role, skill set, seniority, or domain. Use this for recruiter-style qualification questions.",
inputSchema: z.object({
role: z.string().optional().describe("Role or job title, such as full-stack engineer or React Native developer."),
skills: z.array(z.string()).optional().describe("Technologies, tools, or skills to match."),
domain: z.string().optional().describe("Product or business domain to match, if any."),
seniority: z.string().optional().describe("Seniority or responsibility level to match, if any."),
limit: z.number().int().min(1).max(10).optional().describe("Maximum matching entries to return."),
}),
execute: async ({ role, skills, domain, seniority, limit }) => {
const terms = [role, domain, seniority, ...(skills ?? [])].filter((value): value is string => Boolean(value?.trim()));
const query = terms.join(" ");
const results = await buildSearchResults(query, ["cv", "project"]);
const selected = results.slice(0, limit ?? 6);
return {
success: true,
query,
matches: selected.map(({ score, ...result }) => ({
...result,
matchedTerms: matchedTerms(`${result.title} ${result.snippet} ${(result.metadata?.stackItems as string[] | undefined)?.join(" ") ?? ""}`, terms),
whyRelevant: result.type === "project"
? "Project match based on title, description, and technology stack."
: "CV match based on experience title, category, and description.",
})),
};
},
}),
getProjectDetails: tool({
description: "Get detailed information for one of Gregor's projects, including description, stack, source link, release link, and project page URL.",
inputSchema: z.object({
idOrTitle: z.string().min(1).describe("Project id, exact title, or partial project title."),
}),
execute: async ({ idOrTitle }) => {
const projects = await loadProjects();
const found = projects.find((item) => projectMatches(item, idOrTitle));
if (!found) {
return {
success: false,
error: `No project matched "${idOrTitle}".`,
suggestions: projects.slice(0, 5).map((item) => ({
id: item.id,
title: item.title,
url: `/projects#${item.id}`,
})),
};
}
return {
success: true,
project: {
id: found.id,
title: found.title,
description: stripMarkup(found.description),
sourceType: found.sourceType,
sourceLink: found.sourceLink,
releaseStatus: found.releaseStatus,
releaseLink: found.releaseLink,
stackItems: found.techStack?.stackItems ?? [],
url: `/projects#${found.id}`,
},
};
},
}),
getAvailability: tool({
description: "Check Gregor's Google Calendar availability and suggest open meeting slots. Use this directly for questions like 'when is the next open spot?' or 'what times are available?'. If no date range is provided, it checks from now. Use before scheduling when the requested time is flexible.",
inputSchema: z.object({
fromDateTime: z.string().optional().describe("ISO 8601 range start. Defaults to now."),
toDateTime: z.string().optional().describe("ISO 8601 range end. Defaults to 14 days after the range start."),
durationMinutes: z.number().int().min(15).max(120).optional().describe("Desired meeting duration. Defaults to 30 minutes."),
timeZone: z.string().optional().describe("IANA time zone for working-hours filtering. Defaults to Europe/Berlin."),
workdayStartHour: z.number().int().min(0).max(23).optional().describe("Earliest local start hour. Defaults to 9."),
workdayEndHour: z.number().int().min(1).max(24).optional().describe("Latest local end hour. Defaults to 17."),
}),
execute: async (input) => {
const requestId = crypto.randomUUID();
logAvailability(requestId, "start", {
input,
});
const durationMinutes = input.durationMinutes ?? 30;
const timeZone = safeTimeZone(input.timeZone);
const workdayStartHour = input.workdayStartHour ?? 9;
const workdayEndHour = Math.max(input.workdayEndHour ?? 17, workdayStartHour + 1);
const rangeStart = parseDate(input.fromDateTime, new Date());
const defaultEnd = new Date(rangeStart.getTime() + 14 * 24 * 60 * 60 * 1000);
const requestedEnd = parseDate(input.toDateTime, defaultEnd);
const maxEnd = new Date(rangeStart.getTime() + 31 * 24 * 60 * 60 * 1000);
const rangeEnd = requestedEnd <= rangeStart ? defaultEnd : requestedEnd > maxEnd ? maxEnd : requestedEnd;
logAvailability(requestId, "resolved range", {
durationMinutes,
timeZone,
workdayStartHour,
workdayEndHour,
rangeStart: rangeStart.toISOString(),
rangeEnd: rangeEnd.toISOString(),
});
const calendar = getGoogleCalendarClient();
const calendarId = getGoogleCalendarId();
logAvailability(requestId, "service account calendar ready", {
calendarId,
});
let busy: Array<{ start: Date; end: Date }>;
try {
logAvailability(requestId, "freebusy request");
const response = await calendar.freebusy.query({
requestBody: {
timeMin: rangeStart.toISOString(),
timeMax: rangeEnd.toISOString(),
timeZone,
items: [{ id: calendarId }],
},
});
busy = (response.data.calendars?.[calendarId]?.busy ?? [])
.map((item) => ({
start: parseDate(item.start ?? undefined, rangeStart),
end: parseDate(item.end ?? undefined, rangeStart),
}))
.filter((item) => item.end > item.start);
logAvailability(requestId, "freebusy response", {
busyCount: busy.length,
});
} catch (error) {
console.error(`[ai:getAvailability:${requestId}] freebusy failed`, error);
return {
success: false,
error: "Failed to read Gregor's Google Calendar availability.",
};
}
const availableSlots = availabilitySlots({
rangeStart,
rangeEnd,
busy,
durationMinutes,
timeZone,
workdayStartHour,
workdayEndHour,
});
logAvailability(requestId, "complete", {
busyCount: busy.length,
availableSlotCount: availableSlots.length,
firstAvailableSlot: availableSlots[0],
});
const nextAvailableSlot = availableSlots[0] ?? null;
return {
success: true,
range: {
start: rangeStart.toISOString(),
end: rangeEnd.toISOString(),
timeZone,
},
durationMinutes,
busy: busy.map((item) => ({
start: item.start.toISOString(),
end: item.end.toISOString(),
})),
nextAvailableSlot,
availableSlots,
};
},
}),
};
}

View File

@@ -0,0 +1,20 @@
import "server-only";
import { google } from "googleapis";
import { env } from "~/env";
const calendarScope = "https://www.googleapis.com/auth/calendar";
export function getGoogleCalendarClient() {
const auth = new google.auth.JWT({
email: env.GOOGLE_SERVICE_ACCOUNT_EMAIL,
key: env.GOOGLE_SERVICE_ACCOUNT_PRIVATE_KEY.replace(/\\n/g, "\n"),
scopes: [calendarScope],
});
return google.calendar({ version: "v3", auth });
}
export function getGoogleCalendarId() {
return env.GOOGLE_CALENDAR_ID;
}

View File

@@ -1,7 +1,7 @@
import { publicProcedure, router } from "~/server/trpc"; import { publicProcedure, router } from "~/server/trpc";
import { db } from "~/server/db"; import { db } from "~/server/db";
import { music } from "~/server/dbschema/schema"; import { music } from "~/server/dbschema/schema";
import { eq } from "drizzle-orm"; import { eq, sql } from "drizzle-orm";
import { isAdmin } from "~/app/actions"; import { isAdmin } from "~/app/actions";
import { TRPCError } from "@trpc/server"; import { TRPCError } from "@trpc/server";
import { z } from "zod"; import { z } from "zod";
@@ -9,7 +9,7 @@ import { createMusicInputSchema, updateMusicInputSchema, setStreamInputSchema }
import { utapi } from "../uploadthing"; import { utapi } from "../uploadthing";
export const musicRouter = router({ export const musicRouter = router({
list: publicProcedure.query(async () => { list: publicProcedure.query(async () => {
let res = await db.select().from(music).orderBy(music.createdAt); let res = await db.select().from(music).orderBy(sql`${music.createdAt} desc`);
console.log(res); console.log(res);
return res; return res;
}), }),