├── .github ├── CODEOWNERS ├── ISSUE_TEMPLATE │ ├── 1_documentation.yml │ └── config.yml ├── PULL_REQUEST_TEMPLATE.md ├── broken-link-checker │ ├── action.yml │ ├── dist │ │ └── index.js │ ├── index.d.ts │ ├── package.json │ ├── pnpm-lock.yaml │ ├── src │ │ └── index.ts │ └── tsconfig.json └── workflows │ └── broken-link-checker.yml ├── .gitignore ├── .nvmrc ├── .prettierrc.js ├── README.md ├── app ├── (docs) │ ├── [[...slug]] │ │ └── page.tsx │ ├── layout.tsx │ └── not-found.tsx ├── api │ └── search │ │ └── route.ts ├── components │ ├── ImageSection.tsx │ ├── Logo.tsx │ ├── logos │ │ ├── discord.tsx │ │ └── gitbutler-wordmark.tsx │ └── mermaid │ │ ├── Mermaid.tsx │ │ └── index.ts ├── global.css ├── layout.config.tsx ├── layout.tsx ├── manifest.ts ├── provider.tsx ├── sitemap.ts ├── source.ts └── utils │ └── cn.ts ├── content └── docs │ ├── api-reference │ └── meta.json │ ├── community │ ├── contact-us.mdx │ ├── open-source.mdx │ └── supporters.mdx │ ├── development │ └── debugging.mdx │ ├── features │ ├── gitlab-integration.mdx │ ├── meta.json │ ├── stacked-branches.mdx │ ├── timeline.mdx │ └── virtual-branches │ │ ├── branch-lanes.mdx │ │ ├── butler-flow.mdx │ │ ├── commits.mdx │ │ ├── committer-mark.mdx │ │ ├── integration-branch.mdx │ │ ├── merging.mdx │ │ ├── meta.json │ │ ├── overview.mdx │ │ ├── pushing-and-fetching.mdx │ │ ├── remote-branches.mdx │ │ └── signing-commits.mdx │ ├── guide.mdx │ ├── index.mdx │ ├── meta.json │ ├── releases.mdx │ ├── review │ ├── meta.json │ └── overview.mdx │ ├── troubleshooting │ ├── custom-csp.mdx │ ├── fetch-push.mdx │ ├── meta.json │ └── recovering-stuff.mdx │ └── why-gitbutler.mdx ├── eslint.config.js ├── next.config.mjs ├── package.json ├── pnpm-lock.yaml ├── postcss.config.js ├── public ├── cover.png ├── fav │ ├── fav-180.png │ ├── fav-32.png │ ├── fav-64.png │ └── fav-svg.svg ├── fonts │ ├── SplineSansMono-Medium.woff2 │ ├── SplineSansMono-Regular.woff2 │ └── SplineSansMono-Semibold.woff2 ├── img │ ├── docs │ │ ├── branch-lanes-01.webp │ │ ├── branch-lanes-02.webp │ │ ├── branch-lanes-03.webp │ │ ├── commits-01.gif │ │ ├── commits-02.png │ │ ├── commits-03.png │ │ ├── commits-04.png │ │ ├── commits-05.gif │ │ ├── commits-06.gif │ │ ├── commits-07.gif │ │ ├── commits-08.gif │ │ ├── commits-09.gif │ │ ├── committer-01.gif │ │ ├── conflicts-commits.png │ │ ├── conflicts-conflicted.png │ │ ├── conflicts-edit-mode.png │ │ ├── conflicts-edit.png │ │ ├── conflicts-incoming.png │ │ ├── conflicts-resolve.png │ │ ├── gitlab │ │ │ ├── configure-gitbutler.png │ │ │ ├── copy-project-id.png │ │ │ ├── pat-create.png │ │ │ ├── pat-created.png │ │ │ ├── review-card.png │ │ │ └── submit-for-review.png │ │ ├── integration-01.png │ │ ├── issues-01.png │ │ ├── merge-upstream-incoming.png │ │ ├── merge-upstream.png │ │ ├── merging-01.png │ │ ├── merging-02.png │ │ ├── pushing-01.png │ │ ├── pushing-02.png │ │ ├── pushing.png │ │ ├── recovering-01.webp │ │ ├── remote-01.png │ │ ├── remote-02.png │ │ ├── review │ │ │ ├── branch.png │ │ │ ├── branches.png │ │ │ ├── create-review.png │ │ │ ├── patch.png │ │ │ ├── patch2.png │ │ │ ├── review-card-both.png │ │ │ ├── review-card.png │ │ │ └── settings.png │ │ ├── signing-01.png │ │ ├── signing-02.png │ │ ├── signing-03.png │ │ ├── signing-04.png │ │ ├── signing-05.png │ │ ├── signing-06.png │ │ ├── stacked-branches │ │ │ ├── 0_concepts.jpg │ │ │ ├── 10_branch_deletion.jpg │ │ │ ├── 11_overview.jpg │ │ │ ├── 1_creating_stack.jpg │ │ │ ├── 2_new_commits.jpg │ │ │ ├── 3_push_all.jpg │ │ │ ├── 4_create_pr.jpg │ │ │ ├── 5_pr_footer.jpg │ │ │ ├── 6_modify_commits-amend.jpg │ │ │ ├── 6_modify_commits-move.jpg │ │ │ ├── 6_modify_commits-squash.jpg │ │ │ ├── 7_move_to_vb.jpg │ │ │ ├── 8_merging-1.jpg │ │ │ ├── 8_merging-2.jpg │ │ │ └── 9_pr_heads.jpg │ │ ├── started │ │ │ ├── clean.png │ │ │ ├── commit-editing.png │ │ │ ├── import.png │ │ │ ├── initial-workspace.png │ │ │ ├── insert-empty.png │ │ │ ├── more-work.png │ │ │ ├── push-and-pr.png │ │ │ ├── reorder-squash.png │ │ │ ├── setup-git.png │ │ │ ├── single-commit.png │ │ │ ├── two-branches.png │ │ │ ├── unapply.png │ │ │ ├── undo.png │ │ │ └── welcome-screen.png │ │ ├── timeline-01.png │ │ ├── timeline-02.png │ │ ├── timeline-03.png │ │ ├── virtual-branches-01.jpeg │ │ ├── virtual-branches-02.jpeg │ │ ├── virtual-branches-03.jpeg │ │ └── virtual-branches-04.jpeg │ ├── dots-bowtie.svg │ ├── icon-wordmark-combo.svg │ ├── markus-broke.svg │ ├── markus-chill.svg │ ├── nothing-found.svg │ ├── pixel-bowtie.svg │ ├── play-banner.svg │ ├── scribble-bowtie.svg │ ├── sharp-bowtie.svg │ ├── shiny-bowtie.png │ ├── shiny-bowtie.svg │ └── world.svg ├── llms-full.txt └── oss │ └── pledge.json ├── scripts ├── generate-docs.js └── generate-llmstxt.js ├── source.config.ts ├── tailwind.config.js ├── tsconfig.json └── vercel.json /.github/CODEOWNERS: -------------------------------------------------------------------------------- 1 | 2 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/1_documentation.yml: -------------------------------------------------------------------------------- 1 | name: "Documentation" 2 | description: Request to update or improve GitButler documentation 3 | labels: ["triage", "documentation"] 4 | body: 5 | - type: textarea 6 | attributes: 7 | label: What is the improvement or update you wish to see? 8 | description: "Example: The GitButler docs are missing information about X." 9 | validations: 10 | required: true 11 | - type: textarea 12 | attributes: 13 | label: Is there any context that might help us understand? 14 | description: A clear description of any added context that might help us understand. 15 | validations: 16 | required: false 17 | - type: input 18 | attributes: 19 | label: Does the docs page already exist? Please link to it. 20 | description: "Example: https://docs.gitbutler.com/features/virtual-branches" 21 | validations: 22 | required: false 23 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/config.yml: -------------------------------------------------------------------------------- 1 | blank_issues_enabled: true 2 | contact_links: 3 | - name: Get help from the community (Discord) 4 | url: https://discord.com/invite/MmFkmaJ42D 5 | about: Ask questions and discuss with other community members 6 | -------------------------------------------------------------------------------- /.github/PULL_REQUEST_TEMPLATE.md: -------------------------------------------------------------------------------- 1 | ## 🧢 Changes 2 | 3 | 4 | ## ☕️ Reasoning 5 | 6 | 7 | 16 | 17 | 23 | -------------------------------------------------------------------------------- /.github/broken-link-checker/action.yml: -------------------------------------------------------------------------------- 1 | name: "Broken Link Checker" 2 | description: "Recursively checks input URL for broken links" 3 | outputs: 4 | version: 5 | description: "Check for broken internal links" 6 | runs: 7 | using: "node20" 8 | main: "dist/index.js" 9 | -------------------------------------------------------------------------------- /.github/broken-link-checker/dist/index.js: -------------------------------------------------------------------------------- 1 | import p from"broken-link-checker";import{setFailed as c}from"@actions/core";import*as h from"@actions/github";var m="# Broken Link Checker";async function k({octokit:n,owner:t,repo:i,prNumber:o}){try{let{data:e}=await n.rest.issues.listComments({owner:t,repo:i,issue_number:o});return e.find(r=>r.body?.includes(m))}catch(e){c("Error finding bot comment: "+e);return}}var g=async n=>{try{let{context:t,getOctokit:i}=h,o=i(process.env.GITHUB_TOKEN),{owner:e,repo:r}=t.repo,s=t.payload.pull_request;s||(console.log("Skipping since this is not a pull request"),process.exit(0));let d=s.head.repo.fork,u=s.number;if(d)return c("The action could not create a Github comment because it is initiated from a forked repo. View the action logs for a list of broken links."),"";let l=await k({octokit:o,owner:e,repo:r,prNumber:u});if(console.log("botComment",l),l){console.log("Updating Comment");let{data:a}=await o.rest.issues.updateComment({owner:e,repo:r,comment_id:l?.id,body:n});return a.html_url}else{console.log("Creating Comment");let{data:a}=await o.rest.issues.createComment({owner:e,repo:r,issue_number:u,body:n});return a.html_url}}catch(t){return c("Error commenting: "+t),""}},f=n=>{let t=`${m} 2 | 3 | > **${n.links.length}** broken links found. Links organised below by source page, or page where they were found. 4 | `,i=n.links.reduce((o,e)=>(o[e.base.resolved]||(o[e.base.resolved]=[]),o[e.base.resolved].push(e),o),{});return Object.entries(i).forEach(([o,e],r)=>{t+=` 5 | 6 | ### ${r+1}) [${new URL(o).pathname}](${o}) 7 | 8 | | Target Link | Link Text | 9 | |------|------| 10 | `,e.forEach(s=>{t+=`| [${new URL(s.url.resolved).pathname}](${s.url.resolved}) | "${s.html?.text?.trim().replaceAll(` 11 | `,"")}" | 12 | `})}),n.errors.length&&(t+=` 13 | ### Errors 14 | `,n.errors.forEach(o=>{t+=` 15 | ${o} 16 | `})),t};async function b(){if(!process.env.GITHUB_TOKEN)throw new Error("GITHUB_TOKEN is required");let n=process.env.VERCEL_PREVIEW_URL||"https://authjs-nextra-docs.vercel.app",t={errors:[],links:[],pages:[],sites:[]},i={excludeExternalLinks:!0,honorRobotExclusions:!1,filterLevel:0,excludedKeywords:[]};new p.SiteChecker(i,{error:e=>{t.errors.push(e)},link:e=>{e.broken&&t.links.push(e)},end:async()=>{if(console.log("end.output.length",t.links.length),t.links.length){let e=t.links.filter(s=>s.broken&&!["HTTP_308"].includes(s.brokenReason));console.log("links404.length",e.length),console.log("links404.output[1]",JSON.stringify(e[1],null,2)),console.log("links404.output[2]",JSON.stringify(e[2],null,2)),console.log("links404.output[3]",JSON.stringify(e[3],null,2));let r=f({errors:t.errors,links:e,pages:[],sites:[]});await g(r),c("Found broken links")}}}).enqueue(n)}b(); 17 | -------------------------------------------------------------------------------- /.github/broken-link-checker/index.d.ts: -------------------------------------------------------------------------------- 1 | declare module "broken-link-checker"; 2 | -------------------------------------------------------------------------------- /.github/broken-link-checker/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "broken-link-checker", 3 | "private": true, 4 | "version": "0.2.0", 5 | "description": "Find broken links as a GitHub Action", 6 | "main": "dist/index.js", 7 | "type": "module", 8 | "scripts": { 9 | "dev": "tsx src/index.ts", 10 | "build": "npx tsup --clean --minify --format esm src/index.ts", 11 | "types": "tsc" 12 | }, 13 | "keywords": [ 14 | "typescript", 15 | "broken-link-checker", 16 | "github-action" 17 | ], 18 | "author": "ndom91 (https://ndo.dev/)", 19 | "license": "MIT", 20 | "devDependencies": { 21 | "@types/node": "^20.11.15", 22 | "tsup": "^8.0.1", 23 | "tsx": "^4.7.0", 24 | "typescript": "^5.3.3" 25 | }, 26 | "dependencies": { 27 | "@actions/core": "^1.10.1", 28 | "@actions/github": "^6.0.0", 29 | "broken-link-checker": "^0.7.8" 30 | } 31 | } 32 | -------------------------------------------------------------------------------- /.github/broken-link-checker/src/index.ts: -------------------------------------------------------------------------------- 1 | import blc from "broken-link-checker" 2 | import { setFailed } from "@actions/core" 3 | import * as github from "@actions/github" 4 | 5 | type TODO = any 6 | type Output = { 7 | errors: any[] 8 | links: any[] 9 | pages: any[] 10 | sites: any[] 11 | } 12 | type Comment = { 13 | id: number 14 | } 15 | type FindBotComment = { 16 | octokit: TODO 17 | owner: string 18 | repo: string 19 | prNumber: number 20 | } 21 | 22 | const COMMENT_TAG = "# Broken Link Checker" 23 | 24 | async function findBotComment({ 25 | octokit, 26 | owner, 27 | repo, 28 | prNumber 29 | }: FindBotComment): Promise { 30 | try { 31 | const { data: comments } = await octokit.rest.issues.listComments({ 32 | owner, 33 | repo, 34 | issue_number: prNumber 35 | }) 36 | 37 | return comments.find((c: TODO) => c.body?.includes(COMMENT_TAG)) 38 | } catch (error) { 39 | setFailed("Error finding bot comment: " + error) 40 | return undefined 41 | } 42 | } 43 | 44 | async function updateCheckStatus(brokenLinkCount: number, commentUrl?: string): Promise { 45 | const checkName = "Broken Link Checker" 46 | const summary = `Found ${brokenLinkCount} broken links in this PR. Click details for a list.` 47 | const text = `[See the comment for details](${commentUrl})` 48 | const { context, getOctokit } = github 49 | const octokit = getOctokit(process.env.GITHUB_TOKEN!) 50 | const { owner, repo } = context.repo 51 | 52 | // Can only update status on 'pull_request' events 53 | if (context.payload.pull_request) { 54 | const pullRequest = context.payload.pull_request 55 | const sha = pullRequest?.head.sha 56 | 57 | const checkParams = { 58 | owner, 59 | repo, 60 | name: checkName, 61 | head_sha: sha, 62 | status: "completed" as const, 63 | conclusion: "failure" as const, 64 | output: { 65 | title: checkName, 66 | summary: summary, 67 | text: text 68 | } 69 | } 70 | 71 | try { 72 | await octokit.rest.checks.create(checkParams) 73 | } catch (error) { 74 | setFailed("Failed to create check: " + error) 75 | } 76 | } 77 | } 78 | 79 | const postComment = async (outputMd: string, brokenLinkCount: number = 0): Promise => { 80 | try { 81 | const { context, getOctokit } = github 82 | const octokit = getOctokit(process.env.GITHUB_TOKEN!) 83 | const { owner, repo } = context.repo 84 | let prNumber 85 | 86 | // Handle various trigger events 87 | if (context.payload.pull_request) { 88 | // Triggered by `pull_request` 89 | prNumber = context.payload.pull_request?.number 90 | } else if (context.payload.issue) { 91 | // Triggered by `issue_comment` 92 | prNumber = context.payload?.issue?.number 93 | } 94 | 95 | if (!prNumber) { 96 | setFailed("Count not find PR Number") 97 | return "" 98 | } 99 | 100 | const botComment = await findBotComment({ 101 | octokit, 102 | owner, 103 | repo, 104 | prNumber 105 | }) 106 | if (botComment) { 107 | console.log("Updating Comment") 108 | const { data } = await octokit.rest.issues.updateComment({ 109 | owner, 110 | repo, 111 | comment_id: botComment?.id, 112 | body: outputMd 113 | }) 114 | 115 | return data.html_url 116 | } else if (brokenLinkCount > 0) { 117 | console.log("Creating Comment") 118 | const { data } = await octokit.rest.issues.createComment({ 119 | owner, 120 | repo, 121 | issue_number: prNumber, 122 | body: outputMd 123 | }) 124 | return data.html_url 125 | } 126 | return "" 127 | } catch (error) { 128 | setFailed("Error commenting: " + error) 129 | return "" 130 | } 131 | } 132 | 133 | const generateOutputMd = (output: Output): string => { 134 | // Add comment header 135 | let outputMd = `${COMMENT_TAG} 136 | 137 | > **${output.links.length}** broken links found. Links organised below by source page, or page where they were found. 138 | ` 139 | 140 | // Build map of page and array of its found broken links 141 | const linksByPage = output.links.reduce((acc, link) => { 142 | if (!acc[link.base.resolved]) { 143 | acc[link.base.resolved] = [] 144 | acc[link.base.resolved].push(link) 145 | } else { 146 | acc[link.base.resolved].push(link) 147 | } 148 | return acc 149 | }, {}) 150 | 151 | // Write out markdown tables of these links 152 | Object.entries(linksByPage).forEach(([page, links], i) => { 153 | outputMd += ` 154 | 155 | ### ${i + 1}) [${new URL(page).pathname}](${page}) 156 | 157 | | Target Link | Link Text | Reason | 158 | |------|------|------| 159 | ` 160 | 161 | // @ts-expect-error 162 | links.forEach((link: TODO) => { 163 | const siteUrl = process.env.VERCEL_PREVIEW_URL || "https://docs.gitbutler.com" 164 | 165 | // Show paths for internal links only and include hostnames for external links 166 | const targetLinkText = 167 | new URL(link.url.resolved).hostname === new URL(siteUrl).hostname 168 | ? new URL(link.url.resolved).pathname 169 | : link.url.resolved 170 | 171 | outputMd += `| [${targetLinkText}](${ 172 | link.url.resolved 173 | }) | "${link.html?.text?.trim().replaceAll("\n", "")}" | ${link.brokenReason} | 174 | ` 175 | }) 176 | }) 177 | 178 | // If there were scrape errors, append to bottom of comment 179 | if (output.errors.length) { 180 | outputMd += ` 181 | ### Errors 182 | ` 183 | output.errors.forEach((error) => { 184 | outputMd += ` 185 | ${error} 186 | ` 187 | }) 188 | } 189 | 190 | return outputMd 191 | } 192 | 193 | // Main function that triggers link validation across .mdx files 194 | async function brokenLinkChecker(): Promise { 195 | if (!process.env.GITHUB_TOKEN) { 196 | throw new Error("GITHUB_TOKEN is required") 197 | } 198 | const siteUrl = process.env.VERCEL_PREVIEW_URL || "https://docs.gitbutler.com" 199 | const output: Output = { 200 | errors: [], 201 | links: [], 202 | pages: [], 203 | sites: [] 204 | } 205 | 206 | // Options: https://www.npmjs.com/package/broken-link-checker#options 207 | const options = { 208 | excludeExternalLinks: false, 209 | excludedKeywords: ["gitlab.com/-", "platform.openai.com", "https://github.com/gitbutlerapp/gitbutler-docs/blob/main/content/docs", "Edit on GitHub"], 210 | honorRobotExclusions: false, 211 | filterLevel: 0 212 | } 213 | 214 | const siteChecker = new blc.SiteChecker(options, { 215 | error: (error: TODO) => { 216 | output.errors.push(error) 217 | }, 218 | link: (result: TODO) => { 219 | if (result.broken) { 220 | output.links.push(result) 221 | } 222 | }, 223 | end: async () => { 224 | if (output.links.length) { 225 | // Skip links that returned 308 226 | const brokenLinksForAttention = output.links.filter( 227 | (link) => link.broken && !["HTTP_308"].includes(link.brokenReason) 228 | ) 229 | 230 | const outputMd = generateOutputMd({ 231 | errors: output.errors, 232 | links: brokenLinksForAttention, 233 | pages: [], 234 | sites: [] 235 | }) 236 | const commentUrl = await postComment(outputMd, brokenLinksForAttention.length) 237 | 238 | // Update GitHub "check" status 239 | await updateCheckStatus(brokenLinksForAttention.length, commentUrl) 240 | 241 | brokenLinksForAttention.length && 242 | setFailed(`Found ${brokenLinksForAttention.length} broken link(s)`) 243 | } 244 | } 245 | }) 246 | 247 | siteChecker.enqueue(siteUrl) 248 | } 249 | 250 | brokenLinkChecker() 251 | -------------------------------------------------------------------------------- /.github/broken-link-checker/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "noEmit": true, 4 | "target": "esnext", 5 | "moduleResolution": "node", 6 | "rootDir": "./src", 7 | "types": ["./index.d.ts", "node"], 8 | "strict": true, 9 | "noImplicitAny": true, 10 | "allowSyntheticDefaultImports": true 11 | } 12 | } 13 | -------------------------------------------------------------------------------- /.github/workflows/broken-link-checker.yml: -------------------------------------------------------------------------------- 1 | name: "Broken Link Checker" 2 | 3 | on: 4 | issue_comment: 5 | types: [edited] 6 | 7 | permissions: 8 | pull-requests: write 9 | checks: write 10 | 11 | jobs: 12 | broken-link-checker: 13 | runs-on: ubuntu-latest 14 | if: github.actor == 'vercel[bot]' 15 | steps: 16 | - uses: actions/checkout@v4 17 | - uses: actions/setup-node@v4 18 | - run: corepack enable 19 | - uses: aaimio/vercel-preview-url-action@v2.2.0 20 | id: vercel_preview_url 21 | with: 22 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 23 | preview_url_regexp: https.*\/(.*gitbutler.vercel.app) 24 | - name: Install dependencies 25 | run: cd ./.github/broken-link-checker && pnpm install --ignore-workspace && pnpm build 26 | - name: Run link checker 27 | uses: ./.github/broken-link-checker 28 | id: broken-links 29 | env: 30 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 31 | VERCEL_PREVIEW_URL: https://${{ steps.vercel_preview_url.outputs.vercel_preview_url }} 32 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # deps 2 | node_modules 3 | 4 | # generated content 5 | .map.ts 6 | .source 7 | .contentlayer 8 | api-reference.json 9 | content/docs/api-reference/*.mdx 10 | !content/docs/api-reference/index.mdx 11 | 12 | # test & build 13 | /coverage 14 | /.next/ 15 | /out/ 16 | /build 17 | *.tsbuildinfo 18 | 19 | # misc 20 | .DS_Store 21 | *.pem 22 | /.pnp 23 | .pnp.js 24 | npm-debug.log* 25 | yarn-debug.log* 26 | yarn-error.log* 27 | 28 | # others 29 | !.env.example 30 | .env.* 31 | .vercel 32 | next-env.d.ts 33 | 34 | -------------------------------------------------------------------------------- /.nvmrc: -------------------------------------------------------------------------------- 1 | lts/jod 2 | -------------------------------------------------------------------------------- /.prettierrc.js: -------------------------------------------------------------------------------- 1 | /** 2 | * @see https://prettier.io/docs/en/configuration.html 3 | * @type {import("prettier").Config} 4 | */ 5 | const config = { 6 | semi: false, 7 | singleQuote: false, 8 | trailingComma: "none", 9 | printWidth: 100, 10 | endOfLine: "auto", 11 | plugins: ["prettier-plugin-tailwindcss"] 12 | } 13 | 14 | export default config 15 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | 2 | 3 | # GitButler Docs 4 | 5 | [![Static Badge](https://img.shields.io/badge/GitButler-docs-black?style=for-the-badge&logo=data%3Aimage%2Fsvg%2Bxml%3Bbase64%2CPHN2ZyB3aWR0aD0nMTYnIGhlaWdodD0nMTYnIHZpZXdCb3g9JzAgMCAxNiAxNicgZmlsbD0nbm9uZScgeG1sbnM9J2h0dHA6Ly93d3cudzMub3JnLzIwMDAvc3ZnJz48cGF0aCBkPSdNMCA2LjEwMzYzQzAgMy41OTc1OCAwIDIuMzQ0NTUgMC42MjM1NjUgMS40NTczMUMwLjg1MTI1NSAxLjEzMzM0IDEuMTMzMzQgMC44NTEyNTUgMS40NTczMSAwLjYyMzU2NUMyLjM0NDU1IDAgMy41OTc1OCAwIDYuMTAzNjMgMEg5Ljg5NjM3QzEyLjQwMjQgMCAxMy42NTU0IDAgMTQuNTQyNyAwLjYyMzU2NUMxNC44NjY3IDAuODUxMjU1IDE1LjE0ODcgMS4xMzMzNCAxNS4zNzY0IDEuNDU3MzFDMTYgMi4zNDQ1NSAxNiAzLjU5NzU4IDE2IDYuMTAzNjNWOS44OTYzN0MxNiAxMi40MDI0IDE2IDEzLjY1NTQgMTUuMzc2NCAxNC41NDI3QzE1LjE0ODcgMTQuODY2NyAxNC44NjY3IDE1LjE0ODcgMTQuNTQyNyAxNS4zNzY0QzEzLjY1NTQgMTYgMTIuNDAyNCAxNiA5Ljg5NjM3IDE2SDYuMTAzNjNDMy41OTc1OCAxNiAyLjM0NDU1IDE2IDEuNDU3MzEgMTUuMzc2NEMxLjEzMzM0IDE1LjE0ODcgMC44NTEyNTUgMTQuODY2NyAwLjYyMzU2NSAxNC41NDI3QzAgMTMuNjU1NCAwIDEyLjQwMjQgMCA5Ljg5NjM3VjYuMTAzNjNaJyBmaWxsPScjOTNFREU5Jy8%2BPHBhdGggZD0nTTYuODQ4NDUgOC41MjE4MkwxMi42MTEzIDExLjQ1NzZDMTIuOTkyIDExLjY1MTYgMTMuNDQgMTEuMzY5NSAxMy40NCAxMC45MzU4VjUuMDY0MTdDMTMuNDQgNC42MzA0NSAxMi45OTIgNC4zNDgzNyAxMi42MTEzIDQuNTQyMzNMNi44NDg0NSA3LjQ3ODE0QzYuNDI2NCA3LjY5MzE1IDYuNDI2NCA4LjMwNjgxIDYuODQ4NDUgOC41MjE4MlonIGZpbGw9J3VybCgjcGFpbnQwX3JhZGlhbF8yNTdfODApJy8%2BPHBhdGggZD0nTTkuMTUxNTUgOC41MjE4MkwzLjM4ODcxIDExLjQ1NzZDMy4wMDc5NyAxMS42NTE2IDIuNTYgMTEuMzY5NSAyLjU2IDEwLjkzNThWNS4wNjQxN0MyLjU2IDQuNjMwNDUgMy4wMDc5NyA0LjM0ODM3IDMuMzg4NzEgNC41NDIzM0w5LjE1MTU1IDcuNDc4MTRDOS41NzM2IDcuNjkzMTUgOS41NzM2IDguMzA2ODEgOS4xNTE1NSA4LjUyMTgyWicgZmlsbD0nYmxhY2snLz48ZGVmcz48cmFkaWFsR3JhZGllbnQgaWQ9J3BhaW50MF9yYWRpYWxfMjU3XzgwJyBjeD0nMCcgY3k9JzAnIHI9JzEnIGdyYWRpZW50VW5pdHM9J3VzZXJTcGFjZU9uVXNlJyBncmFkaWVudFRyYW5zZm9ybT0ndHJhbnNsYXRlKDguMDg1MDcgNy45OTk5OCkgcm90YXRlKC0xNzkuOTk5KSBzY2FsZSg1LjM2NDkzIDUuNjQyNzIpJz48c3RvcCBzdG9wLW9wYWNpdHk9JzAnLz48c3RvcCBvZmZzZXQ9JzEnLz48L3JhZGlhbEdyYWRpZW50PjwvZGVmcz48L3N2Zz4%3D&labelColor=%2397eae5)](https://docs.gitbutler.com) 6 | ![GitHub last commit](https://img.shields.io/github/last-commit/gitbutlerapp/gitbutler-docs?style=for-the-badge&labelColor=%2397eae5&color=black) 7 | 8 | 9 | 10 | GitButler documentation - https://docs.gitbutler.com 11 | 12 | ## 👷 Development 13 | 14 | 1. Clone the repository 15 | 16 | ```bash 17 | $ git clone https://github.com/gitbutlerapp/gitbutler-docs.git 18 | ``` 19 | 20 | 2. Install dependencies 21 | 22 | ```bash 23 | $ pnpm install 24 | ``` 25 | 26 | 3. Start the development server 27 | 28 | ```bash 29 | $ pnpm dev 30 | ``` 31 | 32 | Finally, open [http://localhost:3000](http://localhost:3000) with your browser to see the result. 33 | 34 | ## 🎒 Learn More 35 | 36 | To learn more about Next.js and Fumadocs, take a look at the following 37 | resources: 38 | 39 | - [Next.js Documentation](https://nextjs.org/docs) - learn about Next.js 40 | features and API. 41 | - [Learn Next.js](https://nextjs.org/learn) - an interactive Next.js tutorial. 42 | - [Fumadocs](https://fumadocs.vercel.app) - learn about Fumadocs 43 | 44 | ## 📝 License 45 | 46 | MIT 47 | -------------------------------------------------------------------------------- /app/(docs)/[[...slug]]/page.tsx: -------------------------------------------------------------------------------- 1 | import { openapi, utils } from "@/app/source" 2 | import { DocsPage, DocsBody, DocsTitle, DocsDescription } from "fumadocs-ui/page" 3 | import { notFound } from "next/navigation" 4 | import defaultComponents from "fumadocs-ui/mdx" 5 | import { Popup, PopupContent, PopupTrigger } from "fumadocs-twoslash/ui" 6 | import { Tab, Tabs } from "fumadocs-ui/components/tabs" 7 | import { Callout } from "fumadocs-ui/components/callout" 8 | import { TypeTable } from "fumadocs-ui/components/type-table" 9 | import { Accordion, Accordions } from "fumadocs-ui/components/accordion" 10 | import ImageSection from "@/app/components/ImageSection" 11 | import type { ComponentProps, FC } from "react" 12 | 13 | interface Param { 14 | slug: string[] 15 | } 16 | 17 | export default async function Page(props: { params: Promise }): Promise { 18 | const params = await props.params 19 | const page = utils.getPage(params.slug) 20 | 21 | if (!page) notFound() 22 | 23 | const footer = ( 24 | <> 25 | 31 | 39 | 40 | 41 | Edit on GitHub 42 | 43 | 49 | 54 | 55 | 63 | 71 | 72 | Give us feedback 73 | 74 | 75 | ) 76 | 77 | return ( 78 | 91 | {page.data.title} 92 | {page.data.description} 93 | 94 | >, 107 | APIPage: openapi.APIPage 108 | }} 109 | /> 110 | 111 | 112 | ) 113 | } 114 | 115 | export function generateStaticParams(): Param[] { 116 | return utils.getPages().map((page) => { 117 | return { 118 | slug: page.slugs 119 | } 120 | }) 121 | } 122 | 123 | export async function generateMetadata(props: { params: Promise<{ slug?: string[] }> }) { 124 | const params = await props.params 125 | const page = utils.getPage(params.slug) 126 | 127 | if (!page) notFound() 128 | 129 | return { 130 | title: page.data.title, 131 | description: page.data.description 132 | } 133 | } 134 | -------------------------------------------------------------------------------- /app/(docs)/layout.tsx: -------------------------------------------------------------------------------- 1 | import { DocsLayout } from "fumadocs-ui/layouts/docs" 2 | import type { ReactNode } from "react" 3 | import { docsOptions } from "@/app/layout.config" 4 | import "fumadocs-twoslash/twoslash.css" 5 | 6 | export default function Layout({ children }: { children: ReactNode }) { 7 | return {children} 8 | } 9 | -------------------------------------------------------------------------------- /app/(docs)/not-found.tsx: -------------------------------------------------------------------------------- 1 | import Link from "next/link" 2 | 3 | export default function NotFound() { 4 | return ( 5 |
6 |
7 |
8 |

