diff --git a/bun.lock b/bun.lock index 3c2e3f402..482e745fb 100644 --- a/bun.lock +++ b/bun.lock @@ -185,7 +185,7 @@ }, "integrations/github": { "name": "@gitbook/integration-github", - "version": "0.6.9", + "version": "0.7.0", "dependencies": { "@gitbook/api": "*", "@gitbook/runtime": "*", @@ -205,7 +205,7 @@ }, "integrations/github-copilot": { "name": "@gitbook/integration-github-copilot", - "version": "0.2.1", + "version": "0.2.2", "dependencies": { "@copilot-extensions/preview-sdk": "^5.0.0", "@gitbook/api": "*", @@ -235,7 +235,7 @@ }, "integrations/gitlab": { "name": "@gitbook/integration-gitlab", - "version": "0.6.8", + "version": "0.7.0", "dependencies": { "@gitbook/api": "*", "@gitbook/runtime": "*", @@ -324,7 +324,7 @@ }, "integrations/intercom": { "name": "@gitbook/integration-intercom", - "version": "0.5.2", + "version": "0.6.0", "dependencies": { "@gitbook/api": "*", "@gitbook/runtime": "*", @@ -402,7 +402,7 @@ }, "integrations/mailchimp": { "name": "@gitbook/integration-mailchimp", - "version": "1.3.1", + "version": "1.3.2", "dependencies": { "@gitbook/runtime": "*", "itty-router": "^2.6.1", @@ -590,7 +590,7 @@ }, "integrations/segment": { "name": "@gitbook/integration-segment", - "version": "2.3.0", + "version": "2.4.0", "dependencies": { "@gitbook/api": "*", "@gitbook/runtime": "*", @@ -772,6 +772,7 @@ "dependencies": { "@1password/op-js": "^0.1.13", "@gitbook/api": "*", + "@gitbook/content": "file:../../../gitbook-x/packages/content/dist", "check-node-version": "^4.2.1", "chokidar": "^4.0.1", "commander": "^9.2.0", @@ -1093,9 +1094,11 @@ "@gitbook/document": ["@gitbook/document@workspace:packages/document"], - "@gitbook/fontawesome-pro": ["@gitbook/fontawesome-pro@1.0.16", "", { "dependencies": { "@fortawesome/fontawesome-common-types": "^7.1.0" } }, "sha512-jypE3FpC5U7lealzpGVq1Jie5Ay5jYv/UkOLxPhOpffjCd98A+Ex08M6GyINMDO7KCBaF5p8pbdl693hf0DwNg=="], + "@gitbook/expr": ["@gitbook/expr@1.3.0", "", { "dependencies": { "acorn": "^8.15.0", "acorn-loose": "^8.5.2", "acorn-walk": "^8.3.4", "assert-never": "^1.4.0", "escodegen": "^2.1.0", "eval-estree-expression": "github:jonschlinkert/eval-estree-expression#fb0246a" } }, "sha512-RVSUOAMaPEnUoBYB9HlBwFDl6zsz/KBgsDQXzqlWm+VamHkAKAPYF2GQtu17X462ILzdRpLREUVJZIVtNneTHw=="], - "@gitbook/icons": ["@gitbook/icons@0.4.3", "", { "dependencies": { "@fortawesome/fontawesome-svg-core": "^7.1.0", "@gitbook/fontawesome-pro": "1.0.16" }, "peerDependencies": { "react": "*" }, "bin": { "gitbook-icons": "bin/gitbook-icons.js" } }, "sha512-BszvEOMdsxQCmRMw7hxKn8p/bYJQ1kBfy6FW3ClgLtziQBjWkxEPPnyoWdDrZji8Qwa/X5DLBT6oeGRzmvum/g=="], + "@gitbook/fontawesome-pro": ["@gitbook/fontawesome-pro@1.0.23", "", { "dependencies": { "@fortawesome/fontawesome-common-types": "^7.2.0" } }, "sha512-igxgeuZvZIDghZhju8vAV3GjznwOUU2RHMGxGLxwf6QhL4soTJIveD+84C6EGQRieX8O1H6b4TOac3Hhom+Jcg=="], + + "@gitbook/icons": ["@gitbook/icons@0.5.0", "", { "dependencies": { "@fortawesome/fontawesome-svg-core": "^7.2.0", "@gitbook/fontawesome-pro": "1.0.23" }, "peerDependencies": { "react": "*" }, "bin": { "gitbook-icons": "bin/gitbook-icons.js" } }, "sha512-OVnL+egquUIfqAAlG01EmvgO+lMh3E/2SfGu8GT0RuE7VogGgLBy5oAY2c1k53TGUbv679fUs6hDc1vNgpvTQw=="], "@gitbook/integration-ahrefs": ["@gitbook/integration-ahrefs@workspace:integrations/ahrefs"], @@ -1505,6 +1508,8 @@ "acorn": ["acorn@8.14.0", "", { "bin": { "acorn": "bin/acorn" } }, "sha512-cl669nCJTZBsL97OF4kUQm5g5hC2uihk0NxY3WENAC0TYdILVkAyHymAntgxGkl7K+t0cXIrH5siy5S4XkFycA=="], + "acorn-loose": ["acorn-loose@8.5.2", "", { "dependencies": { "acorn": "^8.15.0" } }, "sha512-PPvV6g8UGMGgjrMu+n/f9E/tCSkNQ2Y97eFvuVdJfG11+xdIeDcLyNdC8SHcrHbRqkfwLASdplyR6B6sKM1U4A=="], + "acorn-walk": ["acorn-walk@8.3.2", "", {}, "sha512-cjkyv4OtNCIeqhHrfS81QWXoCBPExR/J62oyEqepVw8WaQeSqpW2uhuLPh1m9eWhDuOo/jUXVTlifvesOWp/4A=="], "agent-base": ["agent-base@7.1.3", "", {}, "sha512-jRR5wdylq8CkOe6hei19GGZnxM6rBGwFl3Bg0YItGDimvjGtAvdZk4Pu6Cl4u4Igsws4a1fd1Vq3ezrhn4KmFw=="], @@ -1543,6 +1548,8 @@ "assert": ["assert@2.1.0", "", { "dependencies": { "call-bind": "^1.0.2", "is-nan": "^1.3.2", "object-is": "^1.1.5", "object.assign": "^4.1.4", "util": "^0.12.5" } }, "sha512-eLHpSK/Y4nhMJ07gDaAzoX/XAKS8PSaojml3M0DM4JpV1LAi5JOJ/p6H/XWrl8L+DzVEvVCW1z3vWAaB9oTsQw=="], + "assert-never": ["assert-never@1.4.0", "", {}, "sha512-5oJg84os6NMQNl27T9LnZkvvqzvAnHu03ShCnoj6bsJwS7L8AO4lf+C/XjK/nvzEqQB744moC6V128RucQd1jA=="], + "astral-regex": ["astral-regex@2.0.0", "", {}, "sha512-Z7tMw1ytTXt5jqMcOP+OQteU1VuNK9Y02uuJtKQ1Sv69jXQKKg5cibLwGJow8yzZP+eAc18EmLGPal0bp36rvQ=="], "async-function": ["async-function@1.0.0", "", {}, "sha512-hsU18Ae8CDTR6Kgu9DYf0EbCr/a5iGL0rytQDobUcdpYOKokk8LEjVphnXkDkgpi0wYVsqrXuP0bZxJaTqdgoA=="], @@ -1827,12 +1834,20 @@ "escape-string-regexp": ["escape-string-regexp@4.0.0", "", {}, "sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA=="], + "escodegen": ["escodegen@2.1.0", "", { "dependencies": { "esprima": "^4.0.1", "estraverse": "^5.2.0", "esutils": "^2.0.2" }, "optionalDependencies": { "source-map": "~0.6.1" }, "bin": { "esgenerate": "bin/esgenerate.js", "escodegen": "bin/escodegen.js" } }, "sha512-2NlIDTwUWJN0mRPQOdtQBzbUHvdGY2P1VXSyU83Q3xKxM7WHX2Ql8dKq782Q9TgQUNOLEzEYu9bzLNj1q88I5w=="], + "esprima": ["esprima@4.0.1", "", { "bin": { "esparse": "./bin/esparse.js", "esvalidate": "./bin/esvalidate.js" } }, "sha512-eGuFFw7Upda+g4p+QHvnW0RyTX/SVeJBDM/gCtMARO0cLuT2HcEKnTPvhjV6aGeqrCB/sbNop0Kszm0jsaWU4A=="], + "estraverse": ["estraverse@5.3.0", "", {}, "sha512-MMdARuVEQziNTeJD8DgMqmhwR11BRQ/cBP+pLtYdSTnf3MIO8fFeiINEbX36ZdNlfU/7A9f3gUw49B3oQsvwBA=="], + "estree-walker": ["estree-walker@0.6.1", "", {}, "sha512-SqmZANLWS0mnatqbSfRP5g8OXZC12Fgg1IwNtLsyHDzJizORW4khDfjPqJZsemPWBB2uqykUah5YpQ6epsqC/w=="], + "esutils": ["esutils@2.0.3", "", {}, "sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g=="], + "eta": ["eta@3.5.0", "", {}, "sha512-e3x3FBvGzeCIHhF+zhK8FZA2vC5uFn6b4HJjegUbIWrDb4mJ7JjTGMJY9VGIbRVpmSwHopNiaJibhjIr+HfLug=="], + "eval-estree-expression": ["eval-estree-expression@github:jonschlinkert/eval-estree-expression#fb0246a", {}, "jonschlinkert-eval-estree-expression-fb0246a", "sha512-F47wdNJRDxP2C8zf2MhjLrfjZc7NJ8Rdg5SzK8wr7j8Egtg24EPavxYlY8cuSgMShX+mlahczKD2r9Msk4jZRA=="], + "event-iterator": ["event-iterator@2.0.0", "", {}, "sha512-KGft0ldl31BZVV//jj+IAIGCxkvvUkkON+ScH6zfoX+l+omX6001ggyRSpI0Io2Hlro0ThXotswCtfzS8UkIiQ=="], "event-target-shim": ["event-target-shim@5.0.1", "", {}, "sha512-i/2XbnSz/uxRCU6+NdVJgKWDTM427+MqYbkQzD321DuCQJUqOuJKIA0IM2+W2xtYHdKOmZ4dR6fExsd4SXL+WQ=="], @@ -2743,10 +2758,18 @@ "@gitbook/browser-types/@gitbook/api": ["@gitbook/api@0.163.0", "", { "dependencies": { "event-iterator": "^2.0.0", "eventsource-parser": "^3.0.0" } }, "sha512-iEIJcHuh8qUhtgdd13IK/RKHNyvHvvqY2GuEBUGsCd42P3dm8QkgM7OUoCCaFiKBpuU+OZ+UtEB8N+9yJEgL7Q=="], + "@gitbook/browser-types/@gitbook/icons": ["@gitbook/icons@0.4.3", "", { "dependencies": { "@fortawesome/fontawesome-svg-core": "^7.1.0", "@gitbook/fontawesome-pro": "1.0.16" }, "peerDependencies": { "react": "*" }, "bin": { "gitbook-icons": "bin/gitbook-icons.js" } }, "sha512-BszvEOMdsxQCmRMw7hxKn8p/bYJQ1kBfy6FW3ClgLtziQBjWkxEPPnyoWdDrZji8Qwa/X5DLBT6oeGRzmvum/g=="], + + "@gitbook/cli/@gitbook/content": ["@gitbook/content@file:../gitbook-x/packages/content/dist", { "dependencies": { "@gitbook/expr": "^1.3.0", "@gitbook/icons": "^0.5.0" } }], + "@gitbook/cli/esbuild": ["esbuild@0.17.19", "", { "optionalDependencies": { "@esbuild/android-arm": "0.17.19", "@esbuild/android-arm64": "0.17.19", "@esbuild/android-x64": "0.17.19", "@esbuild/darwin-arm64": "0.17.19", "@esbuild/darwin-x64": "0.17.19", "@esbuild/freebsd-arm64": "0.17.19", "@esbuild/freebsd-x64": "0.17.19", "@esbuild/linux-arm": "0.17.19", "@esbuild/linux-arm64": "0.17.19", "@esbuild/linux-ia32": "0.17.19", "@esbuild/linux-loong64": "0.17.19", "@esbuild/linux-mips64el": "0.17.19", "@esbuild/linux-ppc64": "0.17.19", "@esbuild/linux-riscv64": "0.17.19", "@esbuild/linux-s390x": "0.17.19", "@esbuild/linux-x64": "0.17.19", "@esbuild/netbsd-x64": "0.17.19", "@esbuild/openbsd-x64": "0.17.19", "@esbuild/sunos-x64": "0.17.19", "@esbuild/win32-arm64": "0.17.19", "@esbuild/win32-ia32": "0.17.19", "@esbuild/win32-x64": "0.17.19" }, "bin": { "esbuild": "bin/esbuild" } }, "sha512-XQ0jAPFkK/u3LcVRcvVHQcTIqD6E2H1fvZMA5dQPSOWb3suUbWbfbRf94pjc0bNzRYLfIrDRQXr7X+LHIm5oHw=="], "@gitbook/cli/typescript": ["typescript@5.9.3", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw=="], + "@gitbook/expr/acorn": ["acorn@8.17.0", "", { "bin": { "acorn": "bin/acorn" } }, "sha512-xRQbDb9BnwDafYNn6Vwl839DYVjqXYb1XVGtWAZ1kcDc6iwAL4hg3B1dZlRiuENFeO2H53gFG3in621AdERVAg=="], + + "@gitbook/expr/acorn-walk": ["acorn-walk@8.3.5", "", { "dependencies": { "acorn": "^8.11.0" } }, "sha512-HEHNfbars9v4pgpW6SO1KSPkfoS0xVOM/9UzkJltjlsHZmJasxg8aXkuZa7SMf8vKGIBhpUsPluQSqhJFCqebw=="], + "@gitbook/integration-cognito/itty-router": ["itty-router@4.2.2", "", {}, "sha512-KegPW0l9SNPadProoFT07AB84uOqLUwzlXQ7HsqkS31WUrxkjdhcemRpTDUuetbMJ89uBtWeQSVoiEmUAu31uw=="], "@gitbook/integration-discord/itty-router": ["itty-router@4.2.2", "", {}, "sha512-KegPW0l9SNPadProoFT07AB84uOqLUwzlXQ7HsqkS31WUrxkjdhcemRpTDUuetbMJ89uBtWeQSVoiEmUAu31uw=="], @@ -2889,6 +2912,8 @@ "@whatwg-node/fetch/urlpattern-polyfill": ["urlpattern-polyfill@10.0.0", "", {}, "sha512-H/A06tKD7sS1O1X2SshBVeA5FLycRpjqiBeqGKmBwBDBy28EnRjORxTNe269KSSr5un5qyWi1iL61wLxpd+ZOg=="], + "acorn-loose/acorn": ["acorn@8.17.0", "", { "bin": { "acorn": "bin/acorn" } }, "sha512-xRQbDb9BnwDafYNn6Vwl839DYVjqXYb1XVGtWAZ1kcDc6iwAL4hg3B1dZlRiuENFeO2H53gFG3in621AdERVAg=="], + "ai/zod": ["zod@4.1.12", "", {}, "sha512-JInaHOamG8pt5+Ey8kGmdcAcg3OL9reK8ltczgHTAwNhMys/6ThXHityHxVV2p3fkw/c+MAvBHFVYHFZDmjMCQ=="], "ajv/fast-deep-equal": ["fast-deep-equal@3.1.3", "", {}, "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q=="], @@ -2929,6 +2954,8 @@ "enquirer/strip-ansi": ["strip-ansi@6.0.1", "", { "dependencies": { "ansi-regex": "^5.0.1" } }, "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A=="], + "escodegen/source-map": ["source-map@0.6.1", "", {}, "sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g=="], + "execa/onetime": ["onetime@6.0.0", "", { "dependencies": { "mimic-fn": "^4.0.0" } }, "sha512-1FlR+gjXK7X+AsAHso35MnyN5KqGwJRi/31ft6x0M194ht7S+rWAvd7PHss9xSKMzE0asv1pyIHaJYq+BbacAQ=="], "execa/signal-exit": ["signal-exit@3.0.7", "", {}, "sha512-wnD2ZE+l+SPC/uoS0vXeE9L1+0wuaMqKlfz9AMUo38JsyLSBWSFcHR1Rri62LZc12vLr1gb3jl7iwQhgwpAbGQ=="], @@ -3053,6 +3080,8 @@ "@gitbook/api/esbuild/@esbuild/linux-loong64": ["@esbuild/linux-loong64@0.14.54", "", { "os": "linux", "cpu": "none" }, "sha512-bZBrLAIX1kpWelV0XemxBZllyRmM6vgFQQG2GdNb+r3Fkp0FOh1NJSvekXDs7jq70k4euu1cryLMfU+mTXlEpw=="], + "@gitbook/browser-types/@gitbook/icons/@gitbook/fontawesome-pro": ["@gitbook/fontawesome-pro@1.0.16", "", { "dependencies": { "@fortawesome/fontawesome-common-types": "^7.1.0" } }, "sha512-jypE3FpC5U7lealzpGVq1Jie5Ay5jYv/UkOLxPhOpffjCd98A+Ex08M6GyINMDO7KCBaF5p8pbdl693hf0DwNg=="], + "@gitbook/cli/esbuild/@esbuild/android-arm": ["@esbuild/android-arm@0.17.19", "", { "os": "android", "cpu": "arm" }, "sha512-rIKddzqhmav7MSmoFCmDIb6e2W57geRsM94gV2l38fzhXMwq7hZoClug9USI2pFRGL06f4IOPHHpFNOkWieR8A=="], "@gitbook/cli/esbuild/@esbuild/android-arm64": ["@esbuild/android-arm64@0.17.19", "", { "os": "android", "cpu": "arm64" }, "sha512-KBMWvEZooR7+kzY0BtbTQn0OAYY7CsiydT63pVEaPtVYF0hXbUaOyZog37DKxK7NF3XacBJOpYT4adIJh+avxA=="], diff --git a/packages/cli/build.sh b/packages/cli/build.sh index b8d417f6f..30ee893f5 100755 --- a/packages/cli/build.sh +++ b/packages/cli/build.sh @@ -4,6 +4,8 @@ rm -rf ./dist/ esbuild ./src/cli.ts \ --bundle \ --platform=node \ + --format=esm \ + --banner:js="$(cat ./esm-shims.js)" \ --external:esbuild \ --external:miniflare \ --external:fsevents \ diff --git a/packages/cli/cli.js b/packages/cli/cli.js index 7809afeb3..f2cac48e2 100755 --- a/packages/cli/cli.js +++ b/packages/cli/cli.js @@ -4,4 +4,4 @@ * To avoid the probem of bun/npm only linking the CLI at the installation time. * We use a fixed file that will be linked to the bin folder and requires the actual CLI. */ -require('./dist/cli.js'); +import './dist/cli.js'; diff --git a/packages/cli/esm-shims.js b/packages/cli/esm-shims.js new file mode 100644 index 000000000..54770e286 --- /dev/null +++ b/packages/cli/esm-shims.js @@ -0,0 +1,8 @@ +// Shims for CommonJS globals used by bundled dependencies (require) and by +// the CLI itself (__dirname), injected as a banner in the ESM bundle. +import { createRequire as __createRequire } from 'node:module'; +import { fileURLToPath as __fileURLToPath } from 'node:url'; +import { dirname as __pathDirname } from 'node:path'; +const require = __createRequire(import.meta.url); +const __filename = __fileURLToPath(import.meta.url); +const __dirname = __pathDirname(__filename); diff --git a/packages/cli/package.json b/packages/cli/package.json index ae55e801a..f556b7b98 100644 --- a/packages/cli/package.json +++ b/packages/cli/package.json @@ -2,8 +2,10 @@ "name": "@gitbook/cli", "description": "CLI to build and publish integrations on GitBook.com", "version": "0.28.0", + "type": "module", "dependencies": { "@gitbook/api": "*", + "@gitbook/content": "file:../../../gitbook-x/packages/content/dist", "check-node-version": "^4.2.1", "commander": "^9.2.0", "conf": "^13.1.0", @@ -26,11 +28,11 @@ }, "files": [ "dist/**", - "postinstall.js" + "postinstall.cjs" ], "scripts": { "build": "./build.sh", - "postinstall": "node postinstall.js", + "postinstall": "node postinstall.cjs", "typecheck": "tsc --noEmit" }, "engines": { diff --git a/packages/cli/postinstall.js b/packages/cli/postinstall.cjs similarity index 98% rename from packages/cli/postinstall.js rename to packages/cli/postinstall.cjs index 774326cc8..a47417c28 100644 --- a/packages/cli/postinstall.js +++ b/packages/cli/postinstall.cjs @@ -2,7 +2,7 @@ * Script to install cloudflared binary on the current platform. * Reference: https://github.com/JacobLinCool/node-cloudflared/blob/main/src/install.ts */ -const { execSync } = require('child_process'); +const { execSync } = require('node:child_process'); const fs = require('fs'); const https = require('https'); const path = require('path'); diff --git a/packages/cli/src/cli.ts b/packages/cli/src/cli.ts index 89cd3c1a3..ad4bd0b89 100644 --- a/packages/cli/src/cli.ts +++ b/packages/cli/src/cli.ts @@ -16,6 +16,7 @@ import { publishIntegration, unpublishIntegration } from './publish'; import { authenticate, whoami } from './remote'; import { tailLogs } from './tail'; import { checkIntegrationBuild } from './check'; +import { brokenLinksContentFiles, formatContentFiles, lintContentFiles } from './content'; import { publishOpenAPISpecificationFromFilepath, publishOpenAPISpecificationFromURL, @@ -153,6 +154,54 @@ program }); }); +const contentProgram = program + .command('content') + .description('lint and format markdown content against the GitBook content schema'); +contentProgram + .command('lint') + .description( + 'report content that GitBook would remove or restructure on import, and invalid frontmatter', + ) + .argument('[files...]', 'markdown files or directories (searched recursively for *.md)') + .option('--strict', 'exit with code 1 on warnings, not only errors') + .action(async (files, options) => { + const code = await lintContentFiles(files, { strict: options.strict ?? false }); + if (code !== 0) { + process.exit(code); + } + }); +contentProgram + .command('broken-links') + .description('check for broken internal links and anchors across markdown files') + .argument('[files...]', 'markdown files or directories (searched recursively for *.md)') + .option('--strict', 'exit with code 1 on warnings (broken anchors), not only errors') + .action(async (files, options) => { + const code = await brokenLinksContentFiles(files, { strict: options.strict ?? false }); + if (code !== 0) { + process.exit(code); + } + }); +contentProgram + .command('format') + .description( + "apply GitBook's canonical style to markdown files; never alters content unless --force", + ) + .argument('[files...]', 'markdown files or directories (searched recursively for *.md)') + .option('--write', 'write changes to the files') + .option( + '--force', + "also apply GitBook's normalization: content reported as lint errors is removed or restructured", + ) + .action(async (files, options) => { + const code = await formatContentFiles(files, { + write: options.write ?? false, + force: options.force ?? false, + }); + if (code !== 0) { + process.exit(code); + } + }); + const openAPIProgram = program.command('openapi').description('manage OpenAPI specifications'); openAPIProgram .command('publish') diff --git a/packages/cli/src/content.ts b/packages/cli/src/content.ts new file mode 100644 index 000000000..df5b046c0 --- /dev/null +++ b/packages/cli/src/content.ts @@ -0,0 +1,309 @@ +import * as fs from 'fs/promises'; +import * as path from 'path'; +import * as yaml from 'js-yaml'; + +import type { ContentDiagnostic } from '@gitbook/content'; + +const IGNORED_DIRECTORIES = new Set(['node_modules', '.git']); + +interface GitBookRepoConfig { + /** Absolute path of the GitBook content root. */ + root: string; + /** File name of the table of contents (navigation, not a page). */ + summaryName: string; +} + +/** + * Read the repository's .gitbook.yaml, when present: it defines the content + * root and the structure files, exactly as Git Sync interprets them. + */ +async function loadGitBookRepoConfig(cwd: string): Promise { + for (const name of ['.gitbook.yaml', '.gitbook.yml']) { + try { + const raw = await fs.readFile(path.join(cwd, name), 'utf8'); + const parsed = yaml.load(raw) as { + root?: string; + structure?: { summary?: string }; + } | null; + return { + root: path.resolve(cwd, parsed?.root ?? '.'), + summaryName: path.basename(parsed?.structure?.summary ?? 'SUMMARY.md'), + }; + } catch { + // Try the next name. + } + } + return null; +} + +const DIFF_PREVIEW_LINES = 12; + +const useColor = process.stdout.isTTY === true; +const paint = (code: string, text: string) => (useColor ? `[${code}m${text}` : text); +const red = (text: string) => paint('31', text); +const yellow = (text: string) => paint('33', text); +const green = (text: string) => paint('32', text); +const dim = (text: string) => paint('2', text); + +/** + * Load the content engine lazily so that the (large) engine is only + * initialized when a content command actually runs. + */ +async function loadEngine() { + return await import('@gitbook/content'); +} + +/** + * Expand a list of files and directories into the markdown files they contain. + * `excludeSummaryName` skips the table of contents file (SUMMARY.md by + * default): GitBook parses it as navigation, not as a page, so page-level + * lint/format do not apply to it. Explicitly passed files are always kept. + */ +async function collectMarkdownFiles( + inputs: string[], + options: { excludeSummaryName?: string } = {}, +): Promise { + const files: string[] = []; + + const visit = async (input: string) => { + const stats = await fs.stat(input); + if (stats.isDirectory()) { + const entries = await fs.readdir(input, { withFileTypes: true }); + for (const entry of entries) { + if (entry.isDirectory()) { + if (IGNORED_DIRECTORIES.has(entry.name) || entry.name.startsWith('.')) { + continue; + } + await visit(path.join(input, entry.name)); + } else if ( + entry.name.endsWith('.md') && + entry.name !== options.excludeSummaryName + ) { + files.push(path.join(input, entry.name)); + } + } + } else { + files.push(input); + } + }; + + for (const input of inputs) { + await visit(input); + } + + return files.sort(); +} + +/** + * Resolve the files to check for page-level commands (lint, format): when no + * input is given and the repository has a .gitbook.yaml, the configured + * content root is used, so files outside it (e.g. the GitHub-facing README) + * are not treated as GitBook content. + */ +async function collectPageFiles(inputs: string[]): Promise { + const config = await loadGitBookRepoConfig(process.cwd()); + const defaults = config ? [config.root] : ['.']; + return await collectMarkdownFiles(inputs.length > 0 ? inputs : defaults, { + excludeSummaryName: config?.summaryName ?? 'SUMMARY.md', + }); +} + +function printDiff(diagnostic: ContentDiagnostic) { + const lines: string[] = []; + if (diagnostic.actual) { + for (const line of diagnostic.actual.split('\n')) { + lines.push(red(` - ${line}`)); + } + } + if (diagnostic.expected) { + for (const line of diagnostic.expected.split('\n')) { + lines.push(green(` + ${line}`)); + } + } + for (const line of lines.slice(0, DIFF_PREVIEW_LINES)) { + console.log(line); + } + if (lines.length > DIFF_PREVIEW_LINES) { + console.log(dim(` … ${lines.length - DIFF_PREVIEW_LINES} more lines`)); + } +} + +function printDiagnostic(file: string, diagnostic: ContentDiagnostic) { + const position = diagnostic.range + ? `:${diagnostic.range.start.line}:${diagnostic.range.start.column}` + : ''; + const severity = + diagnostic.severity === 'error' ? red(diagnostic.severity) : yellow(diagnostic.severity); + console.log(`${file}${position} ${severity} ${dim(diagnostic.code)} ${diagnostic.message}`); + printDiff(diagnostic); +} + +/** + * Lint markdown files against the GitBook content schema: content that GitBook + * would remove or restructure on import, and invalid frontmatter. Style is not + * lint's concern. Returns the process exit code. + */ +export async function lintContentFiles( + inputs: string[], + options: { strict: boolean }, +): Promise { + const { lintContent } = await loadEngine(); + + const files = await collectPageFiles(inputs); + if (files.length === 0) { + console.log('No markdown files found.'); + return 0; + } + + let errors = 0; + let warnings = 0; + for (const file of files) { + const source = await fs.readFile(file, 'utf8'); + const { diagnostics } = await lintContent(source); + for (const diagnostic of diagnostics) { + printDiagnostic(file, diagnostic); + if (diagnostic.severity === 'error') { + errors++; + } else { + warnings++; + } + } + } + + const clean = errors === 0 && warnings === 0; + console.log( + clean + ? green(`✓ ${files.length} file(s) checked, no issues found.`) + : `${files.length} file(s) checked: ${errors} error(s), ${warnings} warning(s).`, + ); + return errors > 0 || (options.strict && warnings > 0) ? 1 : 0; +} + +/** + * Check for broken internal links and anchors across markdown files. + * Returns the process exit code. + */ +export async function brokenLinksContentFiles( + inputs: string[], + options: { strict: boolean }, +): Promise { + const { checkBrokenLinks } = await loadEngine(); + + // Links resolve against the GitBook content root when the repository + // declares one. The table of contents is included: it is the navigation + // file, its links matter. + const config = await loadGitBookRepoConfig(process.cwd()); + const root = config?.root ?? process.cwd(); + const files = await collectMarkdownFiles(inputs.length > 0 ? inputs : [root]); + if (files.length === 0) { + console.log('No markdown files found.'); + return 0; + } + + const pages = await Promise.all( + files.map(async (file) => ({ + path: path.relative(root, path.resolve(root, file)).split(path.sep).join('/'), + content: await fs.readFile(file, 'utf8'), + })), + ); + + const { diagnostics } = await checkBrokenLinks({ + pages, + fileExists: async (filePath) => { + try { + await fs.stat(path.resolve(root, filePath)); + return true; + } catch { + return false; + } + }, + }); + + let errors = 0; + let warnings = 0; + for (const diagnostic of diagnostics) { + printDiagnostic(diagnostic.page, diagnostic); + if (diagnostic.severity === 'error') { + errors++; + } else { + warnings++; + } + } + + const clean = errors === 0 && warnings === 0; + console.log( + clean + ? green(`✓ ${files.length} file(s) checked, no broken links found.`) + : `${files.length} file(s) checked: ${errors} broken link(s), ${warnings} broken anchor(s).`, + ); + return errors > 0 || (options.strict && warnings > 0) ? 1 : 0; +} + +/** + * Apply GitBook's canonical style to markdown files. Never removes or + * restructures content unless `force` is set, in which case GitBook's full + * normalization is applied. Returns the process exit code. + */ +export async function formatContentFiles( + inputs: string[], + options: { write: boolean; force: boolean }, +): Promise { + const { formatContent } = await loadEngine(); + + const files = await collectPageFiles(inputs); + if (files.length === 0) { + console.log('No markdown files found.'); + return 0; + } + + let changed = 0; + let failed = 0; + let issues = 0; + for (const file of files) { + const source = await fs.readFile(file, 'utf8'); + try { + const result = await formatContent(source, { normalize: options.force }); + if (result.changed && options.write) { + await fs.writeFile(file, result.output); + } + if (result.changed || result.issues.length > 0) { + if (result.changed) { + changed++; + } + issues += result.issues.length; + const status = result.changed + ? options.write + ? green('formatted') + : yellow('would change') + : dim('unchanged'); + const issueNote = + result.issues.length > 0 + ? ` ${red(`${result.issues.length} content issue(s) preserved`)}` + : ''; + console.log(`${file} ${status}${issueNote}`); + } + } catch (error) { + failed++; + console.log( + `${file} ${red('error')} ${error instanceof Error ? error.message : String(error)}`, + ); + } + } + + console.log( + `${files.length} file(s) checked: ${changed} ${ + options.write ? 'formatted' : 'not formatted' + }, ${failed} failed.`, + ); + if (issues > 0) { + console.log( + dim( + `${issues} content-altering change(s) were not applied. Run 'gitbook content lint' to review them, or 'format --force' to apply GitBook's full normalization.`, + ), + ); + } + if (failed > 0) { + return 2; + } + return !options.write && changed > 0 ? 1 : 0; +}