diff --git a/.env.example b/.env.example index fe917fe..d6b7df4 100644 --- a/.env.example +++ b/.env.example @@ -1,5 +1,3 @@ -# Add your env variables here - -# The environment of the app. This is different from NODE_ENV, and will be used for deployments. -# Default: development -#APP_ENV="staging" +GITHUB_OWNER="github-owner" # Your username or organization name (Optional. For edit/report an issue for the documentation page) +GITHUB_REPO="github-repo" # Repository name (Optional. For edit/report an issue for the documentation page) +APP_ROOT_PATH="/path/to/your/app" # Optional. Default is `process.cwd()` diff --git a/.gitignore b/.gitignore index e8ed9db..f24a830 100644 --- a/.gitignore +++ b/.gitignore @@ -80,3 +80,7 @@ blob-report *.sqlite *.sqlite3 *.db-journal + + +# Content collections output files +.content-collections diff --git a/.vscode/settings.json b/.vscode/settings.json index edb107e..85f2f2c 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -42,5 +42,6 @@ }, "[typescriptreact]": { "editor.defaultFormatter": "biomejs.biome" - } + }, + "editor.formatOnPaste": true } diff --git a/README.md b/README.md index ed8a9a2..ebe5a74 100644 --- a/README.md +++ b/README.md @@ -1,65 +1,89 @@ +# Welcome to Forge 42 Documentation Template +This template is designed to support a flexible content structure using `.md` and `.mdx` files organized into folders. It enables deeply nested sections and subsections, making it easy to manage complex documentation with a clear and scalable hierarchy. + +The project is built using the [@forge-42/base-stack](https://github.com/forge-42/base-stack) and leverages the [content-collections](https://github.com/sdorra/content-collections). + + +## Documentation Template Structure Overview + +`app/` + +This folder contains React Router v7 web application folders and files, including components and UI primitives for the documentation site’s interface, internal hooks and utilities, and the tailwind.css file for styling. + + +`resources/` + +This folder contains all the resources used by the documentation site, such as SVG icons, fonts, and other assets. + +`content/` + +This folder contains sections and subsections with .mdx files that hold your documentation content. Below is the recommended structure to follow. + + +An example of a valid content/ folder structure for organizing your package documentation: -

- -