Page Not Found

9 |

10 | Could not find requested resource, please 11 |
12 | try again later or return home. 13 |

14 |
15 | 16 | 17 |
18 |
19 | ) 20 | } 21 | -------------------------------------------------------------------------------- /app/api/search/route.ts: -------------------------------------------------------------------------------- 1 | import { utils } from "@/app/source" 2 | import { createSearchAPI } from "fumadocs-core/search/server" 3 | 4 | export const { GET } = createSearchAPI("advanced", { 5 | indexes: utils.getPages().map((page) => { 6 | return { 7 | title: page.data.title, 8 | structuredData: page.data.structuredData, 9 | id: page.url, 10 | url: page.url 11 | } 12 | }) 13 | }) 14 | -------------------------------------------------------------------------------- /app/components/ImageSection.tsx: -------------------------------------------------------------------------------- 1 | import { ImageZoom } from "fumadocs-ui/components/image-zoom" 2 | 3 | interface Props { 4 | /** 5 | * Image path relative to `/public/img/docs` 6 | */ 7 | src: string 8 | alt?: string 9 | className?: string 10 | subtitle?: string 11 | } 12 | 13 | const shimmer = (w: number, h: number) => ` 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | ` 26 | 27 | const toBase64 = (str: string) => 28 | typeof window === "undefined" ? Buffer.from(str).toString("base64") : window.btoa(str) 29 | 30 | export default async function ImageSection({ src, alt, subtitle }: Props) { 31 | const img = await import(`../../public/img/docs${src}`).then((mod) => mod.default) 32 | if (!img) return null 33 | 34 | return ( 35 |
36 | 42 | {subtitle ? ( 43 |
44 | {subtitle} 45 |
46 | ) : null} 47 |
48 | ) 49 | } 50 | -------------------------------------------------------------------------------- /app/components/Logo.tsx: -------------------------------------------------------------------------------- 1 | export default function Logo() { 2 | return ( 3 |
4 | GitButler Logo 5 | 11 | 15 | 19 | 23 | 27 | 31 | 35 | 39 | 43 | 47 | 48 |
49 | ) 50 | } 51 | -------------------------------------------------------------------------------- /app/components/logos/discord.tsx: -------------------------------------------------------------------------------- 1 | export default function Discord() { 2 | return ( 3 | 11 | 12 | 18 | 19 | 20 | ) 21 | } 22 | -------------------------------------------------------------------------------- /app/components/logos/gitbutler-wordmark.tsx: -------------------------------------------------------------------------------- 1 | export default function GitButlerWordMark() { 2 | return ( 3 | 4 | 9 | 13 | 14 | 22 | 23 | 24 | 25 | 26 | 27 | ) 28 | } 29 | -------------------------------------------------------------------------------- /app/components/mermaid/Mermaid.tsx: -------------------------------------------------------------------------------- 1 | "use client" 2 | 3 | import { MutableRefObject, ReactElement, useEffect, useId, useRef, useState } from "react" 4 | import { MermaidConfig } from "mermaid" 5 | 6 | function useIsVisible(ref: MutableRefObject) { 7 | const [isIntersecting, setIsIntersecting] = useState(false) 8 | 9 | useEffect(() => { 10 | const observer = new IntersectionObserver(([entry]) => { 11 | if (entry.isIntersecting) { 12 | // disconnect after once visible to avoid re-rendering of chart when `isIntersecting` will 13 | // be changed to true/false 14 | observer.disconnect() 15 | setIsIntersecting(true) 16 | } 17 | }) 18 | 19 | observer.observe(ref.current) 20 | return () => { 21 | observer.disconnect() 22 | } 23 | }, [ref]) 24 | 25 | return isIntersecting 26 | } 27 | 28 | export function Mermaid({ chart }: { chart: string }): ReactElement { 29 | const id = useId() 30 | const [svg, setSvg] = useState("") 31 | const containerRef = useRef(null!) 32 | const isVisible = useIsVisible(containerRef) 33 | 34 | useEffect(() => { 35 | // Fix when inside element with `display: hidden` https://github.com/shuding/nextra/issues/3291 36 | if (!isVisible) { 37 | return 38 | } 39 | const htmlElement = document.documentElement 40 | const observer = new MutationObserver(renderChart) 41 | observer.observe(htmlElement, { attributes: true }) 42 | renderChart() 43 | 44 | return () => { 45 | observer.disconnect() 46 | } 47 | 48 | // Switching themes taken from https://github.com/mermaid-js/mermaid/blob/1b40f552b20df4ab99a986dd58c9d254b3bfd7bc/packages/mermaid/src/docs/.vitepress/theme/Mermaid.vue#L53 49 | async function renderChart() { 50 | const isDarkTheme = 51 | htmlElement.classList.contains("dark") || 52 | htmlElement.attributes.getNamedItem("data-theme")?.value === "dark" 53 | 54 | const mermaidConfig: MermaidConfig = { 55 | securityLevel: "loose", 56 | fontFamily: "inherit", 57 | themeCSS: "margin: 1.5rem auto 0;", 58 | theme: isDarkTheme ? "dark" : "default", 59 | themeVariables: { 60 | background: isDarkTheme ? "#97eae5" : "#003366", 61 | primaryColor: isDarkTheme ? "#97eae5" : "#003366", 62 | git0: "#97eae5", 63 | git1: "#DC606B", 64 | git2: "#DC9B14" 65 | } 66 | } 67 | 68 | const { default: mermaid } = await import("mermaid") 69 | 70 | try { 71 | mermaid.initialize(mermaidConfig) 72 | const { svg } = await mermaid.render( 73 | // strip invalid characters for `id` attribute 74 | id.replaceAll(":", ""), 75 | chart.replaceAll("\\n", "\n"), 76 | containerRef.current 77 | ) 78 | setSvg(svg) 79 | } catch (error) { 80 | console.error("Error while rendering mermaid", error) 81 | } 82 | } 83 | }, [chart, isVisible]) 84 | 85 | return
86 | } 87 | -------------------------------------------------------------------------------- /app/components/mermaid/index.ts: -------------------------------------------------------------------------------- 1 | import { Code, Root, RootContent } from "mdast" 2 | import { Plugin } from "unified" 3 | import { visit } from "unist-util-visit" 4 | 5 | const COMPONENT_NAME = "Mermaid" 6 | 7 | const MERMAID_IMPORT_AST = { 8 | type: "mdxjsEsm", 9 | data: { 10 | estree: { 11 | body: [ 12 | { 13 | type: "ImportDeclaration", 14 | specifiers: [ 15 | { 16 | type: "ImportSpecifier", 17 | imported: { type: "Identifier", name: COMPONENT_NAME }, 18 | local: { type: "Identifier", name: COMPONENT_NAME } 19 | } 20 | ], 21 | source: { type: "Literal", value: "@/components/mermaid/Mermaid" } 22 | } 23 | ] 24 | } 25 | } 26 | } as RootContent 27 | 28 | export const remarkMermaid: Plugin<[], Root> = () => (ast, _file, done) => { 29 | // eslint-disable-next-line 30 | const codeblocks: any[] = [] 31 | visit(ast, { type: "code", lang: "mermaid" }, (node: Code, index, parent) => { 32 | codeblocks.push([node, index, parent]) 33 | }) 34 | 35 | if (codeblocks.length !== 0) { 36 | for (const [node, index, parent] of codeblocks) { 37 | parent.children.splice(index, 1, { 38 | type: "mdxJsxFlowElement", 39 | name: COMPONENT_NAME, 40 | attributes: [ 41 | { 42 | type: "mdxJsxAttribute", 43 | name: "chart", 44 | value: node.value.replaceAll("\n", "\\n") 45 | } 46 | ] 47 | }) 48 | } 49 | ast.children.unshift(MERMAID_IMPORT_AST) 50 | } 51 | 52 | done() 53 | } 54 | -------------------------------------------------------------------------------- /app/global.css: -------------------------------------------------------------------------------- 1 | @tailwind base; 2 | @tailwind components; 3 | @tailwind utilities; 4 | 5 | @import "open-props/easings"; 6 | @import "open-props/animations"; 7 | 8 | :root { 9 | --off-white: #f5f5f3; 10 | --clr-bg: var(--off-white); 11 | /* gray */ 12 | --clr-white: #ffffff; 13 | --clr-black: #000000; 14 | --clr-dark-gray: #707070; 15 | --clr-gray: #d0cfcb; 16 | --clr-light-gray: #f1f1ed; 17 | /* accent */ 18 | --clr-accent: #97eae5; 19 | --clr-err-50: #dc606b; 20 | --clr-warn-50: #dc9b14; 21 | --font-system: system-ui, sans-serif; 22 | } 23 | 24 | /* change selection */ 25 | ::selection { 26 | background-color: var(--clr-accent); 27 | color: var(--clr-black); 28 | } 29 | 30 | html:not(.dark) body { 31 | background-color: var(--clr-bg); 32 | } 33 | 34 | body { 35 | /* optimise font rendering */ 36 | -webkit-font-smoothing: antialiased; 37 | text-rendering: optimizeLegibility; 38 | font-family: var(--font-sans); 39 | 40 | -webkit-font-smoothing: antialiased; 41 | -moz-osx-font-smoothing: grayscale; 42 | text-rendering: optimizeLegibility; 43 | 44 | &::-webkit-scrollbar { 45 | width: 8px; 46 | /* Mostly for vertical scrollbars */ 47 | height: 8px; 48 | /* Mostly for horizontal scrollbars */ 49 | } 50 | 51 | &::-webkit-scrollbar-thumb { 52 | background: color-mix(in srgb, var(--clr-accent) 96%, var(--clr-black)); 53 | } 54 | } 55 | 56 | iframe[src*="youtube"] { 57 | aspect-ratio: 16 / 9; 58 | border-radius: 0.5rem; 59 | @apply h-full w-full; 60 | } 61 | -------------------------------------------------------------------------------- /app/layout.config.tsx: -------------------------------------------------------------------------------- 1 | import { utils } from "@/app/source" 2 | import type { DocsLayoutProps } from "fumadocs-ui/layouts/docs" 3 | import type { HomeLayoutProps } from "fumadocs-ui/layouts/home" 4 | 5 | import Logo from "@/components/Logo" 6 | import Discord from "@/components/logos/discord" 7 | import GitButler from "@/components/logos/gitbutler-wordmark" 8 | 9 | // shared configuration 10 | export const baseOptions: HomeLayoutProps = { 11 | nav: { 12 | title: , 13 | transparentMode: "top" 14 | }, 15 | githubUrl: "https://github.com/gitbutlerapp/gitbutler", 16 | links: [ 17 | { 18 | icon: , 19 | text: "Discord", 20 | url: "https://discord.com/invite/MmFkmaJ42D" 21 | }, 22 | { 23 | icon: , 24 | text: "GitButler Cloud", 25 | url: "https://app.gitbutler.com/" 26 | } 27 | ] 28 | } 29 | 30 | // docs layout configuration 31 | export const docsOptions: DocsLayoutProps = { 32 | ...baseOptions, 33 | sidebar: { 34 | defaultOpenLevel: 0 35 | }, 36 | tree: utils.pageTree 37 | } 38 | -------------------------------------------------------------------------------- /app/layout.tsx: -------------------------------------------------------------------------------- 1 | import "./global.css" 2 | import { Provider } from "./provider" 3 | import { Inter } from "next/font/google" 4 | import type { Metadata, Viewport } from "next" 5 | import type { ReactNode } from "react" 6 | import Script from "next/script" 7 | 8 | const baseUrl = 9 | process.env.NODE_ENV === "development" 10 | ? new URL("http://localhost:3000") 11 | : new URL(`https://${process.env.VERCEL_URL}`) 12 | 13 | const inter = Inter({ 14 | subsets: ["latin"], 15 | display: "swap", 16 | variable: "--font-inter" 17 | }) 18 | 19 | export default function Layout({ children }: { children: ReactNode }) { 20 | return ( 21 | 22 | 23 | {children} 24 | 25 |