Skip to content

Where people and agents ship software together. The open-source git platform for the whole job: issues, agents, checks and deploys to the edge.

Commit

Promote project operations and retain production preview frame

syntaqxcommitted Parent629e57eBrowse files
12 files+146−1120/12 viewed
+1−0
597597 key={page}
598598 to={link.path ? `${base}/${link.path}` : base}
599599 current={at === page}
600+ soon={page !== "overview" && PROJECT_PAGE_LINKS[page].soon}
600601 count={page === "issues" ? counts?.issues : page === "pulls" ? counts?.pulls : undefined}
601602 >
602603 {link.label}
+47−43
1111 * deploy on g1t.page, or of the address of production deployed
1212 * elsewhere), else an image someone uploaded (lib/production-image.ts
1313 * `cardSources`). Each source is tried in turn, the next when one does not
14− * load. Until one has loaded, or when there is none, a quiet frame with
15− * the address stands in; to someone who may change the project's
14+ * load. The browser frame and address stay visible in every state.
15+ * Until an image loads, a quiet page stands in; to someone who may change the project's
1616 * settings it offers to add an image.
1717 */
1818 export function ProductionShot({
7272 const frame = (
7373 <span
7474 className={cn(
75− "relative block aspect-16/10 w-full overflow-hidden rounded-lg border border-line bg-bg",
75+ "block w-full overflow-hidden rounded-lg border border-line bg-bg",
7676 href && "transition-colors group-hover:border-line-strong",
7777 )}
7878 >
79− {!loaded && !under && <Placeholder label={label} waiting={waiting} note={empty && !addImage ? (note ?? null) : null} />}
80− {under && (
81− <img
82− key={under.src}
83− src={under.src}
84− alt=""
85− aria-hidden="true"
86− width={1280}
87− height={800}
88− className="absolute inset-0 size-full object-cover object-top"
89− />
90− )}
91− {source && (
92− <img
93− key={source.src}
94− ref={image}
95− src={source.src}
96− alt={source.kind === "screenshot" ? `Screenshot of ${label}` : `Image of ${label}`}
97− width={1280}
98− height={800}
99− decoding="async"
100− onLoad={() => setLoaded(true)}
101− onError={() => {
102− setLoaded(false);
103− setAt((index) => index + 1);
104− }}
105− className={cn(
106− "absolute inset-0 size-full object-cover object-top transition-opacity duration-300",
107− loaded ? "opacity-100" : "opacity-0",
108− )}
109− />
110− )}
79+ <span className="flex h-7 items-center gap-1.5 border-b border-line bg-surface px-2.5">
80+ <span aria-hidden="true" className="flex shrink-0 gap-1.5">
81+ <span className="size-1.5 rounded-full bg-line-strong" />
82+ <span className="size-1.5 rounded-full bg-line-strong" />
83+ <span className="size-1.5 rounded-full bg-line-strong" />
84+ </span>
85+ {label && <span className="ml-1.5 min-w-0 flex-1 truncate rounded bg-bg px-2 py-px font-mono text-[0.625rem] text-muted">{label}</span>}
86+ </span>
87+ <span className="relative block aspect-16/10 w-full overflow-hidden">
88+ {!loaded && !under && <Placeholder waiting={waiting} note={empty && !addImage ? (note ?? null) : null} />}
89+ {under && (
90+ <img
91+ key={under.src}
92+ src={under.src}
93+ alt=""
94+ aria-hidden="true"
95+ width={1280}
96+ height={800}
97+ className="absolute inset-0 size-full object-cover object-top"
98+ />
99+ )}
100+ {source && (
101+ <img
102+ key={source.src}
103+ ref={image}
104+ src={source.src}
105+ alt={source.kind === "screenshot" ? `Screenshot of ${label}` : `Image of ${label}`}
106+ width={1280}
107+ height={800}
108+ decoding="async"
109+ onLoad={() => setLoaded(true)}
110+ onError={() => {
111+ setLoaded(false);
112+ setAt((index) => index + 1);
113+ }}
114+ className={cn(
115+ "absolute inset-0 size-full object-cover object-top transition-opacity duration-300",
116+ loaded ? "opacity-100" : "opacity-0",
117+ )}
118+ />
119+ )}
120+ </span>
111121 </span>
112122 );
113123
134144 );
135145 }
136146
137−/** The frame without a picture: a browser's bar with the address, and faint lines of a page. */
138−function Placeholder({ label, waiting, note }: { label: string; waiting: string | null; note: string | null }) {
147+/** Faint page content inside the persistent browser frame while no picture is available. */
148+function Placeholder({ waiting, note }: { waiting: string | null; note: string | null }) {
139149 const line = waiting ?? note;
140150 return (
141151 <span className="absolute inset-0 flex flex-col">
142− <span className="flex items-center gap-1.5 border-b border-line bg-surface px-2.5 py-1.5">
143− <span className="size-1.5 rounded-full bg-line-strong" />
144− <span className="size-1.5 rounded-full bg-line-strong" />
145− <span className="size-1.5 rounded-full bg-line-strong" />
146− {label && <span className="ml-1.5 truncate rounded bg-bg px-2 py-px font-mono text-[0.625rem] text-faint">{label}</span>}
147− </span>
148152 <span className="relative flex grow flex-col gap-2 p-3.5">
149153 <span className="h-2 w-2/5 rounded-full bg-line/80" />
150154 <span className="h-1.5 w-4/5 rounded-full bg-line/50" />
+2−2
33 /** Each of a project's settings pages: its name and what it is for. */
44 const PAGES: Record<string, { title: string; about: string }> = {
55 "": { title: "General", about: "The project's name, where its code lives, what it is for, and archiving, moving or deleting it." },
6− deployments: { title: "Deployments", about: "How the project builds, where it runs, and its previews." },
6+ deployments: { title: "Build configuration", about: "How the project builds, where it runs, and its previews." },
77 domains: { title: "Domains", about: "Addresses of your own for the production deployment." },
88 guardrails: { title: "Guardrails", about: "How long this project's runs may take." },
99 repository: { title: "Repository", about: "Its name, details and default branch, and who can see it." },
1111 keys: { title: "Deploy keys", about: "SSH keys that let a machine clone this repository, or push to it, and reach nothing else." },
1212 branches: { title: "Branches and merging", about: "How pull requests merge, and who owns which files." },
1313 rules: { title: "Rules", about: "Rulesets: what may happen to branches and tags, what a pull request needs before it merges, and how the rules judged each push and merge." },
14− secrets: { title: "Secrets and variables", about: "Values workflows, builds and deployments read at run time." },
14+ secrets: { title: "Environment variables", about: "Secrets and configuration for workflows, builds and running apps. Scope values to production, previews or all environments." },
1515 actions: { title: "Actions", about: "What workflows' tokens may do, and whose pull requests' runs wait for approval." },
1616 environments: { title: "Environments", about: "Protection rules for the environments jobs deploy to: reviews, wait timers and branches." },
1717 runners: { title: "Runners", about: "Machines of your own for this project's workflow jobs and sandbox runs." },
+1−1
6767 <div className="max-w-5xl">
6868 <header className="flex flex-wrap items-start justify-between gap-4">
6969 <div>
70− <h2 className="text-lg font-semibold tracking-tight">Secrets and variables</h2>
70+ <h1 className="text-lg font-semibold tracking-tight">{scope === "project" ? "Environment variables" : "Secrets and variables"}</h1>
7171 <p className="mt-1 max-w-2xl text-sm text-muted">
7272 One list for everything that reads them. Each row says which environments it applies to and whether{" "}
7373 <strong className="font-medium text-fg">workflows</strong> (as <code className="text-fg">secrets.KEY</code>{" "}
+29−43
1−import { Activity, BarChart3, BrickWall, Building2, ChartLine, Flag, MessagesSquare, Bell, BookMarked, BookOpen, Bookmark, Blocks, Box, Check, ChevronDown, ChevronLeft, ChevronRight, CircleDot, GripVertical, CircleUserRound, Code2, Compass, CreditCard, Fingerprint, Gauge, GitBranch, GitPullRequest, Globe, History, Keyboard, KeyRound, Layers, LayoutDashboard, LayoutGrid, LifeBuoy, Lock, LogOut, Mail, MonitorCog, MonitorDown, Package, PlayCircle, Plug, Plus, Repeat, Rocket, Search, ServerCog, Settings, Shapes, Shield, ShieldCheck, Scale, Smile, Sparkles, Star, House, Ticket, TrendingUp, UserPlus, UserRoundKey, Users, UsersRound, Webhook, X } from "lucide-react";
1+import { Database, Activity, BarChart3, BrickWall, Building2, ChartLine, Flag, MessagesSquare, Bell, BookMarked, BookOpen, Bookmark, Blocks, Box, Check, ChevronDown, ChevronLeft, ChevronRight, CircleDot, GripVertical, CircleUserRound, Code2, Compass, CreditCard, Fingerprint, Gauge, GitBranch, GitPullRequest, Globe, History, Keyboard, KeyRound, Layers, LayoutDashboard, LayoutGrid, LifeBuoy, Lock, LogOut, Mail, MonitorCog, MonitorDown, Package, PlayCircle, Plug, Plus, Repeat, Rocket, Search, ServerCog, Settings, Shapes, Shield, ShieldCheck, Scale, Smile, Sparkles, Star, House, Ticket, TrendingUp, UserPlus, UserRoundKey, Users, UsersRound, Webhook, X } from "lucide-react";
22 import { Fragment, type ReactNode, useCallback, useEffect, useMemo, useRef, useState } from "react";
33 import { Link, useFetcher, useLocation, useNavigation, useRouteLoaderData, useSubmit } from "react-router";
44
6666 import { useChatSidebar } from "./chat/actions";
6767 import { unreadTotals } from "../lib/chat";
6868 import { SETTINGS_CAPABILITY, type ViewerAccess, seesSettings } from "../lib/access";
69−import { projectPages } from "../lib/chrome";
69+import { PROJECT_OPERATIONS, PROJECT_PAGE_LINKS, projectPageAt, projectPages } from "../lib/chrome";
7070 import { ACCOUNT_SETTINGS, type AccountSettingsPage, FIRST_SETTINGS_PAGE, accountSettingsPage } from "../lib/account-settings";
7171 import { type PinnableApp, sidebarCookie } from "../lib/apps";
7272 import { REASON_FILTERS, inboxReason } from "../lib/inbox";
848848 <Rule />
849849 <NavRows>
850850 {shows.has("deployments") ? (
851− <SidebarLink to={`${base}/deployments`} also={soonPaths(base, "Deployments")} icon={<Rocket size={15} />}>
851+ <SidebarLink to={`${base}/deployments`} also={[`${base}/settings/deployments`, ...soonPaths(base, "Deployments")]} icon={<Rocket size={15} />}>
852852 Deployments
853853 </SidebarLink>
854854 ) : null}
873873 Insights
874874 </SidebarLink>
875875 </NavRows>
876+ {repo.member && (
877+ <>
878+ <Rule />
879+ <NavRows>
880+ {PROJECT_OPERATIONS.filter((page) => shows.has(page)).map((page) => {
881+ const link = PROJECT_PAGE_LINKS[page];
882+ const icons = { secrets: <KeyRound size={15} />, environments: <Layers size={15} />, domains: <Globe size={15} />, runners: <ServerCog size={15} />, webhooks: <Webhook size={15} /> };
883+ return <SidebarLink key={page} to={`${base}/${link.path}`} icon={icons[page]}>{link.label}</SidebarLink>;
884+ })}
885+ <ProjectSoonRow base={base} feature="storage" icon={<Database size={15} />} />
886+ </NavRows>
887+ </>
888+ )}
876889 {(repo.settings ?? shows.has("settings")) && (
877890 <>
878891 <Rule />
879892 <NavRows>
880− <SidebarLink to={`${base}/settings`} icon={<Settings size={15} />} drill>
893+ <SidebarLink to={`${base}/settings`} end icon={<Settings size={15} />} drill>
881894 Settings
882895 </SidebarLink>
883896 </NavRows>
888901 }
889902
890903 /**
891− * A project's settings, drilled into from its list: what is about running
892− * it, then its sandboxes, then its code and who can reach it. The way back
893− * leads to the project.
904+ * Administrative settings: repository policy, access and workflow permissions.
905+ * Day-to-day operations stay in the main project menu.
894906 */
895907 function RepoSettingsMenu({ repo }: { repo: MenuRepo }) {
896908 const base = `/${repo.namespace}/${repo.name}`;
897909 // Each page shows to the roles that can use it, once the role is known.
898910 const shows = (page: string) => !repo.can || repo.can[SETTINGS_CAPABILITY[page] ?? "administer"];
899− const running = shows("deployments");
900911 const guarded = shows("guardrails");
901912 return (
902913 <nav aria-label={`${repo.namespace}/${repo.name} settings`} className={PANEL}>
907918 General
908919 </SidebarLink>
909920 </NavRows>
910− )}
911− {running && (
912− <>
913− {shows("") && <Rule />}
914− <NavRows className={shows("") ? undefined : "mt-2"}>
915− <SidebarLink to={`${base}/settings/deployments`} icon={<Rocket size={15} />}>
916− Deployments
917− </SidebarLink>
918− <SidebarLink to={`${base}/settings/domains`} icon={<Globe size={15} />}>
919− Domains
920− </SidebarLink>
921− </NavRows>
922− </>
923921 )}
924922 {guarded && (
925923 <>
960958 Rules
961959 </SidebarLink>
962960 )}
963− {shows("secrets") && (
964− <SidebarLink to={`${base}/settings/secrets`} icon={<Lock size={15} />}>
965− Secrets and variables
966− </SidebarLink>
967− )}
961+
968962 {shows("actions") && (
969963 <SidebarLink to={`${base}/settings/actions`} icon={<PlayCircle size={15} />}>
970964 Actions
971965 </SidebarLink>
972966 )}
973− {shows("environments") && (
974− <SidebarLink to={`${base}/settings/environments`} icon={<Layers size={15} />}>
975− Environments
976− </SidebarLink>
977− )}
978− {shows("runners") && (
979− <SidebarLink to={`${base}/settings/runners`} icon={<ServerCog size={15} />}>
980− Runners
981− </SidebarLink>
982− )}
983− {shows("webhooks") && (
984− <SidebarLink to={`${base}/settings/webhooks`} icon={<Webhook size={15} />}>
985− Webhooks
986− </SidebarLink>
987− )}
967+
968+
969+
988970 </NavRows>
989971 </nav>
990972 );
10661048 !/^\/[^/]+\/[^/]+\/invitations\/?$/.test(target) &&
10671049 (!lost || sameRepo(active, { namespace: repoPath[1]!, name: repoPath[2]! }));
10681050 // A project's settings, one level further in.
1069− const inRepoSettings = inRepo && REPO_SETTINGS_PAGE.test(target);
1051+ const inRepoSettings = inRepo && REPO_SETTINGS_PAGE.test(target) && projectPageAt(target.split("/").slice(3).join("/")) === "settings";
10701052 // The repository the menu is for: the one loaded if it is the one being
10711053 // gone to, else what the address says, at once.
10721054 const targetRepo = inRepo && repoPath ? { namespace: repoPath[1], name: repoPath[2] } : null;
16621644 else if (third === "commit" && fourth) trail.push({ label: fourth.slice(0, 7), to: `${repo}/commit/${fourth}`, mono: true });
16631645 // A Soon page names what is coming.
16641646 else if (third === "soon" && fourth && roadmapItem(fourth)) trail.push({ label: roadmapItem(fourth)!.title, to: `${repo}/soon/${fourth}` });
1647+ else if (third === "settings" && fourth && projectPageAt(`settings/${fourth}`) !== "settings" && projectPageAt(`settings/${fourth}`) != null) {
1648+ const page = projectPageAt(`settings/${fourth}`) as keyof typeof PROJECT_PAGE_LINKS;
1649+ trail.push({ label: fourth === "deployments" ? "Build configuration" : PROJECT_PAGE_LINKS[page].label, to: `${repo}/settings/${fourth}` });
1650+ }
16651651 else if (third && SECTIONS[third]) {
16661652 trail.push({ label: SECTIONS[third]!, to: `${repo}/${third}` });
16671653 // A settings page names which one: Settings / Secrets and variables.
18071793 ...(repo.member
18081794 ? [
18091795 { label: "Deployments", hint: name, to: `${base}/deployments`, icon: <Rocket size={15} /> },
1810− { label: "Secrets and variables", hint: name, to: `${base}/settings/secrets`, icon: <Lock size={15} /> },
1796+ { label: "Environment variables", hint: name, to: `${base}/settings/secrets`, icon: <Lock size={15} /> },
18111797 { label: "Project settings", hint: name, to: `${base}/settings`, icon: <Settings size={15} /> },
18121798 { label: "Repository settings", hint: name, to: `${base}/settings/repository`, icon: <Settings size={15} /> },
18131799 ]
+17−0
8080 assert.equal(projectPageAt("observability/logs"), "observability");
8181 assert.equal(projectPageAt("pulse"), null);
8282 });
83+
84+
85+test("project operations keep their place in navigation and require integration access", () => {
86+ const operations = ["secrets", "environments", "domains", "runners", "webhooks"] as const;
87+ for (const operation of operations) {
88+ assert.equal(projectPageAt(`settings/${operation}`), operation);
89+ assert.equal(projectPageAt(`settings/${operation}/detail`), operation);
90+ assert.ok(!projectPages(false).includes(operation));
91+ assert.ok(!projectPages(true, { manage_integrations: false }).includes(operation));
92+ assert.ok(projectPages(true, { manage_integrations: true }).includes(operation));
93+ }
94+ assert.equal(projectPageAt("settings/deployments"), "deployments");
95+ assert.equal(projectPageAt("settings/access"), "settings");
96+ assert.equal(projectPageAt("soon/storage"), "storage");
97+ assert.ok(projectPages(true).includes("storage"));
98+ assert.ok(!projectPages(false).includes("storage"));
99+});
+22−2
6868 | "observability"
6969 | "security"
7070 | "insights"
71+ | "secrets"
72+ | "environments"
73+ | "domains"
74+ | "runners"
75+ | "webhooks"
76+ | "storage"
7177 | "settings";
7278
7379 /**
7480 * What a page needs beyond a role on the repository, once what the viewer
7581 * may do is known: Security's findings are for people who can push.
7682 */
77−export const PAGE_NEEDS: Partial<Record<ProjectPage, Capability>> = { security: "push" };
83+export const PROJECT_OPERATIONS = ["secrets", "environments", "domains", "runners", "webhooks"] as const;
84+
85+export const PAGE_NEEDS: Partial<Record<ProjectPage, Capability>> = {
86+ security: "push",
87+ secrets: "manage_integrations", environments: "manage_integrations", domains: "manage_integrations",
88+ runners: "manage_integrations", webhooks: "manage_integrations",
89+};
7890
7991 /**
8092 * A project's menu, in order. Security and Settings are for people with a
94106 member && "observability",
95107 member && "security",
96108 "insights",
109+ ...PROJECT_OPERATIONS.map((page) => member && page),
110+ member && "storage",
97111 member && "settings",
98112 ];
99113 return pages.filter(
112126 issues: { label: "Issues", path: "issues", also: ["milestones", "labels"] },
113127 pulls: { label: "Pull requests", path: "pulls", also: ["pull", "work", "queue", "sessions"] },
114128 actions: { label: "Workflows", path: "actions", also: [] },
115− deployments: { label: "Deployments", path: "deployments", also: [] },
129+ deployments: { label: "Deployments", path: "deployments", also: ["settings/deployments"] },
116130 observability: { label: "Observability", path: "observability", also: [] },
117131 security: { label: "Security", path: "security", also: [] },
118132 insights: { label: "Insights", path: "contributors", also: ["activity", "stargazers"] },
133+ secrets: { label: "Environment variables", path: "settings/secrets", also: [] },
134+ environments: { label: "Environments", path: "settings/environments", also: [] },
135+ domains: { label: "Domains", path: "settings/domains", also: [] },
136+ runners: { label: "Runners", path: "settings/runners", also: [] },
137+ webhooks: { label: "Webhooks", path: "settings/webhooks", also: [] },
138+ storage: { label: "Storage", path: "soon/storage", also: [], soon: true },
119139 settings: { label: "Settings", path: "settings", also: [] },
120140 };
121141
+15−0
22 import { readFileSync } from "node:fs";
33 import { test } from "node:test";
44
5+import { tabsFor } from "./project-nav.ts";
56 import { ROADMAP } from "./roadmap.ts";
67
78 test("code is not docs: a project never gets a Docs tab", () => {
1011 const nav = readFileSync(new URL("./project-nav.ts", import.meta.url), "utf8");
1112 assert.doesNotMatch(nav, /label: "Docs"|soon\("Code"\)/);
1213 });
14+
15+
16+test("build configuration is discoverable under deployments only with integration access", () => {
17+ const writer = tabsFor("settings/deployments", true, { manage_integrations: true });
18+ assert.ok(writer?.some((tab) => tab.path === "deployments"));
19+ assert.ok(writer?.some((tab) => tab.path === "settings/deployments"));
20+ assert.equal(tabsFor("deployments", false), null);
21+ assert.equal(tabsFor("deployments", true, { manage_integrations: false }), null);
22+});
23+
24+test("Storage is planned while implemented environments are no longer marked Soon", () => {
25+ assert.equal(ROADMAP.find((item) => item.key === "storage")?.section, "Project");
26+ assert.equal(ROADMAP.some((item) => item.key === "environments"), false);
27+});
+1−1
6464 },
6565 {
6666 key: "Deployments",
67− tabs: [{ label: "Deployments", path: "deployments" }, ...soon("Deployments")],
67+ tabs: [{ label: "Deployments", path: "deployments" }, { label: "Build configuration", path: "settings/deployments", members: true, needs: "manage_integrations" }, ...soon("Deployments")],
6868 },
6969 {
7070 key: "Observability",
+8−15
3232 // Nothing: code is not docs. Docs is its own workspace mode, filterable
3333 // by project there, and a project never gets a Docs tab.
3434
35− // --- Deployments ----------------------------------------------------------
35+ // --- Pages of their own in the project's sidebar ----------------------
3636 {
37− key: "environments",
38− title: "Environments",
39− section: "Deployments",
40− summary: "Staging, production and others, with approvers and branch rules.",
41− why: "Production and previews exist today. Environments add the rest: staging, QA, per-customer, each with its own variables, approvers and rules for what may deploy there.",
42− plans: [
43− "Named environments with their own variables and secrets",
44− "Required approvers before a deploy",
45− "Branch rules: what may deploy where",
46− "Promote a deployment from one environment to the next",
47− ],
48− today: { label: "Deployments", path: "deployments" },
37+ key: "storage",
38+ title: "Storage",
39+ section: "Project",
40+ summary: "Configure databases and storage for your project.",
41+ why: "Manage the data services your app depends on alongside its deployments, with connections scoped to the right environment.",
42+ plans: ["Connect databases and storage to a project", "Configure connections for production and preview environments", "Manage connection credentials alongside environment variables"],
43+ today: { label: "Environment variables", path: "settings/secrets" },
4944 },
50−
51− // --- Pages of their own in the project's sidebar ----------------------
5245 {
5346 key: "analytics",
5447 title: "Analytics",
+2−4
11 import type { Route } from "./+types/secrets";
22 import { page } from "../../lib/meta";
3−import { RepoSettingsHeading } from "../../components/repo-settings-heading";
43 import { SecretsPanel } from "../../components/secrets";
54 import { actOnSecrets, loadSecrets } from "../../lib/secrets.server";
65 import { assertSameOrigin, requireUser } from "../../lib/session.server";
76 import { requireCapability, requireInsider } from "../../lib/access.server";
87
98 export function meta({ params, ...args }: Route.MetaArgs) {
10− return page(args, { title: `Secrets and variables · ${params.owner}/${params.repo} · g1t` });
9+ return page(args, { title: `Environment variables · ${params.owner}/${params.repo} · g1t` });
1110 }
1211
1312 const ownerOf = (params: { owner: string; repo: string }) => ({ repo: { namespace: params.owner, name: params.repo } });
2625 return actOnSecrets(ownerOf(params), user, await request.formData(), page);
2726 }
2827
29−export default function RepoSecrets({ loaderData, actionData, params }: Route.ComponentProps) {
28+export default function RepoSecrets({ loaderData, actionData }: Route.ComponentProps) {
3029 return (
3130 <div>
32− <RepoSettingsHeading base={`/${params.owner}/${params.repo}`} />
3331 <SecretsPanel data={loaderData} action={actionData} scope="project" manage />
3432 </div>
3533 );
+1−1
1717 }
1818
1919 /** Soon pages that have since been built, and where each is now. */
20−const BUILT: Record<string, string> = { releases: "releases", logs: "observability/logs", errors: "observability/errors" };
20+const BUILT: Record<string, string> = { environments: "settings/environments", releases: "releases", logs: "observability/logs", errors: "observability/errors" };
2121
2222 export function loader({ params }: Route.LoaderArgs) {
2323 const built = BUILT[params.feature];

Comments

Sign in to comment.