From 435e4e58e82495e7374f2e2d4e0fd396e8de15ae Mon Sep 17 00:00:00 2001 From: Rafael Bradley Date: Tue, 28 Jul 2026 03:53:38 -0300 Subject: [PATCH] Checkpoint --- bun.lock | 37 ++- content/guides/tune-a-protection-profile.mdx | 10 + content/guides/write-your-own-sdk.mdx | 4 + content/index.mdx | 3 +- content/meta.json | 17 ++ content/quick-start.mdx | 94 +++++-- content/sdks/c.mdx | 4 + content/sdks/javascript.mdx | 137 ++++++++++ content/sdks/python.mdx | 4 + content/sdks/react.mdx | 4 + content/sdks/rust-no-std.mdx | 4 + content/sdks/rust-std.mdx | 4 + next.config.mjs | 4 +- package.json | 10 +- source.config.ts | 65 ++++- src/app/(docs)/[[...slug]]/page.tsx | 2 +- src/app/global.css | 1 + src/components/codeblock.tsx | 265 +++++++++++++++++++ src/components/github-info.tsx | 118 +++++++++ src/components/mdx.tsx | 5 +- src/components/tabs.tsx | 200 ++++++++++++++ src/components/ui/button.tsx | 28 ++ src/components/ui/tabs.tsx | 179 +++++++++++++ src/lib/merge-refs.ts | 13 + src/lib/shared.ts | 2 +- src/lib/source.ts | 8 +- 26 files changed, 1174 insertions(+), 48 deletions(-) create mode 100644 content/guides/tune-a-protection-profile.mdx create mode 100644 content/guides/write-your-own-sdk.mdx create mode 100644 content/meta.json create mode 100644 content/sdks/c.mdx create mode 100644 content/sdks/javascript.mdx create mode 100644 content/sdks/python.mdx create mode 100644 content/sdks/react.mdx create mode 100644 content/sdks/rust-no-std.mdx create mode 100644 content/sdks/rust-std.mdx create mode 100644 src/components/codeblock.tsx create mode 100644 src/components/github-info.tsx create mode 100644 src/components/tabs.tsx create mode 100644 src/components/ui/button.tsx create mode 100644 src/components/ui/tabs.tsx create mode 100644 src/lib/merge-refs.ts diff --git a/bun.lock b/bun.lock index 850dfaa..2f3856b 100644 --- a/bun.lock +++ b/bun.lock @@ -5,20 +5,29 @@ "": { "name": ".", "dependencies": { + "@base-ui/react": "^1.6.0", + "@fuma-translate/react": "^1.0.2", "@fumadocs/base-ui": "^16.12.1", + "@icons-pack/react-simple-icons": "^13.13.0", "@orama/orama": "^3.1.18", + "@typescript/ata": "^0.9.8", + "@typescript/vfs": "^1.6.4", + "class-variance-authority": "^0.7.1", "cnfast": "^0.0.8", "fumadocs-core": "16.12.1", "fumadocs-mdx": "15.2.0", + "fumadocs-twoslash": "^3.3.0", "fumadocs-ui": "npm:@fumadocs/base-ui@16.12.1", "lucide-react": "^1.3.0", "next": "^16.2.12", "react": "^19.2.8", "react-dom": "^19.2.8", "takumi-js": "^2.4.0", + "twoslash": "^0.3.9", }, "devDependencies": { "@biomejs/biome": "^2.5.5", + "@duckity/js": "^0.1.4", "@tailwindcss/postcss": "^4.3.3", "@types/mdx": "^2.0.14", "@types/node": "^26.1.1", @@ -59,6 +68,10 @@ "@biomejs/cli-win32-x64": ["@biomejs/cli-win32-x64@2.5.5", "", { "os": "win32", "cpu": "x64" }, "sha512-bIBFo+n6MIxdNcVFy5CrurbKiZQiUciK3bt8+O9I4wjFZNTfXLpi+giq47522eXqW5NBc9ulx7dR1SlZKi2J5g=="], + "@duckity/js": ["@duckity/js@0.1.4", "", { "dependencies": { "@duckity/wasm": "1.1.16" }, "peerDependencies": { "typescript": "^5" } }, "sha512-zjzV2vpKdrFwt954x5thbocTAflrNUVj7yTqMUC8E0ICAbJ9YP30arZvGZqTE98+vemxOIb9hH4SWja7BXH37w=="], + + "@duckity/wasm": ["@duckity/wasm@1.1.16", "", {}, "sha512-P0TDKQ187S68D3ri2ZW9m60gCnUj+/iDLgwWwJLAbfiJhxEFfsCb7tJDgaQQR9mJ2PRETuBHc4TjbLphSuAjGg=="], + "@emnapi/runtime": ["@emnapi/runtime@1.11.3", "", { "dependencies": { "tslib": "^2.4.0" } }, "sha512-Xz4Tpyki7XyrpbUK1jR1AhdAdaXyhhY4lZ3neLodmhpuWfy2PAQN5B46sAiU4liOXGLkHypn/qU+jvfWSCYYLA=="], "@esbuild/aix-ppc64": ["@esbuild/aix-ppc64@0.28.1", "", { "os": "aix", "cpu": "ppc64" }, "sha512-Svl7tq8k/08+p6CXPpRjQ1fKX+1odH/BQbb48fV6fj3CWHhsoIOoY87w1oHXm0qEpkIK3ZfVgp0hed3XBXzXMQ=="], @@ -127,6 +140,8 @@ "@fumadocs/tailwind": ["@fumadocs/tailwind@0.1.1", "", { "peerDependencies": { "tailwindcss": "^4.0.0" }, "optionalPeers": ["tailwindcss"] }, "sha512-BnPe52UxSaG8yKlHMKBxXw8h6GpK5qO55ci6+Qd5JnquTvIw6SpfbC1P+qAi82PuPWv1KZAWY8bxRk4+x9ctXw=="], + "@icons-pack/react-simple-icons": ["@icons-pack/react-simple-icons@13.13.0", "", { "peerDependencies": { "react": "^16.13 || ^17 || ^18 || ^19" } }, "sha512-B5HhQMIpcSH4z8IZ8HFhD59CboHceKYMpPC9kAwGyKntvPdyJJv26DLu4Z1wAjcCLyrJhf11tMhiQGom9Rxb9g=="], + "@img/colour": ["@img/colour@1.1.0", "", {}, "sha512-Td76q7j57o/tLVdgS746cYARfSyxk8iEfRxewL9h4OMzYhbW4TAcppl0mT4eyqXddh6L/jwoM75mo7ixa/pCeQ=="], "@img/sharp-darwin-arm64": ["@img/sharp-darwin-arm64@0.34.5", "", { "optionalDependencies": { "@img/sharp-libvips-darwin-arm64": "1.2.4" }, "os": "darwin", "cpu": "arm64" }, "sha512-imtQ3WMJXbMY4fxb/Ndp6HBTNVtWCUI0WdobyheGf5+ad6xX8VIDO8u2xE4qc/fr08CKG/7dDseFtn6M6g/r3w=="], @@ -221,6 +236,8 @@ "@shikijs/themes": ["@shikijs/themes@4.3.1", "", { "dependencies": { "@shikijs/types": "4.3.1" } }, "sha512-dgpoJ4WqNi2yTmizQHBJ5zcX6j2lE6icN/0yt4l1kkf16jrY/pwPLoTb1ETsWMz0OBLf9ZNvwmxft+cH+N9qSA=="], + "@shikijs/twoslash": ["@shikijs/twoslash@4.3.1", "", { "dependencies": { "@shikijs/core": "4.3.1", "@shikijs/types": "4.3.1", "twoslash": "^0.3.9" }, "peerDependencies": { "typescript": ">=5.5.0" } }, "sha512-xK8inH/gK++1V4rTxrwCwjvaNwkkJ7oDjOIpdqONVxIpAFnVC3gzqjH5KiXGTelUcxpUJ3PtOKWct1YQ0kAloA=="], + "@shikijs/types": ["@shikijs/types@4.3.1", "", { "dependencies": { "@shikijs/vscode-textmate": "^10.0.2", "@types/hast": "^3.0.4" } }, "sha512-CHFxE0jztBIZRHH6gxXE7DXUCFXjReEGxZ/j0rfSLGKZuwp2xBYycEP14875DSa9KLL/6700oxIq6oO6ef9K2g=="], "@shikijs/vscode-textmate": ["@shikijs/vscode-textmate@10.0.2", "", {}, "sha512-83yeghZ2xxin3Nj8z1NMd/NCuca+gsYXswywDy5bHvwlWL8tpTQmzGeUuHd9FC3E/SBEMvzJRwWEOz5gGes9Qg=="], @@ -303,6 +320,10 @@ "@types/unist": ["@types/unist@3.0.3", "", {}, "sha512-ko/gIFJRv177XgZsZcBwnqJN5x/Gien8qNOn0D5bQU/zAzVf9Zt3BlcUiLqhV9y4ARk0GbT3tnUiPNgnTXzc/Q=="], + "@typescript/ata": ["@typescript/ata@0.9.8", "", { "peerDependencies": { "typescript": ">=4.4.4" } }, "sha512-+M815CeDRJS5H5ciWfhFCKp25nNfF+LFWawWAaBhNlquFb2wS5IIMDI+2bKWN3GuU6mpj+FzySsOD29M4nG8Xg=="], + + "@typescript/vfs": ["@typescript/vfs@1.6.4", "", { "dependencies": { "debug": "^4.4.3" }, "peerDependencies": { "typescript": "*" } }, "sha512-PJFXFS4ZJKiJ9Qiuix6Dz/OwEIqHD7Dme1UwZhTK11vR+5dqW2ACbdndWQexBzCx+CPuMe5WBYQWCsFyGlQLlQ=="], + "@ungap/structured-clone": ["@ungap/structured-clone@1.3.3", "", {}, "sha512-60YRaenCQcVjYEKOcG824+DRGGIQ3VKErcBoAEDJZz5bKIs2ZG+X/H9Nk+Q6EVkwJk5QNApxbrc5QtBSwtrXAg=="], "@yuku-analyzer/binding-darwin-arm64": ["@yuku-analyzer/binding-darwin-arm64@0.6.12", "", { "os": "darwin", "cpu": "arm64" }, "sha512-9rpIP7IeybjyvWUf6WnU24h1qo+JdxIHr1o3yb06HoE8tM3S/Jh5RrUw9aw5P9BKSIvSPbLyVlItX7PcD3o5bQ=="], @@ -415,7 +436,7 @@ "csstype": ["csstype@3.2.3", "", {}, "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ=="], - "debug": ["debug@2.6.9", "", { "dependencies": { "ms": "2.0.0" } }, "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA=="], + "debug": ["debug@4.4.3", "", { "dependencies": { "ms": "^2.1.3" } }, "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA=="], "decode-named-character-reference": ["decode-named-character-reference@1.3.0", "", { "dependencies": { "character-entities": "^2.0.0" } }, "sha512-GtpQYB283KrPp6nRw50q3U9/VfOutZOe103qlN7BPP6Ad27xYnOIWv4lPzo8HCAL+mMZofJ9KEy30fq6MfaK6Q=="], @@ -477,6 +498,8 @@ "fumadocs-mdx": ["fumadocs-mdx@15.2.0", "", { "dependencies": { "@mdx-js/mdx": "^3.1.1", "@standard-schema/spec": "^1.1.0", "chokidar": "^5.0.0", "esbuild": "^0.28.1", "estree-util-value-to-estree": "^3.5.0", "github-slugger": "^2.0.0", "magic-string": "^0.30.21", "mdast-util-mdx": "^3.0.0", "picocolors": "^1.1.1", "picomatch": "^4.0.5", "tinyexec": "^1.2.4", "tinyglobby": "^0.2.17", "unified": "^11.0.5", "unist-util-remove-position": "^5.0.0", "unist-util-visit": "^5.1.0", "vfile": "^6.0.3", "yaml": "^2.9.0", "yuku-analyzer": "^0.6.3", "zod": "^4.4.3" }, "peerDependencies": { "@fumadocs/satteri": "0.x.x", "@types/mdast": "*", "@types/mdx": "*", "@types/react": "*", "fumadocs-core": "^16.7.0", "mdast-util-directive": "*", "next": "^15.3.0 || ^16.0.0", "react": "^19.2.0", "rolldown": "*", "satteri": "^0.9.4", "vite": "7.x.x || 8.x.x" }, "optionalPeers": ["@fumadocs/satteri", "@types/mdast", "@types/mdx", "@types/react", "mdast-util-directive", "next", "react", "rolldown", "satteri", "vite"], "bin": { "fumadocs-mdx": "./bin.js" } }, "sha512-+yBP8QYw5wA9LF5eVdMhwbP7KT1OF4B/YfC6PZoD2jz0amZi1B+6QHTI6XoRRSTmhWrI4cL5LU1DspW0itk+NA=="], + "fumadocs-twoslash": ["fumadocs-twoslash@3.3.0", "", { "dependencies": { "@base-ui/react": "^1.6.0", "@shikijs/twoslash": "^4.3.1", "cnfast": "^0.0.8", "mdast-util-from-markdown": "^2.0.3", "mdast-util-gfm": "^3.1.0", "mdast-util-to-hast": "^13.2.1", "twoslash": "^0.3.9" }, "peerDependencies": { "@types/react": "*", "fumadocs-core": "^16.7.16", "fumadocs-ui": "^16.7.16", "react": "^19.2.0", "react-dom": "^19.2.0", "shiki": "4.x.x" }, "optionalPeers": ["@types/react"] }, "sha512-IR+oYbjsR59h/R7AvdeEdHEY48ygCHII3tUZGC3QnUVug5+qN1wlRNr30gAfOvrau/mdiRKwONuJCOKOWqEFbg=="], + "fumadocs-ui": ["@fumadocs/base-ui@16.12.1", "", { "dependencies": { "@base-ui/react": "^1.6.0", "@fuma-translate/react": "^1.0.2", "@fumadocs/tailwind": "0.1.1", "class-variance-authority": "^0.7.1", "cnfast": "^0.0.8", "lucide-react": "^1.25.0", "motion": "^12.42.2", "next-themes": "^0.4.6", "react-remove-scroll": "^2.7.2", "rehype-raw": "^7.0.0", "scroll-into-view-if-needed": "^3.1.0", "shiki": "^4.3.1", "unist-util-visit": "^5.1.0" }, "peerDependencies": { "@types/mdx": "*", "@types/react": "*", "fumadocs-core": "16.12.1", "next": "16.x.x", "react": "^19.2.0", "react-dom": "^19.2.0", "takumi-js": "*" }, "optionalPeers": ["@types/mdx", "@types/react", "next", "takumi-js"] }, "sha512-FOHMJgFlyZ5VyujVqCit+6Bn+DQgPf8I9x9vGMJzc7pwsCR+LCeqE3+jNEzKpTTbJhof6MXjX4n3pEaJx3tvBQ=="], "get-nonce": ["get-nonce@1.0.1", "", {}, "sha512-FJhYRoDaiatfEkUK8HKlicmu/3SGFD51q3itKDGoSTysQJBnfOcxU5GxnhE1E6soB76MbT0MBtnKJuXyAx+96Q=="], @@ -695,7 +718,7 @@ "motion-utils": ["motion-utils@12.39.0", "", {}, "sha512-8nadJAJjTtqRkmRF36FoJTrywK9nnFmnPwnSMyxaOCU7GDjN9RTMJIxx9De8ErM+vpPhMccr/6fo5WciyQLnMQ=="], - "ms": ["ms@2.0.0", "", {}, "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A=="], + "ms": ["ms@2.1.3", "", {}, "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA=="], "nanoid": ["nanoid@3.3.16", "", { "bin": { "nanoid": "bin/nanoid.cjs" } }, "sha512-bzlKTyNJ7+LdGIIwy8ijFpIqEQIvafahV7eYykJ8Cvh42EdJeODoJ6gUJXpQJvej1BddH8OqTXZNE/KfbWAu8Q=="], @@ -853,6 +876,10 @@ "tslib": ["tslib@2.8.1", "", {}, "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w=="], + "twoslash": ["twoslash@0.3.9", "", { "dependencies": { "@typescript/vfs": "^1.6.4", "twoslash-protocol": "0.3.9" }, "peerDependencies": { "typescript": "^5.5.0 || ^6.0.0" } }, "sha512-rDclk+OtzuTX+tnea7DYLCkqGQ3eP0IyfD+kzUJ7t46X/NzlaxwrhecmEBNuSCuEn3V+n1PhcjUUQQ7gUJzX5Q=="], + + "twoslash-protocol": ["twoslash-protocol@0.3.9", "", {}, "sha512-9/iwp+CXOnjFMPQuPL5PkuRbZnDoNpBvtJCLs9t8kDYkL3YHujbvnHfZA1i5fApDftVEdBw+T/4F+dH5kIzpYQ=="], + "type-fest": ["type-fest@2.19.0", "", {}, "sha512-RAH822pAdBgcNMAfWnCBU3CFZcfZ/i1eZjwFU/dsLKumyuuP3niueg2UAukXYF0E2AAoc82ZSSf9J0WQBinzHA=="], "typescript": ["typescript@6.0.3", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-y2TvuxSZPDyQakkFRPZHKFm+KKVqIisdg9/CZwm9ftvKXLP8NRWj38/ODjNbr43SsoXqNuAisEf1GdCxqWcdBw=="], @@ -927,9 +954,9 @@ "chalk-template/chalk": ["chalk@4.1.2", "", { "dependencies": { "ansi-styles": "^4.1.0", "supports-color": "^7.1.0" } }, "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA=="], - "fumadocs-ui/lucide-react": ["lucide-react@1.26.0", "", { "peerDependencies": { "react": "^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, "sha512-raglYVR2+VkMfJL158krjVmE+rV5ST2lzA/KQm1FRSjMHT4MnWaegHxoVEpmc2So3nOEhp9oGejJwAPX8MoAjg=="], + "compression/debug": ["debug@2.6.9", "", { "dependencies": { "ms": "2.0.0" } }, "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA=="], - "micromark/debug": ["debug@4.4.3", "", { "dependencies": { "ms": "^2.1.3" } }, "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA=="], + "fumadocs-ui/lucide-react": ["lucide-react@1.26.0", "", { "peerDependencies": { "react": "^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, "sha512-raglYVR2+VkMfJL158krjVmE+rV5ST2lzA/KQm1FRSjMHT4MnWaegHxoVEpmc2So3nOEhp9oGejJwAPX8MoAjg=="], "mime-types/mime-db": ["mime-db@1.33.0", "", {}, "sha512-BHJ/EKruNIqJf/QahvxwQZXKygOQ256myeN/Ew+THcAa5q+PjyTTMMeNQC4DZw5AwfvelsUrA6B67NKMqXDbzQ=="], @@ -947,7 +974,7 @@ "chalk-template/chalk/ansi-styles": ["ansi-styles@4.3.0", "", { "dependencies": { "color-convert": "^2.0.1" } }, "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg=="], - "micromark/debug/ms": ["ms@2.1.3", "", {}, "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA=="], + "compression/debug/ms": ["ms@2.0.0", "", {}, "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A=="], "ansi-align/string-width/strip-ansi/ansi-regex": ["ansi-regex@5.0.1", "", {}, "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ=="], } diff --git a/content/guides/tune-a-protection-profile.mdx b/content/guides/tune-a-protection-profile.mdx new file mode 100644 index 0000000..dd62c79 --- /dev/null +++ b/content/guides/tune-a-protection-profile.mdx @@ -0,0 +1,10 @@ +--- +title: Tune a Protection Profile +--- + +### Throttling Windows + +The first of all settings a protection profile has are the throttling windows. Throttling windows +rate-limit challenge issuance. + +![Screenshot](/images/screenshot-dashboard-throttling-windows.png) diff --git a/content/guides/write-your-own-sdk.mdx b/content/guides/write-your-own-sdk.mdx new file mode 100644 index 0000000..c5c05c6 --- /dev/null +++ b/content/guides/write-your-own-sdk.mdx @@ -0,0 +1,4 @@ +--- +title: Write Your Own SDK +description: Learn to write your own Duckity SDK, for when an official one is not yet available. +--- diff --git a/content/index.mdx b/content/index.mdx index 9d7c067..60186d1 100644 --- a/content/index.mdx +++ b/content/index.mdx @@ -1,5 +1,5 @@ --- -title: Introduction +title: Overview description: Welcome to Duckity's documentation site! icon: Fish --- @@ -16,4 +16,3 @@ DDoS protection service. Use Cloudflare, Bunny.net, or similar for protection of If you're new to Duckity, head over to the [Quick Start](/quick-start). It'll guide you on how to get started and give you some templates to reduce the boilerplate configuration tasks. - diff --git a/content/meta.json b/content/meta.json new file mode 100644 index 0000000..c4dc973 --- /dev/null +++ b/content/meta.json @@ -0,0 +1,17 @@ +{ + "pages": [ + "---Introduction---", + "index", + "quick-start", + "---[BookOpenText]Guides---", + "guides/tune-a-protection-profile", + "guides/write-your-own-sdk", + "---[CodeXml]Integrate---", + "sdks/javascript", + "sdks/react", + "sdks/c", + "sdks/python", + "sdks/rust-std", + "sdks/rust-no-std" + ] +} \ No newline at end of file diff --git a/content/quick-start.mdx b/content/quick-start.mdx index 17856ec..8c6d812 100644 --- a/content/quick-start.mdx +++ b/content/quick-start.mdx @@ -8,34 +8,24 @@ Getting started with Duckity is quite simple. Head over to [Duckity's Dashboard](https://app.duckity.com/) and log in using your favorite method. If it's your first time logging in, your account will be automatically created. -If it's your first time logging into Duckity, your dashboard will be empty. - -![Screenshot](/images/screenshot-dashboard-empty.png) - -## Applications +## Create an Application [step] Duckity is divided into one or more applications within your account. You create one application per project in which you want to integrate Duckity. To create a new application, press on the yellow **+ New Application** button at the top right corner of the screen. -![Screenshot](/images/screenshot-dashboard-create-application.png) - Once you've set the name of it, press on the **โœ“ Create Application** button. Congratulations! You now have your first application created. -![Screenshot](/images/screenshot-dashboard-application.png) + + Additionally, in case your application is a web app, you can set up the CORS + origins to fit your application's origins. For example, if you'll be + protecting the login form of your app hosted at `https://example.com/login`, + add `https://example.com` (scheme and host, no path) to your app's origins. If + your application is not a web app, you can leave the CORS origins empty. + -Each application has a credentials pair consisting of: - -- **Application ID**: The unique identifier of your app. -- **Application Secret**: Your app's "password". As the name implies, keep it secret. - -Additionally, in case your application is a web app, you can set up the CORS origins to fit your -application's origins. For example, if you'll be protecting the login form of your app hosted at -`https://example.com/login`, add `https://example.com` (scheme and host, no path) to your app's -origins. If your application is not a web app, you can leave the CORS origins empty. - -## Protection Profiles +## Create a Protection Profile [step] Applications commonly have multiple features that need to be protected. For example, you may have a signup form and a login form with different protection needs. For each, you'll create one @@ -44,20 +34,66 @@ signup form and a login form with different protection needs. For each, you'll c Each application needs at least one protection profile to work. To create your first one, press the **+ Create Protection Profile** button. -![Screenshot](/images/screenshot-dashboard-create-protection-profile.png) - Set the name to the name of the feature you'll be protecting, like "Sign-up Form" or "Add Friend". This is for you to recognize it later, it won't be displayed to your users. Once you're done -filling it, press on **โœ“ Create Protection Profile**. +filling it, press on **โœ“ Create Protection Profile**. -Congrats! Your first protection profile is now created. You'll see a lot of configuration options, -but don't worry; We'll go one by one explaining them below. +Copy your protection profile's ID using the button at the top right corner, that's all you'll need +to integrate it into your application. -![Screenshot](/images/screenshot-dashboard-protection-profile.png) + + Don't worry about all the settings displayed for now, you'll learn to tune + them later. + -### Throttling Windows +## Install an SDK [step] -The first of all settings a protection profile has are the throttling windows. Throttling windows -rate-limit challenge issuance. +To integrate your protection profile with your application, you'll need a client-side SDK and a +server-side SDK. -![Screenshot](/images/screenshot-dashboard-throttling-windows.png) \ No newline at end of file +Duckity has multiple official SDKs for you to plug and play. + +import { + SiC, + SiJavascript, + SiPython, + SiRust, + SiReact, +} from "@icons-pack/react-simple-icons"; + + + }> + Integrate client-side code with Duckity using the JavaScript SDK. + + }> + Integrate client-side code with Duckity using the React SDK. + + }> + Integrate client-side code with Duckity using the C SDK. + + }> + Integrate client-side code with Duckity using the Python SDK. + + }> + Integrate client-side code with Duckity using the Rust SDK. Uses GMP via + `rug`. + + }> + Integrate client-side code with Duckity using the Rust SDK. Uses + `num-bigint-dig`. + + + +In case we don't have a client SDK for your language yet, you can use the C SDK. It's written in +Rust, you can link to it through the exposed C API, and it wraps all the required encoding and +decoding in a simple API. You may also find the following guide useful: + +import { FileCodeCorner } from "lucide-react"; + +} + href="/guides/write-your-own-sdk" +> + Learn how to write your own SDK in any language. + diff --git a/content/sdks/c.mdx b/content/sdks/c.mdx new file mode 100644 index 0000000..acd1fad --- /dev/null +++ b/content/sdks/c.mdx @@ -0,0 +1,4 @@ +--- +title: C +icon: SiC +--- \ No newline at end of file diff --git a/content/sdks/javascript.mdx b/content/sdks/javascript.mdx new file mode 100644 index 0000000..e1ba1c3 --- /dev/null +++ b/content/sdks/javascript.mdx @@ -0,0 +1,137 @@ +--- +title: JavaScript (Client-Side) +description: Learn how to integrate Duckity into your web application. +icon: SiJavascript +--- + +Welcome to the Duckity JavaScript + WASM SDK documentation! This guide will teach you how to +install and set up the SDK in no time. + +The following SDKs depend on this one and provide specialized wrappers for different frameworks: + +import { SiReact } from "@icons-pack/react-simple-icons"; + + + }> + Integrate client-side code with Duckity using the React SDK. + + + +## Quick Start + +Before you can integrate Duckity into your application, you'll need to have the following: + +1. An application, +2. CORS origins set up pointing to your application's origin(s), +3. At least one protection profile created in that application, and +4. The ID of the protection profiles to use + +If you're missing either of those, head over to the [Duckity Dashboard](https://app.duckity.com) or +read the [Quick Start](/quick-start) guide to learn how to set those up. + +Once you got those ready, follow these steps to get things running on your client: + +### Install the SDK [step] + +Follow these steps depending on your application: + + + + Run the following line in your terminal to install the Duckity SDK. + + ```package-install + @duckity/js + ``` + + Then import it in your code: + + ```ts twoslash + import duckity from "@duckity/js"; + ``` + + + + If you're using the SDK from a static site, import it using a CDN like [esm.sh](https://esm.sh/) + instead. + + ```html + + ``` + + + + +### Solve a Challenge [step] + +Once you have the SDK installed, you can request a challenge whenever you need it using +`duckity.solve()`. + +```ts twoslash +const PROTECTION_PROFILE_ID: string = ""; +// ---cut--- +import duckity from "@duckity/js"; + +let solution = await duckity.solve(PROTECTION_PROFILE_ID); +``` + + + Hover over the code to see the type definitions. + + +## Advanced Usage + +Solving a challenge on request works well for simple setups. However, both security and UX can be +greatly improved changing a few settings and planning when to solve challenges. + +If your challenges do not require threat correlation keys (set up in the protection profile's +settings), issue the challenge as soon as possible. Note, however, that the challenge + +### Threat Correlation Keys + +To pass threat correlation keys when issuing a challenge, set them in the `options` argument of +`duckity.solve()`. + +```ts twoslash +const PROTECTION_PROFILE_ID: string = "py83YHkXV6ZpIsJZGVxzS"; +// ---cut--- +import duckity from "@duckity/js"; + +let solution = await duckity.solve( + PROTECTION_PROFILE_ID, + { + keys: { + email: "john@duckity.com", + } + } +); +``` + +### Using On Self-Hosted Ducklings + +Self-hosted ducklings are hosted at a different domain from Duckity-hosted ducklings. To point it +to a custom domain, change the following setting: + +```ts twoslash +const PROTECTION_PROFILE_ID: string = "py83YHkXV6ZpIsJZGVxzS"; +// ---cut--- +import duckity from "@duckity/js"; + +let solution = await duckity.solve( + PROTECTION_PROFILE_ID, + { + api: "https://quack.duckity.com", + } +); +``` + +The default value for the `api` parameter is `"https://quack.duckity.com"`, which points to +Duckity's hosted duckling. Change the domain name to your \ No newline at end of file diff --git a/content/sdks/python.mdx b/content/sdks/python.mdx new file mode 100644 index 0000000..a71e9fb --- /dev/null +++ b/content/sdks/python.mdx @@ -0,0 +1,4 @@ +--- +title: Python 3 +icon: SiPython +--- \ No newline at end of file diff --git a/content/sdks/react.mdx b/content/sdks/react.mdx new file mode 100644 index 0000000..2bd3153 --- /dev/null +++ b/content/sdks/react.mdx @@ -0,0 +1,4 @@ +--- +title: React +icon: SiReact +--- \ No newline at end of file diff --git a/content/sdks/rust-no-std.mdx b/content/sdks/rust-no-std.mdx new file mode 100644 index 0000000..1ae7e05 --- /dev/null +++ b/content/sdks/rust-no-std.mdx @@ -0,0 +1,4 @@ +--- +title: Rust (No STD) +icon: SiRust +--- \ No newline at end of file diff --git a/content/sdks/rust-std.mdx b/content/sdks/rust-std.mdx new file mode 100644 index 0000000..b9a53a5 --- /dev/null +++ b/content/sdks/rust-std.mdx @@ -0,0 +1,4 @@ +--- +title: Rust (STD) +icon: SiRust +--- \ No newline at end of file diff --git a/next.config.mjs b/next.config.mjs index b020d18..b049a3d 100644 --- a/next.config.mjs +++ b/next.config.mjs @@ -4,9 +4,9 @@ const withMDX = createMDX(); /** @type {import('next').NextConfig} */ const config = { - serverExternalPackages: ["@takumi-rs/core"], + serverExternalPackages: ["@takumi-rs/core", "typescript", "twoslash"], output: "export", - reactStrictMode: false, + reactStrictMode: true, allowedDevOrigins: ["duckity.com"], basePath: "/docs", trailingSlash: true, diff --git a/package.json b/package.json index 482f535..88f2f01 100644 --- a/package.json +++ b/package.json @@ -12,17 +12,25 @@ "format": "biome format --write" }, "dependencies": { + "@base-ui/react": "^1.6.0", + "@fuma-translate/react": "^1.0.2", "@fumadocs/base-ui": "^16.12.1", + "@icons-pack/react-simple-icons": "^13.13.0", "@orama/orama": "^3.1.18", + "@typescript/ata": "^0.9.8", + "@typescript/vfs": "^1.6.4", + "class-variance-authority": "^0.7.1", "cnfast": "^0.0.8", "fumadocs-core": "16.12.1", "fumadocs-mdx": "15.2.0", + "fumadocs-twoslash": "^3.3.0", "fumadocs-ui": "npm:@fumadocs/base-ui@16.12.1", "lucide-react": "^1.3.0", "next": "^16.2.12", "react": "^19.2.8", "react-dom": "^19.2.8", - "takumi-js": "^2.4.0" + "takumi-js": "^2.4.0", + "twoslash": "^0.3.9" }, "devDependencies": { "@biomejs/biome": "^2.5.5", diff --git a/source.config.ts b/source.config.ts index bef5000..e3b96da 100644 --- a/source.config.ts +++ b/source.config.ts @@ -1,10 +1,16 @@ -import { defineConfig, defineDocs } from 'fumadocs-mdx/config'; -import { metaSchema, pageSchema } from 'fumadocs-core/source/schema'; +import ts from "typescript"; +import { setupTypeAcquisition } from "@typescript/ata"; +import { defineConfig, defineDocs } from "fumadocs-mdx/config"; +import { metaSchema, pageSchema } from "fumadocs-core/source/schema"; +import { remarkSteps } from "fumadocs-core/mdx-plugins/remark-steps"; +import { transformerTwoslash } from "fumadocs-twoslash"; +import { rehypeCodeDefaultOptions } from "fumadocs-core/mdx-plugins"; +import { createFileSystemTypesCache } from "fumadocs-twoslash/cache-fs"; // You can customize Zod schemas for frontmatter and `meta.json` here // see https://fumadocs.dev/docs/mdx/collections export const docs = defineDocs({ - dir: 'content', + dir: "content", docs: { schema: pageSchema, postprocess: { @@ -16,8 +22,59 @@ export const docs = defineDocs({ }, }); +// Allow importing @duckity/js from twoslash via `import { Duckity } from "@duckity/js"`. +// +// For this, we need to create a virtual file system that includes the @duckity/js package from node_modules. +const sharedFsMap = new Map(); + +const imports = ` + import duckity from "@duckity/js"; +`; + +const ata = setupTypeAcquisition({ + projectName: "twoslash-cdn", + typescript: ts, + logger: console, + delegate: { + receivedFile: (code, path) => { + // ATA paths look like "/node_modules/@types/lodash/index.d.ts" + // Strip the leading slash to make it a valid path for the virtual file system + + sharedFsMap.set(path, code); + }, + }, +}); + +await ata(imports); + export default defineConfig({ mdxOptions: { - // MDX options + remarkPlugins: [remarkSteps], + rehypeCodeOptions: { + themes: { + light: "github-light", + dark: "github-dark", + }, + transformers: [ + ...(rehypeCodeDefaultOptions.transformers ?? []), + transformerTwoslash({ + typesCache: createFileSystemTypesCache({}), + twoslashOptions: { + fsMap: sharedFsMap, + compilerOptions: { + moduleResolution: ts.ModuleResolutionKind.NodeNext, + target: ts.ScriptTarget.ESNext, + module: ts.ModuleKind.ESNext, + esModuleInterop: true, + allowJs: true, + }, + }, + }), + ], + + // important: Shiki doesn't support lazy loading languages for codeblocks in Twoslash popups + // make sure to define them first (e.g. the common ones) + langs: ["js", "jsx", "ts", "tsx"], + }, }, }); diff --git a/src/app/(docs)/[[...slug]]/page.tsx b/src/app/(docs)/[[...slug]]/page.tsx index f8c78bf..9a13436 100644 --- a/src/app/(docs)/[[...slug]]/page.tsx +++ b/src/app/(docs)/[[...slug]]/page.tsx @@ -58,7 +58,7 @@ export async function generateMetadata( if (!page) notFound(); return { - title: page.data.title, + title: page.data.title + " ยท Duckity Docs", description: page.data.description, openGraph: { images: getPageImageUrl(page).url, diff --git a/src/app/global.css b/src/app/global.css index f86f3c9..80e2847 100644 --- a/src/app/global.css +++ b/src/app/global.css @@ -1,6 +1,7 @@ @import 'tailwindcss'; @import 'fumadocs-ui/css/neutral.css'; @import 'fumadocs-ui/css/preset.css'; +@import 'fumadocs-twoslash/twoslash.css'; html { scrollbar-gutter: stable; diff --git a/src/components/codeblock.tsx b/src/components/codeblock.tsx new file mode 100644 index 0000000..9efb35c --- /dev/null +++ b/src/components/codeblock.tsx @@ -0,0 +1,265 @@ +'use client'; +import { Check, Clipboard } from 'lucide-react'; +import { + type ComponentProps, + createContext, + type HTMLAttributes, + type ReactNode, + type RefObject, + use, + useMemo, + useRef, +} from 'react'; +import { cn } from '../lib/cn'; +import { useCopyButton } from '@fumadocs/base-ui/utils/use-copy-button'; +import { buttonVariants } from './ui/button'; +import { useTranslations } from '@fuma-translate/react'; +import { Tabs, TabsContent, TabsList, TabsTrigger } from './ui/tabs'; +import { mergeRefs } from '../lib/merge-refs'; + +export interface CodeBlockProps extends Omit, 'title'> { + title?: ReactNode; + + /** + * Icon of code block + * + * When passed as a string, it assumes the value is the HTML of icon + */ + icon?: ReactNode; + + /** + * Allow to copy code with copy button + * + * @defaultValue true + */ + allowCopy?: boolean | 'true' | 'false'; + + /** + * Keep original background color generated by Shiki or Rehype Code + * + * @defaultValue false + */ + keepBackground?: boolean; + + viewportProps?: HTMLAttributes; + + /** + * show line numbers + */ + 'data-line-numbers'?: boolean; + + /** + * @defaultValue 1 + */ + 'data-line-numbers-start'?: number; + + Actions?: (props: { className?: string; children?: ReactNode }) => ReactNode; +} + +const TabsContext = createContext<{ + containerRef: RefObject; + nested: boolean; +} | null>(null); + +export function Pre(props: ComponentProps<'pre'>) { + return ( +
+      {props.children}
+    
+ ); +} + +export function CodeBlock({ + ref, + title, + allowCopy = true, + keepBackground = false, + icon, + viewportProps = {}, + children, + Actions = (props) =>
, + ...props +}: CodeBlockProps) { + const inTab = use(TabsContext) !== null; + const areaRef = useRef(null); + if (allowCopy === 'true') allowCopy = true; + else if (allowCopy === 'false') allowCopy = false; + return ( +
+ {title ? ( +
+ {typeof icon === 'string' ? ( +
+ ) : ( + icon + )} +
{title}
+ {Actions({ + className: '-me-2', + children: allowCopy && , + })} +
+ ) : ( + Actions({ + className: + 'absolute top-2 right-2 z-2 backdrop-blur-lg rounded-lg text-fd-muted-foreground', + children: allowCopy && , + }) + )} +
+ {children} +
+
+ ); +} + +function CopyButton({ + className, + containerRef, + ...props +}: ComponentProps<'button'> & { + containerRef: RefObject; +}) { + const t = useTranslations({ note: 'code block' }); + const [checked, onClick] = useCopyButton(() => { + const pre = containerRef.current?.getElementsByTagName('pre').item(0); + if (!pre) return; + + const clone = pre.cloneNode(true) as HTMLElement; + clone.querySelectorAll('.nd-copy-ignore').forEach((node) => { + node.replaceWith('\n'); + }); + + void navigator.clipboard.writeText(clone.textContent ?? ''); + }); + + return ( + + ); +} + +export function CodeBlockTabs({ ref, className, ...props }: ComponentProps) { + const containerRef = useRef(null); + const nested = use(TabsContext) !== null; + + return ( + + cn( + 'bg-fd-card rounded-xl border', + !nested && 'my-4', + typeof className === 'function' ? className(s) : className, + ) + } + > + ({ + containerRef, + nested, + }), + [nested], + )} + > + {props.children} + + + ); +} + +export function CodeBlockTabsList({ className, ...props }: ComponentProps) { + return ( + + cn( + 'flex flex-row px-2 overflow-x-auto text-fd-muted-foreground', + typeof className === 'function' ? className(s) : className, + ) + } + > + {props.children} + + ); +} + +export function CodeBlockTabsTrigger({ + children, + className, + ...props +}: ComponentProps) { + return ( + + cn( + 'relative group inline-flex text-sm font-medium text-nowrap items-center transition-colors gap-2 px-2 py-1.5 [&_svg]:size-3.5', + s.active ? 'text-fd-primary' : 'hover:text-fd-accent-foreground', + typeof className === 'function' ? className(s) : className, + ) + } + > +
+ {children} + + ); +} + +export function CodeBlockTab(props: ComponentProps) { + return ; +} diff --git a/src/components/github-info.tsx b/src/components/github-info.tsx new file mode 100644 index 0000000..0d7c39b --- /dev/null +++ b/src/components/github-info.tsx @@ -0,0 +1,118 @@ +import { cn } from '../lib/cn'; +import { GitFork, Star } from 'lucide-react'; +import { type ComponentProps, use } from 'react'; + +export interface FetchRepositoryInfoOptions { + owner: string; + repo: string; + + baseUrl?: string; + token?: string; + fetchOptions?: RequestInit; +} + +export interface RepositoryInfo { + stars: number; + forks: number; +} + +export interface GithubInfoProps extends ComponentProps<'a'>, FetchRepositoryInfoOptions { + locale?: Intl.LocalesArgument; +} + +export async function fetchRepositoryInfo({ + owner, + repo, + token, + baseUrl = 'https://api.github.com', + fetchOptions = { + // default revalidate options for Next.js (optional) + next: { + revalidate: 60, + }, + } as RequestInit, +}: FetchRepositoryInfoOptions): Promise { + const endpoint = `${baseUrl}/repos/${owner}/${repo}`; + const headers = new Headers(fetchOptions.headers); + + headers.set('Content-Type', 'application/json'); + if (token) headers.set('Authorization', `Bearer ${token}`); + + const response = await fetch(endpoint, { + ...fetchOptions, + headers, + } as RequestInit); + + if (!response.ok) { + const message = await response.text(); + + throw new Error(`Failed to fetch repository data: ${message}`); + } + + const data = await response.json(); + return { + stars: data.stargazers_count, + forks: data.forks_count, + }; +} + +/** + * Uses compact notation (e.g., 1.5K, 2.3M). + */ +const formatterOptions: Intl.NumberFormatOptions = { + notation: 'compact', + maximumFractionDigits: 1, +}; + +const defaultFormatter = new Intl.NumberFormat(undefined, formatterOptions); + +const promises: Record> = {}; + +export function GithubInfo({ + repo, + owner, + token, + baseUrl, + fetchOptions, + locale, + ...props +}: GithubInfoProps) { + const options: FetchRepositoryInfoOptions = { + repo, + owner, + token, + baseUrl, + fetchOptions, + }; + const { stars, forks } = use( + (promises[JSON.stringify(options)] ??= fetchRepositoryInfo(options)), + ); + const formatter = locale ? new Intl.NumberFormat(locale, formatterOptions) : defaultFormatter; + + return ( + +

+ + GitHub + + + {owner}/{repo} +

+
+ + {formatter.format(stars)} + + {formatter.format(forks)} +
+
+ ); +} diff --git a/src/components/mdx.tsx b/src/components/mdx.tsx index ceb8ee9..6f145de 100644 --- a/src/components/mdx.tsx +++ b/src/components/mdx.tsx @@ -1,12 +1,15 @@ import defaultMdxComponents from "fumadocs-ui/mdx"; +import * as TabsComponents from 'fumadocs-ui/components/tabs'; import Image from "next/image"; import type { MDXComponents } from "mdx/types"; +import * as Twoslash from 'fumadocs-twoslash/ui'; export function getMDXComponents(components?: MDXComponents) { - return { ...defaultMdxComponents, img: (props) => , + ...TabsComponents, + ...Twoslash, ...components, } satisfies MDXComponents; } diff --git a/src/components/tabs.tsx b/src/components/tabs.tsx new file mode 100644 index 0000000..9b6ffe5 --- /dev/null +++ b/src/components/tabs.tsx @@ -0,0 +1,200 @@ +'use client'; + +import * as React from 'react'; +import { + type ComponentProps, + createContext, + type ReactNode, + useContext, + useEffect, + useId, + useMemo, + useState, +} from 'react'; +import { cn } from '../lib/cn'; +import * as Unstyled from './ui/tabs'; + +type CollectionKey = string | symbol; + +export interface TabsProps extends Omit< + ComponentProps, + 'value' | 'onValueChange' +> { + /** + * Use simple mode instead of advanced usage as documented in https://radix-ui.com/primitives/docs/components/tabs. + */ + items?: string[]; + + /** + * Shortcut for `defaultValue` when `items` is provided. + * + * @defaultValue 0 + */ + defaultIndex?: number; + + /** + * Additional label in tabs list when `items` is provided. + */ + label?: ReactNode; +} + +const TabsContext = createContext<{ + items?: string[]; + collection: CollectionKey[]; +} | null>(null); + +function useTabContext() { + const ctx = useContext(TabsContext); + if (!ctx) throw new Error('You must wrap your component in '); + return ctx; +} + +export function TabsList({ className, ...props }: React.ComponentProps) { + return ( + + cn( + 'flex gap-3.5 text-fd-secondary-foreground overflow-x-auto px-4 not-prose', + typeof className === 'function' ? className(s) : className, + ) + } + /> + ); +} + +export function TabsTrigger({ + className, + ...props +}: React.ComponentProps) { + return ( + + cn( + 'inline-flex items-center gap-2 whitespace-nowrap text-fd-muted-foreground border-b border-transparent py-2 text-sm font-medium transition-colors [&_svg]:size-4 hover:text-fd-accent-foreground disabled:pointer-events-none disabled:opacity-50 data-[active]:border-fd-primary data-[active]:text-fd-primary', + typeof className === 'function' ? className(s) : className, + ) + } + /> + ); +} + +export function Tabs({ + ref, + className, + items, + label, + defaultIndex = 0, + defaultValue = items ? escapeValue(items[defaultIndex]) : undefined, + ...props +}: TabsProps) { + const [value, setValue] = useState(defaultValue); + const collection = useMemo(() => [], []); + + return ( + + cn( + 'flex flex-col overflow-hidden rounded-xl border bg-fd-secondary my-4', + typeof className === 'function' ? className(s) : className, + ) + } + value={value} + onValueChange={(v: string) => { + if (items && !items.some((item) => escapeValue(item) === v)) return; + setValue(v); + }} + {...props} + > + {items && ( + + {label && {label}} + {items.map((item) => ( + + {item} + + ))} + + )} + ({ items, collection }), [collection, items])}> + {props.children} + + + ); +} + +export interface TabProps extends Omit, 'value'> { + /** + * Value of tab, detect from index if unspecified. + */ + value?: string; +} + +export function Tab({ value, ...props }: TabProps) { + const { items } = useTabContext(); + const resolved = + value ?? + // eslint-disable-next-line react-hooks/rules-of-hooks -- `value` is not supposed to change + items?.at(useCollectionIndex()); + if (!resolved) + throw new Error( + 'Failed to resolve tab `value`, please pass a `value` prop to the Tab component.', + ); + + return ( + + {props.children} + + ); +} + +export function TabsContent({ + value, + className, + ...props +}: ComponentProps) { + return ( + + cn( + 'p-4 text-[0.9375rem] bg-fd-background rounded-xl outline-none prose-no-margin data-[inactive]:hidden [&>figure:only-child]:-m-4 [&>figure:only-child]:border-none', + typeof className === 'function' ? className(s) : className, + ) + } + {...props} + > + {props.children} + + ); +} + +/** + * Inspired by Headless UI. + * + * Return the index of children, this is made possible by registering the order of render from children using React context. + * This is supposed by work with pre-rendering & pure client-side rendering. + */ +function useCollectionIndex() { + const key = useId(); + const { collection } = useTabContext(); + + useEffect(() => { + return () => { + const idx = collection.indexOf(key); + if (idx !== -1) collection.splice(idx, 1); + }; + }, [key, collection]); + + if (!collection.includes(key)) collection.push(key); + return collection.indexOf(key); +} + +/** + * only escape whitespaces in values in simple mode + */ +function escapeValue(v: string): string { + return v.toLowerCase().replace(/\s/, '-'); +} diff --git a/src/components/ui/button.tsx b/src/components/ui/button.tsx new file mode 100644 index 0000000..b427d4e --- /dev/null +++ b/src/components/ui/button.tsx @@ -0,0 +1,28 @@ +import { cva, type VariantProps } from 'class-variance-authority'; + +const variants = { + primary: 'bg-fd-primary text-fd-primary-foreground hover:bg-fd-primary/80', + outline: 'border hover:bg-fd-accent hover:text-fd-accent-foreground', + ghost: 'hover:bg-fd-accent hover:text-fd-accent-foreground', + secondary: + 'border bg-fd-secondary text-fd-secondary-foreground hover:bg-fd-accent hover:text-fd-accent-foreground', +} as const; + +export const buttonVariants = cva( + 'inline-flex items-center justify-center rounded-md p-2 text-sm font-medium transition-colors duration-100 disabled:pointer-events-none disabled:opacity-50 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-fd-ring', + { + variants: { + variant: variants, + // fumadocs use `color` instead of `variant` + color: variants, + size: { + sm: 'gap-1 px-2 py-1.5 text-xs', + icon: 'p-1.5 [&_svg]:size-5', + 'icon-sm': 'p-1.5 [&_svg]:size-4.5', + 'icon-xs': 'p-1 [&_svg]:size-4', + }, + }, + }, +); + +export type ButtonProps = VariantProps; diff --git a/src/components/ui/tabs.tsx b/src/components/ui/tabs.tsx new file mode 100644 index 0000000..19286e4 --- /dev/null +++ b/src/components/ui/tabs.tsx @@ -0,0 +1,179 @@ +'use client'; + +import { + type ComponentProps, + createContext, + use, + useEffectEvent, + useLayoutEffect, + useMemo, + useRef, + useState, +} from 'react'; +import { Tabs as Primitive } from '@base-ui/react/tabs'; +import { mergeRefs } from '../../lib/merge-refs'; + +type ChangeListener = (v: string) => void; +const listeners = new Map>(); + +export interface TabsProps extends ComponentProps { + /** + * Identifier for Sharing value of tabs + */ + groupId?: string; + + /** + * Enable persistent + */ + persist?: boolean; + + /** + * If true, updates the URL hash based on the tab's id + */ + updateAnchor?: boolean; + + onValueChange?: (value: string) => void; +} + +const TabsContext = createContext<{ + valueToIdMap: Map; + /** + * Mounted tab panels, mapped by their value. + * + * Only populated for panels that stay in the DOM (e.g. `keepMounted`), which is + * what allows us to open the tab containing a hash target. + */ + panels: Map; +} | null>(null); + +function useTabContext() { + const ctx = use(TabsContext); + if (!ctx) throw new Error('You must wrap your component in '); + return ctx; +} + +export const TabsList = Primitive.List; + +export const TabsTrigger = Primitive.Tab; + +export function Tabs({ + ref, + groupId, + persist = false, + updateAnchor = false, + defaultValue, + value: _value, + onValueChange: _onValueChange, + ...props +}: TabsProps) { + const tabsRef = useRef(null); + const valueToIdMap = useMemo(() => new Map(), []); + const panels = useMemo(() => new Map(), []); + const [value, setValue] = + _value === undefined + ? // eslint-disable-next-line react-hooks/rules-of-hooks -- not supposed to change controlled/uncontrolled + useState(defaultValue) + : // eslint-disable-next-line react-hooks/rules-of-hooks -- not supposed to change controlled/uncontrolled + [_value, useEffectEvent((v: string) => _onValueChange?.(v))]; + + useLayoutEffect(() => { + if (!groupId) return; + let previous = sessionStorage.getItem(groupId); + if (persist) previous ??= localStorage.getItem(groupId); + if (previous) setValue(previous); + + const groupListeners = listeners.get(groupId) ?? new Set(); + groupListeners.add(setValue); + listeners.set(groupId, groupListeners); + return () => { + groupListeners.delete(setValue); + }; + }, [groupId, persist, setValue]); + + useLayoutEffect(() => { + const openFromHash = () => { + const hash = window.location.hash.slice(1); + if (!hash) return; + + // hash points to a tab's own anchor id + for (const [value, id] of valueToIdMap.entries()) { + if (id === hash) { + setValue(value); + tabsRef.current?.scrollIntoView(); + return; + } + } + + // hash points to an element inside a mounted (e.g. `keepMounted`) panel, + // open the tab it belongs to, then scroll to it once the panel is visible. + const target = document.getElementById(hash); + if (!target) return; + + for (const [value, panel] of panels.entries()) { + if (!panel.contains(target)) continue; + + setValue(value); + requestAnimationFrame(() => target.scrollIntoView()); + return; + } + }; + + openFromHash(); + window.addEventListener('hashchange', openFromHash); + return () => window.removeEventListener('hashchange', openFromHash); + }, [setValue, valueToIdMap, panels]); + + return ( + { + if (updateAnchor) { + const id = valueToIdMap.get(v); + + if (id) { + window.history.replaceState(null, '', `#${id}`); + } + } + + if (groupId) { + const groupListeners = listeners.get(groupId); + if (groupListeners) { + for (const listener of groupListeners) listener(v); + } + + sessionStorage.setItem(groupId, v); + if (persist) localStorage.setItem(groupId, v); + } else { + setValue(v); + } + }} + {...props} + > + ({ valueToIdMap, panels }), [valueToIdMap, panels])}> + {props.children} + + + ); +} + +export function TabsContent({ value, ref, ...props }: ComponentProps) { + const { valueToIdMap, panels } = useTabContext(); + + if (props.id) { + valueToIdMap.set(value, props.id); + } + + return ( + { + if (element) panels.set(value, element); + else panels.delete(value); + })} + value={value} + {...props} + > + {props.children} + + ); +} diff --git a/src/lib/merge-refs.ts b/src/lib/merge-refs.ts new file mode 100644 index 0000000..cf019f1 --- /dev/null +++ b/src/lib/merge-refs.ts @@ -0,0 +1,13 @@ +import type * as React from 'react'; + +export function mergeRefs(...refs: (React.Ref | undefined)[]): React.RefCallback { + return (value) => { + refs.forEach((ref) => { + if (typeof ref === 'function') { + ref(value); + } else if (ref) { + ref.current = value; + } + }); + }; +} diff --git a/src/lib/shared.ts b/src/lib/shared.ts index 0f6c892..a28a8d5 100644 --- a/src/lib/shared.ts +++ b/src/lib/shared.ts @@ -5,7 +5,7 @@ export const docsContentRoute = '/llms.mdx/docs'; // fill this with your actual GitHub info, for example: export const gitConfig = { - user: 'duckity', + user: 'duckity-dev', repo: 'documentation', branch: 'main', }; diff --git a/src/lib/source.ts b/src/lib/source.ts index 873b712..2e199fe 100644 --- a/src/lib/source.ts +++ b/src/lib/source.ts @@ -1,6 +1,7 @@ import { docs } from "collections/server"; import { loader } from "fumadocs-core/source"; -import { icons } from "lucide-react"; +import { icons as lucide } from "lucide-react"; +import * as simple from "@icons-pack/react-simple-icons"; import { createElement } from "react"; import { docsContentRoute, docsImageRoute, docsRoute } from "./shared"; @@ -15,7 +16,10 @@ export const source = loader({ return; } - if (icon in icons) return createElement(icons[icon as keyof typeof icons]); + if (icon in lucide) + return createElement(lucide[icon as keyof typeof lucide]); + if (icon in simple) + return createElement(simple[icon as keyof typeof simple]); }, });