@gogitcms/editor 0.23.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/app/index.html +38 -0
- package/app/src/App.tsx +2665 -0
- package/app/src/apollo.ts +119 -0
- package/app/src/auth.ts +248 -0
- package/app/src/config.ts +88 -0
- package/app/src/main.tsx +18 -0
- package/app/src/media.ts +151 -0
- package/app/src/navigation.tsx +321 -0
- package/app/src/plugins.ts +11 -0
- package/app/src/previewHost.tsx +263 -0
- package/app/src/previewToken.ts +68 -0
- package/app/src/queries.ts +591 -0
- package/app/src/virtual-cms-plugins.d.ts +6 -0
- package/app/vendor/analytics/src/__tests__/config.test.ts +91 -0
- package/app/vendor/analytics/src/config.ts +94 -0
- package/app/vendor/analytics/src/events.ts +56 -0
- package/app/vendor/analytics/src/index.ts +23 -0
- package/app/vendor/analytics/src/provider.tsx +196 -0
- package/app/vendor/design-system/src/ThemeProvider.tsx +86 -0
- package/app/vendor/design-system/src/__tests__/ApplyChangesModal.test.tsx +148 -0
- package/app/vendor/design-system/src/__tests__/BranchImport.test.tsx +46 -0
- package/app/vendor/design-system/src/__tests__/Button.test.tsx +45 -0
- package/app/vendor/design-system/src/__tests__/ChangeRequestSummary.test.tsx +57 -0
- package/app/vendor/design-system/src/__tests__/ContentBrowser.changes.test.tsx +611 -0
- package/app/vendor/design-system/src/__tests__/ContentBrowser.collab.test.tsx +322 -0
- package/app/vendor/design-system/src/__tests__/ContentBrowser.collabsync.test.tsx +264 -0
- package/app/vendor/design-system/src/__tests__/ContentBrowser.contentslot.test.tsx +53 -0
- package/app/vendor/design-system/src/__tests__/ContentBrowser.discriminator.test.tsx +142 -0
- package/app/vendor/design-system/src/__tests__/ContentBrowser.drafts.test.tsx +271 -0
- package/app/vendor/design-system/src/__tests__/ContentBrowser.fields.test.tsx +117 -0
- package/app/vendor/design-system/src/__tests__/ContentBrowser.media.test.tsx +140 -0
- package/app/vendor/design-system/src/__tests__/ContentBrowser.mixedcollab.test.tsx +63 -0
- package/app/vendor/design-system/src/__tests__/ContentBrowser.mixedvalues.test.tsx +38 -0
- package/app/vendor/design-system/src/__tests__/ContentBrowser.pagination.test.tsx +62 -0
- package/app/vendor/design-system/src/__tests__/ContentBrowser.previewtab.test.tsx +212 -0
- package/app/vendor/design-system/src/__tests__/ContentBrowser.reorder.test.tsx +45 -0
- package/app/vendor/design-system/src/__tests__/ContentBrowser.search.test.tsx +135 -0
- package/app/vendor/design-system/src/__tests__/ContentBrowser.selectvalue.test.tsx +185 -0
- package/app/vendor/design-system/src/__tests__/ContentBrowser.staged.test.tsx +132 -0
- package/app/vendor/design-system/src/__tests__/ContentBrowser.usermenu.test.tsx +56 -0
- package/app/vendor/design-system/src/__tests__/MediaBrowser.test.tsx +353 -0
- package/app/vendor/design-system/src/__tests__/MediaField.test.tsx +185 -0
- package/app/vendor/design-system/src/__tests__/Notifications.test.tsx +69 -0
- package/app/vendor/design-system/src/__tests__/Onboarding.test.tsx +287 -0
- package/app/vendor/design-system/src/__tests__/cssTokens.test.ts +201 -0
- package/app/vendor/design-system/src/__tests__/fieldComponents.test.ts +43 -0
- package/app/vendor/design-system/src/__tests__/reorder.test.ts +58 -0
- package/app/vendor/design-system/src/components/ApplyChangesModal.tsx +348 -0
- package/app/vendor/design-system/src/components/BranchImport.tsx +157 -0
- package/app/vendor/design-system/src/components/BranchMenu.tsx +192 -0
- package/app/vendor/design-system/src/components/Button.tsx +131 -0
- package/app/vendor/design-system/src/components/ChangeDetail.tsx +472 -0
- package/app/vendor/design-system/src/components/ChangeRequestSummary.tsx +173 -0
- package/app/vendor/design-system/src/components/CollabField.tsx +388 -0
- package/app/vendor/design-system/src/components/ContentBrowser.tsx +5073 -0
- package/app/vendor/design-system/src/components/Icon.tsx +28 -0
- package/app/vendor/design-system/src/components/Icon.web.tsx +31 -0
- package/app/vendor/design-system/src/components/Input.tsx +106 -0
- package/app/vendor/design-system/src/components/MediaBrowser.tsx +766 -0
- package/app/vendor/design-system/src/components/MediaField.tsx +670 -0
- package/app/vendor/design-system/src/components/MediaPreview.tsx +91 -0
- package/app/vendor/design-system/src/components/MediaPreview.web.tsx +169 -0
- package/app/vendor/design-system/src/components/NavRow.tsx +105 -0
- package/app/vendor/design-system/src/components/Notifications.tsx +301 -0
- package/app/vendor/design-system/src/components/Onboarding.tsx +751 -0
- package/app/vendor/design-system/src/components/ProjectMenu.tsx +124 -0
- package/app/vendor/design-system/src/components/Segment.tsx +87 -0
- package/app/vendor/design-system/src/components/Skeleton.tsx +216 -0
- package/app/vendor/design-system/src/components/Spinner.tsx +44 -0
- package/app/vendor/design-system/src/components/Text.tsx +85 -0
- package/app/vendor/design-system/src/components/documentDrafts.ts +213 -0
- package/app/vendor/design-system/src/components/layout.tsx +284 -0
- package/app/vendor/design-system/src/components/primitives.tsx +143 -0
- package/app/vendor/design-system/src/components/reorder.ts +40 -0
- package/app/vendor/design-system/src/fieldComponents.ts +63 -0
- package/app/vendor/design-system/src/icons.ts +102 -0
- package/app/vendor/design-system/src/index.ts +198 -0
- package/app/vendor/design-system/src/media.ts +229 -0
- package/app/vendor/design-system/src/theme.ts +116 -0
- package/app/vendor/design-system/src/web/Button.tsx +110 -0
- package/app/vendor/design-system/src/web/Icon.tsx +52 -0
- package/app/vendor/design-system/src/web/Input.tsx +39 -0
- package/app/vendor/design-system/src/web/index.ts +43 -0
- package/app/vendor/design-system/src/web/primitives.tsx +119 -0
- package/app/vendor/markdown-editor/src/MarkdownEditor.tsx +248 -0
- package/app/vendor/markdown-editor/src/Toolbar.tsx +157 -0
- package/app/vendor/markdown-editor/src/field.tsx +67 -0
- package/app/vendor/markdown-editor/src/flavors/commonmark/index.ts +27 -0
- package/app/vendor/markdown-editor/src/flavors/gfm/index.ts +30 -0
- package/app/vendor/markdown-editor/src/flavors/gfm/parser.ts +73 -0
- package/app/vendor/markdown-editor/src/flavors/gfm/schema.ts +70 -0
- package/app/vendor/markdown-editor/src/flavors/gfm/serializer.ts +82 -0
- package/app/vendor/markdown-editor/src/flavors/gfm/taskList.ts +39 -0
- package/app/vendor/markdown-editor/src/flavors/registry.ts +13 -0
- package/app/vendor/markdown-editor/src/flavors/shared/inputrules.ts +79 -0
- package/app/vendor/markdown-editor/src/flavors/shared/keymap.ts +89 -0
- package/app/vendor/markdown-editor/src/flavors/shared/placeholder.ts +24 -0
- package/app/vendor/markdown-editor/src/flavors/shared/plugins.ts +58 -0
- package/app/vendor/markdown-editor/src/flavors/types.ts +22 -0
- package/app/vendor/markdown-editor/src/formats/registry.ts +45 -0
- package/app/vendor/markdown-editor/src/icons.tsx +120 -0
- package/app/vendor/markdown-editor/src/index.ts +23 -0
- package/app/vendor/markdown-editor/src/theme.ts +249 -0
- package/app/vendor/markdown-editor/src/types.ts +58 -0
- package/app/vendor/plugin-sdk/src/__tests__/documentActions.test.ts +108 -0
- package/app/vendor/plugin-sdk/src/__tests__/loader.test.ts +75 -0
- package/app/vendor/plugin-sdk/src/__tests__/registry.test.ts +148 -0
- package/app/vendor/plugin-sdk/src/index.ts +33 -0
- package/app/vendor/plugin-sdk/src/loader.ts +45 -0
- package/app/vendor/plugin-sdk/src/navkeys.ts +17 -0
- package/app/vendor/plugin-sdk/src/react.tsx +155 -0
- package/app/vendor/plugin-sdk/src/registry.ts +337 -0
- package/app/vendor/plugin-sdk/src/types.ts +230 -0
- package/app/vendor/realtime/src/__tests__/pure.test.ts +58 -0
- package/app/vendor/realtime/src/__tests__/seeded-room.base64 +1 -0
- package/app/vendor/realtime/src/__tests__/seeding.test.ts +70 -0
- package/app/vendor/realtime/src/collab.ts +308 -0
- package/app/vendor/realtime/src/hooks.ts +57 -0
- package/app/vendor/realtime/src/index.ts +12 -0
- package/app/vendor/realtime/src/pure.ts +43 -0
- package/app/vite.config.mjs +59 -0
- package/bin/gogitcms-editor.mjs +168 -0
- package/npm-shrinkwrap.json +5696 -0
- package/package.json +74 -0
- package/src/commands/build.mjs +100 -0
- package/src/commands/dev.mjs +186 -0
- package/src/commands/init.mjs +144 -0
- package/src/commands/login.mjs +116 -0
- package/src/commands/status.mjs +68 -0
- package/src/lib/api.mjs +122 -0
- package/src/lib/appRoot.mjs +51 -0
- package/src/lib/baked.json +3 -0
- package/src/lib/config.mjs +108 -0
- package/src/lib/credentials.mjs +91 -0
- package/src/lib/defaults.mjs +26 -0
- package/src/lib/graphql.mjs +28 -0
- package/src/lib/localServer.mjs +206 -0
- package/src/lib/open.mjs +23 -0
- package/src/lib/plugins.mjs +164 -0
- package/src/lib/tokenBroker.mjs +85 -0
- package/src/tui/prompts.mjs +92 -0
- package/src/tui/screen.mjs +282 -0
- package/src/tui/theme.mjs +40 -0
|
@@ -0,0 +1,321 @@
|
|
|
1
|
+
import React from "react";
|
|
2
|
+
import {
|
|
3
|
+
NavigationContainer,
|
|
4
|
+
useNavigationBuilder,
|
|
5
|
+
createNavigatorFactory,
|
|
6
|
+
StackRouter,
|
|
7
|
+
type LinkingOptions,
|
|
8
|
+
} from "@react-navigation/native";
|
|
9
|
+
|
|
10
|
+
// React Navigation owns the whole URL tree beneath the mount prefix (BASE below
|
|
11
|
+
// — "/app" under the Go server, "" for a self-hosted build at the root). The
|
|
12
|
+
// workspace id is the first path segment, so the picker at the root can switch
|
|
13
|
+
// between workspaces. Shown here with the server's "/app" mount:
|
|
14
|
+
//
|
|
15
|
+
// /app → WorkspacePicker
|
|
16
|
+
// /app/{ws} → RepoPicker
|
|
17
|
+
// /app/{ws}/repositories/{repo} → Browser (no selection)
|
|
18
|
+
// /app/{ws}/repositories/{repo}/{collection} → a content collection
|
|
19
|
+
// /app/{ws}/repositories/{repo}/{collection}/{path} → a content document
|
|
20
|
+
// /app/{ws}/repositories/{repo}/configure/{path} → a singleton document
|
|
21
|
+
//
|
|
22
|
+
// Two optional segments sit between the repo id and that tree:
|
|
23
|
+
//
|
|
24
|
+
// .../{repo}/branches/{branchId}/… → pin a non-default branch
|
|
25
|
+
// .../{repo}/changes/… → the Changes surface, same shape below it
|
|
26
|
+
//
|
|
27
|
+
// e.g. /app/{ws}/repositories/{repo}/branches/{b}/changes/posts/hello.md
|
|
28
|
+
//
|
|
29
|
+
// Both are omitted in their default case (the default branch, the Edit
|
|
30
|
+
// surface), so every URL that worked before this existed still resolves to
|
|
31
|
+
// exactly the same screen.
|
|
32
|
+
//
|
|
33
|
+
// The server serves index.html for any depth under /app and ignores path
|
|
34
|
+
// segments past the repo id, so deep links reload correctly with no Go change.
|
|
35
|
+
// A self-hosted deployment must do the same for its own mount: serve index.html
|
|
36
|
+
// for unknown paths beneath it, or reloads of a deep link will 404.
|
|
37
|
+
// A pinned config.workspaceId (embedded dashboard/desktop) is handled at the
|
|
38
|
+
// WorkspacePicker screen, which redirects straight into that workspace.
|
|
39
|
+
|
|
40
|
+
// Which surface of the editor is showing. "edit" is the document editor;
|
|
41
|
+
// "changes" is the read-only comparison of this branch against the repository's
|
|
42
|
+
// default branch. They share the collection/path tree beneath them, so a
|
|
43
|
+
// document has an address on both.
|
|
44
|
+
// "changeRequest" is the summary page for an escalated merge, addressed as
|
|
45
|
+
// `changes/[to/{id}/]request` — the changes surface with a `request` marker.
|
|
46
|
+
export type Surface = "edit" | "changes" | "changeRequest";
|
|
47
|
+
|
|
48
|
+
export type BrowserParams = {
|
|
49
|
+
workspaceId: string;
|
|
50
|
+
repositoryId: string;
|
|
51
|
+
// The branch, serialized as an optional `branches/{id}` segment. It is omitted
|
|
52
|
+
// from the URL when it is the repository's default branch (resolved by the
|
|
53
|
+
// Browser screen), which keeps pre-existing links byte-identical while still
|
|
54
|
+
// letting a chosen branch survive a reload — without which the Changes surface
|
|
55
|
+
// would silently snap back to a branch that has no changes.
|
|
56
|
+
branchId?: string;
|
|
57
|
+
// The repository's default branch id, carried in navigation state only (never
|
|
58
|
+
// serialized). It is what lets buildBrowser omit the branch segment for the
|
|
59
|
+
// default branch; navigating without it just means the segment is written.
|
|
60
|
+
defaultBranchId?: string;
|
|
61
|
+
// The project, serialized as an optional `projects/{name}` segment BEFORE the
|
|
62
|
+
// branch — which project you are editing is a coarser choice than which
|
|
63
|
+
// branch of it. Undefined means "the repository's only project", which is
|
|
64
|
+
// every repository without a manifest, so every URL that resolved before
|
|
65
|
+
// projects existed still resolves to exactly the same screen.
|
|
66
|
+
projectName?: string;
|
|
67
|
+
// The name of the sole project when the repository has exactly one, carried in
|
|
68
|
+
// navigation state only (never serialized). It is what lets buildBrowser omit
|
|
69
|
+
// the project segment in the single-project case, mirroring defaultBranchId.
|
|
70
|
+
soleProjectName?: string;
|
|
71
|
+
surface: Surface;
|
|
72
|
+
// The comparison target on the changes surface, serialized as an optional
|
|
73
|
+
// `to/{id}` segment after `changes`. Omitted when it is the default branch, so
|
|
74
|
+
// the common "review against main" case keeps a clean URL and existing
|
|
75
|
+
// change links (which had no target segment) still resolve to source-vs-default.
|
|
76
|
+
targetBranchId?: string;
|
|
77
|
+
// A plugin screen (docs/plugin-system.md), addressed as
|
|
78
|
+
// `plugins/{pluginId}/{path}` where the collection tree would otherwise be.
|
|
79
|
+
// `id` is the plugin's package name; `path` the subpath its routes match.
|
|
80
|
+
plugin?: { id: string; path: string };
|
|
81
|
+
// true → a singleton ("Configure") document, addressed only by its file path.
|
|
82
|
+
configure: boolean;
|
|
83
|
+
// the content collection name (empty for the configure section or the repo root).
|
|
84
|
+
collection: string;
|
|
85
|
+
// the selected document's file path (empty when only a collection is open).
|
|
86
|
+
itemPath: string;
|
|
87
|
+
// true → the "new document" form for `collection` (URL .../{collection}/new).
|
|
88
|
+
create: boolean;
|
|
89
|
+
};
|
|
90
|
+
|
|
91
|
+
export type RootParamList = {
|
|
92
|
+
WorkspacePicker: undefined;
|
|
93
|
+
RepoPicker: { workspaceId: string };
|
|
94
|
+
Browser: BrowserParams;
|
|
95
|
+
};
|
|
96
|
+
|
|
97
|
+
// The CMS-view portion of BrowserParams (everything except which repo/branch),
|
|
98
|
+
// used by selection handlers that keep the current repo fixed.
|
|
99
|
+
export type CmsSelection = Pick<BrowserParams, "surface" | "plugin" | "configure" | "collection" | "itemPath" | "create">;
|
|
100
|
+
export const EMPTY_SELECTION: CmsSelection = {
|
|
101
|
+
surface: "edit",
|
|
102
|
+
plugin: undefined,
|
|
103
|
+
configure: false,
|
|
104
|
+
collection: "",
|
|
105
|
+
itemPath: "",
|
|
106
|
+
create: false,
|
|
107
|
+
};
|
|
108
|
+
|
|
109
|
+
// --- URL <-> state ----------------------------------------------------------
|
|
110
|
+
|
|
111
|
+
// A file path keeps its slashes but each segment is percent-encoded so paths
|
|
112
|
+
// with spaces or reserved characters round-trip through the URL.
|
|
113
|
+
const encPath = (p: string) => p.split("/").map(encodeURIComponent).join("/");
|
|
114
|
+
const decPath = (p: string) => p.split("/").map(decodeURIComponent).join("/");
|
|
115
|
+
|
|
116
|
+
// The SPA mount prefix, taken from the path the bundle was actually built for
|
|
117
|
+
// (Vite's `base`): "/app/" for the copy the Go server embeds, "/" for a
|
|
118
|
+
// self-hosted build from `gogitcms-editor build`, or any subpath a self-hoster
|
|
119
|
+
// chooses. Hardcoding "/app" here used to make a root-mounted instance rewrite
|
|
120
|
+
// its own URL to /app/{ws}/… — a path that doesn't exist on that host, so every
|
|
121
|
+
// reload, refresh and bookmark 404'd. The workspace id is NOT part of this
|
|
122
|
+
// prefix; it is the first routed segment beneath it.
|
|
123
|
+
const rawBase: string =
|
|
124
|
+
(typeof import.meta !== "undefined" && (import.meta as any).env?.BASE_URL) || "/";
|
|
125
|
+
// Normalized to no trailing slash, so the root mount is "" and path building
|
|
126
|
+
// stays a plain concatenation.
|
|
127
|
+
const BASE = rawBase.replace(/\/+$/, "");
|
|
128
|
+
const stripBase = (p: string): string => {
|
|
129
|
+
const clean = p.split("?")[0].split("#")[0];
|
|
130
|
+
// Anything outside the mount resolves to the picker root rather than being
|
|
131
|
+
// mistaken for a workspace id. (With BASE "" every path is inside it.)
|
|
132
|
+
if (!clean.startsWith(BASE)) return "/";
|
|
133
|
+
return clean.slice(BASE.length) || "/";
|
|
134
|
+
};
|
|
135
|
+
|
|
136
|
+
// Parse the repositories-and-below part of a workspace path (the segments after
|
|
137
|
+
// the workspace id). Returns null when the workspace root is being addressed.
|
|
138
|
+
//
|
|
139
|
+
// Exported as the seam navigation.test.mjs drives: the URL grammar is the one
|
|
140
|
+
// piece of this file with no runtime to catch a mistake — a wrong segment order
|
|
141
|
+
// or a missed omission rule shows up as a broken deep link, in production, on
|
|
142
|
+
// somebody's bookmark.
|
|
143
|
+
export function parseBrowser(workspaceId: string, rest: string[]): BrowserParams | null {
|
|
144
|
+
if (rest[0] !== "repositories" || rest.length < 2) return null;
|
|
145
|
+
const repositoryId = decodeURIComponent(rest[1]);
|
|
146
|
+
let tail = rest.slice(2);
|
|
147
|
+
|
|
148
|
+
// Optional `projects/{name}` prefix, ahead of the branch: a project is a
|
|
149
|
+
// coarser choice than a branch of it, and reading the URL left to right should
|
|
150
|
+
// narrow. Absent → the Browser screen resolves the repository's only project.
|
|
151
|
+
let projectName: string | undefined;
|
|
152
|
+
if (tail[0] === "projects" && tail.length >= 2) {
|
|
153
|
+
projectName = decodeURIComponent(tail[1]);
|
|
154
|
+
tail = tail.slice(2);
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
// Optional `branches/{id}` prefix. Absent → the Browser screen resolves the
|
|
158
|
+
// repository's default branch, which is the pre-existing behavior.
|
|
159
|
+
let branchId: string | undefined;
|
|
160
|
+
if (tail[0] === "branches" && tail.length >= 2) {
|
|
161
|
+
branchId = decodeURIComponent(tail[1]);
|
|
162
|
+
tail = tail.slice(2);
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
// Optional `changes` marker, then an optional `to/{id}` target. Everything
|
|
166
|
+
// below is the same collection/path tree as the editor, so a document keeps
|
|
167
|
+
// one address shape on both surfaces.
|
|
168
|
+
let surface: Surface = "edit";
|
|
169
|
+
let targetBranchId: string | undefined;
|
|
170
|
+
if (tail[0] === "changes") {
|
|
171
|
+
surface = "changes";
|
|
172
|
+
tail = tail.slice(1);
|
|
173
|
+
if (tail[0] === "to" && tail.length >= 2) {
|
|
174
|
+
targetBranchId = decodeURIComponent(tail[1]);
|
|
175
|
+
tail = tail.slice(2);
|
|
176
|
+
}
|
|
177
|
+
// A trailing `request` marker addresses the change-request summary page.
|
|
178
|
+
if (tail[0] === "request") {
|
|
179
|
+
surface = "changeRequest";
|
|
180
|
+
tail = tail.slice(1);
|
|
181
|
+
}
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
const base = { workspaceId, repositoryId, projectName, branchId, surface, targetBranchId };
|
|
185
|
+
if (tail.length === 0) return { ...base, configure: false, collection: "", itemPath: "", create: false };
|
|
186
|
+
// A plugin screen: `plugins/{pluginId}[/{subpath...}]`. The id is one
|
|
187
|
+
// percent-encoded segment (package names contain "/", e.g. @acme/analytics);
|
|
188
|
+
// the rest is the path the plugin's own routes match.
|
|
189
|
+
if (tail[0] === "plugins" && tail.length >= 2) {
|
|
190
|
+
return {
|
|
191
|
+
...base,
|
|
192
|
+
plugin: {
|
|
193
|
+
id: decodeURIComponent(tail[1]),
|
|
194
|
+
path: tail.length > 2 ? decPath(tail.slice(2).join("/")) : "",
|
|
195
|
+
},
|
|
196
|
+
configure: false, collection: "", itemPath: "", create: false,
|
|
197
|
+
};
|
|
198
|
+
}
|
|
199
|
+
if (tail[0] === "configure") {
|
|
200
|
+
return { ...base, configure: true, collection: "", itemPath: tail.length > 1 ? decPath(tail.slice(1).join("/")) : "", create: false };
|
|
201
|
+
}
|
|
202
|
+
const collection = decodeURIComponent(tail[0]);
|
|
203
|
+
// ".../{collection}/new" opens the create form for that collection.
|
|
204
|
+
if (tail.length === 2 && tail[1] === "new") {
|
|
205
|
+
return { ...base, configure: false, collection, itemPath: "", create: true };
|
|
206
|
+
}
|
|
207
|
+
return {
|
|
208
|
+
...base,
|
|
209
|
+
configure: false,
|
|
210
|
+
collection,
|
|
211
|
+
itemPath: tail.length > 1 ? decPath(tail.slice(1).join("/")) : "",
|
|
212
|
+
create: false,
|
|
213
|
+
};
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
function buildBrowser(p: BrowserParams): string {
|
|
217
|
+
let path = `/${encodeURIComponent(p.workspaceId)}/repositories/${encodeURIComponent(p.repositoryId)}`;
|
|
218
|
+
// The project segment is written only when the repository has more than one —
|
|
219
|
+
// `soleProjectName` is set by the Browser screen once projects load. An empty
|
|
220
|
+
// name is never written: it belongs to the implicit project of a repository
|
|
221
|
+
// with no manifest, and `projects/` addresses nothing.
|
|
222
|
+
if (p.projectName && p.projectName !== p.soleProjectName) {
|
|
223
|
+
path += `/projects/${encodeURIComponent(p.projectName)}`;
|
|
224
|
+
}
|
|
225
|
+
// Only a non-default branch is written. `defaultBranchId` is set by the
|
|
226
|
+
// Browser screen once repositories load; until then nothing is emitted, so a
|
|
227
|
+
// URL never briefly names the default branch and then drops it.
|
|
228
|
+
if (p.branchId && p.branchId !== p.defaultBranchId) {
|
|
229
|
+
path += `/branches/${encodeURIComponent(p.branchId)}`;
|
|
230
|
+
}
|
|
231
|
+
if (p.surface === "changes" || p.surface === "changeRequest") {
|
|
232
|
+
path += "/changes";
|
|
233
|
+
// The target segment is written only for a non-default target, mirroring the
|
|
234
|
+
// branch segment: "review against main" stays a clean URL, and a change link
|
|
235
|
+
// that predates targets (no `to/…`) still means source-vs-default.
|
|
236
|
+
if (p.targetBranchId && p.targetBranchId !== p.defaultBranchId) {
|
|
237
|
+
path += `/to/${encodeURIComponent(p.targetBranchId)}`;
|
|
238
|
+
}
|
|
239
|
+
if (p.surface === "changeRequest") path += "/request";
|
|
240
|
+
}
|
|
241
|
+
if (p.plugin) {
|
|
242
|
+
path += `/plugins/${encodeURIComponent(p.plugin.id)}`;
|
|
243
|
+
if (p.plugin.path) path += `/${encPath(p.plugin.path)}`;
|
|
244
|
+
} else if (p.configure) path += p.itemPath ? `/configure/${encPath(p.itemPath)}` : "/configure";
|
|
245
|
+
else if (p.collection) {
|
|
246
|
+
path += `/${encodeURIComponent(p.collection)}`;
|
|
247
|
+
if (p.create) path += "/new";
|
|
248
|
+
else if (p.itemPath) path += `/${encPath(p.itemPath)}`;
|
|
249
|
+
}
|
|
250
|
+
return path;
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
// browserUrl builds the absolute, shareable URL for a Browser route (origin +
|
|
254
|
+
// mount base + path) — used to hand a developer a link to the change-request
|
|
255
|
+
// summary page from the "change request already open" prompt.
|
|
256
|
+
export function browserUrl(p: BrowserParams): string {
|
|
257
|
+
const origin = typeof window !== "undefined" && window.location ? window.location.origin : "";
|
|
258
|
+
return `${origin}${BASE}${buildBrowser(p)}`;
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
// getStateFromPath/getPathFromState fully own the mapping so slash-containing
|
|
262
|
+
// file paths stay clean in the URL. A resolved Browser route sits atop a
|
|
263
|
+
// RepoPicker route so browser Back returns to the repository list.
|
|
264
|
+
const linking: LinkingOptions<RootParamList> = {
|
|
265
|
+
prefixes: [],
|
|
266
|
+
getStateFromPath: (path) => {
|
|
267
|
+
const segs = stripBase(path).split("/").filter(Boolean);
|
|
268
|
+
if (segs.length === 0) return { routes: [{ name: "WorkspacePicker" }] };
|
|
269
|
+
const workspaceId = decodeURIComponent(segs[0]);
|
|
270
|
+
const browser = parseBrowser(workspaceId, segs.slice(1));
|
|
271
|
+
return browser
|
|
272
|
+
? { routes: [{ name: "RepoPicker", params: { workspaceId } }, { name: "Browser", params: browser }] }
|
|
273
|
+
: { routes: [{ name: "RepoPicker", params: { workspaceId } }] };
|
|
274
|
+
},
|
|
275
|
+
getPathFromState: (state) => {
|
|
276
|
+
const route = state.routes[state.index ?? state.routes.length - 1];
|
|
277
|
+
if (!route) return `${BASE}/`;
|
|
278
|
+
if (route.name === "Browser") return `${BASE}${buildBrowser(route.params as BrowserParams)}`;
|
|
279
|
+
if (route.name === "RepoPicker") {
|
|
280
|
+
return `${BASE}/${encodeURIComponent((route.params as { workspaceId: string }).workspaceId)}`;
|
|
281
|
+
}
|
|
282
|
+
return `${BASE}/`;
|
|
283
|
+
},
|
|
284
|
+
};
|
|
285
|
+
|
|
286
|
+
// --- minimal navigator ------------------------------------------------------
|
|
287
|
+
// A custom navigator (a documented React Navigation pattern) renders only the
|
|
288
|
+
// focused route. Using it instead of native-stack avoids react-native-screens /
|
|
289
|
+
// gesture-handler — awkward under Vite + react-native-web — while still giving
|
|
290
|
+
// the NavigationContainer + linking (URL sync, back/forward, deep links).
|
|
291
|
+
function BrowserNavigatorImpl({ initialRouteName, children }: any) {
|
|
292
|
+
const { state, descriptors, NavigationContent } = useNavigationBuilder(StackRouter, {
|
|
293
|
+
initialRouteName,
|
|
294
|
+
children,
|
|
295
|
+
});
|
|
296
|
+
const focused = state.routes[state.index];
|
|
297
|
+
return <NavigationContent>{descriptors[focused.key].render()}</NavigationContent>;
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
// React Navigation 7's createNavigatorFactory takes no type argument — it
|
|
301
|
+
// returns `(config?: any) => any`, and the param list is declared globally
|
|
302
|
+
// instead (below), which is what types useNavigation/useRoute at the call sites.
|
|
303
|
+
export const RootStack = createNavigatorFactory(BrowserNavigatorImpl)();
|
|
304
|
+
|
|
305
|
+
// The global registration RootParamList feeds: with it, useNavigation() and
|
|
306
|
+
// useRoute() in App.tsx resolve screen names and params without each call site
|
|
307
|
+
// repeating the type argument. Aliased first because the interface below shares
|
|
308
|
+
// the name — referring to RootParamList directly inside the namespace would
|
|
309
|
+
// resolve to the interface itself.
|
|
310
|
+
type AppParamList = RootParamList;
|
|
311
|
+
|
|
312
|
+
declare global {
|
|
313
|
+
namespace ReactNavigation {
|
|
314
|
+
interface RootParamList extends AppParamList {}
|
|
315
|
+
}
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
// NavigationRoot wraps the app in the container + linking.
|
|
319
|
+
export function NavigationRoot({ children }: { children: React.ReactNode }) {
|
|
320
|
+
return <NavigationContainer linking={linking}>{children}</NavigationContainer>;
|
|
321
|
+
}
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
// The SPA's plugin registry singleton. Populated at boot (main.tsx) from the
|
|
2
|
+
// entries the build generated out of cms.config.js `plugins`; read everywhere
|
|
3
|
+
// a plugin can contribute (field rendering, sidebar, user menu, routes).
|
|
4
|
+
|
|
5
|
+
import { PluginRegistry } from "@gogitcms/plugin-sdk";
|
|
6
|
+
import { getPreviewToken } from "./previewToken";
|
|
7
|
+
|
|
8
|
+
// The host capability a preview plugin needs: a credential for a service that
|
|
9
|
+
// trusts the CMS. Injected rather than reached for, so the plugin never touches
|
|
10
|
+
// this tab's access token — see previewToken.ts.
|
|
11
|
+
export const pluginRegistry = new PluginRegistry({ getPreviewToken });
|
|
@@ -0,0 +1,263 @@
|
|
|
1
|
+
// Host wiring for plugin document actions and plugin tabs.
|
|
2
|
+
//
|
|
3
|
+
// The design system knows nothing about plugins: it renders whatever
|
|
4
|
+
// `documentActions` returns into a document's header, and whatever `content` an
|
|
5
|
+
// OpenTab carries into a column. This module is the adapter between those two
|
|
6
|
+
// generic seams and the plugin registry — the toolbar buttons, the draft bus a
|
|
7
|
+
// plugin tab subscribes to, and the tab-state reducer the app drives.
|
|
8
|
+
|
|
9
|
+
import React, { useCallback, useMemo, useRef, useSyncExternalStore } from "react";
|
|
10
|
+
import { Button, type CmsEntry } from "@gogitcms/design-system";
|
|
11
|
+
import {
|
|
12
|
+
PluginScreenHost,
|
|
13
|
+
usePluginRegistry,
|
|
14
|
+
type PluginDocument,
|
|
15
|
+
type PluginDocumentActionContext,
|
|
16
|
+
type PluginRegistry,
|
|
17
|
+
type PluginTabSpec,
|
|
18
|
+
} from "@gogitcms/plugin-sdk";
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* A plugin tab in the host's open-tab list.
|
|
22
|
+
*
|
|
23
|
+
* It rides in the same list as document tabs so ordering, drag-reorder, close
|
|
24
|
+
* and the active accent are one implementation — a preview column sits between
|
|
25
|
+
* two documents exactly as a third document would.
|
|
26
|
+
*/
|
|
27
|
+
export type PluginTabRef = {
|
|
28
|
+
pluginId: string;
|
|
29
|
+
/** One of the plugin's registered route paths. */
|
|
30
|
+
route: string;
|
|
31
|
+
title: string;
|
|
32
|
+
params?: Record<string, string>;
|
|
33
|
+
/** The document the tab was opened from, so its drafts can be routed here. */
|
|
34
|
+
documentId: string | null;
|
|
35
|
+
};
|
|
36
|
+
|
|
37
|
+
// --- the draft bus ----------------------------------------------------------
|
|
38
|
+
|
|
39
|
+
type DraftListener = (doc: PluginDocument) => void;
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* Fan-out of in-flight document edits, keyed by document id.
|
|
43
|
+
*
|
|
44
|
+
* A bus rather than props because the producer (ContentBrowser's onEntryDraft,
|
|
45
|
+
* one callback for the whole browser) and the consumers (any number of plugin
|
|
46
|
+
* tabs, mounted and unmounted independently) have no component relationship.
|
|
47
|
+
* The last draft per document is retained so a tab opened mid-edit renders the
|
|
48
|
+
* current text rather than waiting for the next keystroke.
|
|
49
|
+
*/
|
|
50
|
+
export class DraftBus {
|
|
51
|
+
private listeners = new Map<string, Set<DraftListener>>();
|
|
52
|
+
private last = new Map<string, PluginDocument>();
|
|
53
|
+
|
|
54
|
+
publish(doc: PluginDocument): void {
|
|
55
|
+
this.last.set(doc.id, doc);
|
|
56
|
+
const set = this.listeners.get(doc.id);
|
|
57
|
+
if (!set) return;
|
|
58
|
+
for (const l of set) {
|
|
59
|
+
try {
|
|
60
|
+
l(doc);
|
|
61
|
+
} catch (err) {
|
|
62
|
+
// A plugin throwing in its draft handler must not break the editor's
|
|
63
|
+
// typing path, which is what called us.
|
|
64
|
+
console.error(`[plugins] a draft subscriber threw: ${String(err)}`);
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
subscribe(documentId: string, listener: DraftListener): () => void {
|
|
70
|
+
let set = this.listeners.get(documentId);
|
|
71
|
+
if (!set) {
|
|
72
|
+
set = new Set();
|
|
73
|
+
this.listeners.set(documentId, set);
|
|
74
|
+
}
|
|
75
|
+
set.add(listener);
|
|
76
|
+
const retained = this.last.get(documentId);
|
|
77
|
+
if (retained) listener(retained);
|
|
78
|
+
return () => {
|
|
79
|
+
set!.delete(listener);
|
|
80
|
+
if (set!.size === 0) this.listeners.delete(documentId);
|
|
81
|
+
};
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/** The last draft seen for a document, or null. */
|
|
85
|
+
peek(documentId: string | null): PluginDocument | null {
|
|
86
|
+
return documentId ? this.last.get(documentId) ?? null : null;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/** Drop retained state for documents that are no longer open. */
|
|
90
|
+
forget(documentId: string): void {
|
|
91
|
+
this.last.delete(documentId);
|
|
92
|
+
this.listeners.delete(documentId);
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
// --- toolbar buttons --------------------------------------------------------
|
|
97
|
+
|
|
98
|
+
export type DocumentActionsProps = {
|
|
99
|
+
registry: PluginRegistry;
|
|
100
|
+
document: PluginDocument;
|
|
101
|
+
/**
|
|
102
|
+
* Everything the action context needs except the parts this component owns.
|
|
103
|
+
*
|
|
104
|
+
* `openTab` is supplied here in a *plugin-scoped* form: the plugin id comes
|
|
105
|
+
* from the registry record, never from the spec the plugin passed. A plugin
|
|
106
|
+
* naming another plugin's id would otherwise mount that plugin's route under
|
|
107
|
+
* its own button.
|
|
108
|
+
*/
|
|
109
|
+
context: Omit<PluginDocumentActionContext, "document" | "openTab"> & {
|
|
110
|
+
openTab: (pluginId: string, spec: PluginTabSpec) => void;
|
|
111
|
+
};
|
|
112
|
+
};
|
|
113
|
+
|
|
114
|
+
/**
|
|
115
|
+
* The plugin buttons for one open document.
|
|
116
|
+
*
|
|
117
|
+
* Subscribes to the registry so buttons appear as plugin chunks land, rather
|
|
118
|
+
* than only for documents opened after loading finished.
|
|
119
|
+
*/
|
|
120
|
+
export function PluginDocumentActions({ registry, document, context }: DocumentActionsProps) {
|
|
121
|
+
usePluginRegistry(registry);
|
|
122
|
+
const actions = registry.documentActionsFor(document);
|
|
123
|
+
if (actions.length === 0) return null;
|
|
124
|
+
return (
|
|
125
|
+
<>
|
|
126
|
+
{actions.map((a) => (
|
|
127
|
+
<Button
|
|
128
|
+
key={a.key}
|
|
129
|
+
title={a.label}
|
|
130
|
+
variant="default"
|
|
131
|
+
size="sm"
|
|
132
|
+
iconLeft={a.icon}
|
|
133
|
+
testID={`doc-action-${a.key}`}
|
|
134
|
+
// Button defaults to alignSelf: "flex-start", which is right in a
|
|
135
|
+
// column (a form) and wrong in a row: it overrides the header's
|
|
136
|
+
// alignItems: "center" and pins the button to the top of the bar.
|
|
137
|
+
style={{ alignSelf: "center" }}
|
|
138
|
+
onPress={() => {
|
|
139
|
+
// A plugin's handler is untrusted code on the click path: a throw
|
|
140
|
+
// (or a rejected promise) costs its own button, not the editor.
|
|
141
|
+
void (async () => {
|
|
142
|
+
try {
|
|
143
|
+
await a.onSelect({
|
|
144
|
+
...context,
|
|
145
|
+
document,
|
|
146
|
+
openTab: (spec) => context.openTab(a.pluginId, spec),
|
|
147
|
+
});
|
|
148
|
+
} catch (err) {
|
|
149
|
+
console.error(`[plugin ${a.pluginId}] document action "${a.id}" failed: ${String(err)}`);
|
|
150
|
+
}
|
|
151
|
+
})();
|
|
152
|
+
}}
|
|
153
|
+
/>
|
|
154
|
+
))}
|
|
155
|
+
</>
|
|
156
|
+
);
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
/** Build the CmsEntry -> PluginDocument projection the actions and bus speak. */
|
|
160
|
+
export function toPluginDocument(
|
|
161
|
+
entry: CmsEntry,
|
|
162
|
+
collection: string,
|
|
163
|
+
fields: Record<string, unknown>,
|
|
164
|
+
body: string | null,
|
|
165
|
+
bodyField?: string,
|
|
166
|
+
): PluginDocument {
|
|
167
|
+
return {
|
|
168
|
+
id: entry.id,
|
|
169
|
+
collection,
|
|
170
|
+
path: entry.path,
|
|
171
|
+
fields,
|
|
172
|
+
body: body ?? undefined,
|
|
173
|
+
bodyField,
|
|
174
|
+
};
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
// --- the tab body -----------------------------------------------------------
|
|
178
|
+
|
|
179
|
+
export type PluginTabBodyProps = {
|
|
180
|
+
registry: PluginRegistry;
|
|
181
|
+
tabId: string;
|
|
182
|
+
tab: PluginTabRef;
|
|
183
|
+
bus: DraftBus;
|
|
184
|
+
/** The document as last loaded, for a tab opened before any edit. */
|
|
185
|
+
initialDocument: PluginDocument | null;
|
|
186
|
+
workspaceId: string;
|
|
187
|
+
repositoryId: string;
|
|
188
|
+
branchId?: string;
|
|
189
|
+
gitRef?: string;
|
|
190
|
+
apiUrl: string;
|
|
191
|
+
navigate: (to: string) => void;
|
|
192
|
+
openDocument: (id: string, field?: string) => void;
|
|
193
|
+
close: () => void;
|
|
194
|
+
};
|
|
195
|
+
|
|
196
|
+
/** Renders a plugin route inside a column, with its tab context attached. */
|
|
197
|
+
export function PluginTabBody(props: PluginTabBodyProps) {
|
|
198
|
+
const { bus, tab, initialDocument } = props;
|
|
199
|
+
|
|
200
|
+
const onDraft = useCallback(
|
|
201
|
+
(cb: (doc: PluginDocument) => void) =>
|
|
202
|
+
tab.documentId ? bus.subscribe(tab.documentId, cb) : () => {},
|
|
203
|
+
[bus, tab.documentId],
|
|
204
|
+
);
|
|
205
|
+
|
|
206
|
+
// The tab context is memoized on its inputs: it is a prop of the plugin's
|
|
207
|
+
// component, and a fresh object each render would re-run every effect the
|
|
208
|
+
// plugin keyed on it — including, for a preview plugin, the iframe's src.
|
|
209
|
+
const tabContext = useMemo(
|
|
210
|
+
() => ({
|
|
211
|
+
id: props.tabId,
|
|
212
|
+
document: initialDocument,
|
|
213
|
+
onDraft,
|
|
214
|
+
openDocument: props.openDocument,
|
|
215
|
+
close: props.close,
|
|
216
|
+
}),
|
|
217
|
+
[props.tabId, initialDocument, onDraft, props.openDocument, props.close],
|
|
218
|
+
);
|
|
219
|
+
|
|
220
|
+
return (
|
|
221
|
+
<PluginScreenHost
|
|
222
|
+
registry={props.registry}
|
|
223
|
+
pluginId={tab.pluginId}
|
|
224
|
+
path={tab.route}
|
|
225
|
+
params={tab.params}
|
|
226
|
+
workspaceId={props.workspaceId}
|
|
227
|
+
repositoryId={props.repositoryId}
|
|
228
|
+
branchId={props.branchId}
|
|
229
|
+
gitRef={props.gitRef}
|
|
230
|
+
apiUrl={props.apiUrl}
|
|
231
|
+
navigate={props.navigate}
|
|
232
|
+
tab={tabContext}
|
|
233
|
+
/>
|
|
234
|
+
);
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
/** The id a plugin tab occupies in the open-tab list. */
|
|
238
|
+
export function pluginTabId(pluginId: string, spec: PluginTabSpec): string {
|
|
239
|
+
return `plugin:${pluginId}:${spec.id}`;
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
/**
|
|
243
|
+
* Stable identity for a value that is rebuilt each render.
|
|
244
|
+
*
|
|
245
|
+
* Used for the per-document `initialDocument`: it is derived from the loaded
|
|
246
|
+
* document on every render, and handing a new object to PluginTabBody each time
|
|
247
|
+
* would defeat the memo above.
|
|
248
|
+
*/
|
|
249
|
+
export function useStable<T>(value: T, equal: (a: T, b: T) => boolean): T {
|
|
250
|
+
const ref = useRef(value);
|
|
251
|
+
if (!equal(ref.current, value)) ref.current = value;
|
|
252
|
+
return ref.current;
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
/** Subscribe a component to a bus entry (for hosts that want it directly). */
|
|
256
|
+
export function useDraft(bus: DraftBus, documentId: string | null): PluginDocument | null {
|
|
257
|
+
const subscribe = useCallback(
|
|
258
|
+
(cb: () => void) => (documentId ? bus.subscribe(documentId, cb) : () => {}),
|
|
259
|
+
[bus, documentId],
|
|
260
|
+
);
|
|
261
|
+
const read = useCallback(() => bus.peek(documentId), [bus, documentId]);
|
|
262
|
+
return useSyncExternalStore(subscribe, read, read);
|
|
263
|
+
}
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
// Preview tokens for plugins.
|
|
2
|
+
//
|
|
3
|
+
// A preview plugin has to authenticate to a service that is not the CMS — the
|
|
4
|
+
// sidecar on a developer's machine, or the hosted preview service — and the
|
|
5
|
+
// obvious credential to hand it is the one already in this tab: the user's
|
|
6
|
+
// access token. That is the wrong answer. Plugin code is third-party by design,
|
|
7
|
+
// and the access token authenticates everything else the CMS can do.
|
|
8
|
+
//
|
|
9
|
+
// So the host mints something narrower on the plugin's behalf: a token naming one
|
|
10
|
+
// repository, good for minutes, refused by every CMS endpoint (the API's
|
|
11
|
+
// auth.ParseAccess rejects its audience). The plugin never sees the access token,
|
|
12
|
+
// and a leaked preview token buys a preview of one repository until it expires.
|
|
13
|
+
|
|
14
|
+
import { config } from "./config";
|
|
15
|
+
import { getAuthToken } from "./apollo";
|
|
16
|
+
|
|
17
|
+
type Cached = { token: string; expiresAt: number };
|
|
18
|
+
|
|
19
|
+
// Cached per repository, because the alternative is a mint on every keystroke:
|
|
20
|
+
// the preview plugin sends a draft as the user types, and each one is a request
|
|
21
|
+
// that needs a credential.
|
|
22
|
+
const cache = new Map<string, Cached>();
|
|
23
|
+
|
|
24
|
+
// Re-minted this long before expiry, so a draft in flight is never the request
|
|
25
|
+
// that discovers the token has died.
|
|
26
|
+
const RENEW_MARGIN_MS = 60_000;
|
|
27
|
+
|
|
28
|
+
export async function getPreviewToken(repositoryId: string): Promise<string> {
|
|
29
|
+
const hit = cache.get(repositoryId);
|
|
30
|
+
if (hit && hit.expiresAt - RENEW_MARGIN_MS > Date.now()) return hit.token;
|
|
31
|
+
|
|
32
|
+
const auth = getAuthToken();
|
|
33
|
+
if (!auth) throw new Error("not signed in");
|
|
34
|
+
|
|
35
|
+
const res = await fetch(`${config.apiUrl}/api/v1/git/repositories/${repositoryId}/preview-token`, {
|
|
36
|
+
method: "POST",
|
|
37
|
+
headers: {
|
|
38
|
+
authorization: `Bearer ${auth}`,
|
|
39
|
+
// The local server's CSRF barrier, same as apollo.ts sends.
|
|
40
|
+
...(config.local ? { "X-GitCms-Local": "1" } : {}),
|
|
41
|
+
},
|
|
42
|
+
});
|
|
43
|
+
if (!res.ok) {
|
|
44
|
+
// Surfaced to the plugin, which shows it in its own pane. 403 here means the
|
|
45
|
+
// user cannot read that repository's content, which is worth saying plainly
|
|
46
|
+
// rather than as "the preview service refused the session".
|
|
47
|
+
throw new Error(
|
|
48
|
+
res.status === 403
|
|
49
|
+
? "you do not have permission to preview this repository"
|
|
50
|
+
: `the CMS refused to mint a preview token (${res.status})`,
|
|
51
|
+
);
|
|
52
|
+
}
|
|
53
|
+
const body = (await res.json()) as { token: string; expiresAt: string };
|
|
54
|
+
const expiresAt = Date.parse(body.expiresAt);
|
|
55
|
+
cache.set(repositoryId, {
|
|
56
|
+
token: body.token,
|
|
57
|
+
// A server whose clock or format surprises us should not produce a token
|
|
58
|
+
// that is cached forever; an unparseable expiry is treated as "one minute".
|
|
59
|
+
expiresAt: Number.isFinite(expiresAt) ? expiresAt : Date.now() + 60_000,
|
|
60
|
+
});
|
|
61
|
+
return body.token;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
// Cleared on sign-out: a token in a module-level map outliving the session is
|
|
65
|
+
// the same mistake as persisting one.
|
|
66
|
+
export function clearPreviewTokens(): void {
|
|
67
|
+
cache.clear();
|
|
68
|
+
}
|