diff --git a/.eslintignore b/.eslintignore deleted file mode 100644 index ea717d99..00000000 --- a/.eslintignore +++ /dev/null @@ -1,9 +0,0 @@ -public -.next -package-lock.json -yarn.lock -bundler/dist -dist -build -_docs.page -og \ No newline at end of file diff --git a/.eslintrc b/.eslintrc deleted file mode 100644 index d07bc392..00000000 --- a/.eslintrc +++ /dev/null @@ -1,40 +0,0 @@ -{ - "parser": "@typescript-eslint/parser", - "extends": [ - "plugin:@typescript-eslint/recommended", - "plugin:prettier/recommended", - "plugin:react/recommended", - "prettier" - ], - "parserOptions": { - "ecmaVersion": 2020, - // Allows for the parsing of modern ECMAScript features - "sourceType": "module" - // Allows for the use of imports - }, - "settings": { - "react": { - "version": "17.0.0" - } - }, - "env": { - "es6": true, - "node": true - }, - "rules": { - "react/no-unescaped-entities": 0, - "react/display-name": 0, - // All images are from remote sources - "@next/next/no-img-element": 0, - "@typescript-eslint/explicit-function-return-type": 0, - "@typescript-eslint/camelcase": 0, - "@typescript-eslint/ban-ts-comment": 0, - "@typescript-eslint/no-var-requires": 0, - "@typescript-eslint/no-non-null-assertion": 0, - // suppress errors for missing 'import React' in files - "react/react-in-jsx-scope": "off", - "react/prop-types": "off", - // allow jsx syntax in js files (for next.js project) - "react/jsx-filename-extension": [1, { "extensions": [".js", ".jsx", ".ts", ".tsx"] }] - } -} diff --git a/.github/workflows/pull_request.yaml b/.github/workflows/pull_request.yaml new file mode 100644 index 00000000..f0067e8b --- /dev/null +++ b/.github/workflows/pull_request.yaml @@ -0,0 +1,17 @@ +name: Code quality + +on: + pull_request: + +jobs: + quality: + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@v4 + - name: Setup Biome + uses: biomejs/setup-biome@v2 + with: + version: latest + - name: Run Biome + run: biome ci . \ No newline at end of file diff --git a/.github/workflows/website.yaml b/.github/workflows/website.yaml deleted file mode 100644 index 6884e635..00000000 --- a/.github/workflows/website.yaml +++ /dev/null @@ -1,22 +0,0 @@ -name: website - -on: - pull_request: - push: - branches: - - main - -jobs: - validate: - runs-on: ubuntu-latest - steps: - - uses: actions/checkout@v2 - - uses: actions/setup-node@v2-beta - with: - node-version: '14' - - name: NPM Install - run: yarn - - name: Check Linting - run: yarn run check:linting - - name: Check Formatting - run: yarn run check:formatting diff --git a/.prettierignore b/.prettierignore deleted file mode 100644 index c56984ad..00000000 --- a/.prettierignore +++ /dev/null @@ -1,8 +0,0 @@ -.next -node_modules -build -dist -domains.json -spelling.json -_docs.page -docs/components/tabs.mdx \ No newline at end of file diff --git a/.prettierrc.json b/.prettierrc.json deleted file mode 100644 index 58c3742b..00000000 --- a/.prettierrc.json +++ /dev/null @@ -1,10 +0,0 @@ -{ - "arrowParens": "avoid", - "trailingComma": "all", - "useTabs": false, - "semi": true, - "singleQuote": true, - "bracketSpacing": true, - "tabWidth": 2, - "printWidth": 100 -} diff --git a/api/Dockerfile b/api/Dockerfile index e984ef7e..e05be861 100644 --- a/api/Dockerfile +++ b/api/Dockerfile @@ -13,7 +13,7 @@ # See the License for the specific language governing permissions and # limitations under the License. -FROM oven/bun:1.0.29 +FROM oven/bun:1.1.3 ARG BUILD_SHA=default_value ENV __BUILD_SHA=$BUILD_SHA diff --git a/api/bun.lockb b/api/bun.lockb index b7a1f90b..57ca1047 100755 Binary files a/api/bun.lockb and b/api/bun.lockb differ diff --git a/api/package.json b/api/package.json index b67ae62f..378a7f8e 100644 --- a/api/package.json +++ b/api/package.json @@ -1,57 +1,58 @@ { - "type": "module", - "private": true, - "name": "docs-page-api", - "version": "1.0.0", - "main": "src/app.ts", - "author": "Invertase ", - "license": "MIT", - "dependencies": { - "@code-hike/mdx": "0.7.2", - "@mdx-js/mdx": "2.2.1", - "@octokit/graphql": "5.0.0", - "@types/cors": "^2.8.12", - "@types/express": "^4.17.13", - "@types/morgan": "^1.9.3", - "@types/node": "^18.0.0", - "a2a": "^0.2.0", - "camelcase": "7.0.0", - "cors": "2.8.5", - "dotenv": "16.0.1", - "esbuild": "0.14.47", - "express": "4.18.1", - "express-basic-auth": "^1.2.1", - "gray-matter": "4.0.3", - "hast-util-heading-rank": "2.1.1", - "hast-util-parse-selector": "3.1.0", - "is-badge": "^2.1.0", - "js-yaml": "4.1.0", - "mdx-bundler": "9.0.1", - "morgan": "1.10.0", - "node-fetch": "3.2.6", - "pm2": "5.3.0", - "probot": "12.2.8", - "rehype-accessible-emojis": "0.3.2", - "rehype-katex": "6.0.2", - "rehype-slug": "5.0.1", - "remark-comment": "1.0.0", - "remark-gfm": "3.0.1", - "remark-math": "5.1.1", - "remark-parse": "10.0.1", - "shiki": "1.1.7", - "unist-util-visit": "4.1.0", - "zod": "3.22.4", - "zod-validation-error": "0.2.2" - }, - "scripts": { - "dev": "bun run src/app.ts --hot", - "start": "bun run src/app.ts" - }, - "devDependencies": { - "@octokit/webhooks-types": "^6.2.4", - "@types/js-yaml": "^4.0.5", - "rollup": "3.9.1", - "ts-node": "^10.8.1", - "typescript": "4.7.4" - } + "type": "module", + "private": true, + "name": "docs-page-api", + "version": "1.0.0", + "main": "src/app.ts", + "author": "Invertase ", + "license": "MIT", + "dependencies": { + "@code-hike/mdx": "0.7.2", + "@mdx-js/mdx": "2.2.1", + "@octokit/graphql": "5.0.0", + "@octokit/webhooks": "^13.2.7", + "@types/express": "^4.17.13", + "@types/morgan": "^1.9.3", + "@types/node": "^18.0.0", + "a2a": "^0.2.0", + "camelcase": "7.0.0", + "dotenv": "16.0.1", + "esbuild": "0.14.47", + "express": "4.18.1", + "express-basic-auth": "^1.2.1", + "gray-matter": "4.0.3", + "hast-util-heading-rank": "2.1.1", + "hast-util-parse-selector": "3.1.0", + "is-badge": "^2.1.0", + "js-yaml": "4.1.0", + "lodash.get": "^4.4.2", + "mdx-bundler": "9.0.1", + "morgan": "1.10.0", + "node-fetch": "3.2.6", + "octokit": "^4.0.2", + "rehype-accessible-emojis": "0.3.2", + "rehype-katex": "6.0.2", + "rehype-slug": "5.0.1", + "remark-comment": "1.0.0", + "remark-gfm": "3.0.1", + "remark-math": "5.1.1", + "remark-parse": "10.0.1", + "shiki": "1.1.7", + "unist-util-visit": "4.1.0", + "zod": "3.22.4", + "zod-to-json-schema": "^3.23.1", + "zod-validation-error": "0.2.2" + }, + "scripts": { + "dev": "bun run src/app.ts --hot", + "start": "bun run src/app.ts" + }, + "devDependencies": { + "@octokit/webhooks-types": "^6.2.4", + "@types/js-yaml": "^4.0.5", + "@types/lodash.get": "^4.4.9", + "rollup": "3.9.1", + "ts-node": "^10.8.1", + "typescript": "5.5.2" + } } diff --git a/api/src/app.ts b/api/src/app.ts index bc922732..88bbea04 100644 --- a/api/src/app.ts +++ b/api/src/app.ts @@ -1,37 +1,35 @@ -import express, { Router, text } from 'express'; -import morgan from 'morgan'; -import cors from 'cors'; -import { config } from 'dotenv'; +import { config } from "dotenv"; +import express, { Router, text } from "express"; +import morgan from "morgan"; -import bundle from './routes/bundle'; -import preview from './routes/preview'; -import mdx from './routes/mdx'; -import probot from './probot'; -import { notFound } from './res'; +import { notFound } from "./res"; +import bundle from "./routes/bundle"; +import preview from "./routes/preview"; +import schema from "./routes/schema"; +import githubWebhook from "./routes/webhooks.github"; config(); const PORT = process.env.PORT || 8080; const app = express(); app.use(text()); -app.use(cors()); -app.use(morgan('dev')); +app.use(morgan("dev")); app.use(express.json()); app.use( - express.urlencoded({ - extended: true, - }), + express.urlencoded({ + extended: true, + }), ); -app.use(probot); const router = Router(); -router.get('/status', (_, res) => res.status(200).send('OK')); -router.post('/preview', preview); -router.get('/bundle', bundle); -router.post('/mdx', mdx); -router.all('*', (_, res) => notFound(res)); +router.get("/status", (_, res) => res.status(200).send("OK")); +router.get("/schema.json", schema); +router.post("/preview", preview); +router.get("/bundle", bundle); +router.post("/webhooks/github", githubWebhook); +router.all("*", (_, res) => notFound(res)); app.use(router); app.listen(PORT, () => { - console.log(`docs.page api server is running at http://localhost:${PORT}`); + console.log(`docs.page api server is running at http://localhost:${PORT}`); }); diff --git a/api/src/bundler/error.ts b/api/src/bundler/error.ts new file mode 100644 index 00000000..0b7b10f2 --- /dev/null +++ b/api/src/bundler/error.ts @@ -0,0 +1,23 @@ +export class BundlerError extends Error { + code: number; + name: string; + source?: string; + + constructor({ + code, + name, + message, + source, + }: { + code: number; + name: string; + message: string; + source?: string; + }) { + super(message); + this.code = code; + this.name = name; + this.message = message; + this.source = source; + } +} diff --git a/api/src/bundler/index.ts b/api/src/bundler/index.ts index 02b14c6e..4231e66f 100644 --- a/api/src/bundler/index.ts +++ b/api/src/bundler/index.ts @@ -1,212 +1,201 @@ -import parseConfig, { Config, defaultConfig } from '../utils/config'; -import { getGitHubContents, getPullRequestMetadata } from '../utils/github'; -import { bundle } from './mdx'; -import { escapeHtml } from '../utils/sanitize'; - -export class BundlerError extends Error { - code: number; - links?: { title: string; url: string }[]; - - constructor({ - code, - name, - message, - cause, - links, - }: { - code: number; - name: string; - message: string; - cause?: string; - links?: { title: string; url: string }[]; - }) { - super(message); - this.code = code; - this.name = name; - this.message = message; - this.cause = cause; - this.links = links; - } -} +import { type Config, defaultConfig, parseConfig } from "../config"; +import { getGitHubContents, getPullRequestMetadata } from "../utils/github"; +import { escapeHtml } from "../utils/sanitize"; +import { replaceMoustacheVariables } from "../utils/variables"; +import { BundlerError } from "./error"; +import { parseMdx } from "./mdx"; +import type { HeadingNode } from "./plugins/rehype-headings"; export const ERROR_CODES = { - REPO_NOT_FOUND: 'REPO_NOT_FOUND', - FILE_NOT_FOUND: 'FILE_NOT_FOUND', - BUNDLE_ERROR: 'BUNDLE_ERROR', + REPO_NOT_FOUND: "REPO_NOT_FOUND", + FILE_NOT_FOUND: "FILE_NOT_FOUND", + BUNDLE_ERROR: "BUNDLE_ERROR", } as const; type Source = { - type: 'PR' | 'commit' | 'branch'; - owner: string; - repository: string; - ref?: string; + type: "PR" | "commit" | "branch"; + owner: string; + repository: string; + ref?: string; }; -class Bundler { - readonly #owner: string; - readonly #repository: string; - readonly #path: string; - #notices: Array = []; - #ref: string | undefined; - #source?: Source; - #config?: Config; - #markdown?: string; - - constructor(params: CreateBundlerParams) { - this.#owner = params.owner; - this.#repository = params.repository; - this.#path = params.path; - this.#ref = params.ref; - } - - /** - * Gets the source of the bundle. - * - * If the ref is a PR, it will fetch the PR metadata and update the source. - */ - private async getSource(): Promise { - if (this.#ref) { - // If the ref is a PR - if (/^[0-9]*$/.test(this.#ref)) { - const pullRequest = await getPullRequestMetadata(this.#owner, this.#repository, this.#ref); - if (pullRequest) { - return { - type: 'PR', - ...pullRequest, - }; - } - } - - // If the ref is a commit hash - if (/^[a-fA-F0-9]{40}$/.test(this.#ref)) { - return { - type: 'commit', - owner: this.#owner, - repository: this.#repository, - ref: this.#ref, - }; - } - } - - return { - type: 'branch', - owner: this.#owner, - repository: this.#repository, - ref: this.#ref, - }; - } - - /** - * Builds the payload with the MDX bundle. - */ - build = async () => { - // Get the real source of the request - this.#source = await this.getSource(); - - // Update the ref to the real ref - this.#ref = this.#source.ref; - - const metadata = await getGitHubContents({ - owner: this.#source.owner, - repository: this.#source.repository, - path: this.#path, - ref: this.#ref, - }); - - if (!metadata) { - throw new BundlerError({ - code: 404, - name: ERROR_CODES.REPO_NOT_FOUND, - message: `The repository ${this.#source.owner}/${this.#source.repository} was not found.`, - }); - } - - if (!metadata.md) { - throw new BundlerError({ - code: 404, - name: ERROR_CODES.FILE_NOT_FOUND, - message: `The file "/docs/${this.#path}.mdx" or "/docs/${ - this.#path - }/index.mdx" in repository /${ - this.#source.owner + '/' + this.#source.repository - } was not found.`, - links: [ - { - title: 'Repository link', - url: `https://github.com/${this.#source.owner}/${this.#source.repository}`, - }, - ], - }); - } - - this.#markdown = metadata.md; - - // If there is no ref (either not provided, or not a PR), use the metadata. - if (!this.#ref) { - this.#ref = metadata.baseBranch; - this.#source.ref = metadata.baseBranch; - } - - // Parse the users config, but fallback if it errors. - try { - this.#config = parseConfig({ - json: metadata.config.configJson, - yaml: metadata.config.configYaml, - }); - } catch { - this.#notices.push( - 'The configuration file is invalid, falling back to the default configuration.', - ); - this.#config = defaultConfig; - } - - try { - // Bundle the markdown file via MDX. - const mdx = await bundle(this.#markdown, { - headerDepth: this.#config.headerDepth, - }); - - return { - source: this.#source, - ref: this.#ref, - baseBranch: metadata.baseBranch, - notices: this.#notices, - path: this.#path, - config: this.#config, - markdown: this.#markdown, - headings: mdx.headings, - frontmatter: mdx.frontmatter, - code: mdx.code, - }; - } catch (e) { - console.error(e); - // @ts-ignore - const message = escapeHtml(e?.message || ''); - throw new BundlerError({ - code: 500, - name: ERROR_CODES.BUNDLE_ERROR, - message: `Something went wrong while bundling the file /${metadata.path}.mdx. Are you sure the MDX is valid?`, - cause: message, - links: [ - { - title: `/${metadata.path}.mdx on GitHub`, - url: `https://github.com/${this.#source.owner}/${this.#source.repository}/blob/${ - this.#ref - }/${metadata.path}.mdx`, - }, - ], - }); - } - }; -} +export type BundlerOutput = { + source: Source; + ref: string; + stars: number; + forks: number; + private: boolean; + baseBranch: string; + path: string; + config: Config; + markdown: string; + headings: HeadingNode[]; + frontmatter: Record; + code: string; +}; type CreateBundlerParams = { - owner: string; - repository: string; - path: string; - ref?: string; + // The owner of the repository to bundle. + owner: string; + // The repository to bundle. + repository: string; + // The path to file in the repository to bundle. + path: string; + // An optional ref to use for the content. + ref?: string; + // A list of components which are supported for bundling. + components?: Array; }; -export default function bundler(params: CreateBundlerParams) { - return new Bundler(params).build(); +export class Bundler { + readonly #owner: string; + readonly #repository: string; + readonly #path: string; + readonly #components: Array; + #ref: string | undefined; + #source?: Source; + #config?: Config; + #markdown?: string; + + constructor(params: CreateBundlerParams) { + this.#owner = params.owner; + this.#repository = params.repository; + this.#path = params.path; + this.#ref = params.ref; + this.#components = params.components || []; + } + + /** + * Gets the source of the bundle. + * + * If the ref is a PR, it will fetch the PR metadata and update the source. + */ + private async getSource(): Promise { + if (this.#ref) { + // If the ref is a PR + if (/^[0-9]*$/.test(this.#ref)) { + const pullRequest = await getPullRequestMetadata( + this.#owner, + this.#repository, + this.#ref, + ); + if (pullRequest) { + return { + type: "PR", + ...pullRequest, + }; + } + } + + // If the ref is a commit hash + if (/^[a-fA-F0-9]{40}$/.test(this.#ref)) { + return { + type: "commit", + owner: this.#owner, + repository: this.#repository, + ref: this.#ref, + }; + } + } + + return { + type: "branch", + owner: this.#owner, + repository: this.#repository, + ref: this.#ref, + }; + } + + /** + * Builds the payload with the MDX bundle. + */ + async build(): Promise { + // Get the real source of the request + this.#source = await this.getSource(); + + // Update the ref to the real ref + this.#ref = this.#source.ref; + + const metadata = await getGitHubContents({ + owner: this.#source.owner, + repository: this.#source.repository, + path: this.#path, + ref: this.#ref, + }); + + if (!metadata) { + throw new BundlerError({ + code: 404, + name: ERROR_CODES.REPO_NOT_FOUND, + message: `The repository ${this.#source.owner}/${ + this.#source.repository + } was not found.`, + }); + } + + if (!metadata.md) { + throw new BundlerError({ + code: 404, + name: ERROR_CODES.FILE_NOT_FOUND, + message: `No file was found in the repository matching this path. Ensure a file exists at /docs/${ + this.#path + }.mdx or /docs/${this.#path}/index.mdx.`, + source: `https://github.com/${this.#source.owner}/${ + this.#source.repository + }`, + }); + } + + this.#markdown = metadata.md; + + // If there is no ref (either not provided, or not a PR), use the metadata. + if (!this.#ref) { + this.#ref = metadata.baseBranch; + this.#source.ref = metadata.baseBranch; + } + + // Parse the users config, but fallback if it errors. + try { + this.#config = parseConfig({ + json: metadata.config.configJson, + yaml: metadata.config.configYaml, + }); + } catch { + this.#config = defaultConfig; + } + + try { + // Bundle the markdown file via MDX. + const mdx = await parseMdx(this.#markdown, { + headerDepth: this.#config.content?.headerDepth ?? 3, + components: this.#components, + }); + + return { + source: this.#source, + ref: this.#ref, + stars: metadata.stars, + forks: metadata.forks, + private: metadata.isPrivate, + baseBranch: metadata.baseBranch, + path: this.#path, + config: this.#config, + markdown: this.#markdown, + headings: mdx.headings, + frontmatter: mdx.frontmatter, + code: replaceMoustacheVariables(this.#config.variables ?? {}, mdx.code), + }; + } catch (e) { + console.error(e); + // @ts-ignore + throw new BundlerError({ + code: 500, + name: ERROR_CODES.BUNDLE_ERROR, + message: `Something went wrong while bundling the file /${metadata.path}.mdx. Are you sure the MDX is valid?`, + source: `https://github.com/${this.#source.owner}/${ + this.#source.repository + }`, + }); + } + } } diff --git a/api/src/bundler/mdx.ts b/api/src/bundler/mdx.ts index fa5c6de6..465af1a2 100644 --- a/api/src/bundler/mdx.ts +++ b/api/src/bundler/mdx.ts @@ -1,66 +1,69 @@ -import { compile } from '@mdx-js/mdx'; -import { Message } from 'esbuild'; -import frontmatter from 'gray-matter'; -import { getRehypePlugins, getRemarkPlugins } from './plugins/index'; -import rehypeHeadings, { HeadingNode } from './plugins/rehype-headings'; +import { compile } from "@mdx-js/mdx"; +import type { Message } from "esbuild"; +import frontmatter from "gray-matter"; +import { getRehypePlugins, getRemarkPlugins } from "./plugins/index"; +import rehypeHeadings, { type HeadingNode } from "./plugins/rehype-headings"; -type MdxBundlerResponse = { - code: string; - frontmatter: Record; - errors: Message[]; - headings: HeadingNode[]; +type MdxResponse = { + code: string; + frontmatter: Record; + errors: Message[]; + headings: HeadingNode[]; }; export function headerDepthToHeaderList(depth: number): string[] { - const list: string[] = []; - if (depth === 0) return list; + const list: string[] = []; + if (depth === 0) return list; - for (let i = 2; i <= depth; i++) { - list.push(`h${i}`); - } + for (let i = 2; i <= depth; i++) { + list.push(`h${i}`); + } - return list; + return list; } -export async function bundle( - rawText: string, - options: { - headerDepth: number; - }, -): Promise { - const output = { - headings: [] as HeadingNode[], - frontmatter: {} as { [key: string]: string }, - }; +export async function parseMdx( + rawText: string, + options: { + headerDepth: number; + components: Array; + }, +): Promise { + const output = { + headings: [] as HeadingNode[], + frontmatter: {} as { [key: string]: string }, + }; - const parsed = frontmatter(rawText); - output.frontmatter = parsed.data; + const parsed = frontmatter(rawText); + output.frontmatter = parsed.data; - const vfile = await compile(parsed.content, { - // prevent this error `_jsxDEV is not a function` - // enable next line - // development: process.env.NODE_ENV === 'production', - format: 'mdx', - outputFormat: 'function-body', - remarkPlugins: getRemarkPlugins(), - rehypePlugins: [ - ...getRehypePlugins(), - [ - rehypeHeadings, - { - headings: headerDepthToHeaderList(options.headerDepth), - callback: (headings: HeadingNode[]) => { - output.headings = headings; - }, - }, - ], - ], - }); + const vfile = await compile(parsed.content, { + // prevent this error `_jsxDEV is not a function` + // enable next line + // development: process.env.NODE_ENV === 'production', + format: "mdx", + outputFormat: "function-body", + remarkPlugins: getRemarkPlugins({ + components: options.components, + }), + rehypePlugins: [ + ...getRehypePlugins(), + [ + rehypeHeadings, + { + headings: headerDepthToHeaderList(options.headerDepth), + callback: (headings: HeadingNode[]) => { + output.headings = headings; + }, + }, + ], + ], + }); - return { - code: String(vfile), - frontmatter: output.frontmatter, - errors: [], - headings: output.headings, - }; + return { + code: String(vfile), + frontmatter: output.frontmatter, + errors: [], + headings: output.headings, + }; } diff --git a/api/src/bundler/plugins/index.ts b/api/src/bundler/plugins/index.ts index 979c69e0..b6f87ec4 100644 --- a/api/src/bundler/plugins/index.ts +++ b/api/src/bundler/plugins/index.ts @@ -1,40 +1,51 @@ -import type { PluggableList } from '@mdx-js/mdx/lib/core'; +import type { PluggableList } from "@mdx-js/mdx/lib/core"; +import remarkComment from "remark-comment"; // Remark Plugins -import remarkGfm from 'remark-gfm'; -import remarkComment from 'remark-comment'; -import remarkComponentCheck from './remark-component-check'; -import remarkUndeclaredVariables from './remark-undeclared-variables'; +import remarkGfm from "remark-gfm"; +import remarkComponentCheck from "./remark-component-check"; +import remarkUndeclaredVariables from "./remark-undeclared-variables"; // import { remarkCodeHike } from '@code-hike/mdx'; // import { theme as codeHikeTheme } from './codeHikeTheme'; +import { rehypeAccessibleEmojis } from "rehype-accessible-emojis"; // Rehype Plugins -import rehypeSlug from 'rehype-slug'; -import { rehypeAccessibleEmojis } from 'rehype-accessible-emojis'; -import rehypeCodeBlocks from './rehype-code-blocks'; -import rehypeInlineBadges from './rehype-inline-badges'; +import rehypeSlug from "rehype-slug"; +import rehypeCodeBlocks from "./rehype-code-blocks"; +import rehypeInlineBadges from "./rehype-inline-badges"; type PluginOptions = { - codeHike?: boolean; - math?: boolean; + components?: Array; + codeHike?: boolean; + math?: boolean; }; export function getRemarkPlugins(options?: PluginOptions): PluggableList { - const plugins = [remarkComponentCheck, remarkUndeclaredVariables, remarkGfm, remarkComment]; - - if (options?.codeHike) { - // plugins.push([remarkCodeHike, { theme: codeHikeTheme, lineNumbers: true }]); - } - - return plugins; + const plugins = [ + remarkComponentCheck(options?.components ?? []), + remarkUndeclaredVariables, + remarkGfm, + remarkComment, + ]; + + if (options?.codeHike) { + // plugins.push([remarkCodeHike, { theme: codeHikeTheme, lineNumbers: true }]); + } + + return plugins; } export function getRehypePlugins(options?: PluginOptions): PluggableList { - const plugins = [rehypeCodeBlocks, rehypeSlug, rehypeInlineBadges, rehypeAccessibleEmojis]; - - if (options?.codeHike) { - // plugins.push([]); - } - - return plugins; + const plugins = [ + rehypeCodeBlocks, + rehypeSlug, + rehypeInlineBadges, + rehypeAccessibleEmojis, + ]; + + if (options?.codeHike) { + // plugins.push([]); + } + + return plugins; } diff --git a/api/src/bundler/plugins/rehype-code-blocks.ts b/api/src/bundler/plugins/rehype-code-blocks.ts index 55648e44..dd388e31 100644 --- a/api/src/bundler/plugins/rehype-code-blocks.ts +++ b/api/src/bundler/plugins/rehype-code-blocks.ts @@ -1,93 +1,96 @@ -import { visit } from 'unist-util-visit'; -import { Node } from 'unist'; -import { toString } from 'mdast-util-to-string'; -import * as shiki from 'shiki'; -import { Element } from 'hast'; +import type { Element } from "hast"; +import { toString } from "mdast-util-to-string"; +import * as shiki from "shiki"; +import type { Node } from "unist"; +import { visit } from "unist-util-visit"; let highlighter: shiki.Highlighter; const languages: Record = { - '': 'text', - gradle: 'groovy', + "": "text", + gradle: "groovy", }; -shiki.bundledLanguagesInfo.forEach(lang => { - languages[lang.id] = lang.id; - for (const alias of lang.aliases || []) { - languages[alias] = lang.id; - } -}); + +for (const lang of shiki.bundledLanguagesInfo) { + languages[lang.id] = lang.id; + for (const alias of lang.aliases || []) { + languages[alias] = lang.id; + } +} const cssVariablesTheme = shiki.createCssVariablesTheme({ - name: 'css-variables', - variablePrefix: '--shiki-', - variableDefaults: {}, - fontStyle: true, + name: "css-variables", + variablePrefix: "--shiki-", + variableDefaults: {}, + fontStyle: true, }); export default function rehypeCodeBlocks(): (ast: Node) => void { - function visitor(node: Element, _i: number, parent: Element) { - if (!parent || parent.tagName !== 'pre' || node.tagName !== 'code') { - return; - } + function visitor(node: Element, _i: number, parent: Element) { + if (!parent || parent.tagName !== "pre" || node.tagName !== "code") { + return; + } - const raw = toString(node); - const blockLanguage = getLanguage(node) || ''; - const languageActual: string = languages[blockLanguage] || 'text'; - if (!parent.properties) parent.properties = {}; - parent.properties['raw'] = raw; // Used to support copy/paste functionality, - parent.properties['language'] = languageActual; - parent.properties['html'] = highlighter.codeToHtml(raw, { - lang: languageActual, - theme: 'css-variables', - }); - const meta = (node.data?.meta as string) ?? ''; - const title = extractTitle(meta); - if (title) parent.properties['title'] = title; - } - return async (ast: Node): Promise => { - if (!highlighter) { - highlighter = await shiki.getHighlighter({ - langs: Array.from(new Set(Object.values(languages))), - themes: [cssVariablesTheme], - }); - } - visit(ast, 'element', visitor); - }; + const raw = toString(node); + const blockLanguage = getLanguage(node) || ""; + const languageActual: string = languages[blockLanguage] || "text"; + if (!parent.properties) parent.properties = {}; + parent.properties.raw = raw; // Used to support copy/paste functionality, + parent.properties.language = languageActual; + parent.properties.html = highlighter.codeToHtml(raw, { + lang: languageActual, + theme: "css-variables", + }); + const meta = (node.data?.meta as string) ?? ""; + const title = extractTitle(meta); + if (title) parent.properties.title = title; + } + return async (ast: Node): Promise => { + if (!highlighter) { + highlighter = await shiki.getHighlighter({ + langs: Array.from(new Set(Object.values(languages))), + themes: [cssVariablesTheme], + }); + } + visit(ast, "element", visitor); + }; } function extractTitle(meta: string): string | null { - // https://regex101.com/r/4JngU0/1 - const match = - /(?:title="(?.*)"|title='(?.*)'|title=(?.*?)\s|title=(?.*?)$)/gm.exec( - meta, - ); + // https://regex101.com/r/4JngU0/1 + const match = + /(?:title="(?.*)"|title='(?.*)'|title=(?.*?)\s|title=(?.*?)$)/gm.exec( + meta, + ); - if (!match) { - return null; - } + if (!match) { + return null; + } - const title = Object.values(match.groups ?? []).find(value => value !== undefined); - return title || null; + const title = Object.values(match.groups ?? []).find( + (value) => value !== undefined, + ); + return title || null; } // Get the programming language of `node`. function getLanguage(node: Element): string | undefined { - const className: string[] = (node.properties?.className as string[]) || []; - let index = -1; - let value: string; - while (++index < className.length) { - value = className[index]; + const className: string[] = (node.properties?.className as string[]) || []; + let index = -1; + let value: string; + while (++index < className.length) { + value = className[index]; - if (value === 'no-highlight' || value === 'nohighlight') { - return undefined; - } + if (value === "no-highlight" || value === "nohighlight") { + return undefined; + } - if (value.slice(0, 5) === 'lang-') { - return value.slice(5); - } + if (value.slice(0, 5) === "lang-") { + return value.slice(5); + } - if (value.slice(0, 9) === 'language-') { - return value.slice(9); - } - } + if (value.slice(0, 9) === "language-") { + return value.slice(9); + } + } } diff --git a/api/src/bundler/plugins/rehype-headings.ts b/api/src/bundler/plugins/rehype-headings.ts index e02e0e08..1f45e08c 100644 --- a/api/src/bundler/plugins/rehype-headings.ts +++ b/api/src/bundler/plugins/rehype-headings.ts @@ -1,36 +1,36 @@ -import { visit } from 'unist-util-visit'; -import { hasProperty } from 'hast-util-has-property'; -import { headingRank } from 'hast-util-heading-rank'; -import type { Node } from 'hast-util-heading-rank/lib'; -import { toString } from 'mdast-util-to-string'; -import { parseSelector } from 'hast-util-parse-selector'; -import { Data as UnistData, Node as UnistNode } from 'unist'; -import { Element as HastElement, Content as HastContent } from 'hast'; +import type { Content as HastContent, Element as HastElement } from "hast"; +import { hasProperty } from "hast-util-has-property"; +import { headingRank } from "hast-util-heading-rank"; +import type { Node } from "hast-util-heading-rank/lib"; +import { parseSelector } from "hast-util-parse-selector"; +import { toString } from "mdast-util-to-string"; +import type { Data as UnistData, Node as UnistNode } from "unist"; +import { visit } from "unist-util-visit"; export type HeadingNode = { - id: string; - title: string; - rank: number | null; + id: string; + title: string; + rank: number | null; }; type RehypeHeadingsOptions = { - headings: string[]; - callback: (nodes: HeadingNode[]) => void; + headings: string[]; + callback: (nodes: HeadingNode[]) => void; }; const defaultOptions: RehypeHeadingsOptions = { - headings: ['h2', 'h3', 'h4', 'h5', 'h6'], - callback: () => { - return; - }, + headings: ["h2", "h3", "h4", "h5", "h6"], + callback: () => { + return; + }, }; interface MyData extends UnistData { - children: UnistNode[]; - tagName: string; - properties: { - id: string | number | boolean | (string | number)[]; - }; + children: UnistNode[]; + tagName: string; + properties: { + id: string | number | boolean | (string | number)[]; + }; } /** @@ -39,60 +39,67 @@ interface MyData extends UnistData { * @returns */ export default function rehypeHeadings( - options: RehypeHeadingsOptions = defaultOptions, + options: RehypeHeadingsOptions = defaultOptions, ): (ast: UnistNode) => void { - const nodes: HeadingNode[] = []; + const nodes: HeadingNode[] = []; - function visitor(node: HastElement): void { - if (headingRank(node) && hasProperty(node, 'id')) { - if (options.headings.includes(node.tagName as string)) { - nodes.push({ - id: (node.properties as Record).id, - title: toString(node), - rank: headingRank(node), - }); - } - } - } + function visitor(node: HastElement): void { + if (headingRank(node) && hasProperty(node, "id")) { + if (options.headings.includes(node.tagName as string)) { + nodes.push({ + id: (node.properties as Record).id, + title: toString(node), + rank: headingRank(node), + }); + } + } + } - function newVisitor(node: Node & { children: HastElement[] }) { - const newChildren = partition(node.children, headingTest).map(part => { - const id = - ( - part.filter((child: Node) => headingTest(child))[0] as HastElement - )?.properties?.id?.toString() || ''; + function newVisitor(node: Node & { children: HastElement[] }) { + const newChildren = partition(node.children, headingTest).map( + (part) => { + const id = + ( + part.filter((child: Node) => headingTest(child))[0] as HastElement + )?.properties?.id?.toString() || ""; - return wrapSection(part as HastElement[], id); - }); + return wrapSection(part as HastElement[], id); + }, + ); - node.children = newChildren; - } + node.children = newChildren; + } - return (ast: UnistNode): void => { - visit(ast, 'element', visitor); - visit(ast, 'root', newVisitor); - options.callback(nodes); - }; + return (ast: UnistNode): void => { + visit(ast, "element", visitor); + visit(ast, "root", newVisitor); + options.callback(nodes); + }; } -const wrapSection: (children: HastElement[], id: string) => HastElement = (children, id) => { - const wrap = parseSelector(`section${id ? `#${id}` : ''}`); - wrap.children = children; - return wrap; +const wrapSection: (children: HastElement[], id: string) => HastElement = ( + children, + id, +) => { + const wrap = parseSelector(`section${id ? `#${id}` : ""}`); + wrap.children = children; + return wrap; }; -const headingTest: (node: Node) => boolean = node => !!headingRank(node) && hasProperty(node, 'id'); +const headingTest: (node: Node) => boolean = (node) => + !!headingRank(node) && hasProperty(node, "id"); // partition an array based on a test function, e.g [a,b,b,b,a,b,b,a,b] should become [[a,b,b,b],[a,b,b],[a,b]] function partition(array: T[], test: (input: T) => boolean): T[][] { - return array.reduce((prev, current) => { - if (prev.length === 0) { - return [[current]]; - } - if (test(current)) { - return [...prev, [current]]; - } + return array.reduce((prev, current) => { + if (prev.length === 0) { + return [[current]]; + } + if (test(current)) { + // biome-ignore lint/performance/noAccumulatingSpread: TODO: Fix this lint + return [...prev, [current]]; + } - return [...prev.slice(0, -1), [...prev[prev.length - 1], current]]; - }, []); + return [...prev.slice(0, -1), [...prev[prev.length - 1], current]]; + }, []); } diff --git a/api/src/bundler/plugins/rehype-inline-badges.ts b/api/src/bundler/plugins/rehype-inline-badges.ts index 11f910a2..02a11446 100644 --- a/api/src/bundler/plugins/rehype-inline-badges.ts +++ b/api/src/bundler/plugins/rehype-inline-badges.ts @@ -3,10 +3,10 @@ * @typedef {import('mdast').Content} Content */ -import { isBadge } from 'is-badge'; +import { isBadge } from "is-badge"; -import { visit } from 'unist-util-visit'; -import type { Node } from 'hast-util-heading-rank/lib'; +import type { Node } from "hast-util-heading-rank/lib"; +import { visit } from "unist-util-visit"; /** * Provides a list of heading elements in the AST. @@ -14,19 +14,19 @@ import type { Node } from 'hast-util-heading-rank/lib'; * @returns */ export default function rehypeInlineBadges(): (ast: Node) => void { - //@ts-ignore - function visitor(node: NodeWithChildren) { - node.visited === 'true'; - node.children[0].properties.style = 'display: inline;'; - } - return (ast: Node): void => { - //@ts-ignore - visit(ast, containsBadge, visitor); - }; + //@ts-ignore + function visitor(node: NodeWithChildren) { + node.visited === "true"; + node.children[0].properties.style = "display: inline;"; + } + return (ast: Node): void => { + //@ts-ignore + visit(ast, containsBadge, visitor); + }; } //@ts-ignore -const containsBadge = node => - node.tagName === 'a' && - node.children[0].tagName === 'img' && - isBadge(node?.children[0]?.properties.src) && - node.visited !== 'true'; +const containsBadge = (node) => + node.tagName === "a" && + node.children[0].tagName === "img" && + isBadge(node?.children[0]?.properties.src) && + node.visited !== "true"; diff --git a/api/src/bundler/plugins/remark-component-check.ts b/api/src/bundler/plugins/remark-component-check.ts index 0dfba114..087f1b15 100644 --- a/api/src/bundler/plugins/remark-component-check.ts +++ b/api/src/bundler/plugins/remark-component-check.ts @@ -1,25 +1,6 @@ +import type { Data, Node } from "unist"; /* eslint-disable @typescript-eslint/no-explicit-any */ -import { visit } from 'unist-util-visit'; -import { Node } from 'unist'; - -const components = [ - 'Accordion', - 'CodeGroup', - 'Icon', - 'Info', - 'Warning', - 'Error', - 'Success', - 'Heading', - 'Tweet', - 'Tabs', - 'TabItem', - 'Image', - 'YouTube', - 'Vimeo', - 'Video', - 'Zapp', -]; +import { visit } from "unist-util-visit"; /** * Converts undefined JSX components into plain text nodes @@ -27,54 +8,75 @@ const components = [ */ interface DeclaredNode extends Node { - value: string; + value: string; } interface UnDeclaredNode extends Node { - name: string; - data: any; - value: any; + name: string; + data?: Data; + value?: string; + attributes?: { + type: string; + name: string; + value: string; + }[]; } -export default function remarkComponentCheck(): (ast: Node) => void { - const keywords = ['var', 'let', 'const', 'function']; - const withExport = keywords.map(k => new RegExp(`(export)[ \t]+${k}[ \t]`)); +export default function remarkComponentCheck( + components: Array, +): () => (ast: Node) => void { + return () => { + const keywords = ["var", "let", "const", "function"]; + const withExport = keywords.map( + (k) => new RegExp(`(export)[ \t]+${k}[ \t]`), + ); + + const declared: string[] = []; - const declared: string[] = []; + function visitorForDeclared(node: DeclaredNode) { + // Get the kind of export. This is actually stored in the Node, but the following was quicker for typescript: + const exportKeyword = withExport.filter((re) => re.test(node.value))[0]; - function visitorForDeclared(node: DeclaredNode) { - // Get the kind of export. This is actually stored in the Node, but the following was quicker for typescript: - const exportKeyword = withExport.filter(re => re.test(node.value))[0]; + if (exportKeyword) { + declared.push( + node.value + .replace(exportKeyword, "") + .replace(/^[a-z0-9-_A-Z]*[ \t][a-z0-9-_A-Z]*[ \t]/, "") + .split(" ")[0], + ); + } + } - if (exportKeyword) { - declared.push( - node.value - .replace(exportKeyword, '') - .replace(/^[a-z0-9-_A-Z]*[ \t][a-z0-9-_A-Z]*[ \t]/, '') - .split(' ')[0], - ); - } - } + function visitorForUndeclared(node: UnDeclaredNode) { + // HTML elements are not components (e.g.
) + if (!isUppercase(node.name.charAt(0))) { + return; + } - function visitorForUndeclared(node: UnDeclaredNode) { - // HTML elements are not components (e.g.
) - if (!isUppercase(node.name.charAt(0))) { - return; - } + if (!declared.includes(node.name) && !components.includes(node.name)) { + console.log(components); + // Override all props of the component with our own. + node.attributes = [ + { + type: "mdxJsxAttribute", + name: "name", + value: node.name, + }, + ]; - if (!declared.includes(node.name) && !components.includes(node.name)) { - node.type = 'text'; - node.data = undefined; - node.value = `\<${node.name}\ />`; - } - } + // Update the name of the component to the internal docs page component + // which renders a warning message. + node.name = "__InvalidComponent__"; + } + } - return async (ast: Node): Promise => { - visit(ast, 'mdxjsEsm', visitorForDeclared); - visit(ast, 'mdxJsxFlowElement', visitorForUndeclared); - visit(ast, 'mdxJsxTextElement', visitorForUndeclared); - }; + return async (ast: Node): Promise => { + visit(ast, "mdxjsEsm", visitorForDeclared); + visit(ast, "mdxJsxFlowElement", visitorForUndeclared); + visit(ast, "mdxJsxTextElement", visitorForUndeclared); + }; + }; } function isUppercase(str: string) { - return str === str.toUpperCase(); + return str === str.toUpperCase(); } diff --git a/api/src/bundler/plugins/remark-undeclared-variables.ts b/api/src/bundler/plugins/remark-undeclared-variables.ts index bef5eb25..a8a0882f 100644 --- a/api/src/bundler/plugins/remark-undeclared-variables.ts +++ b/api/src/bundler/plugins/remark-undeclared-variables.ts @@ -1,7 +1,6 @@ +import type { Data, Node } from "unist"; /* eslint-disable @typescript-eslint/no-explicit-any */ -// @ts-ignore -import { visit } from 'unist-util-visit'; -import { Node } from 'unist'; +import { visit } from "unist-util-visit"; /** * Converts undeclared variables into plain text nodes @@ -9,44 +8,44 @@ import { Node } from 'unist'; */ interface DeclaredNode extends Node { - value: string; + value: string; } interface UnDeclaredNode extends Node { - value: string; - data: any; + value: string; + data?: Data; } export default function remarkUndeclaredVariables(): (ast: Node) => void { - const keywords = ['var', 'let', 'const', 'function']; - const withExport = keywords.map(k => new RegExp(`(export)[ \t]+${k}[ \t]`)); - - const declared: string[] = []; - - function visitorForDeclared(node: DeclaredNode) { - // Get the kind of export. This is actually stored in the Node, but the following was quicker for typescript: - const exportKeyword = withExport.filter(re => re.test(node.value))[0]; - - if (exportKeyword) { - declared.push( - node.value - .replace(exportKeyword, '') - .replace(/^[a-z0-9-_A-Z]*[ \t][a-z0-9-_A-Z]*[ \t]/, '') - .split(' ')[0], - ); - } - } - - function visitorForUndeclared(node: UnDeclaredNode) { - if (node.value && !declared.includes(node.value)) { - node.type = 'text'; - node.data = undefined; - node.value = `\{${node.value}\}`; - } - } - - return async (ast: Node): Promise => { - visit(ast, 'mdxjsEsm', visitorForDeclared); - visit(ast, 'mdxFlowExpression', visitorForUndeclared); - visit(ast, 'mdxJsxTextElement', visitorForUndeclared); - }; + const keywords = ["var", "let", "const", "function"]; + const withExport = keywords.map((k) => new RegExp(`(export)[ \t]+${k}[ \t]`)); + + const declared: string[] = []; + + function visitorForDeclared(node: DeclaredNode) { + // Get the kind of export. This is actually stored in the Node, but the following was quicker for typescript: + const exportKeyword = withExport.filter((re) => re.test(node.value))[0]; + + if (exportKeyword) { + declared.push( + node.value + .replace(exportKeyword, "") + .replace(/^[a-z0-9-_A-Z]*[ \t][a-z0-9-_A-Z]*[ \t]/, "") + .split(" ")[0], + ); + } + } + + function visitorForUndeclared(node: UnDeclaredNode) { + if (node.value && !declared.includes(node.value)) { + node.type = "text"; + node.data = undefined; + node.value = `\{${node.value}\}`; + } + } + + return async (ast: Node): Promise => { + visit(ast, "mdxjsEsm", visitorForDeclared); + visit(ast, "mdxFlowExpression", visitorForUndeclared); + visit(ast, "mdxJsxTextElement", visitorForUndeclared); + }; } diff --git a/api/src/config/index.ts b/api/src/config/index.ts new file mode 100644 index 00000000..ac66d6ba --- /dev/null +++ b/api/src/config/index.ts @@ -0,0 +1,28 @@ +import yaml from "js-yaml"; +import { type Config, ConfigSchema } from "./schema"; +import { V1ConfigSchema } from "./v1.schema"; + +export type { Config } from "./schema"; + +// Given a user config, merges the config with the default config. +export function parseConfig(configs: { json?: string; yaml?: string }): Config { + let parsedConfig: object = {}; + + if (configs.json) { + parsedConfig = JSON.parse(configs.json); + } else if (configs.yaml) { + parsedConfig = yaml.load(configs.yaml) as object; + } + + const isV1Schema = + ("logo" in parsedConfig && typeof parsedConfig.logo === "string") || + ("theme" in parsedConfig && typeof parsedConfig.theme === "string"); + + if (isV1Schema) { + return V1ConfigSchema.parse(parsedConfig); + } + + return ConfigSchema.parse(parsedConfig); +} + +export const defaultConfig = ConfigSchema.parse({}); diff --git a/api/src/config/schema.ts b/api/src/config/schema.ts new file mode 100644 index 00000000..6f9d138d --- /dev/null +++ b/api/src/config/schema.ts @@ -0,0 +1,177 @@ +import { z } from "zod"; + +// Represents a single page in the sidebar +const SidebarPageItemSchema = z.object({ + title: z.string(), + href: z.string(), + icon: z.string().optional(), +}); + +// Represents a group of pages in the sidebar +export type SidebarGroup = { + group: string; + tab?: string; + href?: string; + icon?: string; + pages: (z.infer | SidebarGroup)[]; +}; + +// The overall schema for the sidebar +const SidebarSchema: z.ZodType = z.lazy(() => + z.object({ + group: z.string(), + tab: z.string().optional(), + href: z.string().optional(), + icon: z.string().optional(), + pages: z.array(z.union([SidebarPageItemSchema, SidebarSchema])), + }), +); + +export type Sidebar = z.infer; + +export const ConfigSchema = z + .object({ + // The name of the project + name: z.string().optional().catch(undefined), + // The description of the project + description: z.string().optional().catch(undefined), + // The logo of the project, used in the header + logo: z + .object({ + href: z.string().optional(), + light: z.string().optional(), + dark: z.string().optional(), + }) + .optional() + .catch(undefined), + // The favicon of the project + favicon: z.string().optional().catch(undefined), + theme: z + .object({ + defaultTheme: z + .union([z.literal("light"), z.literal("dark")]) + .optional() + .catch(undefined), + // grayScale: z.boolean().catch(false), // TODO? + primary: z.string().optional().catch(undefined), + primaryLight: z.string().optional().catch(undefined), + primaryDark: z.string().optional().catch(undefined), + backgroundLight: z.string().optional().catch(undefined), + backgroundDark: z.string().optional().catch(undefined), + }) + .optional() + .catch(undefined), + header: z + .object({ + showName: z.boolean().optional().catch(true), + showThemeToggle: z.boolean().optional().catch(true), + showGitHubCard: z.boolean().optional().catch(true), + links: z + .array( + z.object({ + title: z.string(), + href: z.string(), + cta: z.boolean().optional().catch(false), + locale: z.string().optional().catch(undefined), + }), + ) + .optional() + .catch([]), + }) + .optional() + .catch(undefined), + anchors: z + .array( + z + .object({ + icon: z.string(), + title: z.string(), + href: z.string(), + locale: z.string().optional().catch(undefined), + tab: z.string().optional().catch(undefined), + }) + .optional() + .catch(undefined), + ) + .optional() + .catch([]), + social: z + .object({ + preview: z.string().optional().catch(undefined), + website: z.string().optional().catch(undefined), + x: z.string().optional().catch(undefined), + youtube: z.string().optional().catch(undefined), + facebook: z.string().optional().catch(undefined), + instagram: z.string().optional().catch(undefined), + linkedin: z.string().optional().catch(undefined), + github: z.string().optional().catch(undefined), + slack: z.string().optional().catch(undefined), + discord: z.string().optional().catch(undefined), + }) + .optional() + .catch(undefined), + seo: z + .object({ + noindex: z.boolean().catch(false), + }) + .optional() + .catch(undefined), + variables: z.record(z.any()).catch({}), + search: z + .object({ + docsearch: z + .object({ + appId: z.string().catch(""), + apiKey: z.string().catch(""), + indexName: z.string().catch(""), + }) + .optional() + .catch(undefined), + }) + .optional() + .catch(undefined), + scripts: z + .object({ + googleTagManager: z.string().optional().catch(undefined), + googleAnalytics: z.string().optional().catch(undefined), + plausible: z + .union([z.string(), z.boolean()]) + .optional() + .catch(undefined), + }) + .optional() + .catch(undefined), + content: z + .object({ + headerDepth: z.number().catch(3), + zoomImages: z.boolean().catch(false), + automaticallyInferNextPrevious: z.boolean().catch(true), + showPageTitle: z.boolean().catch(false), + showPageImage: z.boolean().catch(false), + }) + .optional() + .catch(undefined), + tabs: z + .array( + z.object({ + id: z.string(), + title: z.string(), + href: z.string(), + locale: z.string().optional().catch(undefined), + }), + ) + .catch([]), + sidebar: z + .union([z.record(z.array(SidebarSchema)), z.array(SidebarSchema)]) + .catch({}), + }) + .transform((config) => { + return { + ...config, + locales: Array.isArray(config.sidebar) + ? [] + : Object.keys(config.sidebar).filter((key) => key !== "default"), + }; + }); + +export type Config = z.infer; diff --git a/api/src/config/v1.schema.ts b/api/src/config/v1.schema.ts new file mode 100644 index 00000000..bca613ca --- /dev/null +++ b/api/src/config/v1.schema.ts @@ -0,0 +1,176 @@ +import { z } from "zod"; +import { ConfigSchema } from "./schema"; +import type { Config, Sidebar } from "./schema"; + +const V1SidebarItem = z.tuple([ + z.coerce.string(), + z + .union([ + // URL + z.string(), + // Nested children + z + .array( + z + .tuple([z.coerce.string(), z.coerce.string()]) + .optional() + .catch(undefined), + ) + // Remove any undefined items from the array. + .transform((items) => { + return items.filter(Boolean); + }), + ]) + // Fallback to empty array if something is wrong, so the entire sidebar doesn't break + .catch([]), +]); + +export const V1ConfigSchema = z + .object({ + name: z.string().catch(""), + description: z.string().catch(""), + logo: z.string().catch(""), + logoDark: z.string().catch(""), + favicon: z.string().catch(""), + socialPreview: z.string().catch(""), + twitter: z.string().catch(""), + noindex: z.boolean().catch(false), + theme: z.string().catch(""), + headerDepth: z.number().catch(3), + variables: z.record(z.any()).catch({}), + googleTagManager: z.string().catch(""), + googleAnalytics: z.string().catch(""), + zoomImages: z.boolean().catch(false), + experimentalCodehike: z.boolean().catch(false), + experimentalMath: z.boolean().catch(false), + automaticallyDisplayName: z.boolean().catch(true), + automaticallyInferNextPrevious: z.boolean().catch(true), + plausibleAnalytics: z.boolean().catch(false), + plausibleAnalyticsScript: z + .string() + .catch("https://plausible.io/js/script.js"), + anchors: z + .array( + z + .object({ + icon: z.string(), + title: z.string(), + link: z.string(), + }) + .optional() + .catch(undefined), + ) + .transform((items) => items.filter(Boolean)) + .catch([]), + docsearch: z + .object({ + appId: z.string().catch(""), + apiKey: z.string().catch(""), + indexName: z.string().catch(""), + }) + .optional() + .catch(undefined), + sidebar: z + .union([z.record(z.array(V1SidebarItem)), z.array(V1SidebarItem)]) + .catch([]), + }) + .transform((v1) => { + const config: Config = { + name: v1.name, + description: v1.description, + logo: { + href: "/", + light: v1.logo, + dark: v1.logoDark, + }, + favicon: v1.favicon, + theme: { + primary: v1.theme, + }, + social: { + x: v1.twitter, + }, + anchors: v1.anchors.filter(Boolean).map((anchor) => ({ + title: anchor!.title, + href: anchor!.link, + icon: anchor!.icon, + })), + seo: { + noindex: v1.noindex, + }, + search: { + docsearch: v1.docsearch, + }, + variables: v1.variables, + scripts: { + googleTagManager: v1.googleTagManager, + googleAnalytics: v1.googleAnalytics, + plausible: + // If they have `true` for plausible + v1.plausibleAnalytics + ? // Check if they have a custom script + v1.plausibleAnalyticsScript + ? v1.plausibleAnalyticsScript + : true + : undefined, + }, + content: { + headerDepth: v1.headerDepth, + zoomImages: v1.zoomImages, + automaticallyInferNextPrevious: v1.automaticallyInferNextPrevious, + showPageTitle: false, + showPageImage: false, + }, + tabs: [], // V1 doesn't have tabs + sidebar: [], // This is transformed below + locales: [], // This is overridden in the v2 schema + }; + + // Utility function to transform a sidebar item from v1 to latest + function transformSidebarItem( + item: z.infer, + ): Sidebar { + const [title, hrefOrChildren] = item; + + if (typeof hrefOrChildren === "string") { + return { + group: "", + pages: [ + { + title, + href: hrefOrChildren, + }, + ], + }; + } + + if (Array.isArray(hrefOrChildren)) { + return { + group: title, + pages: hrefOrChildren.map((child) => { + const [childTitle, childHref] = child || ["", ""]; + return { + title: childTitle, + href: childHref, + }; + }), + }; + } + + return { group: "", pages: [] }; + } + + if (Array.isArray(v1.sidebar)) { + config.sidebar = v1.sidebar.map(transformSidebarItem); + } else { + const sidebar: Record = {}; + Object.entries(v1.sidebar).map((entry) => { + const locale = entry[0]; + const sidebarItems = entry[1]; + sidebar[locale] = sidebarItems.map(transformSidebarItem); + }); + config.sidebar = sidebar; + } + + return ConfigSchema.parse(config); + }); diff --git a/api/src/octokit.ts b/api/src/octokit.ts new file mode 100644 index 00000000..b1a7443d --- /dev/null +++ b/api/src/octokit.ts @@ -0,0 +1,40 @@ +import { App } from "octokit"; + +export const app = new App({ + appId: process.env.GITHUB_APP_ID!, + privateKey: process.env.GITHUB_APP_PRIVATE_KEY!, +}); + +// Type for a getFile response - assumes the repository is available +type GetFileResponse = { + repository: { + file?: { + text: string; + }; + }; +}; + +// Queries a repository and extracts a file +export async function getDomains(): Promise> { + const response = await app.octokit.graphql( + ` + query GetDomains($owner: String!, $repo: String!, $file: String!) { + repository(owner: $owner, name: $repo) { + file: object(expression: $file) { + ... on Blob { + text + } + } + } + } + `, + { + owner: "invertase", + repo: "docs.page", + file: "main:domains.json", + }, + ); + + const file = response.repository.file?.text || "[]"; + return JSON.parse(file) as Array<[string, string]>; +} diff --git a/api/src/probot.ts b/api/src/probot.ts deleted file mode 100644 index 1476251b..00000000 --- a/api/src/probot.ts +++ /dev/null @@ -1,72 +0,0 @@ -import { createNodeMiddleware, Probot, createProbot } from 'probot'; - -// Create a probot instance for the docs.page app -const probot = createProbot({ - overrides: { - appId: process.env.GITHUB_APP_ID, - privateKey: process.env.GITHUB_APP_PRIVATE_KEY, - }, -}); - -// Queries a repository and extracts a file -const getFile = ` - query GetDomains($owner: String!, $repo: String!, $file: String!) { - repository(owner: $owner, name: $repo) { - file: object(expression: $file) { - ... on Blob { - text - } - } - } - } -`; - -// Type for a getFile response - assumes the repository is available -type GetFileResponse = { - repository: { - file?: { - text: string; - }; - }; -}; - -const app = (app: Probot) => { - app.on('pull_request.opened', async context => { - app.log.info(context); - - const pull_request = context.payload.pull_request; - - const { repository } = context.payload; - - // e.g. org/repo - const name = repository.full_name.toLowerCase(); - - // Fetch the domains file from the main repository - const response = await context.octokit.graphql(getFile, { - owner: 'invertase', - repo: 'docs.page', - file: 'main:domains.json', - }); - - const file = response.repository.file?.text || '[]'; - - // Find and set a custom domain, if it exists - const domains = JSON.parse(file) as Array<[string, string]>; - const domain = domains.find(([, repository]) => repository === name)?.[0]; - - const url = domain - ? `${domain}/~${pull_request.number}` - : `docs.page/${name}~${pull_request.number}`; - - const comment = context.issue({ - body: `To view this pull requests documentation preview, visit the following URL:\n\n[${url}](https://${url})\n\nDocumentation is deployed and generated using [docs.page](https://docs.page).`, - }); - - await context.octokit.issues.createComment(comment); - }); -}; - -export default createNodeMiddleware(app, { - probot, - webhooksPath: '/webhooks/github', -}); diff --git a/api/src/res.ts b/api/src/res.ts index e16f52f5..87b9c450 100644 --- a/api/src/res.ts +++ b/api/src/res.ts @@ -1,77 +1,66 @@ -import type { Response } from 'express'; -import { ZodError } from 'zod'; -import { fromZodError } from 'zod-validation-error'; +import type { Response } from "express"; +import type { ZodError } from "zod"; +import { fromZodError } from "zod-validation-error"; +import type { BundlerError } from "./bundler/error"; const status = { - 200: 'OK', - 400: 'BAD_REQUEST', - 404: 'NOT_FOUND', - 500: 'INTERNAL_SERVER_ERROR', + 200: "OK", + 400: "BAD_REQUEST", + 404: "NOT_FOUND", + 500: "INTERNAL_SERVER_ERROR", } as const; -export function response( - res: Response, - status: number, - code: string, - other: - | { - error: { - message: string; - cause?: string | unknown; - links?: { title: string; url: string }[]; - }; - } - | { - data: T; - }, -): Response { - res.status(status); - return res.json({ - code, - ...other, - }); +export function ok(res: Response, data: T): Response { + res.status(200); + return res.json({ + code: status[200], + data, + }); } -export function ok(res: Response, data: T): Response { - res.status(200); - return res.json({ - code: status[200], - data, - }); +export function bundleError(res: Response, error: BundlerError): Response { + res.status(error.code); + return res.json({ + code: error.name, + error: { + message: error.message, + source: error.source, + }, + }); } export function badRequest(res: Response, message: string): Response; export function badRequest(res: Response, error: ZodError): Response; export function badRequest(res: Response, input: string | ZodError): Response { - // Set the HTTP Status code - res.status(400); + // Set the HTTP Status code + res.status(400); - if (typeof input === 'string') { - return res.json({ - code: status[400], - error: input, - }); - } + if (typeof input === "string") { + return res.json({ + code: status[400], + error: input, + }); + } - return res.json({ - code: status[400], - error: fromZodError(input).message, - }); + return res.json({ + code: status[400], + error: fromZodError(input).message, + }); } export function notFound(res: Response): Response { - res.status(404); - return res.json({ - code: status[404], - error: 'Resource not found.', - }); + res.status(404); + return res.json({ + code: status[404], + error: "Resource not found.", + }); } export function serverError(res: Response, error: unknown): Response { - console.error(error); - res.status(500); - return res.json({ - code: status[500], - error: 'Something went wrong.', - }); + console.error(error); + res.status(500); + return res.json({ + code: status[500], + error: "Something went wrong.", + }); } diff --git a/api/src/routes/bundle.ts b/api/src/routes/bundle.ts index 75d2538f..a27a9aac 100644 --- a/api/src/routes/bundle.ts +++ b/api/src/routes/bundle.ts @@ -1,44 +1,67 @@ -import { Request, Response } from 'express'; -import { z } from 'zod'; -import { ok, badRequest, serverError, response } from '../res'; -import bundler, { BundlerError } from '../bundler/index'; +import type { Request, Response } from "express"; +import { z } from "zod"; +import { BundlerError } from "../bundler/error"; +import { Bundler, type BundlerOutput } from "../bundler/index"; +import { badRequest, bundleError, ok, serverError } from "../res"; -const $input = z.object({ - owner: z - .string({ - required_error: 'Missing owner parameter.', - invalid_type_error: 'Owner parameter must be a string.', - }) - .min(1), - repository: z - .string({ - required_error: 'Missing repository parameter.', - invalid_type_error: 'Repository parameter must be a string.', - }) - .min(1), - ref: z.string().optional(), - path: z.string().optional().default('index'), +const QuerySchema = z.object({ + owner: z + .string({ + required_error: "Missing owner parameter.", + invalid_type_error: "Owner parameter must be a string.", + }) + .min(1), + repository: z + .string({ + required_error: "Missing repository parameter.", + invalid_type_error: "Repository parameter must be a string.", + }) + .min(1), + ref: z.string().optional(), + path: z.string().optional().default("index"), + components: z.array(z.string()).optional(), }); -export default async function bundle(req: Request, res: Response): Promise { - const input = $input.safeParse(req.query); +export type BundleResponse = + | { + code: "OK"; + data: BundlerOutput; + } + | BundleErrorResponse; - if (!input.success) { - return badRequest(res, input.error); - } +export type BundleErrorResponse = { + code: + | "NOT_FOUND" + | "BAD_REQUEST" + | "REPO_NOT_FOUND" + | "FILE_NOT_FOUND" + | "BUNDLE_ERROR" + | "INTERNAL_SERVER_ERROR"; + error: + | string + | { + message: string; + source?: string; + }; +}; - try { - return ok(res, await bundler(input.data)); - } catch (e: unknown) { - if (e instanceof BundlerError) { - return response(res, e.code, e.name, { - error: { - message: e.message, - cause: e.cause, - links: e.links, - }, - }); - } - return serverError(res, e); - } +export default async function bundle( + req: Request, + res: Response, +): Promise { + const input = QuerySchema.safeParse(req.query); + + if (!input.success) { + return badRequest(res, input.error); + } + + try { + const bundler = new Bundler(input.data); + return ok(res, await bundler.build()); + } catch (e: unknown) { + if (e instanceof BundlerError) { + return bundleError(res, e); + } + return serverError(res, e); + } } diff --git a/api/src/routes/mdx.ts b/api/src/routes/mdx.ts deleted file mode 100644 index 24971f70..00000000 --- a/api/src/routes/mdx.ts +++ /dev/null @@ -1,25 +0,0 @@ -import { Request, Response } from 'express'; -import { z } from 'zod'; -import { ok, badRequest, serverError } from '../res'; -import { bundle } from '../bundler/mdx'; - -const $input = z.object({ - markdown: z.string(), -}); - -export default async function mdx(req: Request, res: Response): Promise { - const input = $input.safeParse(req.body); - - if (!input.success) { - return badRequest(res, input.error); - } - - try { - const response = await bundle(input.data.markdown, { - headerDepth: 3, - }); - return ok(res, response); - } catch (e: unknown) { - return serverError(res, e); - } -} diff --git a/api/src/routes/preview.ts b/api/src/routes/preview.ts index 20afb629..5e2efeeb 100644 --- a/api/src/routes/preview.ts +++ b/api/src/routes/preview.ts @@ -1,41 +1,64 @@ -import { Request, Response } from 'express'; -import { z } from 'zod'; -import { ok, badRequest, serverError } from '../res'; -import { bundle } from '../bundler/mdx'; -import parseConfig from '../utils/config'; +import type { Request, Response } from "express"; +import { z } from "zod"; +import type { BundlerOutput } from "../bundler"; +import { parseMdx } from "../bundler/mdx"; +import { parseConfig } from "../config"; +import { badRequest, ok, serverError } from "../res"; -const $input = z.object({ - markdown: z.string(), - config: z.object({ - json: z.string().optional(), - yaml: z.string().optional(), - }), +const PreviewSchema = z.object({ + markdown: z.string().nullable(), + config: z.object({ + json: z.string().nullable(), + yaml: z.string().nullable(), + }), + components: z.array(z.string()).optional(), }); -export default async function preview(req: Request, res: Response): Promise { - const input = $input.safeParse(JSON.parse(req.body)); +export default async function preview( + req: Request, + res: Response, +): Promise { + const input = PreviewSchema.safeParse(JSON.parse(req.body)); - if (!input.success) { - return badRequest(res, input.error); - } + if (!input.success) { + console.error(input.error); + return badRequest(res, input.error); + } - try { - const config = parseConfig({ - json: input.data.config.json, - yaml: input.data.config.yaml, - }); + try { + const config = parseConfig({ + json: input.data.config.json ?? undefined, + yaml: input.data.config.yaml ?? undefined, + }); - const mdx = await bundle(input.data.markdown, { - headerDepth: config.headerDepth, - }); + const mdx = await parseMdx(input.data.markdown ?? "", { + headerDepth: config.content?.headerDepth ?? 3, + components: input.data.components ?? [], + }); - return ok(res, { - config, - headings: mdx.headings, - frontmatter: mdx.frontmatter, - code: mdx.code, - }); - } catch (e: unknown) { - return serverError(res, e); - } + const output: BundlerOutput = { + source: { + type: "branch", + owner: "owner", + repository: "repository", + ref: "preview", + }, + private: false, + ref: "preview", + stars: 0, + forks: 0, + baseBranch: "preview", + path: "preview", + config, + markdown: input.data.markdown ?? "", + headings: mdx.headings, + frontmatter: mdx.frontmatter, + code: mdx.code, + }; + + return ok(res, output); + } catch (e: unknown) { + console.error(e); + return serverError(res, e); + } } diff --git a/api/src/routes/schema.ts b/api/src/routes/schema.ts new file mode 100644 index 00000000..460daaa2 --- /dev/null +++ b/api/src/routes/schema.ts @@ -0,0 +1,11 @@ +import type { Request, Response } from "express"; +import { zodToJsonSchema } from "zod-to-json-schema"; +import { ConfigSchema } from "../config/schema"; + +export default async function schema( + req: Request, + res: Response, +): Promise { + res.status(200); + return res.json(zodToJsonSchema(ConfigSchema)); +} diff --git a/api/src/routes/webhooks.github.ts b/api/src/routes/webhooks.github.ts new file mode 100644 index 00000000..c8c1a963 --- /dev/null +++ b/api/src/routes/webhooks.github.ts @@ -0,0 +1,88 @@ +import { type EmitterWebhookEvent, Webhooks } from "@octokit/webhooks"; +import type { Request, Response } from "express"; +import { badRequest, ok } from "../res"; + +import { app, getDomains } from "../octokit"; + +export default async function githubWebhook( + req: Request, + res: Response, +): Promise { + // Webhooks are POST requests from GitHub + if (req.method.toUpperCase() !== "POST") { + return badRequest(res, "Invalid method."); + } + + // Get the body of the request. + const body = req.body; + + // Create a new instance of the Webhooks class with the GitHub App secret. + const webhook = new Webhooks({ + secret: process.env.GITHUB_APP_WEBHOOK_SECRET!, + }); + + // Verify the signature of the request. + const verified = await webhook.verify( + body, + String(req.headers["x-hub-signature-256"]), + ); + + if (!verified) { + return badRequest(res, "Invalid signature."); + } + + webhook.on("pull_request.opened", onPullRequestOpened); + + try { + const id = req.headers["x-github-hook-id"] as string; + // biome-ignore lint/suspicious/noExplicitAny: This will be a valid event name from GitHub. + const name = req.headers["x-github-event"] as any; + const payload = JSON.parse(body); + + await webhook.receive({ + id, + name, + payload, + }); + + return ok(res, { message: "OK" }); + } catch (e) { + console.error(e); + return badRequest(res, "Webhook request failed."); + } +} + +async function onPullRequestOpened( + event: EmitterWebhookEvent<"pull_request.opened">, +) { + const pull_request = event.payload.pull_request; + const { repository } = event.payload; + + // org/repo + const name = repository.full_name.toLowerCase(); + + // Fetch the domains file from the main repository + const domains = await getDomains(); + + // Find a custom domain for the repository, if it exists + const domain = domains.find(([, repository]) => repository === name)?.[0]; + + // Build a domain URL for the comment + const url = domain + ? `${domain}/~${pull_request.number}` + : `docs.page/${name}~${pull_request.number}`; + + const comment = `To view this pull requests documentation preview, visit the following URL: +\n\n\ +[${url}](https://${url}) +\n\n\ +Documentation is deployed and generated using [docs.page](https://docs.page).`; + + // Post a comment on the pull request + await app.octokit.rest.issues.createComment({ + owner: repository.owner.login, + repo: repository.name, + issue_number: pull_request.number, + body: comment, + }); +} diff --git a/api/src/types.ts b/api/src/types.ts new file mode 100644 index 00000000..759aed40 --- /dev/null +++ b/api/src/types.ts @@ -0,0 +1,3 @@ +export type { BundlerOutput } from "./bundler/index"; +export type { BundleResponse, BundleErrorResponse } from "./routes/bundle"; +export type { SidebarGroup } from "./config/schema"; diff --git a/api/src/utils/config.ts b/api/src/utils/config.ts deleted file mode 100644 index cfb93c54..00000000 --- a/api/src/utils/config.ts +++ /dev/null @@ -1,93 +0,0 @@ -import { z } from 'zod'; -import yaml from 'js-yaml'; - -const $SidebarItem = z.tuple([ - z.coerce.string(), - z - .union([ - // URL - z.string(), - // Nested children - z - .array(z.tuple([z.coerce.string(), z.coerce.string()]).optional().catch(undefined)) - // Remove any undefined items from the array. - .transform(items => { - return items.filter(Boolean); - }), - ]) - // Fallback to empty array if something is wrong, so the entire sidebar doesn't break - .catch([]), -]); - -const $Config = z - .object({ - name: z.string().catch(''), - description: z.string().catch(''), - logo: z.string().catch(''), - logoDark: z.string().catch(''), - favicon: z.string().catch(''), - socialPreview: z.string().catch(''), - twitter: z.string().catch(''), - noindex: z.boolean().catch(false), - theme: z.string().catch(''), - headerDepth: z.number().catch(3), - variables: z.record(z.any()).catch({}), - googleTagManager: z.string().catch(''), - googleAnalytics: z.string().catch(''), - zoomImages: z.boolean().catch(false), - experimentalCodehike: z.boolean().catch(false), - experimentalMath: z.boolean().catch(false), - automaticallyDisplayName: z.boolean().catch(true), - automaticallyInferNextPrevious: z.boolean().catch(true), - plausibleAnalytics: z.boolean().catch(false), - plausibleAnalyticsScript: z.string().catch('https://plausible.io/js/script.js'), - anchors: z - .array( - z - .object({ - icon: z.string(), - title: z.string(), - link: z.string(), - }) - .optional() - .catch(undefined), - ) - .transform(items => items.filter(Boolean)) - .catch([]), - docsearch: z - .object({ - appId: z.string().catch(''), - apiKey: z.string().catch(''), - indexName: z.string().catch(''), - }) - .optional() - .catch(undefined), - sidebar: z.union([z.record(z.array($SidebarItem)), z.array($SidebarItem)]).catch([]), - }) - .transform(config => { - return { - ...config, - locales: Array.isArray(config.sidebar) - ? [] - : Object.keys(config.sidebar).filter(key => key !== 'default'), - }; - }); - -export type Config = z.infer; - -// The default config is used to fill in missing values. -export const defaultConfig = $Config.parse({}); - -// Given a user config, merges the config with the default config. -export default function parseConfig(configs: { json?: string; yaml?: string }): Config { - let parsedConfig: unknown; - - if (configs.json) { - parsedConfig = JSON.parse(configs.json); - } else if (configs.yaml) { - parsedConfig = yaml.load(configs.yaml); - } - - // return $Config.parse(JSON.parse(test)); - return $Config.parse(parsedConfig); -} diff --git a/api/src/utils/github.ts b/api/src/utils/github.ts index 70061575..6edd9fed 100644 --- a/api/src/utils/github.ts +++ b/api/src/utils/github.ts @@ -1,101 +1,112 @@ -import A2A from 'a2a'; -import { graphql } from '@octokit/graphql'; -import dotenv from 'dotenv'; +import { graphql } from "@octokit/graphql"; +import A2A from "a2a"; +import dotenv from "dotenv"; dotenv.config(); const getGitHubToken = (() => { - let index = 0; - const tokens = process.env.GITHUB_PAT ? process.env.GITHUB_PAT.split(',') : []; - - if (!tokens.length) { - throw new Error( - 'Environment variable GITHUB_PAT is not defined or has no tokens or an invalid token.', - ); - } - - return function () { - if (index >= tokens.length) index = 0; - return tokens[index++]; - }; + let index = 0; + const tokens = process.env.GITHUB_PAT + ? process.env.GITHUB_PAT.split(",") + : []; + + if (!tokens.length) { + throw new Error( + "Environment variable GITHUB_PAT is not defined or has no tokens or an invalid token.", + ); + } + + return () => { + if (index >= tokens.length) index = 0; + return tokens[index++]; + }; })(); export function getGithubGQLClient(): typeof graphql { - const token = getGitHubToken(); - if (!token) { - throw new Error( - 'Environment variable GITHUB_PAT is not defined or has no tokens or an invalid token.', - ); - } - return graphql.defaults({ - headers: { - authorization: `token ${token}`, - }, - }); + const token = getGitHubToken(); + if (!token) { + throw new Error( + "Environment variable GITHUB_PAT is not defined or has no tokens or an invalid token.", + ); + } + return graphql.defaults({ + headers: { + authorization: `token ${token}`, + }, + }); } type MetaData = { - owner: string; - repository: string; - ref?: string; - path: string; + owner: string; + repository: string; + ref?: string; + path: string; }; type PageContentsQuery = { - repository: { - baseBranch: { - name: string; - }; - isFork: boolean; - configJson?: { - text: string; - }; - configYaml?: { - text: string; - }; - configToml?: { - text: string; - }; - mdx?: { - text: string; - }; - mdxIndex?: { - text: string; - }; - }; + repository: { + stars: number; + forks: number; + baseBranch: { + name: string; + }; + isFork: boolean; + isPrivate: boolean; + configJson?: { + text: string; + }; + configYaml?: { + text: string; + }; + configToml?: { + text: string; + }; + mdx?: { + text: string; + }; + mdxIndex?: { + text: string; + }; + }; }; export type Contents = { - isFork: boolean; - baseBranch: string; - config: { - configJson?: string; - configYaml?: string; - configToml?: string; - }; - md?: string; - path: string; - repositoryFound: boolean; + stars: number; + forks: number; + isFork: boolean; + isPrivate: boolean; + baseBranch: string; + config: { + configJson?: string; + configYaml?: string; + configToml?: string; + }; + md?: string; + path: string; + repositoryFound: boolean; }; export async function getGitHubContents( - metadata: MetaData, - noDir?: boolean, + metadata: MetaData, + noDir?: boolean, ): Promise { - const base = noDir ? '' : 'docs/'; - const absolutePath = `${base}${metadata.path}`; - const indexPath = `${base}${metadata.path}/index`; + const base = noDir ? "" : "docs/"; + const absolutePath = `${base}${metadata.path}`; + const indexPath = `${base}${metadata.path}/index`; - const ref = metadata.ref || 'HEAD'; + const ref = metadata.ref || "HEAD"; - const [error, response] = await A2A( - getGithubGQLClient()({ - query: ` + const [error, response] = await A2A( + getGithubGQLClient()({ + query: ` query RepositoryConfig($owner: String!, $repository: String!, $configJson: String!, $configYaml: String!, $mdx: String!, $mdxIndex: String!) { repository(owner: $owner, name: $repository) { + stars: stargazerCount + forks: forkCount baseBranch: defaultBranchRef { name } isFork + isPrivate configJson: object(expression: $configJson) { ... on Blob { text @@ -119,63 +130,66 @@ export async function getGitHubContents( } } `, - owner: metadata.owner, - repository: metadata.repository, - configJson: `${ref}:docs.json`, - configYaml: `${ref}:docs.yaml`, - mdx: `${ref}:${absolutePath}.mdx`, - mdxIndex: `${ref}:${indexPath}.mdx`, - }), - ); - - // if an error is thrown then the repo is not found, if the repo is private then response = { repository: null } - if (error || response?.repository === null) { - return; - } - - return { - repositoryFound: true, - isFork: response?.repository?.isFork ?? false, - baseBranch: response?.repository.baseBranch.name ?? 'main', - config: { - configJson: response?.repository.configJson?.text, - configYaml: response?.repository.configYaml?.text, - }, - md: response?.repository.mdxIndex?.text || response?.repository.mdx?.text, - path: response?.repository.mdxIndex?.text ? indexPath : absolutePath, - }; + owner: metadata.owner, + repository: metadata.repository, + configJson: `${ref}:docs.json`, + configYaml: `${ref}:docs.yaml`, + mdx: `${ref}:${absolutePath}.mdx`, + mdxIndex: `${ref}:${indexPath}.mdx`, + }), + ); + + // if an error is thrown then the repo is not found, if the repo is private then response = { repository: null } + if (error || response?.repository === null) { + return; + } + + return { + stars: response?.repository?.stars ?? 0, + forks: response?.repository?.forks ?? 0, + repositoryFound: true, + isFork: response?.repository?.isFork ?? false, + isPrivate: response?.repository?.isPrivate ?? false, + baseBranch: response?.repository.baseBranch.name ?? "main", + config: { + configJson: response?.repository.configJson?.text, + configYaml: response?.repository.configYaml?.text, + }, + md: response?.repository.mdxIndex?.text || response?.repository.mdx?.text, + path: response?.repository.mdxIndex?.text ? indexPath : absolutePath, + }; } export type PullRequestMetadata = { - owner: string; - repository: string; - ref: string; + owner: string; + repository: string; + ref: string; }; type PullRequestQuery = { - repository: { - pullRequest: { - owner: { - login: string; - }; - repository: { - name: string; - }; - ref: { - name: string; - }; - }; - }; + repository: { + pullRequest: { + owner: { + login: string; + }; + repository: { + name: string; + }; + ref: { + name: string; + }; + }; + }; }; export async function getPullRequestMetadata( - owner: string, - repository: string, - pullRequest: string, + owner: string, + repository: string, + pullRequest: string, ): Promise { - const [error, response] = await A2A( - getGithubGQLClient()({ - query: ` + const [error, response] = await A2A( + getGithubGQLClient()({ + query: ` query RepositoryConfig($owner: String!, $repository: String!, $pullRequest: Int!) { repository(owner: $owner, name: $repository) { pullRequest(number: $pullRequest) { @@ -192,18 +206,18 @@ export async function getPullRequestMetadata( } } `, - owner: owner, - repository: repository, - pullRequest: parseInt(pullRequest), - }), - ); - if (error || !response) { - return null; - } - - return { - owner: response?.repository?.pullRequest?.owner?.login, - repository: response?.repository?.pullRequest?.repository?.name, - ref: response?.repository?.pullRequest?.ref?.name, - }; + owner: owner, + repository: repository, + pullRequest: Number.parseInt(pullRequest), + }), + ); + if (error || !response) { + return null; + } + + return { + owner: response?.repository?.pullRequest?.owner?.login, + repository: response?.repository?.pullRequest?.repository?.name, + ref: response?.repository?.pullRequest?.ref?.name, + }; } diff --git a/api/src/utils/sanitize.ts b/api/src/utils/sanitize.ts index a5591b01..696d7a0d 100644 --- a/api/src/utils/sanitize.ts +++ b/api/src/utils/sanitize.ts @@ -1,8 +1,8 @@ export const escapeHtml = (text: string): string => { - return text - .replace(/&/g, '&') - .replace(//g, '>') - .replace(/"/g, '"') - .replace(/'/g, '''); + return text + .replace(/&/g, "&") + .replace(//g, ">") + .replace(/"/g, """) + .replace(/'/g, "'"); }; diff --git a/api/src/utils/variables.ts b/api/src/utils/variables.ts new file mode 100644 index 00000000..f18b4f88 --- /dev/null +++ b/api/src/utils/variables.ts @@ -0,0 +1,22 @@ +import get from "lodash.get"; +const VARIABLE_REGEX = /{{\s([a-zA-Z0-9_.]*)\s}}/gm; + +// Replaces an object of variables with their moustache values in a string +export function replaceMoustacheVariables( + variables: Record, + value: string, +) { + let output = value; + let m: RegExpExecArray | null; + + // biome-ignore lint/suspicious/noAssignInExpressions: This is a false positive. + while ((m = VARIABLE_REGEX.exec(value)) !== null) { + // This is necessary to avoid infinite loops with zero-width matches + if (m.index === VARIABLE_REGEX.lastIndex) { + VARIABLE_REGEX.lastIndex++; + } + output = output.replace(m[0], get(variables, m[1], m[0])); + } + + return output; +} diff --git a/api/tsconfig.json b/api/tsconfig.json index f8000d1a..0f129e52 100644 --- a/api/tsconfig.json +++ b/api/tsconfig.json @@ -1,15 +1,16 @@ { - "compilerOptions": { - "resolveJsonModule": true, - "target": "ESNEXT" /* Specify ECMAScript target version: 'ES3' (default), 'ES5', 'ES2015', 'ES2016', 'ES2017', 'ES2018', 'ES2019', 'ES2020', 'ES2021', or 'ESNEXT'. */, - "module": "ESNext" /* Specify module code generation: 'none', 'commonjs', 'amd', 'system', 'umd', 'es2015', 'es2020', or 'ESNext'. */, - "outDir": "./dist" /* Redirect output structure to the directory. */, - "rootDir": "src" /* Specify the root directory of input files. Use to control the output directory structure with --outDir. */, - "strict": true /* Enable all strict type-checking options. */, - "moduleResolution": "node" /* Specify module resolution strategy: 'node' (Node.js) or 'classic' (TypeScript pre-1.6). */, - "esModuleInterop": true /* Enables emit interoperability between CommonJS and ES Modules via creation of namespace objects for all imports. Implies 'allowSyntheticDefaultImports'. */, - "skipLibCheck": true /* Skip type checking of declaration files. */, - "forceConsistentCasingInFileNames": true /* Disallow inconsistently-cased references to the same file. */, - "typeRoots": ["node_modules/@types"] - } + "compilerOptions": { + "resolveJsonModule": true, + "target": "ESNEXT" /* Specify ECMAScript target version: 'ES3' (default), 'ES5', 'ES2015', 'ES2016', 'ES2017', 'ES2018', 'ES2019', 'ES2020', 'ES2021', or 'ESNEXT'. */, + "module": "ESNext" /* Specify module code generation: 'none', 'commonjs', 'amd', 'system', 'umd', 'es2015', 'es2020', or 'ESNext'. */, + "outDir": "./dist" /* Redirect output structure to the directory. */, + "rootDir": "src" /* Specify the root directory of input files. Use to control the output directory structure with --outDir. */, + "strict": true /* Enable all strict type-checking options. */, + "moduleResolution": "node" /* Specify module resolution strategy: 'node' (Node.js) or 'classic' (TypeScript pre-1.6). */, + "esModuleInterop": true /* Enables emit interoperability between CommonJS and ES Modules via creation of namespace objects for all imports. Implies 'allowSyntheticDefaultImports'. */, + "skipLibCheck": true /* Skip type checking of declaration files. */, + "forceConsistentCasingInFileNames": true /* Disallow inconsistently-cased references to the same file. */, + "typeRoots": ["node_modules/@types"], + "verbatimModuleSyntax": true + } } diff --git a/biome.json b/biome.json new file mode 100644 index 00000000..87d7ee3d --- /dev/null +++ b/biome.json @@ -0,0 +1,28 @@ +{ + "$schema": "https://biomejs.dev/schemas/1.8.3/schema.json", + "files": { + "ignore": ["node_modules", ".git", ".vercel", "dist/**"] + }, + "organizeImports": { + "enabled": true + }, + "linter": { + "enabled": true, + "rules": { + "recommended": true, + "style": { + "noNonNullAssertion": "off" + }, + "suspicious": { + "noShadowRestrictedNames": "off", + "noArrayIndexKey": "off" + }, + "security": { + "noDangerouslySetInnerHtml": "off" + }, + "complexity": { + "noUselessFragments": "off" + } + } + } +} diff --git a/docs.json b/docs.json index a5c2dfc6..eea1d513 100644 --- a/docs.json +++ b/docs.json @@ -1,56 +1,186 @@ { - "name": "docs.page", - "logo": "https://static.invertase.io/assets/docs.page/docs-page-logo.png", - "theme": "#36B9B9", - "twitter": "invertaseio", - "anchors": [ - { "title": "Homepage", "icon": "house", "link": "https://docs.page" }, - { "title": "Discord", "icon": "discord", "link": "https://invertase.link/discord" } - ], - "sidebar": [ - [ - "Getting Started", - [ - ["Overview", "/"], - ["Getting Started", "/getting-started"], - ["Configuration", "/configuration"], - ["Writing Content", "/writing-content"] - ] - ], - [ - "Components", - [ - ["Accordion", "/components/accordion"], - ["Code Blocks", "/components/code-blocks"], - ["Callouts", "/components/callouts"], - ["Headings", "/components/headings"], - ["Tweet", "/components/tweet"], - ["Tabs", "/components/tabs"], - ["Images", "/components/images"], - ["YouTube", "/components/youtube"], - ["Vimeo", "/components/vimeo"], - ["Video", "/components/video"], - ["Zapp!", "/components/zapp"], - ["Custom Components", "/components/custom"] - ] - ], - [ - "Advanced", - [ - ["Frontmatter", "/frontmatter"], - ["Previews", "/previews"], - ["Search", "/search"], - ["Custom Domains", "/custom-domains"], - ["Locales", "/locales"], - ["GitHub Bot", "/github-bot"] - ] - ], - ["Misc.", [["Contributing", "/contributing"]]] - ], - "docsearch": { - "apiKey": "9b58d13ee3195094105d528fc6161a01", - "appId": "BH4D9OD16A", - "indexName": "use_docs_page" - }, - "googleTagManager": "GTM-W89J6BX" + "name": "docs.page", + "description": "Zero config GitHub documentation site generator", + "logo": { + "href": "https://docs.page", + "light": "https://static.invertase.io/assets/docs.page/docs-page-logo.png" + }, + "favicon": "https://static.invertase.io/assets/docs.page/docs-page-logo.png", + "theme": { + "primary": "#36B9B9", + "primaryDark": "#E06BAA" + }, + "content": { + "showPageTitle": true, + "showPageImage": true + }, + "header": { + "showName": true, + "showThemeToggle": true, + "showGitHubCard": true, + "links": [ + { + "title": "Hello", + "href": "https://docs.page" + }, + { + "title": "World", + "href": "https://docs.page", + "cta": true + } + ] + }, + "anchors": [ + { "title": "Homepage", "icon": "house", "href": "https://docs.page" }, + { + "title": "Discord", + "icon": "discord", + "href": "https://invertase.link/discord" + } + ], + "social": { + "x": "invertaseio", + "github": "invertase/docs.page", + "discord": "invertase" + }, + "tabs": [ + { + "id": "root", + "title": "Documentation", + "href": "/" + }, + { + "id": "components", + "title": "Components", + "href": "/components" + } + ], + "sidebar": [ + { + "group": "Getting Started", + "tab": "root", + "pages": [ + { "title": "Overview", "href": "/" }, + { "title": "Getting Started", "href": "/getting-started" }, + { "title": "Configuration", "href": "/configuration" }, + { "title": "Writing Content", "href": "/writing-content" } + ] + }, + { + "group": "Components", + "tab": "components", + "pages": [ + { + "title": "Accordion", + "href": "/components/accordion", + "icon": "square-caret-down" + }, + { + "title": "Callouts", + "href": "/components/callouts", + "icon": "bullhorn" + }, + { + "title": "Code Blocks", + "href": "/components/code-blocks", + "icon": "code" + }, + { + "title": "Headings", + "href": "/components/headings", + "icon": "heading" + }, + { + "title": "Tweet", + "href": "/components/tweet", + "icon": "twitter" + }, + { + "title": "Tabs", + "href": "/components/tabs", + "icon": "table-columns" + }, + { + "title": "Images", + "href": "/components/images", + "icon": "image" + }, + { + "title": "YouTube", + "href": "/components/youtube", + "icon": "youtube" + }, + { + "title": "Vimeo", + "href": "/components/vimeo", + "icon": "vimeo" + }, + { + "title": "Video", + "href": "/components/video", + "icon": "video" + }, + { + "title": "Zapp!", + "href": "/components/zapp", + "icon": "bolt" + }, + { + "title": "Custom Components", + "href": "/components/custom", + "icon": "square-pen" + } + ] + }, + { + "group": "Advanced", + "tab": "root", + "pages": [ + { + "title": "Frontmatter", + "href": "/frontmatter" + }, + { + "title": "Previews", + "href": "/previews" + }, + { + "title": "Search", + "href": "/search" + }, + { + "title": "Custom Domains", + "href": "/custom-domains" + }, + { + "title": "Locales", + "href": "/locales" + }, + { + "title": "GitHub Bot", + "href": "/github-bot" + } + ] + }, + { + "tab": "root", + "group": "Misc.", + "pages": [ + { + "title": "Contributing", + "href": "/contributing" + } + ] + } + ], + "search": { + "docsearch": { + "apiKey": "9b58d13ee3195094105d528fc6161a01", + "appId": "BH4D9OD16A", + "indexName": "use_docs_page" + } + }, + "scripts": { + "googleTagManager": "GTM-W89J6BX" + } } diff --git a/docs/components/accordion.mdx b/docs/components/accordion.mdx index e8be3bd6..339ac128 100644 --- a/docs/components/accordion.mdx +++ b/docs/components/accordion.mdx @@ -3,8 +3,6 @@ title: Accordion description: Display an accordion component with collapsible content panels. --- -# Accordion - An accordion can be used to render collapsible content, useful for information which might not be relevant to all users. @@ -31,3 +29,42 @@ Pass the `defaultOpen` prop to have the accordion open by default. This is collapsible content! + +### Icons + +Use the `icon` prop to specify an icon to display next to the accordion title. + +```jsx + + This is collapsible content! + +``` + + + This is collapsible content! + + +## Groups + +To group accordions into a single display unit, use the `` component, with each +`` component as a child. + +```jsx + + + This is collapsible content! + + + This is collapsible content! + + +``` + + + + This is collapsible content! + + + This is collapsible content! + + \ No newline at end of file diff --git a/docs/components/callouts.mdx b/docs/components/callouts.mdx index bcc34454..3af3d688 100644 --- a/docs/components/callouts.mdx +++ b/docs/components/callouts.mdx @@ -3,8 +3,6 @@ title: Callouts description: Add callout boxes to your pages to display important information. --- -# Callouts - Callouts help break up the page flow by providing colorful boxes with text information to notifiy the user about something important on the page. ## Info Callout diff --git a/docs/components/code-blocks.mdx b/docs/components/code-blocks.mdx index 563888fc..4cd4beb9 100644 --- a/docs/components/code-blocks.mdx +++ b/docs/components/code-blocks.mdx @@ -3,8 +3,6 @@ title: Code Blocks description: Display code blocks with syntax highlighting. --- -# Code Blocks - Render code blocks inline or as a code snippet with syntax highlighting by enclosing them in backticks (`). ## Inline Code diff --git a/docs/components/custom.mdx b/docs/components/custom.mdx index d43390b0..0ca30a90 100644 --- a/docs/components/custom.mdx +++ b/docs/components/custom.mdx @@ -3,8 +3,6 @@ title: Custom Components description: Use page level custom components in your documentation --- -# Custom Components - Those familar with [MDX](https://mdxjs.com/) may be familiar with the concept of custom components. With MDX, you can create your own components and use them in your markdown files. diff --git a/docs/components/headings.mdx b/docs/components/headings.mdx index 2b785fc6..b87ee258 100644 --- a/docs/components/headings.mdx +++ b/docs/components/headings.mdx @@ -3,8 +3,6 @@ title: Headings description: Use headings in your documentation --- -# Headings - All markdown headings will be rendered in a size ordered format. You can render headings using the `#` symbol, with the number of hashes indicating the heading type. diff --git a/docs/components/images.mdx b/docs/components/images.mdx index a0172c90..78f6e716 100644 --- a/docs/components/images.mdx +++ b/docs/components/images.mdx @@ -3,8 +3,6 @@ title: Images description: Add images to your documentation --- -# Images - Images can be displayed from remote, or local sources using either standard Markdown syntax, or the `` component. diff --git a/docs/components/index.mdx b/docs/components/index.mdx new file mode 100644 index 00000000..20652a83 --- /dev/null +++ b/docs/components/index.mdx @@ -0,0 +1,3 @@ +--- +redirect: /components/accordion +--- \ No newline at end of file diff --git a/docs/components/tabs.mdx b/docs/components/tabs.mdx index ece546ba..859d44ef 100644 --- a/docs/components/tabs.mdx +++ b/docs/components/tabs.mdx @@ -1,10 +1,8 @@ --- -title: Display content within different tabs +title: Tabs description: Learn how to display content within different tabs --- -# Tabs - Tabs are a great way to display content within different tabs. Tabs can be displayed using the `` component, providing a label and value. diff --git a/docs/components/tweet.mdx b/docs/components/tweet.mdx index e783e21a..9d9f266a 100644 --- a/docs/components/tweet.mdx +++ b/docs/components/tweet.mdx @@ -1,11 +1,9 @@ --- -title: Embed Tweets +title: Tweets description: Embed Tweets in your documentation. --- -# Tweets - -To imbed a tweet into the documentation, use the `` component and provide the ID of the tweet. +To embed a tweet into the documentation, use the `` component and provide the ID of the tweet. ```jsx diff --git a/docs/components/video.mdx b/docs/components/video.mdx index b1c3e441..65a8c4ba 100644 --- a/docs/components/video.mdx +++ b/docs/components/video.mdx @@ -1,10 +1,8 @@ --- -title: Add videos to your documentation +title: Videos description: Learn how to add videos to your documentation. --- -# Video - A general purpose video component, extends the HTML `