- -# Welcome to Forge 42 base-stack - -This is a base-stack for Forge 42 projects. This stack is a starting point for all Forge 42 stacks with more -advanced features. This is an ESM Vite stack with Remix.run / React Router v7. - -It includes a basic setup for a project with react-router v7 framework mode and: -- React 19 & react-compiler -- TypeScript -- TailwindCSS -- Vite -- Vitest (unit tests) -- Scripting -- Biome (linter & formatter) -- i18n support (client and server) -- Icons spritesheet generator -- lefthook hooks -- CI checks for quality control -- react-router-devtools -- Hono server -- .env var handling for server and client -- SEO robots.txt, sitemap-index and sitemap built in. - -## Internationalization - -This stack uses i18next for internationalization. It supports both client and server side translations. -Features included out of the box: -- Support for multiple languages -- Typesafe resources -- client side translations are fetched only when needed -- language switcher -- language detector (uses the request to detect the language, falls back to your fallback language) - -## Hono server - -This stack uses Hono for the server. More information about Hono can be found [here](https://honojs.dev/). -Another important thing to note is that we use a dependency called `react-router-hono-server` which is a wrapper for Hono that allows us to use Hono in our React Router application. - -The server comes preconfigured with: -- i18next middleware -- caching middleware for assets -- easily extendable global application context -- .env injection into context - -In order to add your own middleware, extend the context, or anything along those lines, all you have to do is edit the server -inside the `entry.server.tsx` file. - -## .env handling - -This stack parses your `.env` file and injects it into the server context. For the client side, in the `root.tsx` file, we use the `useLoaderData` hook to get the `clientEnv` from the server and set it as a global variable on the `window` called `env`. -If you need to access the env variables in both environments, you can create a polyEnv helper like this: -```ts -// app/utils/env.ts -// This will return the process.env on the server and window.env on the client -export const polyEnv = typeof process !== "undefined" ? process.env : window.env; ``` -The server will fail at runtime if you don't set your `.env` file properly. +content/ +├── _index.mdx +├── 01-changelog.mdx +├── 02-introduction.mdx +├── 03-overview.mdx +├── 04-getting-started/ +│ ├── index.md +│ ├── 01-installation.mdx +│ ├── 02-quick-start.mdx +│ └── 03-project-setup.mdx +└── 05-core-features/ + ├── index.md + ├── 01-authentication.mdx + ├── 02-authorization.mdx + ├── 03-data-management/ + │ ├── index.md + │ ├── 01-fetching-data.mdx + │ └── 02-caching-strategies.mdx + └── 04-ui-components/ + ├── index.md + ├── 01-buttons.mdx + └── 02-modals.mdx +``` +- Top-level .mdx files (like 01-changelog.mdx) are allowed. +- Sections (like 04-getting-started, 05-core-features) are subfolders inside the content/ folder. +- Subsections (like 03-data-management, 04-ui-components) are nested folders within sections. +- Each section or subsection should have an index.md file for its sidebar title. + +### Example of the valid `02-introduction.mdx` file: +``` +--- +title: "Introduction to Forge42 Base Stack" +summary: "Overview of the Stack" +description: "Get started with the Forge42 Base Stack — a modern web app starter template designed for speed, scalability, and developer experience." +--- + +## What is Forge42 Base Stack? + +The Forge42 Base Stack is a full-featured web application starter template. It combines modern tools and technologies like **Remix**, **Tailwind CSS**, **TypeScript**, **Vitest**, and **React Aria Components** to help you build accessible and scalable web apps quickly. + +This documentation will guide you through setting up the project, understanding its structure, and customizing it for your needs. + +## Installation + +To get started with the base stack, simply clone the repository and install dependencies: + +```bash +npx degit forge42/base-stack my-app +cd my-app +npm install +``` + +### Example of the valid `04-getting-started/index.md` file: +``` +--- +title: Getting Started +--- + +``` + ## Getting started @@ -71,17 +95,10 @@ pnpm install ``` 3. Read through the README.md files in the project to understand our decisions. -4. Run the cleanup script: -```bash -pnpm cleanup -``` - -This will remove everything in the project related to the base-stack like README.md etc. -This is the first thing you should run after initializing the project. -After it is run it will remove itself from the package.json. +4. Add `content` folder 5. Start the development server: ```bash pnpm run dev ``` -6. Happy coding! +5. Happy coding! diff --git a/app/components/code-block/code-block-diff.ts b/app/components/code-block/code-block-diff.ts new file mode 100644 index 0000000..0812efb --- /dev/null +++ b/app/components/code-block/code-block-diff.ts @@ -0,0 +1,45 @@ +/** + * This module provides utilities for processing and styling lines of a text diff. + */ +const DIFF_STYLES = { + added: { + backgroundColor: "var(--color-diff-added-bg)", + borderLeft: "2px solid", + borderLeftColor: "var(--color-diff-added-border)", + indicator: "+", + }, + removed: { + backgroundColor: "var(--color-diff-removed-bg)", + borderLeft: "2px solid", + borderLeftColor: "var(--color-diff-removed-border)", + indicator: "-", + }, + normal: { + backgroundColor: "transparent", + borderLeft: "none", + borderLeftColor: "transparent", + indicator: "", + }, +} as const + +type DiffType = keyof typeof DIFF_STYLES + +const DIFF_PATTERNS = { + "+ ": "added", + "- ": "removed", +} as const + +type DiffPatternPrefix = keyof typeof DIFF_PATTERNS + +const isDiffPatternPrefix = (prefix: string): prefix is DiffPatternPrefix => { + return prefix in DIFF_PATTERNS +} + +export const getDiffType = (line: string): DiffType => { + const prefix = line.trimStart().slice(0, 2) + return isDiffPatternPrefix(prefix) ? DIFF_PATTERNS[prefix] : "normal" +} + +export const cleanDiffLine = (line: string) => line.replace(/^(\s*)[+-] /, "$1") + +export const getDiffStyles = (diffType: DiffType) => DIFF_STYLES[diffType] diff --git a/app/components/code-block/code-block-elements.tsx b/app/components/code-block/code-block-elements.tsx new file mode 100644 index 0000000..3546db0 --- /dev/null +++ b/app/components/code-block/code-block-elements.tsx @@ -0,0 +1,66 @@ +import type { ComponentPropsWithoutRef } from "react" +import { cn } from "~/utils/css" +import { createLineData } from "./code-block-parser" +import { getTokenColor, isTokenType, type tokenize } from "./code-block-syntax-highlighter" + +const TokenElement = ({ token }: { token: ReturnType[0] }) => { + const { type, value } = token + const color = isTokenType(type) ? getTokenColor(type) : "" + + return {value} +} + +const DiffIndicator = ({ indicator }: { indicator: string }) => ( + + {indicator} + +) + +const LineElement = ({ line }: { line: string }) => { + const { tokens, styles, isNormalDiff } = createLineData(line) + + return ( +
+
+ {!isNormalDiff && } + + {tokens.map((token, index) => ( + + ))} + +
+
+ ) +} + +const CodeElement = ({ lines }: { lines: string[] }) => ( + + {lines.map((line, index) => ( + + ))} + +) + +interface PreElementProps extends Omit, "children"> { + lines: string[] + className?: string +} + +export const PreElement = ({ lines, className = "", ...props }: PreElementProps) => ( +
+		
+	
+) diff --git a/app/components/code-block/code-block-parser.ts b/app/components/code-block/code-block-parser.ts new file mode 100644 index 0000000..ec64f3d --- /dev/null +++ b/app/components/code-block/code-block-parser.ts @@ -0,0 +1,53 @@ +import { cleanDiffLine, getDiffStyles, getDiffType } from "./code-block-diff" +import { tokenize } from "./code-block-syntax-highlighter" + +interface CodeBlockChild { + props?: { + children?: string + } +} + +export const extractCodeContent = (children: string | CodeBlockChild) => { + const code = typeof children === "string" ? children : (children?.props?.children ?? "") + return { code } +} + +export const processLines = (content: string) => { + const lines = content.split("\n") + return filterEmptyLines(lines) +} + +const filterEmptyLines = (lines: string[]) => { + return lines.filter((line, index, array) => { + const isLastLine = index === array.length - 1 + const isEmpty = line.trim() === "" + return !(isEmpty && isLastLine) + }) +} + +export const createLineData = (line: string) => { + const diffType = getDiffType(line) + const cleanLine = cleanDiffLine(line) + const tokens = tokenize(cleanLine) + const styles = getDiffStyles(diffType) + const isNormalDiff = diffType === "normal" + + return { + diffType, + cleanLine, + tokens, + styles, + isNormalDiff, + } +} + +export const processCopyContent = (content: string): { code: string } => { + // removes diff markers from content + const code = content + .split("\n") + .filter((line) => !line.trimStart().startsWith("- ")) + .map((line) => line.replace(/^(\s*)\+ /, "$1")) + .join("\n") + + return { code } +} diff --git a/app/components/code-block/code-block-syntax-highlighter.ts b/app/components/code-block/code-block-syntax-highlighter.ts new file mode 100644 index 0000000..d452b8d --- /dev/null +++ b/app/components/code-block/code-block-syntax-highlighter.ts @@ -0,0 +1,153 @@ +/** + * Tokenization utility for syntax highlighting code snippets. + * This utils will produce syntax-highlighted JSX output using theme colors. + */ + +type TokenType = "keyword" | "string" | "number" | "comment" | "operator" | "punctuation" | "function" | "text" + +const MASTER_REGEX = new RegExp( + [ + // whitespace + "\\s+", + // single-line comment + "//.*?(?=\\n|$)", + // multi-line comment + "/\\*[\\s\\S]*?\\*/", + // strings + "(['\"])(?:(?!\\1)[^\\\\]|\\\\.)*\\1", + // numbers + "\\d+\\.?\\d*", + // identifiers + "[a-zA-Z_$][a-zA-Z0-9_$]*", + // operators and punctuation + "===|!==|<=|>=|==|!=|&&|\\|\\||\\+\\+|--|[+\\-*/%=<>!?:(){}\\[\\];,.]|[+\\-*/%]=", + // arrow function + "=>", + ].join("|"), + "g" +) + +const KEYWORDS = [ + "import", + "export", + "default", + "from", + "const", + "let", + "var", + "function", + "return", + "if", + "else", + "for", + "while", + "do", + "switch", + "case", + "break", + "continue", + "try", + "catch", + "finally", + "throw", + "new", + "class", + "extends", + "interface", + "type", + "public", + "private", + "protected", + "static", + "async", + "await", + "true", + "false", + "null", + "undefined", + "typeof", + "instanceof", +] + +const OPERATORS = [ + "+", + "-", + "*", + "/", + "=", + "==", + "===", + "!=", + "!==", + "<", + ">", + "<=", + ">=", + "&&", + "||", + "!", + "?", + ":", + "++", + "--", + "+=", + "-=", + "*=", + "/=", + "=>", +] + +const isKeyword = (value: string) => KEYWORDS.includes(value) +const isOperator = (value: string) => OPERATORS.includes(value) +const isFunction = (value: string) => /^[A-Z]/.test(value) +const isWhitespace = (value: string) => /^\s/.test(value) +const isComment = (value: string) => value.startsWith("//") || value.startsWith("/*") +const isString = (value: string) => /^['"`]/.test(value) +const isNumber = (value: string) => /^\d/.test(value) +const isIdentifier = (value: string) => /^[a-zA-Z_$]/.test(value) + +const classifyIdentifier = (value: string) => { + return isKeyword(value) ? "keyword" : isFunction(value) ? "function" : "text" +} + +const classifyToken = (value: string) => { + switch (true) { + case isWhitespace(value): + return "text" + case isComment(value): + return "comment" + case isString(value): + return "string" + case isNumber(value): + return "number" + case isIdentifier(value): + return classifyIdentifier(value) + case isOperator(value): + return "operator" + default: + return "punctuation" + } +} + +export const tokenize = (code: string) => + Array.from(code.matchAll(MASTER_REGEX), (match) => ({ + type: classifyToken(match[0]), + value: match[0], + })) + +const TOKEN_COLORS = { + keyword: "var(--color-code-keyword)", + string: "var(--color-code-string)", + number: "var(--color-code-number)", + comment: "var(--color-code-comment)", + operator: "var(--color-code-operator)", + punctuation: "var(--color-code-punctuation)", + function: "var(--color-code-function)", + text: "var(--color-code-block-text)", +} as const + +export const getTokenColor = (type: TokenType) => TOKEN_COLORS[type] + +export function isTokenType(value: unknown): value is TokenType { + return typeof value === "string" && value in TOKEN_COLORS +} diff --git a/app/components/code-block/code-block.tsx b/app/components/code-block/code-block.tsx new file mode 100644 index 0000000..85870ef --- /dev/null +++ b/app/components/code-block/code-block.tsx @@ -0,0 +1,20 @@ +import type { ComponentPropsWithoutRef } from "react" +import { PreElement } from "./code-block-elements" +import { extractCodeContent, processLines } from "./code-block-parser" +import { CopyButton } from "./copy-button" + +interface CodeBlockProps extends Omit, "children"> { + children: string +} + +export const CodeBlock = ({ children, className = "", ...props }: CodeBlockProps) => { + const { code } = extractCodeContent(children) + const lines = processLines(code) + + return ( +
+ + +
+ ) +} diff --git a/app/components/code-block/copy-button.tsx b/app/components/code-block/copy-button.tsx new file mode 100644 index 0000000..79e9c19 --- /dev/null +++ b/app/components/code-block/copy-button.tsx @@ -0,0 +1,38 @@ +import { useState } from "react" +import { useTranslation } from "react-i18next" +import { Icon } from "~/ui/icon/icon" +import { cn } from "~/utils/css" +import { processCopyContent } from "./code-block-parser" + +export const CopyButton = ({ lines }: { lines: string[] }) => { + const [copyState, setCopyState] = useState<"copy" | "copied">("copy") + const disabled = copyState === "copied" + const { t } = useTranslation() + + const handleCopy = async () => { + const reconstructedContent = lines.join("\n") + const { code } = processCopyContent(reconstructedContent) + + await navigator.clipboard.writeText(code) + setCopyState("copied") + setTimeout(() => setCopyState("copy"), 2000) + } + + return ( + + ) +} diff --git a/app/components/code-block/tests/code-block-diff.test.ts b/app/components/code-block/tests/code-block-diff.test.ts new file mode 100644 index 0000000..0386e80 --- /dev/null +++ b/app/components/code-block/tests/code-block-diff.test.ts @@ -0,0 +1,75 @@ +import { describe, expect, it } from "vitest" +import { cleanDiffLine, getDiffStyles, getDiffType } from "../code-block-diff" + +describe("getDiffType test suite", () => { + it("should return 'added' for lines starting with '+ '", () => { + expect(getDiffType("+ something")).toBe("added") + }) + + it("should return 'removed' for lines starting with '- '", () => { + expect(getDiffType("- something")).toBe("removed") + }) + + it("should return 'normal' for lines without a diff prefix", () => { + expect(getDiffType(" unchanged line")).toBe("normal") + }) + + it("should handle leading whitespace before '+ '", () => { + expect(getDiffType(" + spaced")).toBe("added") + }) + + it("should handle leading whitespace before '- '", () => { + expect(getDiffType(" - spaced")).toBe("removed") + }) + + it("should return 'normal' for '+' without space after", () => { + expect(getDiffType("+no-space")).toBe("normal") + }) + + it("should return 'normal' for '-' without space after", () => { + expect(getDiffType("-no-space")).toBe("normal") + }) +}) + +describe("cleanDiffLine test suite", () => { + it("should remove '+ ' from start while preserving indentation", () => { + expect(cleanDiffLine("+ added")).toBe("added") + expect(cleanDiffLine(" + added")).toBe(" added") + }) + + it("should remove '- ' from start while preserving indentation", () => { + expect(cleanDiffLine("- removed")).toBe("removed") + expect(cleanDiffLine(" - removed")).toBe(" removed") + }) + + it("should not change lines without diff prefix", () => { + expect(cleanDiffLine("unchanged")).toBe("unchanged") + expect(cleanDiffLine(" unchanged")).toBe(" unchanged") + }) +}) + +describe("getDiffStyles test suite", () => { + it.each(["added", "removed", "normal"] as const)("should have backgroundColor and indicator for '%s'", (diffType) => { + const styles = getDiffStyles(diffType) + expect(styles).toHaveProperty("backgroundColor") + expect(styles).toHaveProperty("indicator") + }) + + it("should return correct backgroundColor and indicator for 'added'", () => { + const styles = getDiffStyles("added") + expect(styles.backgroundColor).toBe("var(--color-diff-added-bg)") + expect(styles.indicator).toBe("+") + }) + + it("should return correct backgroundColor and indicator for 'removed'", () => { + const styles = getDiffStyles("removed") + expect(styles.backgroundColor).toBe("var(--color-diff-removed-bg)") + expect(styles.indicator).toBe("-") + }) + + it("should return correct backgroundColor and indicator for 'normal'", () => { + const styles = getDiffStyles("normal") + expect(styles.backgroundColor).toBe("transparent") + expect(styles.indicator).toBe("") + }) +}) diff --git a/app/components/code-block/tests/code-block-parser.test.ts b/app/components/code-block/tests/code-block-parser.test.ts new file mode 100644 index 0000000..a32db06 --- /dev/null +++ b/app/components/code-block/tests/code-block-parser.test.ts @@ -0,0 +1,67 @@ +import { extractCodeContent, processCopyContent, processLines } from "../code-block-parser" + +describe("extractCodeContent test suite", () => { + it("should return code when children is a string", () => { + expect(extractCodeContent("console.log('hello')")).toEqual({ + code: "console.log('hello')", + }) + }) + + it("should return code from props.children when children is an object", () => { + expect(extractCodeContent({ props: { children: "const x = 1" } })).toEqual({ + code: "const x = 1", + }) + }) + + it("should return empty string if children has no props or children", () => { + // biome-ignore lint/suspicious/noExplicitAny: in tests we may use any type + expect(extractCodeContent({} as any)).toEqual({ code: "" }) + }) +}) + +describe("processLines test suite", () => { + it("should split lines by newline", () => { + expect(processLines("line1\nline2")).toEqual(["line1", "line2"]) + }) + + it("should remove trailing empty line", () => { + expect(processLines("line1\n")).toEqual(["line1"]) + }) + + it("should keep empty lines in the middle", () => { + expect(processLines("a\n\nb")).toEqual(["a", "", "b"]) + }) + + it("should return empty array for empty string", () => { + expect(processLines("")).toEqual([]) + }) +}) + +describe("processCopyContent test suite", () => { + it("should remove removed lines (starting with '- ')", () => { + const result = processCopyContent("- removed\nunchanged") + expect(result.code).toBe("unchanged") + }) + + it("should strip '+ ' from added lines but keep indentation", () => { + const result = processCopyContent("+ added\n unchanged") + expect(result.code).toBe("added\n unchanged") + }) + + it("should handle mixed added, removed, and unchanged lines", () => { + const content = ` +- removed ++ added + unchanged +` + const result = processCopyContent(content) + expect(result.code).toContain("added") + expect(result.code).toContain("unchanged") + expect(result.code).not.toContain("removed") + }) + + it("should return empty string if all lines are removed", () => { + const result = processCopyContent("- a\n- b") + expect(result.code).toBe("") + }) +}) diff --git a/app/components/code-block/tests/code-block-syntax-highlighter.test.ts b/app/components/code-block/tests/code-block-syntax-highlighter.test.ts new file mode 100644 index 0000000..d2cf1f5 --- /dev/null +++ b/app/components/code-block/tests/code-block-syntax-highlighter.test.ts @@ -0,0 +1,110 @@ +import { getTokenColor, isTokenType, tokenize } from "../code-block-syntax-highlighter" + +describe("tokenize test suite", () => { + it("should tokenize keywords", () => { + const tokens = tokenize("const let var function return if else") + expect(tokens.map((t) => t.type)).toContain("keyword") + expect(tokens.some((t) => t.value === "const")).toBe(true) + }) + + it("should tokenize strings (single and double quotes)", () => { + const tokens = tokenize(`'hello' "world"`) + expect(tokens.filter((t) => t.type === "string")).toHaveLength(2) + }) + + it("should tokenize numbers (integers and floats)", () => { + const tokens = tokenize("42 3.14") + expect(tokens.filter((t) => t.type === "number")).toHaveLength(2) + }) + + it("should tokenize single-line comments", () => { + const tokens = tokenize("// comment here") + expect(tokens[0]).toEqual({ type: "comment", value: "// comment here" }) + }) + + it("should tokenize multi-line comments", () => { + const tokens = tokenize("/* multi\nline\ncomment */") + expect(tokens[0].type).toBe("comment") + }) + + it("should tokenize operators", () => { + const tokens = tokenize("a + b - c * d / e == f && g || h") + expect(tokens.filter((t) => t.type === "operator").length).toBeGreaterThan(0) + }) + + it("should tokenize punctuation", () => { + const tokens = tokenize("{ } ( ) [ ] ; , .") + expect(tokens.filter((t) => t.type === "punctuation").length).toBeGreaterThan(0) + }) + + it("should classify whitespace as text", () => { + const tokens = tokenize(" \n\t") + expect(tokens.every((t) => t.type === "text")).toBe(true) + }) + + it("should classify lowercase identifiers as text when not keywords", () => { + const tokens = tokenize("myVariable anotherThing") + const nonWhitespaceTextTokens = tokens.filter((t) => t.type === "text" && t.value.trim() !== "") + expect(nonWhitespaceTextTokens.length).toBe(2) + }) + it("should handle empty input", () => { + expect(tokenize("")).toEqual([]) + }) + + it("should handle mixed code sample", () => { + const code = ` + // comment + const x = 42; + function Test() { + return "hello"; + } + ` + const tokens = tokenize(code) + expect(tokens.some((t) => t.type === "keyword")).toBe(true) + expect(tokens.some((t) => t.type === "function")).toBe(true) + expect(tokens.some((t) => t.type === "string")).toBe(true) + expect(tokens.some((t) => t.type === "comment")).toBe(true) + expect(tokens.some((t) => t.type === "number")).toBe(true) + }) +}) + +describe("getTokenColor", () => { + it("should return a valid CSS variable for each TokenType", () => { + const tokenTypes = [ + "keyword", + "string", + "number", + "comment", + "operator", + "punctuation", + "function", + "text", + ] as const + + for (const type of tokenTypes) { + const color = getTokenColor(type) + expect(color).toMatch(/^var\(--color-code-/) + } + }) +}) + +describe("isTokenType", () => { + it("should return true for valid token types", () => { + expect(isTokenType("keyword")).toBe(true) + expect(isTokenType("string")).toBe(true) + expect(isTokenType("function")).toBe(true) + }) + + it("should return false for invalid strings", () => { + expect(isTokenType("not-a-type")).toBe(false) + expect(isTokenType("")).toBe(false) + expect(isTokenType("KEYWORD")).toBe(false) + }) + + it("should return false for non-string values", () => { + expect(isTokenType(undefined)).toBe(false) + expect(isTokenType(null)).toBe(false) + expect(isTokenType(42)).toBe(false) + expect(isTokenType({})).toBe(false) + }) +}) diff --git a/app/components/github-contribute-links.tsx b/app/components/github-contribute-links.tsx new file mode 100644 index 0000000..02fb3a6 --- /dev/null +++ b/app/components/github-contribute-links.tsx @@ -0,0 +1,28 @@ +import { useTranslation } from "react-i18next" +import { useRouteLoaderData } from "react-router" +import { createGitHubContributionLinks } from "~/utils/create-github-contribution-links" + +const linkStyles = "hover:text-[var(--color-text-accent)] hover:underline" + +export default function GithubContributeLinks({ pagePath }: { pagePath: string }) { + const { clientEnv } = useRouteLoaderData("root") + const { t } = useTranslation() + + const { GITHUB_OWNER, GITHUB_REPO } = clientEnv + + if (!GITHUB_OWNER || !GITHUB_REPO) { + return null + } + + const { issueUrl, editUrl } = createGitHubContributionLinks({ pagePath, owner: GITHUB_OWNER, repo: GITHUB_REPO }) + return ( +
+ + {t("links.report_an_issue_on_this_page")} + + + {t("links.edit_this_page")} + +
+ ) +} diff --git a/app/components/header.tsx b/app/components/header.tsx new file mode 100644 index 0000000..cb521a4 --- /dev/null +++ b/app/components/header.tsx @@ -0,0 +1,19 @@ +import { cn } from "~/utils/css" + +interface HeaderProps { + children: React.ReactNode + className?: string +} + +export const Header = ({ children, className }: HeaderProps) => { + return ( +
+ {children} +
+ ) +} diff --git a/app/components/logo.tsx b/app/components/logo.tsx new file mode 100644 index 0000000..7a6644a --- /dev/null +++ b/app/components/logo.tsx @@ -0,0 +1,9 @@ +import type { ReactNode } from "react" + +export const Logo = ({ children }: { children: ReactNode }) => { + return ( +
+ {children} +
+ ) +} diff --git a/app/components/mdx-wrapper.tsx b/app/components/mdx-wrapper.tsx new file mode 100644 index 0000000..0d29f10 --- /dev/null +++ b/app/components/mdx-wrapper.tsx @@ -0,0 +1,26 @@ +import { MDXContent } from "@content-collections/mdx/react" +import { Anchor } from "~/ui/anchor-tag" +import { InfoAlert } from "~/ui/info-alert" +import { InlineCode } from "~/ui/inline-code" +import { ListItem } from "~/ui/list-item" +import { OrderedList } from "~/ui/ordered-list" +import { Strong } from "~/ui/strong-text" +import { WarningAlert } from "~/ui/warning-alert" +import { CodeBlock } from "./code-block/code-block" + +export const MDXWrapper = ({ content }: { content: string }) => ( + +) diff --git a/app/components/page-mdx-article.tsx b/app/components/page-mdx-article.tsx new file mode 100644 index 0000000..89f8de2 --- /dev/null +++ b/app/components/page-mdx-article.tsx @@ -0,0 +1,21 @@ +import type { Page } from "~/routes/documentation-page" +import { Title } from "~/ui/title" +import { MDXWrapper } from "./mdx-wrapper" + +export default function PageMdxArticle({ page }: { page: Page }) { + return ( +
+
+ + {page.title} + + {page.description && ( + + {page.description} + + )} +
+ +
+ ) +} diff --git a/app/components/page-navigation.tsx b/app/components/page-navigation.tsx new file mode 100644 index 0000000..fe3c772 --- /dev/null +++ b/app/components/page-navigation.tsx @@ -0,0 +1,75 @@ +import clsx from "clsx" +import { useTranslation } from "react-i18next" +import { Link } from "react-router" +import { Icon } from "~/ui/icon/icon" +import { cn } from "~/utils/css" + +interface PageNavigationItem { + title: string + to: string +} + +interface PageNavigationProps { + previous?: PageNavigationItem + next?: PageNavigationItem +} + +interface PageNavigationLinkProps { + item: PageNavigationItem + direction: "previous" | "next" + label: string +} + +function PageNavigationLink({ item, direction, label }: PageNavigationLinkProps) { + const isPrevious = direction === "previous" + + return ( +
+
{label}
+ + {isPrevious &&
+ ) +} + +/** + * A pagination navigation component that displays "Previous" and "Next" links with + * accessible labels, styled arrows, and localized link text. + * + * It accepts optional `previous` and `next` props, each containing a `title` and `to` URL. + * When present, the component renders navigational links with arrow indicators. + * + * Example usage: + * + * + * @param previous - Optional previous page link data with title and path. + * @param next - Optional next page link data with title and path. + */ +export function PageNavigation({ previous, next }: PageNavigationProps) { + const { t } = useTranslation() + + return ( + + ) +} diff --git a/app/components/sidebar/build-breadcrumbs.tsx b/app/components/sidebar/build-breadcrumbs.tsx new file mode 100644 index 0000000..9c0d82d --- /dev/null +++ b/app/components/sidebar/build-breadcrumbs.tsx @@ -0,0 +1,29 @@ +import { href } from "react-router" +import { splitSlug } from "~/utils/split-slug" +import type { SidebarSection } from "./sidebar" + +// builds a breadcrumb trail from sidebar sections based on the current pathname +export const buildBreadcrumb = (items: SidebarSection[], pathname: string) => { + let trail: string[] = [] + + const walk = (section: SidebarSection, acc: string[]) => { + for (const doc of section.documentationPages) { + const docPath = href("/:version/:section/:subsection?/:filename", splitSlug(doc.slug)) + if (docPath === pathname) { + trail = [...acc, section.title, doc.title] + return true + } + } + + for (const sub of section.subsections) { + if (walk(sub, [...acc, section.title])) return true + } + return false + } + + for (const root of items) { + if (walk(root, [])) break + } + + return trail +} diff --git a/app/components/sidebar/desktop-sidebar.tsx b/app/components/sidebar/desktop-sidebar.tsx new file mode 100644 index 0000000..49bd847 --- /dev/null +++ b/app/components/sidebar/desktop-sidebar.tsx @@ -0,0 +1,14 @@ +import { cn } from "~/utils/css" +import type { SidebarSection } from "./sidebar" +import { SidebarContent } from "./sidebar-content" + +export const DesktopSidebarPanel = ({ items, className }: { items: SidebarSection[]; className: string }) => ( +
+ +
+) diff --git a/app/components/sidebar/mobile-sidebar-context.tsx b/app/components/sidebar/mobile-sidebar-context.tsx new file mode 100644 index 0000000..6573f75 --- /dev/null +++ b/app/components/sidebar/mobile-sidebar-context.tsx @@ -0,0 +1,29 @@ +import { createContext, useContext, useState } from "react" + +interface MobileSidebarContextValue { + isOpen: boolean + open: () => void + close: () => void + toggle: () => void +} + +const MobileSidebarContext = createContext(null) + +export const MobileSidebarProvider = ({ children }: { children: React.ReactNode }) => { + const [isOpen, setOpen] = useState(false) + + const value: MobileSidebarContextValue = { + isOpen, + open: () => setOpen(true), + close: () => setOpen(false), + toggle: () => setOpen((prev) => !prev), + } + + return {children} +} + +export const useMobileSidebar = () => { + const ctx = useContext(MobileSidebarContext) + if (!ctx) throw new Error("Missing MobileSidebarProvider") + return ctx +} diff --git a/app/components/sidebar/mobile-sidebar.tsx b/app/components/sidebar/mobile-sidebar.tsx new file mode 100644 index 0000000..f0c06f7 --- /dev/null +++ b/app/components/sidebar/mobile-sidebar.tsx @@ -0,0 +1,88 @@ +import { BreadcrumbItem, Breadcrumbs } from "~/ui/breadcrumbs" +import { Icon } from "~/ui/icon/icon" +import { cn } from "~/utils/css" +import { useMobileSidebar } from "./mobile-sidebar-context" +import type { SidebarSection } from "./sidebar" +import { SidebarContent } from "./sidebar-content" + +const MobileSidebarMenuButton = () => { + const { open } = useMobileSidebar() + + return ( + + ) +} + +export const MobileSidebarHeader = ({ breadcrumbs }: { breadcrumbs: string[] }) => { + return ( +
+ + + {breadcrumbs.map((item) => ( + {item} + ))} + +
+ ) +} + +export const MobileSidebarOverlay = () => { + const { isOpen, close } = useMobileSidebar() + + return ( + // biome-ignore lint/a11y/useKeyWithClickEvents: We don't need keyboard support for this overlay +