| 1 | /** |
| 2 | * Which frame a page is drawn in. |
| 3 | * |
| 4 | * - `standalone`: signing in, signing up and choosing a workspace. The |
| 5 | * page alone, centred, with the logo and a quiet row of links at the |
| 6 | * foot: no header, no sidebar, no rail. |
| 7 | * - `app`: someone signed in, everywhere else. The rail, the mode's |
| 8 | * sidebar and the page in its panel, always about their current |
| 9 | * workspace, even on public pages such as Explore, a profile or another |
| 10 | * workspace's public project. |
| 11 | * - `public`: a visitor who is not signed in (or has not confirmed their |
| 12 | * address yet), on every other page: a plain header with the logo, |
| 13 | * Explore, Docs and signing in, the page at full width, and the footer. |
| 14 | */ |
| 15 | |
| 16 | import type { Abilities, Capability, RepoMirror } from "@g1t/contracts"; |
| 17 | |
| 18 | import { type RepoArea, offers, writable } from "./mirror.ts"; |
| 19 | |
| 20 | export type Frame = "standalone" | "public" | "app"; |
| 21 | |
| 22 | /** Pages that are drawn on their own, whoever opens them. */ |
| 23 | const STANDALONE = new Set([ |
| 24 | "/login", |
| 25 | "/login/two-factor", |
| 26 | "/register", |
| 27 | "/logout", |
| 28 | "/verify", |
| 29 | "/confirm-email", |
| 30 | "/forgot", |
| 31 | "/reset", |
| 32 | "/device", |
| 33 | "/oauth/authorize", |
| 34 | "/workspaces/new", |
| 35 | "/integrations/github/setup", |
| 36 | ]); |
| 37 | |
| 38 | /** Marketing pages: the public header and footer, even for someone signed in. */ |
| 39 | const MARKETING = new Set(["/download"]); |
| 40 | |
| 41 | /** The page's path without a trailing slash or a click's `.data`. */ |
| 42 | function framePath(pathname: string): string { |
| 43 | const path = pathname.replace(/\.data$/, ""); |
| 44 | return path.length > 1 ? path.replace(/\/+$/, "") : path; |
| 45 | } |
| 46 | |
| 47 | /** |
| 48 | * The frame for `pathname`. `viewer` says whether someone is signed in and |
| 49 | * confirmed (`signedIn`), and whether they belong to a workspace yet: until |
| 50 | * they do, answering an invitation is part of getting started too. |
| 51 | */ |
| 52 | export function frameOf(pathname: string, viewer: { signedIn: boolean; workspace: boolean }): Frame { |
| 53 | const path = framePath(pathname); |
| 54 | if (STANDALONE.has(path)) return "standalone"; |
| 55 | // Marketing pages are the site's front, whoever reads them: never inside the app's rail and sidebar. |
| 56 | if (MARKETING.has(path)) return "public"; |
| 57 | // An invite link, and signing in with GitHub, are the front door. |
| 58 | if (path.startsWith("/invite/") || path === "/auth/github" || path.startsWith("/auth/github/")) return "standalone"; |
| 59 | if (path === "/invitations" && viewer.signedIn && !viewer.workspace) return "standalone"; |
| 60 | return viewer.signedIn ? "app" : "public"; |
| 61 | } |
| 62 | |
| 63 | /** The project pages the sidebar lists, by key, for a member or not. */ |
| 64 | export type ProjectPage = |
| 65 | | "code" |
| 66 | | "issues" |
| 67 | | "pulls" |
| 68 | | "agents" |
| 69 | | "actions" |
| 70 | | "deployments" |
| 71 | | "observability" |
| 72 | | "security" |
| 73 | | "insights" |
| 74 | | "settings"; |
| 75 | |
| 76 | /** |
| 77 | * What a page needs beyond a role on the repository, once what the viewer |
| 78 | * may do is known: Security's findings are for people who can push. |
| 79 | */ |
| 80 | export const PAGE_NEEDS: Partial<Record<ProjectPage, Capability>> = { security: "push" }; |
| 81 | |
| 82 | /** |
| 83 | * The part of the repository each page is about (`RepoArea` in the |
| 84 | * contracts): what a mirror offers decides which pages it lists. |
| 85 | */ |
| 86 | export const PAGE_AREA: Record<ProjectPage, RepoArea> = { |
| 87 | code: "code", |
| 88 | issues: "issues", |
| 89 | pulls: "pulls", |
| 90 | agents: "agents", |
| 91 | actions: "workflows", |
| 92 | deployments: "deployments", |
| 93 | // Logs and traces are of something running here. |
| 94 | observability: "deployments", |
| 95 | security: "security", |
| 96 | insights: "insights", |
| 97 | settings: "settings", |
| 98 | }; |
| 99 | |
| 100 | /** |
| 101 | * A project's menu, in order. Security and Settings are for people with a |
| 102 | * role on its repository, and a page in `PAGE_NEEDS` only for those whose |
| 103 | * role has its capability (`can`, once the repository's page has loaded); |
| 104 | * everyone who can see the project sees the rest. A mirror lists only what |
| 105 | * it offers now (`mirror`, the repository's, once its page has loaded): a |
| 106 | * standby mirror is a read-only copy, so Code, Security, Insights and |
| 107 | * Settings, no Issues, Pull requests, Agents or Deployments, and Workflows |
| 108 | * only when they run here. |
| 109 | */ |
| 110 | export function projectPages(member: boolean, can?: Partial<Abilities>, mirror?: RepoMirror | null): ProjectPage[] { |
| 111 | const pages: (ProjectPage | false)[] = [ |
| 112 | "code", |
| 113 | "issues", |
| 114 | "pulls", |
| 115 | "agents", |
| 116 | "actions", |
| 117 | // A public repository's deployments and build logs are for anyone, as its code is. |
| 118 | "deployments", |
| 119 | "observability", |
| 120 | member && "security", |
| 121 | "insights", |
| 122 | member && "settings", |
| 123 | ]; |
| 124 | return pages.filter( |
| 125 | (page): page is ProjectPage => |
| 126 | page !== false && (!can || !PAGE_NEEDS[page] || Boolean(can[PAGE_NEEDS[page]!])) && offers(mirror, PAGE_AREA[page]), |
| 127 | ); |
| 128 | } |
| 129 | |
| 130 | /** |
| 131 | * The page under a project that a mirror does not offer now and `rest` |
| 132 | * (the path under the project) is on, with what to call it, or null when |
| 133 | * the page is there. An issue or a pull request by number stays readable, |
| 134 | * as does a release; making a release is a write. |
| 135 | */ |
| 136 | export function hiddenOnMirror(rest: string, mirror: RepoMirror | null | undefined): { page: ProjectPage; what: string } | null { |
| 137 | if (!mirror || writable(mirror)) return null; |
| 138 | const path = rest.replace(/^\/+|\/+$/g, ""); |
| 139 | if (path === "releases/new") return { page: "code", what: "releases" }; |
| 140 | if (/^(issues|pull)\/\d+(\/|$)/.test(path)) return null; |
| 141 | const page = projectPageAt(path); |
| 142 | if (!page || page === "overview" || offers(mirror, PAGE_AREA[page])) return null; |
| 143 | return { page, what: PROJECT_PAGE_LINKS[page].label.toLowerCase() }; |
| 144 | } |
| 145 | |
| 146 | /** |
| 147 | * A project's pages as the phone's strip of tabs under its name names them |
| 148 | * (routes/repo/layout.tsx): the sidebar's words, and the paths under the |
| 149 | * project at which each is the current one. |
| 150 | */ |
| 151 | export const PROJECT_PAGE_LINKS: Record<ProjectPage, { label: string; path: string; also: string[]; soon?: boolean }> = { |
| 152 | code: { label: "Code", path: "code", also: ["tree", "blob", "commits", "commit", "branches", "tags", "releases", "compare"] }, |
| 153 | issues: { label: "Issues", path: "issues", also: ["plans", "milestones", "labels"] }, |
| 154 | pulls: { label: "Pull requests", path: "pulls", also: ["pull", "queue"] }, |
| 155 | agents: { label: "Agents", path: "agents", also: ["sessions", "memory"] }, |
| 156 | actions: { label: "Workflows", path: "actions", also: [] }, |
| 157 | deployments: { label: "Deployments", path: "deployments", also: [] }, |
| 158 | observability: { label: "Observability", path: "soon/logs", also: [], soon: true }, |
| 159 | security: { label: "Security", path: "security", also: [] }, |
| 160 | insights: { label: "Insights", path: "contributors", also: ["activity", "stargazers"] }, |
| 161 | settings: { label: "Settings", path: "settings", also: [] }, |
| 162 | }; |
| 163 | |
| 164 | /** |
| 165 | * Which of a project's pages `rest` (the path under the project, no |
| 166 | * leading slash) is on: "overview" for the project itself. `soon` says |
| 167 | * which page a roadmap page (`soon/<key>`) sits under. |
| 168 | */ |
| 169 | export function projectPageAt(rest: string, soon: Record<string, ProjectPage> = {}): ProjectPage | "overview" | null { |
| 170 | const path = rest.replace(/^\/+|\/+$/g, ""); |
| 171 | if (path === "") return "overview"; |
| 172 | const soonKey = /^soon\/([^/]+)/.exec(path)?.[1]; |
| 173 | if (soonKey && soon[soonKey]) return soon[soonKey]!; |
| 174 | for (const [page, link] of Object.entries(PROJECT_PAGE_LINKS) as [ProjectPage, (typeof PROJECT_PAGE_LINKS)[ProjectPage]][]) { |
| 175 | if ([link.path, ...link.also].some((prefix) => path === prefix || path.startsWith(`${prefix}/`))) return page; |
| 176 | } |
| 177 | return null; |
| 178 | } |