@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.
Files changed (143) hide show
  1. package/app/index.html +38 -0
  2. package/app/src/App.tsx +2665 -0
  3. package/app/src/apollo.ts +119 -0
  4. package/app/src/auth.ts +248 -0
  5. package/app/src/config.ts +88 -0
  6. package/app/src/main.tsx +18 -0
  7. package/app/src/media.ts +151 -0
  8. package/app/src/navigation.tsx +321 -0
  9. package/app/src/plugins.ts +11 -0
  10. package/app/src/previewHost.tsx +263 -0
  11. package/app/src/previewToken.ts +68 -0
  12. package/app/src/queries.ts +591 -0
  13. package/app/src/virtual-cms-plugins.d.ts +6 -0
  14. package/app/vendor/analytics/src/__tests__/config.test.ts +91 -0
  15. package/app/vendor/analytics/src/config.ts +94 -0
  16. package/app/vendor/analytics/src/events.ts +56 -0
  17. package/app/vendor/analytics/src/index.ts +23 -0
  18. package/app/vendor/analytics/src/provider.tsx +196 -0
  19. package/app/vendor/design-system/src/ThemeProvider.tsx +86 -0
  20. package/app/vendor/design-system/src/__tests__/ApplyChangesModal.test.tsx +148 -0
  21. package/app/vendor/design-system/src/__tests__/BranchImport.test.tsx +46 -0
  22. package/app/vendor/design-system/src/__tests__/Button.test.tsx +45 -0
  23. package/app/vendor/design-system/src/__tests__/ChangeRequestSummary.test.tsx +57 -0
  24. package/app/vendor/design-system/src/__tests__/ContentBrowser.changes.test.tsx +611 -0
  25. package/app/vendor/design-system/src/__tests__/ContentBrowser.collab.test.tsx +322 -0
  26. package/app/vendor/design-system/src/__tests__/ContentBrowser.collabsync.test.tsx +264 -0
  27. package/app/vendor/design-system/src/__tests__/ContentBrowser.contentslot.test.tsx +53 -0
  28. package/app/vendor/design-system/src/__tests__/ContentBrowser.discriminator.test.tsx +142 -0
  29. package/app/vendor/design-system/src/__tests__/ContentBrowser.drafts.test.tsx +271 -0
  30. package/app/vendor/design-system/src/__tests__/ContentBrowser.fields.test.tsx +117 -0
  31. package/app/vendor/design-system/src/__tests__/ContentBrowser.media.test.tsx +140 -0
  32. package/app/vendor/design-system/src/__tests__/ContentBrowser.mixedcollab.test.tsx +63 -0
  33. package/app/vendor/design-system/src/__tests__/ContentBrowser.mixedvalues.test.tsx +38 -0
  34. package/app/vendor/design-system/src/__tests__/ContentBrowser.pagination.test.tsx +62 -0
  35. package/app/vendor/design-system/src/__tests__/ContentBrowser.previewtab.test.tsx +212 -0
  36. package/app/vendor/design-system/src/__tests__/ContentBrowser.reorder.test.tsx +45 -0
  37. package/app/vendor/design-system/src/__tests__/ContentBrowser.search.test.tsx +135 -0
  38. package/app/vendor/design-system/src/__tests__/ContentBrowser.selectvalue.test.tsx +185 -0
  39. package/app/vendor/design-system/src/__tests__/ContentBrowser.staged.test.tsx +132 -0
  40. package/app/vendor/design-system/src/__tests__/ContentBrowser.usermenu.test.tsx +56 -0
  41. package/app/vendor/design-system/src/__tests__/MediaBrowser.test.tsx +353 -0
  42. package/app/vendor/design-system/src/__tests__/MediaField.test.tsx +185 -0
  43. package/app/vendor/design-system/src/__tests__/Notifications.test.tsx +69 -0
  44. package/app/vendor/design-system/src/__tests__/Onboarding.test.tsx +287 -0
  45. package/app/vendor/design-system/src/__tests__/cssTokens.test.ts +201 -0
  46. package/app/vendor/design-system/src/__tests__/fieldComponents.test.ts +43 -0
  47. package/app/vendor/design-system/src/__tests__/reorder.test.ts +58 -0
  48. package/app/vendor/design-system/src/components/ApplyChangesModal.tsx +348 -0
  49. package/app/vendor/design-system/src/components/BranchImport.tsx +157 -0
  50. package/app/vendor/design-system/src/components/BranchMenu.tsx +192 -0
  51. package/app/vendor/design-system/src/components/Button.tsx +131 -0
  52. package/app/vendor/design-system/src/components/ChangeDetail.tsx +472 -0
  53. package/app/vendor/design-system/src/components/ChangeRequestSummary.tsx +173 -0
  54. package/app/vendor/design-system/src/components/CollabField.tsx +388 -0
  55. package/app/vendor/design-system/src/components/ContentBrowser.tsx +5073 -0
  56. package/app/vendor/design-system/src/components/Icon.tsx +28 -0
  57. package/app/vendor/design-system/src/components/Icon.web.tsx +31 -0
  58. package/app/vendor/design-system/src/components/Input.tsx +106 -0
  59. package/app/vendor/design-system/src/components/MediaBrowser.tsx +766 -0
  60. package/app/vendor/design-system/src/components/MediaField.tsx +670 -0
  61. package/app/vendor/design-system/src/components/MediaPreview.tsx +91 -0
  62. package/app/vendor/design-system/src/components/MediaPreview.web.tsx +169 -0
  63. package/app/vendor/design-system/src/components/NavRow.tsx +105 -0
  64. package/app/vendor/design-system/src/components/Notifications.tsx +301 -0
  65. package/app/vendor/design-system/src/components/Onboarding.tsx +751 -0
  66. package/app/vendor/design-system/src/components/ProjectMenu.tsx +124 -0
  67. package/app/vendor/design-system/src/components/Segment.tsx +87 -0
  68. package/app/vendor/design-system/src/components/Skeleton.tsx +216 -0
  69. package/app/vendor/design-system/src/components/Spinner.tsx +44 -0
  70. package/app/vendor/design-system/src/components/Text.tsx +85 -0
  71. package/app/vendor/design-system/src/components/documentDrafts.ts +213 -0
  72. package/app/vendor/design-system/src/components/layout.tsx +284 -0
  73. package/app/vendor/design-system/src/components/primitives.tsx +143 -0
  74. package/app/vendor/design-system/src/components/reorder.ts +40 -0
  75. package/app/vendor/design-system/src/fieldComponents.ts +63 -0
  76. package/app/vendor/design-system/src/icons.ts +102 -0
  77. package/app/vendor/design-system/src/index.ts +198 -0
  78. package/app/vendor/design-system/src/media.ts +229 -0
  79. package/app/vendor/design-system/src/theme.ts +116 -0
  80. package/app/vendor/design-system/src/web/Button.tsx +110 -0
  81. package/app/vendor/design-system/src/web/Icon.tsx +52 -0
  82. package/app/vendor/design-system/src/web/Input.tsx +39 -0
  83. package/app/vendor/design-system/src/web/index.ts +43 -0
  84. package/app/vendor/design-system/src/web/primitives.tsx +119 -0
  85. package/app/vendor/markdown-editor/src/MarkdownEditor.tsx +248 -0
  86. package/app/vendor/markdown-editor/src/Toolbar.tsx +157 -0
  87. package/app/vendor/markdown-editor/src/field.tsx +67 -0
  88. package/app/vendor/markdown-editor/src/flavors/commonmark/index.ts +27 -0
  89. package/app/vendor/markdown-editor/src/flavors/gfm/index.ts +30 -0
  90. package/app/vendor/markdown-editor/src/flavors/gfm/parser.ts +73 -0
  91. package/app/vendor/markdown-editor/src/flavors/gfm/schema.ts +70 -0
  92. package/app/vendor/markdown-editor/src/flavors/gfm/serializer.ts +82 -0
  93. package/app/vendor/markdown-editor/src/flavors/gfm/taskList.ts +39 -0
  94. package/app/vendor/markdown-editor/src/flavors/registry.ts +13 -0
  95. package/app/vendor/markdown-editor/src/flavors/shared/inputrules.ts +79 -0
  96. package/app/vendor/markdown-editor/src/flavors/shared/keymap.ts +89 -0
  97. package/app/vendor/markdown-editor/src/flavors/shared/placeholder.ts +24 -0
  98. package/app/vendor/markdown-editor/src/flavors/shared/plugins.ts +58 -0
  99. package/app/vendor/markdown-editor/src/flavors/types.ts +22 -0
  100. package/app/vendor/markdown-editor/src/formats/registry.ts +45 -0
  101. package/app/vendor/markdown-editor/src/icons.tsx +120 -0
  102. package/app/vendor/markdown-editor/src/index.ts +23 -0
  103. package/app/vendor/markdown-editor/src/theme.ts +249 -0
  104. package/app/vendor/markdown-editor/src/types.ts +58 -0
  105. package/app/vendor/plugin-sdk/src/__tests__/documentActions.test.ts +108 -0
  106. package/app/vendor/plugin-sdk/src/__tests__/loader.test.ts +75 -0
  107. package/app/vendor/plugin-sdk/src/__tests__/registry.test.ts +148 -0
  108. package/app/vendor/plugin-sdk/src/index.ts +33 -0
  109. package/app/vendor/plugin-sdk/src/loader.ts +45 -0
  110. package/app/vendor/plugin-sdk/src/navkeys.ts +17 -0
  111. package/app/vendor/plugin-sdk/src/react.tsx +155 -0
  112. package/app/vendor/plugin-sdk/src/registry.ts +337 -0
  113. package/app/vendor/plugin-sdk/src/types.ts +230 -0
  114. package/app/vendor/realtime/src/__tests__/pure.test.ts +58 -0
  115. package/app/vendor/realtime/src/__tests__/seeded-room.base64 +1 -0
  116. package/app/vendor/realtime/src/__tests__/seeding.test.ts +70 -0
  117. package/app/vendor/realtime/src/collab.ts +308 -0
  118. package/app/vendor/realtime/src/hooks.ts +57 -0
  119. package/app/vendor/realtime/src/index.ts +12 -0
  120. package/app/vendor/realtime/src/pure.ts +43 -0
  121. package/app/vite.config.mjs +59 -0
  122. package/bin/gogitcms-editor.mjs +168 -0
  123. package/npm-shrinkwrap.json +5696 -0
  124. package/package.json +74 -0
  125. package/src/commands/build.mjs +100 -0
  126. package/src/commands/dev.mjs +186 -0
  127. package/src/commands/init.mjs +144 -0
  128. package/src/commands/login.mjs +116 -0
  129. package/src/commands/status.mjs +68 -0
  130. package/src/lib/api.mjs +122 -0
  131. package/src/lib/appRoot.mjs +51 -0
  132. package/src/lib/baked.json +3 -0
  133. package/src/lib/config.mjs +108 -0
  134. package/src/lib/credentials.mjs +91 -0
  135. package/src/lib/defaults.mjs +26 -0
  136. package/src/lib/graphql.mjs +28 -0
  137. package/src/lib/localServer.mjs +206 -0
  138. package/src/lib/open.mjs +23 -0
  139. package/src/lib/plugins.mjs +164 -0
  140. package/src/lib/tokenBroker.mjs +85 -0
  141. package/src/tui/prompts.mjs +92 -0
  142. package/src/tui/screen.mjs +282 -0
  143. 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
+ }