├── .nvmrc ├── .github ├── CODEOWNERS ├── dependabot.yml ├── ISSUE_TEMPLATE │ ├── question.yml │ ├── bug_report.yml │ └── feature_request.yml ├── workflows │ ├── build-docker-image.yml │ ├── lint.yml │ ├── run-unit-tests.yml │ ├── build.yml │ ├── test-sql-queries.yml │ └── check-changes-to-env.yml └── pull_request_template.md ├── logo.png ├── wiki ├── home.png ├── login.png ├── pr-comment.png ├── version-picker.png ├── viewer-picker.png ├── create-repository.png ├── invite-collaborator.png ├── specification-picker.png ├── example-openapi-repository-with-config.png └── example-openapi-repository-without-config.png ├── src ├── app │ ├── favicon.ico │ ├── api │ │ ├── auth │ │ │ └── [...nextauth] │ │ │ │ └── route.ts │ │ ├── health │ │ │ └── route.ts │ │ ├── refresh-projects │ │ │ └── route.ts │ │ ├── hooks │ │ │ └── github │ │ │ │ └── route.ts │ │ ├── blob │ │ │ └── [owner] │ │ │ │ └── [repository] │ │ │ │ └── [...path] │ │ │ │ └── route.ts │ │ ├── diff │ │ │ └── [owner] │ │ │ │ └── [repository] │ │ │ │ └── [...path] │ │ │ │ └── route.ts │ │ └── remotes │ │ │ └── [encodedRemoteConfig] │ │ │ └── route.ts │ ├── (authed) │ │ ├── (home) │ │ │ ├── new │ │ │ │ ├── loading.tsx │ │ │ │ ├── layout.tsx │ │ │ │ └── page.tsx │ │ │ ├── encrypt │ │ │ │ ├── layout.tsx │ │ │ │ └── page.tsx │ │ │ └── (welcome) │ │ │ │ ├── layout.tsx │ │ │ │ └── page.tsx │ │ ├── (project-doc) │ │ │ └── [...slug] │ │ │ │ ├── layout.tsx │ │ │ │ └── page.tsx │ │ └── layout.tsx │ ├── documentation-viewer │ │ └── page.tsx │ ├── layout.tsx │ └── globals.css ├── features │ ├── docs │ │ ├── navigation │ │ │ └── index.ts │ │ └── view │ │ │ ├── swagger.css │ │ │ ├── Redocly.tsx │ │ │ ├── DocumentationViewer.tsx │ │ │ ├── LoadingWrapper.tsx │ │ │ ├── Swagger.tsx │ │ │ └── Stoplight.tsx │ ├── hooks │ │ ├── data │ │ │ └── index.ts │ │ └── domain │ │ │ ├── index.ts │ │ │ ├── IPullRequestEventHandler.ts │ │ │ ├── PostCommentPullRequestEventHandler.ts │ │ │ ├── FilteringPullRequestEventHandler.ts │ │ │ └── RepositoryNameEventFilter.ts │ ├── auth │ │ ├── data │ │ │ ├── index.ts │ │ │ └── GitHubOAuthTokenRefresher.ts │ │ ├── domain │ │ │ ├── log-out │ │ │ │ ├── ILogOutHandler.ts │ │ │ │ ├── index.ts │ │ │ │ ├── CompositeLogOutHandler.ts │ │ │ │ ├── ErrorIgnoringLogOutHandler.ts │ │ │ │ └── UserDataCleanUpLogOutHandler.ts │ │ │ ├── index.ts │ │ │ ├── log-in │ │ │ │ ├── index.ts │ │ │ │ ├── ILogInHandler.ts │ │ │ │ └── LogInHandler.ts │ │ │ ├── session-validity │ │ │ │ ├── SessionValidity.ts │ │ │ │ ├── index.ts │ │ │ │ └── OAuthTokenSessionValidator.ts │ │ │ └── oauth-token │ │ │ │ ├── data-source │ │ │ │ ├── IOAuthTokenDataSource.ts │ │ │ │ └── OAuthTokenDataSource.ts │ │ │ │ ├── refresher │ │ │ │ ├── IOAuthTokenRefresher.ts │ │ │ │ ├── LockingOAuthTokenRefresher.ts │ │ │ │ └── PersistingOAuthTokenRefresher.ts │ │ │ │ ├── OAuthToken.ts │ │ │ │ ├── repository │ │ │ │ ├── IOAuthTokenRepository.ts │ │ │ │ ├── AuthjsAccountsOAuthTokenRepository.ts │ │ │ │ ├── FallbackOAuthTokenRepository.ts │ │ │ │ └── OAuthTokenRepository.ts │ │ │ │ └── index.ts │ │ └── view │ │ │ └── SessionBarrier.tsx │ ├── settings │ │ ├── domain │ │ │ ├── index.ts │ │ │ └── DocumentationVisualizer.ts │ │ └── data │ │ │ ├── index.ts │ │ │ └── useDocumentationVisualizer.ts │ ├── projects │ │ ├── domain │ │ │ ├── IGitHubLoginDataSource.ts │ │ │ ├── IProjectDataSource.ts │ │ │ ├── IProjectRepository.ts │ │ │ ├── RemoteSpecAuth.ts │ │ │ ├── RemoteConfig.ts │ │ │ ├── ProjectConfigParser.ts │ │ │ ├── Project.ts │ │ │ ├── IGitHubGraphQLClient.ts │ │ │ ├── OpenApiSpecification.ts │ │ │ ├── Version.ts │ │ │ ├── IGitHubRepositoryDataSource.ts │ │ │ ├── CachingProjectDataSource.ts │ │ │ ├── IProjectConfig.ts │ │ │ ├── FilteringGitHubRepositoryDataSource.ts │ │ │ ├── updateWindowTitle.ts │ │ │ ├── index.ts │ │ │ ├── RemoteConfigEncoder.ts │ │ │ └── ProjectRepository.ts │ │ ├── data │ │ │ ├── index.ts │ │ │ ├── GitHubLoginDataSource.ts │ │ │ └── useProjectSelection.ts │ │ └── view │ │ │ ├── Documentation.tsx │ │ │ ├── DocumentationIframe.tsx │ │ │ ├── NotFound.tsx │ │ │ ├── toolbar │ │ │ ├── MobileToolbar.tsx │ │ │ └── Selector.tsx │ │ │ └── ProjectsContextProvider.tsx │ ├── sidebar │ │ ├── view │ │ │ ├── index.ts │ │ │ ├── internal │ │ │ │ ├── diffbar │ │ │ │ │ └── components │ │ │ │ │ │ ├── levelConfig.ts │ │ │ │ │ │ ├── MonoQuotedText.tsx │ │ │ │ │ │ ├── PopulatedDiffList.tsx │ │ │ │ │ │ └── DiffList.tsx │ │ │ │ ├── sidebar │ │ │ │ │ ├── projects │ │ │ │ │ │ ├── PopulatedProjectList.tsx │ │ │ │ │ │ ├── ProjectListFallback.tsx │ │ │ │ │ │ ├── ProjectList.tsx │ │ │ │ │ │ └── ProjectAvatar.tsx │ │ │ │ │ ├── user │ │ │ │ │ │ └── UserSkeleton.tsx │ │ │ │ │ ├── NewProjectButton.tsx │ │ │ │ │ └── settings │ │ │ │ │ │ ├── SettingsList.tsx │ │ │ │ │ │ └── DocumentationVisualizationPicker.tsx │ │ │ │ ├── secondary │ │ │ │ │ └── ToggleMobileToolbarButton.tsx │ │ │ │ ├── tertiary │ │ │ │ │ └── RightContainer.tsx │ │ │ │ └── primary │ │ │ │ │ └── Container.tsx │ │ │ ├── SidebarTogglableContextProvider.tsx │ │ │ ├── SplitView.tsx │ │ │ └── SecondarySplitHeaderPlaceholder.tsx │ │ └── data │ │ │ ├── useDiffbarOpen.ts │ │ │ ├── useSidebarOpen.ts │ │ │ ├── index.ts │ │ │ ├── useCloseSidebarOnSelection.ts │ │ │ └── useDiff.ts │ ├── encrypt │ │ ├── view │ │ │ └── encryptAction.ts │ │ └── EncryptionService.ts │ ├── diff │ │ ├── domain │ │ │ └── DiffChange.ts │ │ └── data │ │ │ └── IOasDiffCalculator.ts │ └── new-project │ │ └── view │ │ └── NewProjectSteps.tsx └── common │ ├── key-value-store │ ├── index.ts │ ├── IKeyValueStore.ts │ └── RedisKeyValueStore.ts │ ├── mutex │ ├── IMutex.ts │ ├── IMutexFactory.ts │ ├── IKeyedMutexFactory.ts │ ├── index.ts │ ├── withMutex.ts │ ├── SessionMutexFactory.ts │ └── RedisKeyedMutexFactory.ts │ ├── session │ ├── index.ts │ ├── ISession.ts │ └── AuthjsSession.ts │ ├── utils │ ├── isMac.ts │ ├── getBaseFilename.ts │ ├── listFromCommaSeparatedString.ts │ ├── saneParseInt.ts │ ├── splitOwnerAndRepository.ts │ ├── fetcher.ts │ ├── index.ts │ ├── useKeyboardShortcut.ts │ ├── ZodJSONCoder.ts │ ├── env.ts │ ├── makeFullRepositoryName.ts │ └── makeNewGitHubRepositoryLink.ts │ ├── db │ ├── index.ts │ ├── IDB.ts │ └── PostgreSQLDB.ts │ ├── user-data │ ├── index.ts │ ├── IUserDataRepository.ts │ └── KeyValueUserDataRepository.ts │ ├── images │ └── base64EncodedLogo.ts │ ├── errors │ ├── index.ts │ └── makeAPIErrorResponse.ts │ ├── github │ └── index.ts │ ├── index.ts │ ├── ui │ ├── ErrorMessage.tsx │ ├── ThickDivider.tsx │ ├── ErrorHandler.tsx │ ├── DelayedLoadingIndicator.tsx │ ├── SpacedList.tsx │ ├── MessageLinkFooter.tsx │ ├── MenuItemHover.tsx │ ├── LoadingIndicator.tsx │ └── HighlightText.tsx │ ├── context │ └── ProjectsContext.ts │ └── theme │ └── ThemeRegistry.tsx ├── public └── images │ └── logo.png ├── postcss.config.js ├── drop-tables.sql ├── types ├── globals.d.ts └── @next-auth.d.ts ├── next.config.js ├── SECURITY.md ├── __test__ ├── auth │ ├── ErrorIgnoringLogOutHandler.test.ts │ ├── UserDataCleanUpLogOutHandler.test.ts │ ├── CompositeLogOutHandler.test.ts │ ├── OAuthTokenSessionValidator.test.ts │ ├── OAuthTokenDataSource.test.ts │ ├── AuthjsAccountsOAuthTokenRepository.test.ts │ └── LockingAccessTokenRefresher.test.ts ├── common │ ├── utils │ │ ├── saneParseInt.test.ts │ │ ├── ZodJSONCoder.test.ts │ │ ├── listFromCommaSeparatedString.test.ts │ │ ├── makeFullRepositoryName.test.ts │ │ └── makeNewGitHubRepositoryLink.test.ts │ └── userData │ │ └── KeyValueUserDataRepository.test.ts ├── utils │ ├── splitOwnerAndRepository.test.ts │ └── getBaseFilename.test.ts ├── projects │ ├── ProjectConfigParser.test.ts │ ├── CachingProjectDataSource.test.ts │ └── RemoteConfigEncoder.test.ts └── hooks │ └── PostCommentPullRequestEventHandler.test.ts ├── jest.config.js ├── .gitignore ├── docker-compose.yaml ├── .vscode └── launch.json ├── tsconfig.json ├── eslint.config.mjs ├── create-tables.sql ├── LICENSE ├── .env.example ├── package.json └── Dockerfile /.nvmrc: -------------------------------------------------------------------------------- 1 | 24 2 | -------------------------------------------------------------------------------- /.github/CODEOWNERS: -------------------------------------------------------------------------------- 1 | * @ulrikandersen @simonbs 2 | -------------------------------------------------------------------------------- /logo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/shapehq/framna-docs/HEAD/logo.png -------------------------------------------------------------------------------- /wiki/home.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/shapehq/framna-docs/HEAD/wiki/home.png -------------------------------------------------------------------------------- /wiki/login.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/shapehq/framna-docs/HEAD/wiki/login.png -------------------------------------------------------------------------------- /src/app/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/shapehq/framna-docs/HEAD/src/app/favicon.ico -------------------------------------------------------------------------------- /wiki/pr-comment.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/shapehq/framna-docs/HEAD/wiki/pr-comment.png -------------------------------------------------------------------------------- /src/features/docs/navigation/index.ts: -------------------------------------------------------------------------------- 1 | export { scrollToOperation } from "./scrollToOperation" 2 | -------------------------------------------------------------------------------- /public/images/logo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/shapehq/framna-docs/HEAD/public/images/logo.png -------------------------------------------------------------------------------- /src/features/hooks/data/index.ts: -------------------------------------------------------------------------------- 1 | export { default as GitHubHookHandler } from "./GitHubHookHandler" 2 | -------------------------------------------------------------------------------- /wiki/version-picker.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/shapehq/framna-docs/HEAD/wiki/version-picker.png -------------------------------------------------------------------------------- /wiki/viewer-picker.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/shapehq/framna-docs/HEAD/wiki/viewer-picker.png -------------------------------------------------------------------------------- /postcss.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | plugins: { 3 | '@tailwindcss/postcss': {}, 4 | }, 5 | } 6 | -------------------------------------------------------------------------------- /src/common/key-value-store/index.ts: -------------------------------------------------------------------------------- 1 | export type { default as IKeyValueStore } from "./IKeyValueStore" 2 | -------------------------------------------------------------------------------- /wiki/create-repository.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/shapehq/framna-docs/HEAD/wiki/create-repository.png -------------------------------------------------------------------------------- /wiki/invite-collaborator.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/shapehq/framna-docs/HEAD/wiki/invite-collaborator.png -------------------------------------------------------------------------------- /wiki/specification-picker.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/shapehq/framna-docs/HEAD/wiki/specification-picker.png -------------------------------------------------------------------------------- /drop-tables.sql: -------------------------------------------------------------------------------- 1 | DROP TABLE oauth_tokens; 2 | DROP TABLE accounts; 3 | DROP TABLE sessions; 4 | DROP TABLE users; 5 | -------------------------------------------------------------------------------- /src/features/auth/data/index.ts: -------------------------------------------------------------------------------- 1 | export { default as GitHubOAuthTokenRefresher } from "./GitHubOAuthTokenRefresher" 2 | -------------------------------------------------------------------------------- /src/features/settings/domain/index.ts: -------------------------------------------------------------------------------- 1 | export { default as DocumentationVisualizer } from "./DocumentationVisualizer" 2 | -------------------------------------------------------------------------------- /src/features/settings/data/index.ts: -------------------------------------------------------------------------------- 1 | export { default as useDocumentationVisualizer } from "./useDocumentationVisualizer" 2 | -------------------------------------------------------------------------------- /src/common/mutex/IMutex.ts: -------------------------------------------------------------------------------- 1 | export default interface IMutex { 2 | acquire(): Promise 3 | release(): Promise 4 | } 5 | -------------------------------------------------------------------------------- /src/features/auth/domain/log-out/ILogOutHandler.ts: -------------------------------------------------------------------------------- 1 | export default interface ILogOutHandler { 2 | handleLogOut(): Promise 3 | } 4 | -------------------------------------------------------------------------------- /src/app/api/auth/[...nextauth]/route.ts: -------------------------------------------------------------------------------- 1 | import { authHandlers } from "@/composition" 2 | 3 | export const { GET, POST } = authHandlers 4 | -------------------------------------------------------------------------------- /src/common/session/index.ts: -------------------------------------------------------------------------------- 1 | export { default as AuthjsSession } from "./AuthjsSession" 2 | export type { default as ISession } from "./ISession" 3 | -------------------------------------------------------------------------------- /src/common/utils/isMac.ts: -------------------------------------------------------------------------------- 1 | const isMac = () => { 2 | return window.navigator.userAgent.toLowerCase().includes("mac") 3 | } 4 | 5 | export default isMac -------------------------------------------------------------------------------- /src/features/projects/domain/IGitHubLoginDataSource.ts: -------------------------------------------------------------------------------- 1 | export default interface IGitHubLoginDataSource { 2 | getLogins(): Promise 3 | } 4 | -------------------------------------------------------------------------------- /types/globals.d.ts: -------------------------------------------------------------------------------- 1 | export {} 2 | 3 | declare global { 4 | interface Window { 5 | SwaggerUIBundle: (options: unknown) => unknown 6 | } 7 | } 8 | -------------------------------------------------------------------------------- /wiki/example-openapi-repository-with-config.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/shapehq/framna-docs/HEAD/wiki/example-openapi-repository-with-config.png -------------------------------------------------------------------------------- /src/common/mutex/IMutexFactory.ts: -------------------------------------------------------------------------------- 1 | import IMutex from "./IMutex" 2 | 3 | export default interface IMutexFactory { 4 | makeMutex(): Promise 5 | } 6 | -------------------------------------------------------------------------------- /src/common/session/ISession.ts: -------------------------------------------------------------------------------- 1 | export default interface ISession { 2 | getIsAuthenticated(): Promise 3 | getUserId(): Promise 4 | } 5 | -------------------------------------------------------------------------------- /wiki/example-openapi-repository-without-config.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/shapehq/framna-docs/HEAD/wiki/example-openapi-repository-without-config.png -------------------------------------------------------------------------------- /src/features/auth/domain/index.ts: -------------------------------------------------------------------------------- 1 | export * from "./log-in" 2 | export * from "./log-out" 3 | export * from "./oauth-token" 4 | export * from "./session-validity" 5 | -------------------------------------------------------------------------------- /src/common/db/index.ts: -------------------------------------------------------------------------------- 1 | export type { default as IDB, IDBConnection } from "./IDB" 2 | export { default as PostgreSQLDB, PostgreSQLDBConnection } from "./PostgreSQLDB" 3 | -------------------------------------------------------------------------------- /src/common/mutex/IKeyedMutexFactory.ts: -------------------------------------------------------------------------------- 1 | import IMutex from "./IMutex" 2 | 3 | export default interface IKeyedMutexFactory { 4 | makeMutex(key: string): IMutex 5 | } 6 | -------------------------------------------------------------------------------- /src/features/docs/view/swagger.css: -------------------------------------------------------------------------------- 1 | .swagger-ui .info span.url { 2 | display: block; 3 | text-overflow: ellipsis; 4 | overflow: hidden; 5 | max-width: 80%; 6 | } 7 | -------------------------------------------------------------------------------- /src/app/(authed)/(home)/new/loading.tsx: -------------------------------------------------------------------------------- 1 | import LoadingIndicator from "@/common/ui/LoadingIndicator"; 2 | 3 | export default function Loading() { 4 | return 5 | } -------------------------------------------------------------------------------- /src/features/projects/domain/IProjectDataSource.ts: -------------------------------------------------------------------------------- 1 | import Project from "./Project" 2 | 3 | export default interface IProjectDataSource { 4 | getProjects(): Promise 5 | } 6 | -------------------------------------------------------------------------------- /src/features/sidebar/view/index.ts: -------------------------------------------------------------------------------- 1 | export { default as SidebarTogglableContextProvider } from "./SidebarTogglableContextProvider" 2 | export { default as SplitView } from "./SplitView" 3 | -------------------------------------------------------------------------------- /src/features/settings/domain/DocumentationVisualizer.ts: -------------------------------------------------------------------------------- 1 | enum DocumentationVisualizer { 2 | SWAGGER, 3 | REDOCLY, 4 | STOPLIGHT 5 | } 6 | 7 | export default DocumentationVisualizer 8 | -------------------------------------------------------------------------------- /src/common/user-data/index.ts: -------------------------------------------------------------------------------- 1 | export type { default as IUserDataRepository } from "./IUserDataRepository" 2 | export { default as KeyValueUserDataRepository } from "./KeyValueUserDataRepository" 3 | -------------------------------------------------------------------------------- /src/app/api/health/route.ts: -------------------------------------------------------------------------------- 1 | import { NextResponse } from "next/server" 2 | 3 | export const GET = async (): Promise => { 4 | return NextResponse.json({ status: "Healthy" }) 5 | } 6 | -------------------------------------------------------------------------------- /src/features/auth/domain/log-in/index.ts: -------------------------------------------------------------------------------- 1 | export type { default as ILogInHandler, IUser, IAccount, HandleLoginParams } from "./ILogInHandler" 2 | export { default as LogInHandler } from "./LogInHandler" 3 | -------------------------------------------------------------------------------- /src/features/auth/domain/session-validity/SessionValidity.ts: -------------------------------------------------------------------------------- 1 | enum SessionValidity { 2 | VALID = "valid", 3 | INVALID_ACCESS_TOKEN = "invalid_access_token" 4 | } 5 | 6 | export default SessionValidity 7 | -------------------------------------------------------------------------------- /next.config.js: -------------------------------------------------------------------------------- 1 | /** @type {import('next').NextConfig} */ 2 | const nextConfig = { 3 | // Output standalone to be used for Docker builds. 4 | output: 'standalone', 5 | } 6 | 7 | module.exports = nextConfig 8 | -------------------------------------------------------------------------------- /src/features/auth/domain/oauth-token/data-source/IOAuthTokenDataSource.ts: -------------------------------------------------------------------------------- 1 | import { OAuthToken } from ".." 2 | 3 | export default interface IOAuthTokenDataSource { 4 | getOAuthToken(): Promise 5 | } 6 | -------------------------------------------------------------------------------- /SECURITY.md: -------------------------------------------------------------------------------- 1 | # Security Policy 2 | 3 | ## Reporting a Vulnerability 4 | 5 | Please contact us at security@shape.dk if you find a security vulnerability in the software. Do note that we do not offer a bug bounty program. 6 | -------------------------------------------------------------------------------- /src/common/images/base64EncodedLogo.ts: -------------------------------------------------------------------------------- 1 | import fs from "fs" 2 | 3 | let str = "unavailable" 4 | if (process.env.NODE_ENV !== "test") { 5 | str = fs.readFileSync("public/images/logo.png", "base64") 6 | } 7 | export default str 8 | -------------------------------------------------------------------------------- /src/common/errors/index.ts: -------------------------------------------------------------------------------- 1 | export { default as makeAPIErrorResponse } from "./makeAPIErrorResponse" 2 | export { makeUnauthenticatedAPIErrorResponse } from "./makeAPIErrorResponse" 3 | export class UnauthorizedError extends Error {} 4 | -------------------------------------------------------------------------------- /src/features/auth/domain/oauth-token/refresher/IOAuthTokenRefresher.ts: -------------------------------------------------------------------------------- 1 | import { OAuthToken } from ".." 2 | 3 | export default interface IOAuthTokenRefresher { 4 | refreshOAuthToken(oauthToken: OAuthToken): Promise 5 | } 6 | -------------------------------------------------------------------------------- /src/features/sidebar/data/useDiffbarOpen.ts: -------------------------------------------------------------------------------- 1 | import { useSessionStorage } from "usehooks-ts" 2 | 3 | export default function useDiffbarOpen() { 4 | return useSessionStorage("isDiffbarOpen", false, { initializeWithValue: false }) 5 | } -------------------------------------------------------------------------------- /src/features/sidebar/data/useSidebarOpen.ts: -------------------------------------------------------------------------------- 1 | import { useSessionStorage } from "usehooks-ts" 2 | 3 | export default function useSidebarOpen() { 4 | return useSessionStorage("isSidebarOpen", true, { initializeWithValue: false }) 5 | } -------------------------------------------------------------------------------- /src/features/encrypt/view/encryptAction.ts: -------------------------------------------------------------------------------- 1 | 'use server' 2 | 3 | import { encryptionService } from '@/composition' 4 | 5 | export async function encrypt(text: string): Promise { 6 | return encryptionService.encrypt(text) 7 | } 8 | -------------------------------------------------------------------------------- /src/features/sidebar/data/index.ts: -------------------------------------------------------------------------------- 1 | export { default as useCloseSidebarOnSelection } from "./useCloseSidebarOnSelection" 2 | export { default as useDiff } from "./useDiff" 3 | export { default as useSidebarOpen } from "./useSidebarOpen" 4 | -------------------------------------------------------------------------------- /src/features/auth/domain/session-validity/index.ts: -------------------------------------------------------------------------------- 1 | export { default as OAuthTokenSessionValidator } from "./OAuthTokenSessionValidator" 2 | export { default as SessionValidity } from "./SessionValidity" 3 | export * from "./SessionValidity" 4 | -------------------------------------------------------------------------------- /src/features/projects/domain/IProjectRepository.ts: -------------------------------------------------------------------------------- 1 | import Project from "./Project" 2 | 3 | export default interface IProjectRepository { 4 | get(): Promise 5 | set(projects: Project[]): Promise 6 | delete(): Promise 7 | } 8 | -------------------------------------------------------------------------------- /src/common/github/index.ts: -------------------------------------------------------------------------------- 1 | export { default as OAuthTokenRefreshingGitHubClient } from "./OAuthTokenRefreshingGitHubClient" 2 | export { default as GitHubClient } from "./GitHubClient" 3 | export type { default as IGitHubClient } from "./IGitHubClient" 4 | export * from "./IGitHubClient" 5 | -------------------------------------------------------------------------------- /src/common/utils/getBaseFilename.ts: -------------------------------------------------------------------------------- 1 | export default function getBaseFilename(filePath: string): string { 2 | const filename = filePath.split("/").pop() || "" 3 | if (!filename.includes(".")) { 4 | return filename 5 | } 6 | return filename.split(".").slice(0, -1).join(".") 7 | } 8 | -------------------------------------------------------------------------------- /src/app/api/refresh-projects/route.ts: -------------------------------------------------------------------------------- 1 | 2 | import { NextResponse } from "next/server" 3 | import { projectDataSource } from "@/composition"; 4 | 5 | 6 | export async function POST() { 7 | const projects = await projectDataSource.refreshProjects() 8 | return NextResponse.json({ projects }) 9 | } -------------------------------------------------------------------------------- /src/features/auth/domain/oauth-token/OAuthToken.ts: -------------------------------------------------------------------------------- 1 | import { z } from "zod" 2 | 3 | export const OAuthTokenSchema = z.object({ 4 | accessToken: z.string(), 5 | refreshToken: z.string() 6 | }) 7 | 8 | type OAuthToken = z.infer 9 | 10 | export default OAuthToken 11 | -------------------------------------------------------------------------------- /src/common/index.ts: -------------------------------------------------------------------------------- 1 | export * from "./context/ProjectsContext" 2 | export * from "./db" 3 | export * from "./errors" 4 | export * from "./github" 5 | export * from "./key-value-store" 6 | export * from "./mutex" 7 | export * from "./session" 8 | export * from "./user-data" 9 | export * from "./utils" 10 | -------------------------------------------------------------------------------- /src/app/api/hooks/github/route.ts: -------------------------------------------------------------------------------- 1 | import { NextRequest, NextResponse } from "next/server" 2 | import { gitHubHookHandler } from "@/composition" 3 | 4 | export const POST = async (req: NextRequest): Promise => { 5 | await gitHubHookHandler.handle(req) 6 | return NextResponse.json({ status: "OK" }) 7 | } -------------------------------------------------------------------------------- /src/common/user-data/IUserDataRepository.ts: -------------------------------------------------------------------------------- 1 | export default interface IUserDataRepository { 2 | get(userId: string): Promise 3 | set(userId: string, value: T): Promise 4 | setExpiring(userId: string, value: T, timeToLive: number): Promise 5 | delete(userId: string): Promise 6 | } 7 | -------------------------------------------------------------------------------- /src/features/auth/domain/oauth-token/repository/IOAuthTokenRepository.ts: -------------------------------------------------------------------------------- 1 | import { OAuthToken } from ".." 2 | 3 | export default interface IOAuthTokenRepository { 4 | get(userId: string): Promise 5 | set(userId: string, token: OAuthToken): Promise 6 | delete(userId: string): Promise 7 | } 8 | -------------------------------------------------------------------------------- /src/common/utils/listFromCommaSeparatedString.ts: -------------------------------------------------------------------------------- 1 | const listFromCommaSeparatedString = (str?: string) => { 2 | if (!str) { 3 | return [] 4 | } 5 | return str 6 | .split(",") 7 | .map(e => e.trim()) 8 | .filter(e => e.length > 0) 9 | } 10 | 11 | export default listFromCommaSeparatedString 12 | -------------------------------------------------------------------------------- /src/common/utils/saneParseInt.ts: -------------------------------------------------------------------------------- 1 | const saneParseInt = (str: string) => { 2 | const forcedString = `${str}` 3 | const num = parseInt(forcedString, 10) 4 | if (isNaN(num) || forcedString.trim() !== num.toString()) { 5 | return undefined 6 | } 7 | return num 8 | } 9 | 10 | export default saneParseInt 11 | -------------------------------------------------------------------------------- /src/features/projects/domain/RemoteSpecAuth.ts: -------------------------------------------------------------------------------- 1 | import { z } from 'zod' 2 | 3 | export const RemoteSpecAuthSchema = z.object({ 4 | type: z.string(), 5 | username: z.string(), 6 | password: z.string(), 7 | }) 8 | 9 | type RemoteSpecAuth = z.infer 10 | 11 | export default RemoteSpecAuth 12 | -------------------------------------------------------------------------------- /src/common/mutex/index.ts: -------------------------------------------------------------------------------- 1 | export type { default as IKeyedMutexFactory } from "./IKeyedMutexFactory" 2 | export type { default as IMutex } from "./IMutex" 3 | export type { default as IMutexFactory } from "./IMutexFactory" 4 | export { default as SessionMutexFactory } from "./SessionMutexFactory" 5 | export { default as withMutex } from "./withMutex" 6 | -------------------------------------------------------------------------------- /src/features/settings/data/useDocumentationVisualizer.ts: -------------------------------------------------------------------------------- 1 | "use client" 2 | 3 | import { useLocalStorage } from "usehooks-ts" 4 | import { DocumentationVisualizer } from "@/features/settings/domain" 5 | 6 | export default function useDocumentationVisualizer() { 7 | return useLocalStorage("documentationVisualizer", DocumentationVisualizer.SWAGGER) 8 | } 9 | -------------------------------------------------------------------------------- /src/features/auth/domain/log-out/index.ts: -------------------------------------------------------------------------------- 1 | export { default as CompositeLogOutHandler } from "./CompositeLogOutHandler" 2 | export { default as ErrorIgnoringLogOutHandler } from "./ErrorIgnoringLogOutHandler" 3 | export type { default as ILogOutHandler } from "./ILogOutHandler" 4 | export { default as UserDataCleanUpLogOutHandler } from "./UserDataCleanUpLogOutHandler" 5 | -------------------------------------------------------------------------------- /types/@next-auth.d.ts: -------------------------------------------------------------------------------- 1 | /* eslint-disable @typescript-eslint/no-unused-vars */ 2 | import NextAuth from "next-auth" 3 | 4 | declare module "next-auth" { 5 | interface Session { 6 | readonly user: { 7 | readonly id: string 8 | readonly email: string 9 | readonly name?: string 10 | readonly image?: string 11 | } 12 | } 13 | } 14 | -------------------------------------------------------------------------------- /src/features/projects/domain/RemoteConfig.ts: -------------------------------------------------------------------------------- 1 | import { z } from 'zod' 2 | import { RemoteSpecAuthSchema } from './RemoteSpecAuth' 3 | 4 | export const RemoteConfigSchema = z.object({ 5 | url: z.string().url(), 6 | auth: RemoteSpecAuthSchema.optional(), 7 | }) 8 | 9 | type RemoteConfig = z.infer 10 | 11 | export default RemoteConfig 12 | -------------------------------------------------------------------------------- /src/common/key-value-store/IKeyValueStore.ts: -------------------------------------------------------------------------------- 1 | export default interface IKeyValueStore { 2 | get(key: string): Promise 3 | set(key: string, data: string | number | Buffer): Promise 4 | setExpiring( 5 | key: string, 6 | data: string | number | Buffer, 7 | timeToLive: number 8 | ): Promise 9 | delete(key: string): Promise 10 | } 11 | -------------------------------------------------------------------------------- /__test__/auth/ErrorIgnoringLogOutHandler.test.ts: -------------------------------------------------------------------------------- 1 | import { ErrorIgnoringLogOutHandler } from "@/features/auth/domain" 2 | 3 | test("It ignores errors", async () => { 4 | const sut = new ErrorIgnoringLogOutHandler({ 5 | async handleLogOut() { 6 | throw new Error("Mock") 7 | } 8 | }) 9 | // Test will fail if the following throws. 10 | await sut.handleLogOut() 11 | }) 12 | -------------------------------------------------------------------------------- /jest.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | clearMocks: true, 3 | moduleFileExtensions: ["js", "ts"], 4 | testEnvironment: "node", 5 | testMatch: ["**/*.test.ts"], 6 | extensionsToTreatAsEsm: [".ts"], 7 | transform: { 8 | "^.+\\.tsx?$": ["ts-jest", { useESM: true }] 9 | }, 10 | moduleNameMapper: { 11 | "^@/(.*)$": "/src/$1" 12 | }, 13 | verbose: true 14 | } 15 | -------------------------------------------------------------------------------- /src/features/projects/data/index.ts: -------------------------------------------------------------------------------- 1 | export { default as GitHubProjectDataSource } from "./GitHubProjectDataSource" 2 | export * from "./GitHubProjectDataSource" 3 | export { default as useProjectSelection } from "./useProjectSelection" 4 | export { default as GitHubLoginDataSource } from "./GitHubLoginDataSource" 5 | export { default as GitHubRepositoryDataSource } from "./GitHubRepositoryDataSource" 6 | -------------------------------------------------------------------------------- /src/common/mutex/withMutex.ts: -------------------------------------------------------------------------------- 1 | import IMutex from "./IMutex" 2 | 3 | export default async function withMutex( 4 | mutex: IMutex, 5 | f: () => Promise 6 | ): Promise { 7 | await mutex.acquire() 8 | try { 9 | const value = await f() 10 | await mutex.release() 11 | return value 12 | } catch(error) { 13 | await mutex.release() 14 | throw error 15 | } 16 | } 17 | -------------------------------------------------------------------------------- /.github/dependabot.yml: -------------------------------------------------------------------------------- 1 | version: 2 2 | updates: 3 | - package-ecosystem: "npm" 4 | directory: "/" 5 | schedule: 6 | interval: "monthly" 7 | groups: 8 | react: 9 | patterns: 10 | - "react" 11 | - "react-*" 12 | everything-else: 13 | patterns: 14 | - "*" 15 | exclude-patterns: 16 | - "react" 17 | - "react-*" 18 | -------------------------------------------------------------------------------- /src/features/projects/domain/ProjectConfigParser.ts: -------------------------------------------------------------------------------- 1 | import { parse } from "yaml" 2 | import IProjectConfig, { IProjectConfigSchema } from "./IProjectConfig" 3 | 4 | export default class ProjectConfigParser { 5 | parse(rawConfig: string): IProjectConfig { 6 | const obj = parse(rawConfig) 7 | if (obj === null) { 8 | return {} 9 | } 10 | return IProjectConfigSchema.parse(obj) 11 | } 12 | } 13 | -------------------------------------------------------------------------------- /src/common/errors/makeAPIErrorResponse.ts: -------------------------------------------------------------------------------- 1 | import { NextResponse } from "next/server" 2 | 3 | export default function makeAPIErrorResponse( 4 | status: number, 5 | message: string 6 | ): NextResponse { 7 | return NextResponse.json({ status, message }, { status }) 8 | } 9 | 10 | export function makeUnauthenticatedAPIErrorResponse(): NextResponse { 11 | return makeAPIErrorResponse(401, "Unauthenticated") 12 | } 13 | -------------------------------------------------------------------------------- /src/features/sidebar/view/internal/diffbar/components/levelConfig.ts: -------------------------------------------------------------------------------- 1 | export type Level = 1 | 2 | 3 2 | 3 | export const getLevelConfig = (level: Level) => { 4 | switch (level) { 5 | case 3: 6 | return { label: "breaking", color: "#ff5555" } 7 | case 2: 8 | return { label: "warn", color: "#ffaa33" } 9 | case 1: 10 | default: 11 | return { label: "info", color: "#44ddee" } 12 | } 13 | } 14 | -------------------------------------------------------------------------------- /src/features/diff/domain/DiffChange.ts: -------------------------------------------------------------------------------- 1 | import { z } from "zod" 2 | 3 | export const DiffChangeSchema = z.object({ 4 | id: z.string(), 5 | text: z.string(), 6 | level: z.number(), 7 | operation: z.string().optional(), 8 | operationId: z.string().optional(), 9 | path: z.string().optional(), 10 | source: z.string().optional(), 11 | section: z.string().optional(), 12 | }) 13 | 14 | export type DiffChange = z.infer 15 | -------------------------------------------------------------------------------- /src/common/ui/ErrorMessage.tsx: -------------------------------------------------------------------------------- 1 | import { Box, Typography } from "@mui/material" 2 | 3 | const ErrorMessage = ({ text }: { text: string }) => { 4 | return ( 5 | 12 | 13 | {text} 14 | 15 | 16 | ) 17 | } 18 | 19 | export default ErrorMessage 20 | -------------------------------------------------------------------------------- /src/common/ui/ThickDivider.tsx: -------------------------------------------------------------------------------- 1 | "use client" 2 | 3 | import { SxProps } from "@mui/system" 4 | import { Box } from "@mui/material" 5 | import { useTheme } from "@mui/material/styles" 6 | 7 | const ThickDivider = ({ sx }: { sx?: SxProps }) => { 8 | const theme = useTheme() 9 | return ( 10 | 16 | ) 17 | } 18 | 19 | export default ThickDivider 20 | -------------------------------------------------------------------------------- /src/features/hooks/domain/index.ts: -------------------------------------------------------------------------------- 1 | export type { default as IPullRequestEventHandler } from "./IPullRequestEventHandler" 2 | export { default as PostCommentPullRequestEventHandler } from "./PostCommentPullRequestEventHandler" 3 | export { default as FilteringPullRequestEventHandler } from "./FilteringPullRequestEventHandler" 4 | export { default as RepositoryNameEventFilter } from "./RepositoryNameEventFilter" 5 | export { default as PullRequestCommenter } from "./PullRequestCommenter" 6 | -------------------------------------------------------------------------------- /src/app/documentation-viewer/page.tsx: -------------------------------------------------------------------------------- 1 | import DocumentationViewer from "@/features/docs/view/DocumentationViewer" 2 | 3 | type SearchParams = { visualizer: string, url: string } 4 | 5 | export default async function Page({ 6 | searchParams 7 | }: { 8 | searchParams: Promise 9 | }) { 10 | const { visualizer, url } = await searchParams 11 | return ( 12 | 16 | ) 17 | } -------------------------------------------------------------------------------- /src/features/auth/domain/log-out/CompositeLogOutHandler.ts: -------------------------------------------------------------------------------- 1 | import ILogOutHandler from "./ILogOutHandler" 2 | 3 | export default class CompositeLogOutHandler implements ILogOutHandler { 4 | private readonly handlers: ILogOutHandler[] 5 | 6 | constructor(handlers: ILogOutHandler[]) { 7 | this.handlers = handlers 8 | } 9 | 10 | async handleLogOut(): Promise { 11 | const promises = this.handlers.map(e => e.handleLogOut()) 12 | await Promise.all(promises) 13 | } 14 | } 15 | -------------------------------------------------------------------------------- /src/features/projects/domain/Project.ts: -------------------------------------------------------------------------------- 1 | import { z } from "zod" 2 | import { VersionSchema } from "./Version" 3 | 4 | export const ProjectSchema = z.object({ 5 | id: z.string(), 6 | name: z.string(), 7 | displayName: z.string(), 8 | versions: VersionSchema.array(), 9 | imageURL: z.string().optional(), 10 | url: z.string().optional(), 11 | owner: z.string(), 12 | ownerUrl: z.string() 13 | }) 14 | 15 | type Project = z.infer 16 | 17 | export default Project 18 | -------------------------------------------------------------------------------- /__test__/auth/UserDataCleanUpLogOutHandler.test.ts: -------------------------------------------------------------------------------- 1 | import { UserDataCleanUpLogOutHandler } from "@/features/auth/domain" 2 | 3 | test("It deletes data for the read user ID", async () => { 4 | let deletedUserId: string | undefined 5 | const sut = new UserDataCleanUpLogOutHandler({ 6 | async getUserId() { 7 | return "foo" 8 | }, 9 | }, { 10 | async delete(userId) { 11 | deletedUserId = userId 12 | } 13 | }) 14 | await sut.handleLogOut() 15 | expect(deletedUserId).toBe("foo") 16 | }) 17 | -------------------------------------------------------------------------------- /src/features/diff/data/IOasDiffCalculator.ts: -------------------------------------------------------------------------------- 1 | import { DiffChange } from "../domain/DiffChange" 2 | 3 | export interface DiffResult { 4 | from: string 5 | to: string 6 | changes: DiffChange[] 7 | error?: string | null 8 | isNewFile?: boolean 9 | } 10 | 11 | export interface IOasDiffCalculator { 12 | calculateDiff( 13 | owner: string, 14 | repository: string, 15 | path: string, 16 | baseRefOid: string, 17 | toRef: string 18 | ): Promise 19 | } 20 | -------------------------------------------------------------------------------- /src/common/context/ProjectsContext.ts: -------------------------------------------------------------------------------- 1 | "use client" 2 | 3 | import { createContext } from "react" 4 | import { Project } from "@/features/projects/domain" 5 | 6 | export const SidebarTogglableContext = createContext(true) 7 | 8 | type ProjectsContextValue = { 9 | refreshing: boolean, 10 | projects: Project[], 11 | refreshProjects: () => void, 12 | } 13 | 14 | export const ProjectsContext = createContext({ 15 | refreshing: false, 16 | projects: [], 17 | refreshProjects: () => {}, 18 | }) 19 | -------------------------------------------------------------------------------- /src/features/auth/domain/log-out/ErrorIgnoringLogOutHandler.ts: -------------------------------------------------------------------------------- 1 | import ILogOutHandler from "./ILogOutHandler" 2 | 3 | export default class ErrorIgnoringLogOutHandler implements ILogOutHandler { 4 | private readonly handler: ILogOutHandler 5 | 6 | constructor(handler: ILogOutHandler) { 7 | this.handler = handler 8 | } 9 | 10 | async handleLogOut(): Promise { 11 | try { 12 | await this.handler.handleLogOut() 13 | } catch { 14 | // We intentionally do not handle errors. 15 | } 16 | } 17 | } 18 | -------------------------------------------------------------------------------- /src/features/projects/domain/IGitHubGraphQLClient.ts: -------------------------------------------------------------------------------- 1 | export type GitHubGraphQLClientRequest = { 2 | readonly query: string 3 | /* eslint-disable-next-line @typescript-eslint/no-explicit-any */ 4 | readonly variables?: {[key: string]: any} 5 | } 6 | 7 | export type GitHubGraphQLClientResponse = { 8 | /* eslint-disable-next-line @typescript-eslint/no-explicit-any */ 9 | readonly [key: string]: any 10 | } 11 | 12 | export default interface IGitHubGraphQLClient { 13 | graphql(request: GitHubGraphQLClientRequest): Promise 14 | } 15 | -------------------------------------------------------------------------------- /src/features/projects/domain/OpenApiSpecification.ts: -------------------------------------------------------------------------------- 1 | import { z } from "zod" 2 | 3 | export const OpenApiSpecificationSchema = z.object({ 4 | id: z.string(), 5 | name: z.string(), 6 | url: z.string(), 7 | editURL: z.string().optional(), 8 | diffURL: z.string().optional(), 9 | diffBaseBranch: z.string().optional(), 10 | diffBaseOid: z.string().optional(), 11 | diffPrUrl: z.string().optional(), 12 | isDefault: z.boolean() 13 | }) 14 | 15 | type OpenApiSpecification = z.infer 16 | 17 | export default OpenApiSpecification 18 | -------------------------------------------------------------------------------- /src/features/docs/view/Redocly.tsx: -------------------------------------------------------------------------------- 1 | import { useState } from "react" 2 | import { RedocStandalone } from "redoc" 3 | import LoadingWrapper from "./LoadingWrapper" 4 | 5 | const Redocly = ({ url }: { url: string }) => { 6 | const [isLoading, setLoading] = useState(true) 7 | return ( 8 | 9 | setLoading(false)} 13 | /> 14 | 15 | ) 16 | } 17 | 18 | export default Redocly 19 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # See https://help.github.com/articles/ignoring-files/ for more about ignoring files. 2 | 3 | # dependencies 4 | /node_modules 5 | /.pnp 6 | .pnp.js 7 | 8 | # testing 9 | /coverage 10 | 11 | # next.js 12 | /.next/ 13 | /out/ 14 | 15 | # production 16 | /build 17 | 18 | # misc 19 | .DS_Store 20 | *.pem 21 | 22 | # debug 23 | npm-debug.log* 24 | yarn-debug.log* 25 | yarn-error.log* 26 | 27 | # local env files 28 | .env*.local 29 | .env 30 | # vercel 31 | .vercel 32 | 33 | # typescript 34 | *.tsbuildinfo 35 | next-env.d.ts 36 | 37 | .github/actions/invite-user/lib 38 | -------------------------------------------------------------------------------- /src/common/ui/ErrorHandler.tsx: -------------------------------------------------------------------------------- 1 | "use client" 2 | 3 | import { SWRConfig } from "swr" 4 | import { FetcherError } from "@/common" 5 | 6 | export default function ErrorHandler({ children }: { children: React.ReactNode }) { 7 | const onSWRError = (error: FetcherError) => { 8 | if (typeof window === "undefined") { 9 | return 10 | } 11 | if (error.status == 401) { 12 | window.location.href = "/api/auth/signout" 13 | } 14 | } 15 | return ( 16 | 17 | {children} 18 | 19 | ) 20 | } 21 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/question.yml: -------------------------------------------------------------------------------- 1 | name: ❓ Question 2 | description: Ask a question about this project 3 | labels: ["question"] 4 | body: 5 | - type: textarea 6 | id: question 7 | attributes: 8 | label: Your Question 9 | description: A clear and concisely formulated question. 10 | placeholder: I'd like to ask... 11 | validations: 12 | required: true 13 | - type: textarea 14 | id: context 15 | attributes: 16 | label: Any additional context? 17 | description: Add any other context or screenshots about the question request here. 18 | -------------------------------------------------------------------------------- /src/features/sidebar/view/internal/sidebar/projects/PopulatedProjectList.tsx: -------------------------------------------------------------------------------- 1 | "use client" 2 | import SpacedList from "@/common/ui/SpacedList" 3 | import { Project } from "@/features/projects/domain" 4 | import ProjectListItem from "./ProjectListItem" 5 | 6 | const PopulatedProjectList = ({ projects }: { projects: Project[] }) => { 7 | return ( 8 | 9 | {projects.map(project => ( 10 | 11 | ))} 12 | 13 | ) 14 | } 15 | 16 | export default PopulatedProjectList 17 | -------------------------------------------------------------------------------- /src/app/(authed)/(home)/encrypt/layout.tsx: -------------------------------------------------------------------------------- 1 | import { Box, Stack } from "@mui/material" 2 | import SecondarySplitHeader from "@/features/sidebar/view/SecondarySplitHeader" 3 | 4 | export default function Page({ children }: { children: React.ReactNode }) { 5 | return ( 6 | 7 | 8 | 16 | {children} 17 | 18 | 19 | ) 20 | } -------------------------------------------------------------------------------- /src/app/(authed)/(home)/new/layout.tsx: -------------------------------------------------------------------------------- 1 | import { Box, Stack } from "@mui/material" 2 | import SecondarySplitHeader from "@/features/sidebar/view/SecondarySplitHeader" 3 | 4 | export default function Page({ children }: { children: React.ReactNode }) { 5 | return ( 6 | 7 | 8 | 16 | {children} 17 | 18 | 19 | ) 20 | } -------------------------------------------------------------------------------- /src/common/ui/DelayedLoadingIndicator.tsx: -------------------------------------------------------------------------------- 1 | "use client" 2 | 3 | import { useState, useEffect } from "react" 4 | import LoadingIndicator from "./LoadingIndicator" 5 | 6 | const DelayedLoadingIndicator = ({ delay }: { delay?: number }) => { 7 | const [isVisible, setVisible] = useState(false) 8 | useEffect(() => { 9 | const timer = setTimeout(() => { 10 | setVisible(true) 11 | }, delay || 1000) 12 | return () => clearTimeout(timer) 13 | }, [delay, setVisible]) 14 | return <>{isVisible && } 15 | } 16 | 17 | export default DelayedLoadingIndicator 18 | -------------------------------------------------------------------------------- /src/common/utils/splitOwnerAndRepository.ts: -------------------------------------------------------------------------------- 1 | // Split full repository names into owner and repository. 2 | // acme/foo becomes { owner: "acme", "repository": "foo" } 3 | const splitOwnerAndRepository = (str: string) => { 4 | const index = str.indexOf("/") 5 | if (index === -1) { 6 | return undefined 7 | } 8 | const owner = str.substring(0, index) 9 | const repository = str.substring(index + 1) 10 | if (owner.length == 0 || repository.length == 0) { 11 | return undefined 12 | } 13 | return { owner, repository } 14 | } 15 | 16 | export default splitOwnerAndRepository 17 | -------------------------------------------------------------------------------- /src/app/(authed)/(home)/(welcome)/layout.tsx: -------------------------------------------------------------------------------- 1 | import { Box, Stack } from "@mui/material" 2 | import SecondarySplitHeader from "@/features/sidebar/view/SecondarySplitHeader" 3 | 4 | export default function Page({ children }: { children: React.ReactNode }) { 5 | return ( 6 | 7 | 8 | 16 | {children} 17 | 18 | 19 | ) 20 | } -------------------------------------------------------------------------------- /src/features/auth/domain/log-in/ILogInHandler.ts: -------------------------------------------------------------------------------- 1 | export interface IUser { 2 | readonly id?: string 3 | readonly email?: string | null 4 | } 5 | 6 | export interface IAccount { 7 | readonly provider: string 8 | readonly providerAccountId: string 9 | readonly access_token?: string 10 | readonly refresh_token?: string 11 | } 12 | 13 | export type HandleLoginParams = { 14 | readonly user: IUser 15 | readonly account: IAccount | null | undefined 16 | } 17 | 18 | export default interface ILogInHandler { 19 | handleLogIn(params: HandleLoginParams): Promise 20 | } 21 | -------------------------------------------------------------------------------- /src/features/auth/view/SessionBarrier.tsx: -------------------------------------------------------------------------------- 1 | import { redirect } from "next/navigation" 2 | import { blockingSessionValidator } from "@/composition" 3 | import { SessionValidity } from "../domain" 4 | 5 | export default async function SessionBarrier({ 6 | children 7 | }: { 8 | children: React.ReactNode 9 | }) { 10 | const sessionValidity = await blockingSessionValidator.validateSession() 11 | switch (sessionValidity) { 12 | case SessionValidity.VALID: 13 | return <>{children} 14 | case SessionValidity.INVALID_ACCESS_TOKEN: 15 | return redirect("/api/auth/signout") 16 | } 17 | } 18 | -------------------------------------------------------------------------------- /src/features/sidebar/data/useCloseSidebarOnSelection.ts: -------------------------------------------------------------------------------- 1 | "use client" 2 | 3 | import useMediaQuery from "@mui/material/useMediaQuery" 4 | import { useTheme } from "@mui/material" 5 | import useSidebarOpen from "./useSidebarOpen" 6 | 7 | export default function useCloseSidebarOnSelection() { 8 | const theme = useTheme() 9 | const isDesktopLayout = useMediaQuery(theme.breakpoints.up("sm")) 10 | const [, setSidebarOpen] = useSidebarOpen() 11 | return { 12 | closeSidebarIfNeeded: () => { 13 | if (!isDesktopLayout) { 14 | setSidebarOpen(false) 15 | } 16 | } 17 | } 18 | } 19 | -------------------------------------------------------------------------------- /src/features/sidebar/view/internal/sidebar/user/UserSkeleton.tsx: -------------------------------------------------------------------------------- 1 | import { Box, Stack, Skeleton } from "@mui/material" 2 | import MenuItemHover from "@/common/ui/MenuItemHover" 3 | 4 | const UserSkeleton = () => { 5 | return ( 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | ) 15 | } 16 | 17 | export default UserSkeleton 18 | -------------------------------------------------------------------------------- /src/common/utils/fetcher.ts: -------------------------------------------------------------------------------- 1 | export class FetcherError extends Error { 2 | readonly status: number 3 | 4 | constructor(status: number, message: string) { 5 | super(message) 6 | this.status = status 7 | } 8 | } 9 | 10 | /* eslint-disable-next-line @typescript-eslint/no-explicit-any */ 11 | export default async function fetcher( 12 | input: RequestInfo, 13 | init?: RequestInit 14 | ): Promise { 15 | const res = await fetch(input, init) 16 | if (!res.ok) { 17 | throw new FetcherError(res.status, "An error occurred while fetching the data.") 18 | } 19 | return res.json() 20 | } -------------------------------------------------------------------------------- /src/common/utils/index.ts: -------------------------------------------------------------------------------- 1 | export * from "./fetcher" 2 | export { default as fetcher } from "./fetcher" 3 | export { default as ZodJSONCoder } from "./ZodJSONCoder" 4 | export { default as listFromCommaSeparatedString } from "./listFromCommaSeparatedString" 5 | export { default as env } from "./env" 6 | export { default as getBaseFilename } from "./getBaseFilename" 7 | export { default as isMac } from "./isMac" 8 | export { default as useKeyboardShortcut } from "./useKeyboardShortcut" 9 | export { default as saneParseInt } from "./saneParseInt" 10 | export { default as splitOwnerAndRepository } from "./splitOwnerAndRepository" 11 | -------------------------------------------------------------------------------- /src/common/utils/useKeyboardShortcut.ts: -------------------------------------------------------------------------------- 1 | "use client" 2 | 3 | import { useEffect } from "react" 4 | 5 | const useKeyboardShortcut = ( 6 | handleKeyDown: (event: KeyboardEvent) => void, 7 | /* eslint-disable-next-line @typescript-eslint/no-explicit-any */ 8 | dependencies: any[] 9 | ) => { 10 | useEffect(() => { 11 | window.addEventListener("keydown", handleKeyDown) 12 | return () => { 13 | window.removeEventListener("keydown", handleKeyDown) 14 | } 15 | /* eslint-disable-next-line react-hooks/exhaustive-deps */ 16 | }, [handleKeyDown, ...dependencies]) 17 | } 18 | 19 | export default useKeyboardShortcut -------------------------------------------------------------------------------- /src/features/projects/domain/Version.ts: -------------------------------------------------------------------------------- 1 | import { z } from "zod" 2 | import { OpenApiSpecificationSchema } from "./OpenApiSpecification" 3 | 4 | export const VersionSchema = z.object({ 5 | id: z.string(), 6 | name: z.string(), 7 | specifications: OpenApiSpecificationSchema.array(), 8 | url: z.string().optional(), 9 | isDefault: z.boolean().default(false), 10 | }) 11 | 12 | type Version = z.infer 13 | 14 | export default Version 15 | 16 | export function getDefaultSpecification(version: Version) { 17 | return version.specifications.find((spec) => spec.isDefault) || version.specifications[0] 18 | } 19 | -------------------------------------------------------------------------------- /.github/workflows/build-docker-image.yml: -------------------------------------------------------------------------------- 1 | name: Build Docker Image 2 | permissions: 3 | contents: read 4 | on: 5 | workflow_dispatch: 6 | pull_request: 7 | concurrency: 8 | group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }} 9 | cancel-in-progress: true 10 | env: 11 | NEXT_TELEMETRY_DISABLED: 1 12 | jobs: 13 | build: 14 | name: Build Docker Image 15 | runs-on: ubuntu-latest 16 | steps: 17 | - name: Checkout Repository 18 | uses: actions/checkout@v4 19 | with: 20 | persist-credentials: false 21 | - name: Build Docker Image 22 | run: docker build . 23 | -------------------------------------------------------------------------------- /docker-compose.yaml: -------------------------------------------------------------------------------- 1 | services: 2 | cache: 3 | image: redis:6.2-alpine 4 | restart: always 5 | ports: 6 | - '6379:6379' 7 | command: redis-server --save 20 1 --loglevel warning 8 | volumes: 9 | - cache:/data 10 | 11 | postgres: 12 | image: postgres:16.8 13 | restart: always 14 | ports: 15 | - '5432:5432' 16 | environment: 17 | - POSTGRES_USER=oscar 18 | - POSTGRES_PASSWORD=passw0rd 19 | - POSTGRES_DB=shape-docs 20 | 21 | app: 22 | build: . 23 | ports: 24 | - '3000:3000' 25 | env_file: 26 | - .env 27 | depends_on: 28 | - cache 29 | 30 | volumes: 31 | cache: 32 | driver: local 33 | -------------------------------------------------------------------------------- /src/common/utils/ZodJSONCoder.ts: -------------------------------------------------------------------------------- 1 | import { ZodType, z } from "zod" 2 | 3 | export default class ZodJSONCoder { 4 | static encode(schema: Schema, value: z.input): string { 5 | const validatedValue = schema.parse(value) 6 | return JSON.stringify(validatedValue) 7 | } 8 | 9 | static decode(schema: Schema, string: string): z.output { 10 | /* eslint-disable-next-line @typescript-eslint/no-explicit-any */ 11 | let obj: any | undefined 12 | try { 13 | obj = JSON.parse(string) 14 | } catch { 15 | throw new Error("Could not parse JSON.") 16 | } 17 | return schema.parse(obj) 18 | } 19 | } 20 | -------------------------------------------------------------------------------- /src/features/auth/domain/session-validity/OAuthTokenSessionValidator.ts: -------------------------------------------------------------------------------- 1 | import SessionValidity from "./SessionValidity" 2 | import { IOAuthTokenDataSource } from ".." 3 | 4 | export default class OAuthTokenSessionValidator { 5 | private readonly oauthTokenDataSource: IOAuthTokenDataSource 6 | 7 | constructor(config: { oauthTokenDataSource: IOAuthTokenDataSource }) { 8 | this.oauthTokenDataSource = config.oauthTokenDataSource 9 | } 10 | 11 | async validateSession(): Promise { 12 | try { 13 | await this.oauthTokenDataSource.getOAuthToken() 14 | return SessionValidity.VALID 15 | } catch { 16 | return SessionValidity.INVALID_ACCESS_TOKEN 17 | } 18 | } 19 | } 20 | -------------------------------------------------------------------------------- /src/features/docs/view/DocumentationViewer.tsx: -------------------------------------------------------------------------------- 1 | "use client" 2 | 3 | import Redocly from "./Redocly" 4 | import Stoplight from "./Stoplight" 5 | import Swagger from "./Swagger" 6 | import { DocumentationVisualizer } from "@/features/settings/domain" 7 | 8 | const DocumentationViewer = ({ 9 | visualizer, 10 | url 11 | }: { 12 | visualizer: DocumentationVisualizer, 13 | url: string 14 | }) => { 15 | switch (visualizer) { 16 | case DocumentationVisualizer.REDOCLY: 17 | return 18 | case DocumentationVisualizer.STOPLIGHT: 19 | return 20 | case DocumentationVisualizer.SWAGGER: 21 | return 22 | } 23 | } 24 | 25 | export default DocumentationViewer 26 | -------------------------------------------------------------------------------- /.github/pull_request_template.md: -------------------------------------------------------------------------------- 1 | 2 | 3 | ## Description 4 | 5 | 6 | ## Motivation and Context 7 | 8 | 9 | 10 | ## Screenshots (if appropriate): 11 | 12 | ## Types of changes 13 | 14 | - [ ] Bug fix (non-breaking change which fixes an issue) 15 | - [ ] New feature (non-breaking change which adds functionality) 16 | - [ ] Breaking change (fix or feature that would cause existing functionality to not work as expected) 17 | -------------------------------------------------------------------------------- /src/features/sidebar/view/internal/diffbar/components/MonoQuotedText.tsx: -------------------------------------------------------------------------------- 1 | "use client" 2 | 3 | import { Box } from "@mui/material" 4 | 5 | const MonoQuotedText = ({ text }: { text: string }) => { 6 | return ( 7 | <> 8 | {text.split(/(['`])([^'`]+)\1/g).map((part, i) => 9 | i % 3 === 2 ? ( 10 | 19 | {part} 20 | 21 | ) : i % 3 === 1 ? null : ( 22 | part 23 | ) 24 | )} 25 | 26 | ) 27 | } 28 | 29 | export default MonoQuotedText 30 | -------------------------------------------------------------------------------- /.github/workflows/lint.yml: -------------------------------------------------------------------------------- 1 | name: Lint 2 | permissions: 3 | contents: read 4 | on: 5 | workflow_dispatch: 6 | pull_request: 7 | concurrency: 8 | group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }} 9 | cancel-in-progress: true 10 | env: 11 | NEXT_TELEMETRY_DISABLED: 1 12 | jobs: 13 | build: 14 | name: Lint 15 | runs-on: ubuntu-latest 16 | steps: 17 | - name: Checkout Repository 18 | uses: actions/checkout@v4 19 | with: 20 | persist-credentials: false 21 | - name: Setup Node.js 22 | uses: actions/setup-node@v4 23 | with: 24 | node-version: 24 25 | - name: Install modules 26 | run: npm install 27 | - name: Run ESLint 28 | run: npm run lint 29 | -------------------------------------------------------------------------------- /src/features/auth/domain/oauth-token/data-source/OAuthTokenDataSource.ts: -------------------------------------------------------------------------------- 1 | import { ISession } from "@/common" 2 | import { OAuthToken, IOAuthTokenRepository } from ".." 3 | import IOAuthTokenDataSource from "./IOAuthTokenDataSource" 4 | 5 | export default class PersistingOAuthTokenDataSource implements IOAuthTokenDataSource { 6 | private readonly session: ISession 7 | private readonly repository: IOAuthTokenRepository 8 | 9 | constructor(config: { session: ISession, repository: IOAuthTokenRepository }) { 10 | this.session = config.session 11 | this.repository = config.repository 12 | } 13 | 14 | async getOAuthToken(): Promise { 15 | const userId = await this.session.getUserId() 16 | return await this.repository.get(userId) 17 | } 18 | } 19 | -------------------------------------------------------------------------------- /src/features/auth/domain/log-out/UserDataCleanUpLogOutHandler.ts: -------------------------------------------------------------------------------- 1 | import ILogOutHandler from "./ILogOutHandler" 2 | 3 | interface IUserIDReader { 4 | getUserId(): Promise 5 | } 6 | 7 | interface Repository { 8 | delete(userId: string): Promise 9 | } 10 | 11 | export default class UserDataCleanUpLogOutHandler implements ILogOutHandler { 12 | private readonly userIdReader: IUserIDReader 13 | private readonly repository: Repository 14 | 15 | constructor(userIdReader: IUserIDReader, repository: Repository) { 16 | this.userIdReader = userIdReader 17 | this.repository = repository 18 | } 19 | 20 | async handleLogOut(): Promise { 21 | const userId = await this.userIdReader.getUserId() 22 | return await this.repository.delete(userId) 23 | } 24 | } 25 | -------------------------------------------------------------------------------- /__test__/auth/CompositeLogOutHandler.test.ts: -------------------------------------------------------------------------------- 1 | import { CompositeLogOutHandler } from "@/features/auth/domain" 2 | 3 | test("It invokes all log out handlers", async () => { 4 | let didCallLogOutHandler1 = false 5 | let didCallLogOutHandler2 = false 6 | let didCallLogOutHandler3 = false 7 | const sut = new CompositeLogOutHandler([{ 8 | async handleLogOut() { 9 | didCallLogOutHandler1 = true 10 | } 11 | }, { 12 | async handleLogOut() { 13 | didCallLogOutHandler2 = true 14 | } 15 | }, { 16 | async handleLogOut() { 17 | didCallLogOutHandler3 = true 18 | } 19 | }]) 20 | await sut.handleLogOut() 21 | expect(didCallLogOutHandler1).toBeTruthy() 22 | expect(didCallLogOutHandler2).toBeTruthy() 23 | expect(didCallLogOutHandler3).toBeTruthy() 24 | }) 25 | -------------------------------------------------------------------------------- /src/features/projects/view/Documentation.tsx: -------------------------------------------------------------------------------- 1 | import DocumentationViewer from "@/features/docs/view/DocumentationViewer" 2 | import DocumentationIframe from "./DocumentationIframe" 3 | import { DocumentationVisualizer } from "@/features/settings/domain" 4 | import { useDocumentationVisualizer } from "@/features/settings/data" 5 | 6 | const Documentation = ({ url }: { url: string }) => { 7 | const [visualizer] = useDocumentationVisualizer() 8 | switch (visualizer) { 9 | case DocumentationVisualizer.REDOCLY: 10 | return 11 | case DocumentationVisualizer.STOPLIGHT: 12 | case DocumentationVisualizer.SWAGGER: 13 | return 14 | } 15 | } 16 | 17 | export default Documentation 18 | -------------------------------------------------------------------------------- /src/common/ui/SpacedList.tsx: -------------------------------------------------------------------------------- 1 | import React from "react" 2 | import { List, Box, SxProps } from "@mui/material" 3 | 4 | interface SpacedListProps { 5 | itemSpacing: number 6 | sx?: SxProps 7 | children?: React.ReactNode 8 | } 9 | 10 | const SpacedList = ({ itemSpacing, sx, children }: SpacedListProps) => { 11 | const childrenArray = React.Children.toArray(children) 12 | const lastIndex = childrenArray.length - 1 13 | 14 | return ( 15 | 16 | {childrenArray.map((child, idx) => ( 17 | 23 | {child} 24 | 25 | ))} 26 | 27 | ) 28 | } 29 | 30 | export default SpacedList 31 | -------------------------------------------------------------------------------- /.github/workflows/run-unit-tests.yml: -------------------------------------------------------------------------------- 1 | name: Run Unit Tests 2 | permissions: 3 | contents: read 4 | on: 5 | workflow_dispatch: 6 | pull_request: 7 | concurrency: 8 | group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }} 9 | cancel-in-progress: true 10 | env: 11 | NEXT_TELEMETRY_DISABLED: 1 12 | jobs: 13 | test: 14 | name: Run Unit Tests 15 | runs-on: ubuntu-latest 16 | steps: 17 | - name: Checkout Repository 18 | uses: actions/checkout@v4 19 | with: 20 | persist-credentials: false 21 | - name: Setup Node.js 22 | uses: actions/setup-node@v4 23 | with: 24 | node-version: 24 25 | - name: Install Dependencies 26 | run: npm install 27 | - name: Run Unit Tests 28 | run: npm test 29 | -------------------------------------------------------------------------------- /src/common/ui/MessageLinkFooter.tsx: -------------------------------------------------------------------------------- 1 | import { 2 | Typography, 3 | Stack, 4 | SxProps, 5 | } from "@mui/material" 6 | import Link from "next/link" 7 | 8 | interface MessageLinkFooterProps { 9 | url: string 10 | content: string 11 | sx?: SxProps 12 | } 13 | 14 | const MessageLinkFooter = ({ 15 | url, 16 | content, 17 | sx, 18 | }: MessageLinkFooterProps) => { 19 | 20 | return ( 21 | 22 | 23 | 30 | {content} 31 | 32 | 33 | 34 | 35 | )} 36 | 37 | export default MessageLinkFooter -------------------------------------------------------------------------------- /src/features/sidebar/view/SidebarTogglableContextProvider.tsx: -------------------------------------------------------------------------------- 1 | "use client" 2 | 3 | import { SidebarTogglableContext } from "@/common" 4 | import useMediaQuery from "@mui/material/useMediaQuery" 5 | import { useTheme } from "@mui/material" 6 | import { useProjectSelection } from "@/features/projects/data" 7 | 8 | const SidebarTogglableContextProvider = ({ children }: { children?: React.ReactNode }) => { 9 | const { project } = useProjectSelection() 10 | const theme = useTheme() 11 | const isDesktopLayout = useMediaQuery(theme.breakpoints.up("sm")) 12 | const isSidebarTogglable = !isDesktopLayout || project !== undefined 13 | return ( 14 | 15 | {children} 16 | 17 | ) 18 | } 19 | 20 | export default SidebarTogglableContextProvider 21 | -------------------------------------------------------------------------------- /src/common/session/AuthjsSession.ts: -------------------------------------------------------------------------------- 1 | import { Session } from "next-auth" 2 | import { UnauthorizedError } from "@/common" 3 | import ISession from "./ISession" 4 | 5 | export default class AuthjsSession implements ISession { 6 | private readonly auth: () => Promise 7 | 8 | constructor(config: { auth: () => Promise }) { 9 | this.auth = config.auth 10 | } 11 | 12 | async getIsAuthenticated(): Promise { 13 | const session = await this.auth() 14 | return session != null 15 | } 16 | 17 | async getUserId(): Promise { 18 | const session = await this.auth() 19 | if (!session || !session.user || !session.user.id) { 20 | throw new UnauthorizedError("User ID is unavailable because the user is not authenticated.") 21 | } 22 | return session.user.id 23 | } 24 | } 25 | -------------------------------------------------------------------------------- /.github/workflows/build.yml: -------------------------------------------------------------------------------- 1 | name: Build 2 | permissions: 3 | contents: read 4 | on: 5 | workflow_dispatch: 6 | pull_request: 7 | concurrency: 8 | group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }} 9 | cancel-in-progress: true 10 | env: 11 | NEXT_TELEMETRY_DISABLED: 1 12 | jobs: 13 | build: 14 | name: Build 15 | runs-on: ubuntu-latest 16 | steps: 17 | - name: Checkout Repository 18 | uses: actions/checkout@v4 19 | with: 20 | persist-credentials: false 21 | - name: Setup Node.js 22 | uses: actions/setup-node@v4 23 | with: 24 | node-version: 24 25 | - name: Setup Environment 26 | run: cp .env.example .env.local 27 | - name: Install Dependencies 28 | run: npm install 29 | - name: Build 30 | run: npm run build 31 | -------------------------------------------------------------------------------- /src/common/db/IDB.ts: -------------------------------------------------------------------------------- 1 | export interface IDBRow { 2 | /* eslint-disable-next-line @typescript-eslint/no-explicit-any */ 3 | readonly [column: string]: any 4 | } 5 | 6 | export interface IDBQueryResult { 7 | readonly rows: T[] 8 | } 9 | 10 | export interface IDBConnection { 11 | /* eslint-disable-next-line @typescript-eslint/no-explicit-any */ 12 | query(query: string, values: any[]): Promise> 13 | query(query: string): Promise> 14 | disconnect(): Promise 15 | } 16 | 17 | export default interface IDB { 18 | connect(): Promise 19 | /* eslint-disable-next-line @typescript-eslint/no-explicit-any */ 20 | query(query: string, values: any[]): Promise> 21 | query(query: string): Promise> 22 | } 23 | -------------------------------------------------------------------------------- /__test__/common/utils/saneParseInt.test.ts: -------------------------------------------------------------------------------- 1 | import { saneParseInt } from "@/common" 2 | 3 | test("It parses an integer", async () => { 4 | // @ts-expect-error - verifying that non-string input is rejected 5 | const val = saneParseInt(42 as string) 6 | expect(val).toBe(42) 7 | }) 8 | 9 | test("It parses a string representing an integer", async () => { 10 | const val = saneParseInt("42") 11 | expect(val).toBe(42) 12 | }) 13 | 14 | test("It fails parsing a string representing a float", async () => { 15 | const val = saneParseInt("4.2") 16 | expect(val).toBeUndefined() 17 | }) 18 | 19 | test("It fails parsing a string", async () => { 20 | const val = saneParseInt("foo") 21 | expect(val).toBeUndefined() 22 | }) 23 | 24 | test("It fails parsing a UUID", async () => { 25 | const val = saneParseInt("30729470-25e4-4a50-8a0a-106fc67948f1") 26 | expect(val).toBeUndefined() 27 | }) 28 | -------------------------------------------------------------------------------- /src/common/utils/env.ts: -------------------------------------------------------------------------------- 1 | type EnvMethods = { 2 | get(key: string): string | undefined, 3 | getOrThrow(key: string): string 4 | } 5 | 6 | type EnvObject = EnvMethods & { 7 | [key: string]: string | undefined 8 | } 9 | 10 | const base: EnvMethods = { 11 | get(key: string): string | undefined { 12 | return process.env[key] 13 | }, 14 | getOrThrow(key: string): string { 15 | const value = process.env[key] 16 | if (!value || value.length === 0) { 17 | throw new Error(`Environment variable "${key}" is not set`) 18 | } 19 | return value 20 | } 21 | } 22 | 23 | const env = new Proxy(base, { 24 | get(target, prop: string) { 25 | if (prop in target) { 26 | /* eslint-disable-next-line @typescript-eslint/no-explicit-any */ 27 | return (target as any)[prop] 28 | } 29 | return target.get(prop) 30 | } 31 | }) as EnvObject 32 | 33 | export default env 34 | -------------------------------------------------------------------------------- /src/app/layout.tsx: -------------------------------------------------------------------------------- 1 | import "./globals.css" 2 | import type { Metadata } from "next" 3 | import { config as fontAwesomeConfig } from "@fortawesome/fontawesome-svg-core" 4 | import { CssBaseline } from "@mui/material" 5 | import ThemeRegistry from "@/common/theme/ThemeRegistry" 6 | import "@fortawesome/fontawesome-svg-core/styles.css" 7 | import { env } from "@/common" 8 | 9 | fontAwesomeConfig.autoAddCss = false 10 | 11 | export const metadata: Metadata = { 12 | title: env.getOrThrow("FRAMNA_DOCS_TITLE"), 13 | description: env.getOrThrow("FRAMNA_DOCS_DESCRIPTION") 14 | } 15 | 16 | export default function RootLayout({ children }: { children: React.ReactNode }) { 17 | return ( 18 | 19 | 20 | 21 | 22 | {children} 23 | 24 | 25 | 26 | ) 27 | } 28 | -------------------------------------------------------------------------------- /src/features/projects/domain/IGitHubRepositoryDataSource.ts: -------------------------------------------------------------------------------- 1 | export type GitHubRepository = { 2 | readonly name: string 3 | readonly owner: string 4 | readonly defaultBranchRef: { 5 | readonly id: string 6 | readonly name: string 7 | } 8 | readonly configYml?: { 9 | readonly text: string 10 | } 11 | readonly configYaml?: { 12 | readonly text: string 13 | } 14 | readonly branches: GitHubRepositoryRef[] 15 | readonly tags: GitHubRepositoryRef[] 16 | } 17 | 18 | export type GitHubRepositoryRef = { 19 | readonly id: string 20 | readonly name: string 21 | readonly baseRef?: string 22 | readonly baseRefOid?: string 23 | readonly prNumber?: number 24 | readonly files: { 25 | readonly name: string 26 | }[] 27 | readonly changedFiles?: string[] 28 | } 29 | 30 | export default interface IGitHubRepositoryDataSource { 31 | getRepositories(): Promise 32 | } 33 | -------------------------------------------------------------------------------- /src/features/auth/domain/oauth-token/refresher/LockingOAuthTokenRefresher.ts: -------------------------------------------------------------------------------- 1 | import { IMutexFactory, withMutex } from "@/common" 2 | import { IOAuthTokenRefresher, OAuthToken } from ".." 3 | 4 | export default class LockingOAuthTokenRefresher implements IOAuthTokenRefresher { 5 | private readonly mutexFactory: IMutexFactory 6 | private readonly oauthTokenRefresher: IOAuthTokenRefresher 7 | 8 | constructor(config: { 9 | mutexFactory: IMutexFactory 10 | oauthTokenRefresher: IOAuthTokenRefresher 11 | }) { 12 | this.mutexFactory = config.mutexFactory 13 | this.oauthTokenRefresher = config.oauthTokenRefresher 14 | } 15 | 16 | async refreshOAuthToken(oauthToken: OAuthToken): Promise { 17 | const mutex = await this.mutexFactory.makeMutex() 18 | return await withMutex(mutex, async () => { 19 | return await this.oauthTokenRefresher.refreshOAuthToken(oauthToken) 20 | }) 21 | } 22 | } 23 | -------------------------------------------------------------------------------- /src/features/sidebar/view/internal/sidebar/projects/ProjectListFallback.tsx: -------------------------------------------------------------------------------- 1 | "use client" 2 | 3 | import { useContext } from "react" 4 | import { ProjectsContext } from "@/common" 5 | import SpacedList from "@/common/ui/SpacedList" 6 | import PopulatedProjectList from "./PopulatedProjectList" 7 | import { Skeleton as ProjectListItemSkeleton } from "./ProjectListItem" 8 | 9 | const StaleProjectList = () => { 10 | const { projects } = useContext(ProjectsContext) 11 | if (projects.length > 0) { 12 | return 13 | } else { 14 | return 15 | } 16 | } 17 | 18 | export default StaleProjectList 19 | 20 | const LoadingProjectList = () => { 21 | return ( 22 | 23 | { 24 | [...new Array(6)].map((_, idx) => ( 25 | 26 | )) 27 | } 28 | 29 | ) 30 | } -------------------------------------------------------------------------------- /src/features/sidebar/view/internal/secondary/ToggleMobileToolbarButton.tsx: -------------------------------------------------------------------------------- 1 | import { IconButton } from "@mui/material" 2 | import { FontAwesomeIcon } from "@fortawesome/react-fontawesome" 3 | import { faChevronDown } from "@fortawesome/free-solid-svg-icons" 4 | 5 | const ToggleMobileToolbarButton = ({ 6 | direction, 7 | onToggle 8 | }: { 9 | direction: "up" | "down" 10 | onToggle: () => void 11 | }) => { 12 | return <> 13 | 19 | 28 | 29 | 30 | } 31 | 32 | export default ToggleMobileToolbarButton 33 | -------------------------------------------------------------------------------- /.vscode/launch.json: -------------------------------------------------------------------------------- 1 | { 2 | "version": "0.2.0", 3 | "configurations": [ 4 | { 5 | "name": "Next.js: debug server-side", 6 | "type": "node-terminal", 7 | "request": "launch", 8 | "command": "npm run dev" 9 | }, 10 | { 11 | "name": "Next.js: debug client-side", 12 | "type": "chrome", 13 | "request": "launch", 14 | "url": "http://localhost:3000" 15 | }, 16 | { 17 | "name": "Next.js: debug full stack", 18 | "type": "node", 19 | "request": "launch", 20 | "program": "${workspaceFolder}/node_modules/.bin/next", 21 | "runtimeArgs": ["--inspect"], 22 | "skipFiles": ["/**"], 23 | "serverReadyAction": { 24 | "action": "debugWithChrome", 25 | "killOnServerStop": true, 26 | "pattern": "- Local:.+(https?://.+)", 27 | "uriFormat": "%s", 28 | "webRoot": "${workspaceFolder}" 29 | } 30 | } 31 | ] 32 | } -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "es5", 4 | "lib": [ 5 | "dom", 6 | "dom.iterable", 7 | "esnext" 8 | ], 9 | "allowJs": true, 10 | "skipLibCheck": true, 11 | "strict": true, 12 | "noEmit": true, 13 | "esModuleInterop": true, 14 | "module": "esnext", 15 | "moduleResolution": "bundler", 16 | "resolveJsonModule": true, 17 | "isolatedModules": true, 18 | "jsx": "react-jsx", 19 | "incremental": true, 20 | "plugins": [ 21 | { 22 | "name": "next" 23 | } 24 | ], 25 | "baseUrl": ".", 26 | "paths": { 27 | "@/*": [ 28 | "./src/*" 29 | ] 30 | } 31 | }, 32 | "include": [ 33 | "next-env.d.ts", 34 | "**/*.ts", 35 | "**/*.tsx", 36 | ".next/types/**/*.ts", 37 | "types/*.d.ts", 38 | ".next/dev/types/**/*.ts" 39 | ], 40 | "exclude": [ 41 | "node_modules", 42 | "infrastructure" 43 | ] 44 | } 45 | -------------------------------------------------------------------------------- /src/features/auth/domain/oauth-token/index.ts: -------------------------------------------------------------------------------- 1 | export type { default as OAuthToken } from "./OAuthToken" 2 | export type { default as IOAuthTokenDataSource } from "./data-source/IOAuthTokenDataSource" 3 | export { default as OAuthTokenDataSource } from "./data-source/OAuthTokenDataSource" 4 | export type { default as IOAuthTokenRefresher } from "./refresher/IOAuthTokenRefresher" 5 | export { default as LockingOAuthTokenRefresher } from "./refresher/LockingOAuthTokenRefresher" 6 | export { default as PersistingOAuthTokenRefresher } from "./refresher/PersistingOAuthTokenRefresher" 7 | export { default as AuthjsAccountsOAuthTokenRepository } from "./repository/AuthjsAccountsOAuthTokenRepository" 8 | export { default as FallbackOAuthTokenRepository } from "./repository/FallbackOAuthTokenRepository" 9 | export type { default as IOAuthTokenRepository } from "./repository/IOAuthTokenRepository" 10 | export { default as OAuthTokenRepository } from "./repository/OAuthTokenRepository" 11 | -------------------------------------------------------------------------------- /src/features/docs/view/LoadingWrapper.tsx: -------------------------------------------------------------------------------- 1 | import { Box } from "@mui/material" 2 | import LoadingIndicator from "@/common/ui/LoadingIndicator" 3 | 4 | const LoadingWrapper = ({ 5 | showLoadingIndicator, 6 | children 7 | }: { 8 | showLoadingIndicator: boolean, 9 | children: React.ReactNode 10 | }) => { 11 | return ( 12 | 18 | {showLoadingIndicator && 19 | 20 | 21 | 22 | } 23 | 29 | {children} 30 | 31 | 32 | ) 33 | } 34 | 35 | export default LoadingWrapper 36 | -------------------------------------------------------------------------------- /src/features/sidebar/view/internal/sidebar/projects/ProjectList.tsx: -------------------------------------------------------------------------------- 1 | 'use client' 2 | 3 | import { Suspense, useContext } from "react" 4 | import { Typography } from "@mui/material" 5 | import ProjectListFallback from "./ProjectListFallback" 6 | import PopulatedProjectList from "./PopulatedProjectList" 7 | import { ProjectsContext } from "@/common" 8 | 9 | const ProjectList = () => { 10 | 11 | const { projects } = useContext(ProjectsContext) 12 | 13 | return ( 14 | }> 15 | {projects.length > 0 ? : } 16 | 17 | ) 18 | } 19 | 20 | export default ProjectList 21 | 22 | const EmptyProjectList = () => { 23 | return ( 24 | 31 | Your list of projects is empty. 32 | 33 | ) 34 | } 35 | -------------------------------------------------------------------------------- /src/common/ui/MenuItemHover.tsx: -------------------------------------------------------------------------------- 1 | "use client" 2 | 3 | import { SxProps } from "@mui/system" 4 | import { Box } from "@mui/material" 5 | import useMediaQuery from "@mui/material/useMediaQuery" 6 | 7 | const MenuItemHover = ({ 8 | disabled, 9 | children, 10 | sx 11 | }: { 12 | disabled?: boolean 13 | children: React.ReactNode 14 | sx?: SxProps 15 | }) => { 16 | const isHoverSupported = useMediaQuery("(hover: hover)") 17 | const classNames = ["menu-item-highlight"] 18 | if (isHoverSupported) { 19 | classNames.push("hover-highlight") 20 | if (disabled) { 21 | classNames.push("hover-highlight-disabled") 22 | } 23 | } else { 24 | classNames.push("active-highlight") 25 | if (disabled) { 26 | classNames.push("active-highlight-disabled") 27 | } 28 | } 29 | return ( 30 | 31 | {children} 32 | 33 | ) 34 | } 35 | 36 | export default MenuItemHover 37 | -------------------------------------------------------------------------------- /src/common/mutex/SessionMutexFactory.ts: -------------------------------------------------------------------------------- 1 | import IKeyedMutexFactory from "./IKeyedMutexFactory" 2 | import IMutex from "./IMutex" 3 | import IMutexFactory from "./IMutexFactory" 4 | 5 | interface IUserIDReader { 6 | getUserId(): Promise 7 | } 8 | 9 | export default class SessionMutexFactory implements IMutexFactory { 10 | private readonly mutexFactory: IKeyedMutexFactory 11 | private readonly userIdReader: IUserIDReader 12 | private readonly baseKey: string 13 | 14 | constructor(config: { 15 | userIdReader: IUserIDReader, 16 | mutexFactory: IKeyedMutexFactory, 17 | baseKey: string 18 | }) { 19 | this.userIdReader = config.userIdReader 20 | this.baseKey = config.baseKey 21 | this.mutexFactory = config.mutexFactory 22 | } 23 | 24 | async makeMutex(): Promise { 25 | const userId = await this.userIdReader.getUserId() 26 | const key = `${this.baseKey}[${userId}]` 27 | return this.mutexFactory.makeMutex(key) 28 | } 29 | } 30 | -------------------------------------------------------------------------------- /src/features/sidebar/view/SplitView.tsx: -------------------------------------------------------------------------------- 1 | import ClientSplitView from "./internal/ClientSplitView" 2 | import BaseSidebar from "./internal/sidebar/Sidebar" 3 | import ProjectList from "./internal/sidebar/projects/ProjectList" 4 | import DiffContent from "./internal/diffbar/DiffContent" 5 | import { env } from "@/common" 6 | 7 | const SITE_NAME = env.getOrThrow("FRAMNA_DOCS_TITLE") 8 | const HELP_URL = env.get("FRAMNA_DOCS_HELP_URL") 9 | 10 | const SplitView = ({ children }: { children?: React.ReactNode }) => { 11 | return ( 12 | } sidebarRight={}> 13 | {children} 14 | 15 | ) 16 | } 17 | 18 | export default SplitView 19 | 20 | const Sidebar = () => { 21 | return ( 22 | // The site name and help URL are passed as a properties to ensure the environment variables are read server-side. 23 | 24 | 25 | 26 | ) 27 | } 28 | -------------------------------------------------------------------------------- /src/app/(authed)/(project-doc)/[...slug]/layout.tsx: -------------------------------------------------------------------------------- 1 | "use client" 2 | 3 | import { Box, Stack } from "@mui/material" 4 | import { useTheme } from "@mui/material/styles" 5 | import TrailingToolbarItem from "@/features/projects/view/toolbar/TrailingToolbarItem" 6 | import MobileToolbar from "@/features/projects/view/toolbar/MobileToolbar" 7 | import SecondarySplitHeader from "@/features/sidebar/view/SecondarySplitHeader" 8 | 9 | export default function Page({ children }: { children: React.ReactNode }) { 10 | const theme = useTheme() 11 | 12 | return ( 13 | 14 | <> 15 | }> 16 | 17 | 18 | 19 |
20 | {children} 21 |
22 | 23 |
24 | ) 25 | } -------------------------------------------------------------------------------- /src/features/projects/view/DocumentationIframe.tsx: -------------------------------------------------------------------------------- 1 | import { Box } from "@mui/material" 2 | import DelayedLoadingIndicator from "@/common/ui/DelayedLoadingIndicator" 3 | import { DocumentationVisualizer } from "@/features/settings/domain" 4 | 5 | const DocumentationIframe = ({ 6 | visualizer, 7 | url 8 | }: { 9 | visualizer: DocumentationVisualizer, 10 | url: string 11 | }) => { 12 | const searchParams = new URLSearchParams() 13 | searchParams.append("visualizer", visualizer.toString()) 14 | searchParams.append("url", url) 15 | return ( 16 | 17 | 18 | 19 | 20 |