@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,164 @@
|
|
|
1
|
+
// Resolving the plugins listed in a project's cms.config.js `plugins` and
|
|
2
|
+
// feeding them to Vite as the `virtual:cms-plugins` module the SPA boots from
|
|
3
|
+
// (apps/frontend/src/main.tsx). This is the gogitcms-editor counterpart of the same
|
|
4
|
+
// logic in apps/frontend/bin/cms-frontend.mjs — duplicated rather than shared
|
|
5
|
+
// because gogitcms-editor publishes a self-contained tarball that vendors its
|
|
6
|
+
// dependencies and cannot import from apps/frontend at a consumer's runtime.
|
|
7
|
+
//
|
|
8
|
+
// plugins: [
|
|
9
|
+
// "@acme/cms-analytics", // npm package in the project
|
|
10
|
+
// ["@acme/cms-colors", { palette: 1 }], // with options → setup(cms, options)
|
|
11
|
+
// "./local-plugins/experiments", // relative path during development
|
|
12
|
+
// ]
|
|
13
|
+
//
|
|
14
|
+
// Each entry resolves from the project directory (where cms.config.js and the
|
|
15
|
+
// node_modules holding its plugins live), and is emitted as a dynamic import so
|
|
16
|
+
// the plugin loads lazily at runtime.
|
|
17
|
+
|
|
18
|
+
import { createRequire } from "node:module";
|
|
19
|
+
import fs from "node:fs";
|
|
20
|
+
import path from "node:path";
|
|
21
|
+
|
|
22
|
+
// The shared modules a plugin imports by the same @gogitcms/* names the app
|
|
23
|
+
// uses. dedupe collapses each to the app's own copy so one React / one
|
|
24
|
+
// design-system (one theme context) is shared across the app and its plugins.
|
|
25
|
+
// In a published gogitcms-editor the staged Vite config also aliases these onto the
|
|
26
|
+
// vendored copies, which resolves them even when the project has not installed
|
|
27
|
+
// them (they are optional peers).
|
|
28
|
+
export const PLUGIN_PEER_DEPS = [
|
|
29
|
+
"react",
|
|
30
|
+
"react-dom",
|
|
31
|
+
"react-native-web",
|
|
32
|
+
"@gogitcms/design-system",
|
|
33
|
+
"@gogitcms/plugin-sdk",
|
|
34
|
+
"@gogitcms/markdown-editor",
|
|
35
|
+
];
|
|
36
|
+
|
|
37
|
+
// LEGACY_SCOPE_ALIASES redirects the pre-rename @go-git-cms/* names onto the
|
|
38
|
+
// @gogitcms/* ones the host actually provides.
|
|
39
|
+
//
|
|
40
|
+
// The workspace moved scope when the packages moved to npmjs, but plugins
|
|
41
|
+
// published before that — @go-git-cms/plugin-mdx is the one in this repo —
|
|
42
|
+
// import the old names. Without this they resolve to whatever copy of the old
|
|
43
|
+
// package the project happens to have installed, which is a SECOND
|
|
44
|
+
// design-system and therefore a second theme React context: the plugin's
|
|
45
|
+
// controls render unthemed beside the host's. dedupe cannot fix that, because
|
|
46
|
+
// dedupe collapses copies of one name rather than bridging two names.
|
|
47
|
+
//
|
|
48
|
+
// Aliases, not dedupe entries, for that reason. Harmless when a plugin never
|
|
49
|
+
// names them. Remove once the ecosystem has republished under @gogitcms.
|
|
50
|
+
const LEGACY_SCOPE = "@go-git-cms";
|
|
51
|
+
export const LEGACY_SCOPE_ALIASES = Object.fromEntries(
|
|
52
|
+
PLUGIN_PEER_DEPS.filter((name) => name.startsWith("@gogitcms/")).map((name) => [
|
|
53
|
+
`${LEGACY_SCOPE}/${name.slice("@gogitcms/".length)}`,
|
|
54
|
+
name,
|
|
55
|
+
]),
|
|
56
|
+
);
|
|
57
|
+
|
|
58
|
+
// The directory a project exposes to its plugins, and the specifier they reach
|
|
59
|
+
// it by. A plugin that renders the project's own components has to name them in
|
|
60
|
+
// a literal `import.meta.glob` — that is the only form Vite can see — but the
|
|
61
|
+
// directory it should glob belongs to the project, and no relative path from an
|
|
62
|
+
// installed package reaches it (under pnpm the package is a symlink into the
|
|
63
|
+
// content-addressed store). Vite resolves `resolve.alias` inside a glob
|
|
64
|
+
// pattern, so the alias is what closes that gap: the plugin's pattern stays
|
|
65
|
+
// literal and the host decides where it points.
|
|
66
|
+
//
|
|
67
|
+
// Always aliased, whether or not the directory exists — an alias pointing at
|
|
68
|
+
// nothing simply matches nothing, and creating the directory should not require
|
|
69
|
+
// restarting the dev server to be noticed.
|
|
70
|
+
export const PROJECT_COMPONENTS_SPECIFIER = "@cms-components";
|
|
71
|
+
export const PROJECT_COMPONENTS_DIR = "cms-components";
|
|
72
|
+
|
|
73
|
+
// projectAliases returns the resolve.alias entries for a project directory.
|
|
74
|
+
export function projectAliases(cwd = process.cwd()) {
|
|
75
|
+
return {
|
|
76
|
+
...LEGACY_SCOPE_ALIASES,
|
|
77
|
+
[PROJECT_COMPONENTS_SPECIFIER]: path.resolve(cwd, PROJECT_COMPONENTS_DIR),
|
|
78
|
+
};
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
// nearestPackageName walks up from a resolved entry file to name a plugin
|
|
82
|
+
// linked by path, so its id matches what it would be if installed from a
|
|
83
|
+
// registry.
|
|
84
|
+
function nearestPackageName(entryFile) {
|
|
85
|
+
for (let dir = path.dirname(entryFile); ; dir = path.dirname(dir)) {
|
|
86
|
+
const pkg = path.join(dir, "package.json");
|
|
87
|
+
if (fs.existsSync(pkg)) {
|
|
88
|
+
try {
|
|
89
|
+
const name = JSON.parse(fs.readFileSync(pkg, "utf8")).name;
|
|
90
|
+
if (name) return name;
|
|
91
|
+
} catch {
|
|
92
|
+
/* unparseable package.json — keep walking */
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
if (dir === path.dirname(dir)) return null;
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
// resolvePlugins normalizes a config's `plugins` to { id, options, entry },
|
|
100
|
+
// with `entry` the absolute path to each plugin's main file, resolved from the
|
|
101
|
+
// project directory.
|
|
102
|
+
export function resolvePlugins(cfg, cwd = process.cwd()) {
|
|
103
|
+
const specs = Array.isArray(cfg?.plugins) ? cfg.plugins : [];
|
|
104
|
+
// Resolve as the project would: from a real file inside it. package.json is
|
|
105
|
+
// always present; cms.config.* may be .mjs/.ts, so package.json is the stable
|
|
106
|
+
// anchor for createRequire.
|
|
107
|
+
const req = createRequire(path.join(cwd, "package.json"));
|
|
108
|
+
const seen = new Set();
|
|
109
|
+
return specs.map((spec) => {
|
|
110
|
+
let name, options;
|
|
111
|
+
if (typeof spec === "string") name = spec;
|
|
112
|
+
else if (Array.isArray(spec)) [name, options] = spec;
|
|
113
|
+
else if (spec && typeof spec === "object") ({ name, options } = spec);
|
|
114
|
+
if (!name || typeof name !== "string") {
|
|
115
|
+
throw new Error(`cms.config plugins: invalid entry ${JSON.stringify(spec)}`);
|
|
116
|
+
}
|
|
117
|
+
const isPath = name.startsWith(".") || path.isAbsolute(name);
|
|
118
|
+
let entry;
|
|
119
|
+
try {
|
|
120
|
+
entry = req.resolve(isPath ? path.resolve(cwd, name) : name);
|
|
121
|
+
} catch {
|
|
122
|
+
throw new Error(
|
|
123
|
+
`cms.config plugins: cannot resolve "${name}" from ${cwd} — is it installed?`,
|
|
124
|
+
);
|
|
125
|
+
}
|
|
126
|
+
const id = (isPath ? nearestPackageName(entry) : null) ?? name;
|
|
127
|
+
if (seen.has(id)) throw new Error(`cms.config plugins: duplicate plugin "${id}"`);
|
|
128
|
+
seen.add(id);
|
|
129
|
+
return { id, options, entry };
|
|
130
|
+
});
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
// cmsPluginsVite provides the `virtual:cms-plugins` module the SPA imports.
|
|
134
|
+
// Always installed by dev/build (even with an empty list, so the import
|
|
135
|
+
// resolves), it replaces the empty stub the staged config used to ship.
|
|
136
|
+
export function cmsPluginsVite(resolved) {
|
|
137
|
+
const VIRTUAL = "virtual:cms-plugins";
|
|
138
|
+
const RESOLVED = "\0" + VIRTUAL;
|
|
139
|
+
return {
|
|
140
|
+
name: "cms-plugins",
|
|
141
|
+
// enforce:"pre" so this resolves the virtual id before any other plugin.
|
|
142
|
+
enforce: "pre",
|
|
143
|
+
resolveId(id) {
|
|
144
|
+
return id === VIRTUAL ? RESOLVED : undefined;
|
|
145
|
+
},
|
|
146
|
+
load(id) {
|
|
147
|
+
if (id !== RESOLVED) return undefined;
|
|
148
|
+
const entries = resolved.map(
|
|
149
|
+
(p) =>
|
|
150
|
+
` { id: ${JSON.stringify(p.id)}, options: ${
|
|
151
|
+
p.options === undefined ? "undefined" : JSON.stringify(p.options)
|
|
152
|
+
}, load: () => import(${JSON.stringify(p.entry)}) },`,
|
|
153
|
+
);
|
|
154
|
+
return `export const plugins = [\n${entries.join("\n")}\n];\n`;
|
|
155
|
+
},
|
|
156
|
+
};
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
// pluginDirs returns the absolute directories the plugin entries live in, so
|
|
160
|
+
// the dev server's fs.allow can include them (they sit in the project's
|
|
161
|
+
// node_modules, outside the editor's app root).
|
|
162
|
+
export function pluginDirs(resolved) {
|
|
163
|
+
return [...new Set(resolved.map((p) => path.dirname(p.entry)))];
|
|
164
|
+
}
|
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
// The dev server's token endpoint.
|
|
2
|
+
//
|
|
3
|
+
// `gogitcms-editor dev` serves the editor from a throwaway localhost origin, which
|
|
4
|
+
// the server's PKCE redirect allowlist will never contain (it admits the
|
|
5
|
+
// same-origin /app path and editor deployments registered on a workspace — see
|
|
6
|
+
// validRedirectURI in packages/rest/v1.0.0/auth_handlers.go). Rather than ask
|
|
7
|
+
// people to register every dev port, the CLI lends the SPA the session it
|
|
8
|
+
// already has.
|
|
9
|
+
//
|
|
10
|
+
// That makes this endpoint a bearer-token dispenser, so it is fenced in:
|
|
11
|
+
// * the dev server binds to the requested host (localhost by default), and
|
|
12
|
+
// * requests must come from a loopback Host and carry no cross-site Origin,
|
|
13
|
+
// which keeps a page on some other origin in the same browser from fetching a
|
|
14
|
+
// token out of it.
|
|
15
|
+
|
|
16
|
+
import * as api from "./api.mjs";
|
|
17
|
+
|
|
18
|
+
export const TOKEN_PATH = "/__gitcms/token";
|
|
19
|
+
|
|
20
|
+
function isLoopbackHost(host = "") {
|
|
21
|
+
const name = host.replace(/:\d+$/, "").replace(/^\[|\]$/g, "");
|
|
22
|
+
return name === "localhost" || name === "127.0.0.1" || name === "::1";
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
// originAllowed rejects a cross-site fetch. Same-origin requests from the SPA
|
|
26
|
+
// either omit Origin or send the dev server's own, so anything else — a page on
|
|
27
|
+
// another site trying to read a token out of the dev server — is refused.
|
|
28
|
+
function originAllowed(req) {
|
|
29
|
+
const origin = req.headers.origin;
|
|
30
|
+
if (!origin) return true;
|
|
31
|
+
try {
|
|
32
|
+
return isLoopbackHost(new URL(origin).host) && new URL(origin).host === req.headers.host;
|
|
33
|
+
} catch {
|
|
34
|
+
return false;
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
// localTokenBroker serves the local server's session token instead of a hosted
|
|
39
|
+
// access token. Same endpoint, same loopback fencing — the SPA's boot path
|
|
40
|
+
// (Root() in App.tsx) prefers a brokered token over the PKCE bounce, so local
|
|
41
|
+
// mode lands on "authed" without touching an issuer that does not exist.
|
|
42
|
+
export function localTokenBroker(token) {
|
|
43
|
+
return {
|
|
44
|
+
name: "gitcms-local-token-broker",
|
|
45
|
+
configureServer(server) {
|
|
46
|
+
server.middlewares.use(TOKEN_PATH, (req, res) => {
|
|
47
|
+
if (!isLoopbackHost(req.headers.host) || !originAllowed(req)) {
|
|
48
|
+
res.statusCode = 403;
|
|
49
|
+
res.end(JSON.stringify({ error: "forbidden" }));
|
|
50
|
+
return;
|
|
51
|
+
}
|
|
52
|
+
res.setHeader("Content-Type", "application/json");
|
|
53
|
+
res.setHeader("Cache-Control", "no-store");
|
|
54
|
+
res.end(JSON.stringify({ accessToken: token }));
|
|
55
|
+
});
|
|
56
|
+
},
|
|
57
|
+
};
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
// tokenBroker is a Vite plugin serving a fresh access token for apiUrl.
|
|
61
|
+
export function tokenBroker(apiUrl) {
|
|
62
|
+
return {
|
|
63
|
+
name: "gitcms-token-broker",
|
|
64
|
+
configureServer(server) {
|
|
65
|
+
server.middlewares.use(TOKEN_PATH, async (req, res) => {
|
|
66
|
+
if (!isLoopbackHost(req.headers.host) || !originAllowed(req)) {
|
|
67
|
+
res.statusCode = 403;
|
|
68
|
+
res.end(JSON.stringify({ error: "forbidden" }));
|
|
69
|
+
return;
|
|
70
|
+
}
|
|
71
|
+
try {
|
|
72
|
+
const accessToken = await api.accessToken(apiUrl);
|
|
73
|
+
res.setHeader("Content-Type", "application/json");
|
|
74
|
+
// A short-lived credential must not linger in any cache.
|
|
75
|
+
res.setHeader("Cache-Control", "no-store");
|
|
76
|
+
res.end(JSON.stringify({ accessToken }));
|
|
77
|
+
} catch (e) {
|
|
78
|
+
res.statusCode = e instanceof api.NotLoggedInError ? 401 : 502;
|
|
79
|
+
res.setHeader("Content-Type", "application/json");
|
|
80
|
+
res.end(JSON.stringify({ error: e.message }));
|
|
81
|
+
}
|
|
82
|
+
});
|
|
83
|
+
},
|
|
84
|
+
};
|
|
85
|
+
}
|
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
// Interactive pickers. Each one tears the sticky block down first — terminal-kit
|
|
2
|
+
// draws its menus at the cursor and manages the lines itself, so leaving our
|
|
3
|
+
// status line pinned underneath would corrupt both — and hands stdin back
|
|
4
|
+
// afterwards via withInput (see releaseInput for why that is mandatory).
|
|
5
|
+
//
|
|
6
|
+
// None of these work without a TTY. Callers must resolve the value from a flag
|
|
7
|
+
// in that case; `requireInteractive` turns the mistake into a clear error rather
|
|
8
|
+
// than a hang on stdin.
|
|
9
|
+
|
|
10
|
+
import { getTerm, isInteractive, releaseInput } from "./screen.mjs";
|
|
11
|
+
import { THEME } from "./theme.mjs";
|
|
12
|
+
|
|
13
|
+
export class NotInteractiveError extends Error {
|
|
14
|
+
constructor(what, flag) {
|
|
15
|
+
super(`${what} must be chosen interactively — pass ${flag} when running without a terminal.`);
|
|
16
|
+
this.name = "NotInteractiveError";
|
|
17
|
+
}
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export function requireInteractive(what, flag) {
|
|
21
|
+
if (!isInteractive()) throw new NotInteractiveError(what, flag);
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
// withInput runs one terminal-kit widget and guarantees stdin is handed back,
|
|
25
|
+
// whatever happens. While input is grabbed the terminal is in raw mode, so
|
|
26
|
+
// Ctrl-C is delivered as a key event instead of a signal — the listener below
|
|
27
|
+
// restores that behaviour by hand, or the user could not abort a prompt at all.
|
|
28
|
+
// 130 is the conventional status for "terminated by Ctrl-C".
|
|
29
|
+
async function withInput(screen, fn) {
|
|
30
|
+
const term = getTerm();
|
|
31
|
+
const onKey = (name) => {
|
|
32
|
+
if (name !== "CTRL_C") return;
|
|
33
|
+
screen.finish();
|
|
34
|
+
term("\n");
|
|
35
|
+
process.exit(130);
|
|
36
|
+
};
|
|
37
|
+
term.on("key", onKey);
|
|
38
|
+
try {
|
|
39
|
+
return await fn(term);
|
|
40
|
+
} finally {
|
|
41
|
+
term.off("key", onKey);
|
|
42
|
+
releaseInput();
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
// select renders a single-column menu and resolves the chosen item. `items` are
|
|
47
|
+
// { label, value } pairs; Escape cancels, which resolves to null.
|
|
48
|
+
export async function select(screen, title, items) {
|
|
49
|
+
requireInteractive(title, "the matching flag");
|
|
50
|
+
screen.clearSticky();
|
|
51
|
+
const term = getTerm();
|
|
52
|
+
term.colorRgbHex(THEME.dim, title);
|
|
53
|
+
term("\n");
|
|
54
|
+
const result = await withInput(screen, (t) =>
|
|
55
|
+
t.singleColumnMenu(
|
|
56
|
+
items.map((i) => i.label),
|
|
57
|
+
{ cancelable: true, selectedStyle: t.bgBrightWhite.black, leftPadding: " ", selectedLeftPadding: "› " },
|
|
58
|
+
).promise,
|
|
59
|
+
);
|
|
60
|
+
term("\n");
|
|
61
|
+
screen.drawSticky();
|
|
62
|
+
if (result.canceled || result.selectedIndex == null) return null;
|
|
63
|
+
return items[result.selectedIndex].value;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
// ask reads a line of input with an optional default.
|
|
67
|
+
export async function ask(screen, label, { defaultValue = "" } = {}) {
|
|
68
|
+
requireInteractive(label, "the matching flag");
|
|
69
|
+
screen.clearSticky();
|
|
70
|
+
const term = getTerm();
|
|
71
|
+
term.colorRgbHex(THEME.dim, `${label} `);
|
|
72
|
+
const value = await withInput(screen, (t) =>
|
|
73
|
+
t.inputField({ default: defaultValue, cancelable: true }).promise,
|
|
74
|
+
);
|
|
75
|
+
term("\n");
|
|
76
|
+
screen.drawSticky();
|
|
77
|
+
return value == null ? null : value.trim();
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
// confirm asks a yes/no question, defaulting to yes.
|
|
81
|
+
export async function confirm(screen, question, defaultYes = true) {
|
|
82
|
+
if (!isInteractive()) return defaultYes;
|
|
83
|
+
screen.clearSticky();
|
|
84
|
+
const term = getTerm();
|
|
85
|
+
term.colorRgbHex(THEME.dim, `${question} ${defaultYes ? "[Y/n]" : "[y/N]"} `);
|
|
86
|
+
const answer = await withInput(screen, (t) =>
|
|
87
|
+
t.yesOrNo({ yes: ["y", "Y", "ENTER"], no: ["n", "N"] }).promise,
|
|
88
|
+
);
|
|
89
|
+
term("\n");
|
|
90
|
+
screen.drawSticky();
|
|
91
|
+
return answer;
|
|
92
|
+
}
|
|
@@ -0,0 +1,282 @@
|
|
|
1
|
+
// The screen: header, streamed output lines, and the sticky bottom block
|
|
2
|
+
// (optional spinner line + status line) from the terminal onboarding design.
|
|
3
|
+
//
|
|
4
|
+
// Output is written as ordinary lines so scrollback still works; the sticky
|
|
5
|
+
// block lives on the last one or two lines and is erased and redrawn around each
|
|
6
|
+
// write. Everything degrades to plain `console.log` when stdout is not a TTY, so
|
|
7
|
+
// piping the CLI into a file or a CI log yields clean text.
|
|
8
|
+
|
|
9
|
+
import tk from "terminal-kit";
|
|
10
|
+
import { classStyle, STEPS, THEME } from "./theme.mjs";
|
|
11
|
+
|
|
12
|
+
// terminal-kit resolves the terminal on first access to `tk.terminal`, and on
|
|
13
|
+
// macOS its detection does path.parse() on TERM/COLORTERM/TERM_PROGRAM without
|
|
14
|
+
// checking that any of them is set — which throws under a bare pty (`script`,
|
|
15
|
+
// some CI runners, cron). Acquire it lazily, so a non-TTY run never touches it
|
|
16
|
+
// at all, and name a terminal when the environment names none.
|
|
17
|
+
let _term = null;
|
|
18
|
+
|
|
19
|
+
export function getTerm() {
|
|
20
|
+
if (!_term) {
|
|
21
|
+
if (!process.env.TERM && !process.env.COLORTERM && !process.env.TERM_PROGRAM) {
|
|
22
|
+
process.env.TERM = "xterm-256color";
|
|
23
|
+
}
|
|
24
|
+
_term = tk.terminal;
|
|
25
|
+
}
|
|
26
|
+
return _term;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
const SPINNER_FRAMES = ["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"];
|
|
30
|
+
|
|
31
|
+
// Line cadence from the design, so output reads as it arrives rather than
|
|
32
|
+
// dumping at once. Suppressed off a TTY and under GITCMS_NO_ANIMATION (tests).
|
|
33
|
+
const DELAY = { default: 155, ok: 280, blank: 55 };
|
|
34
|
+
|
|
35
|
+
export function isInteractive() {
|
|
36
|
+
return Boolean(process.stdout.isTTY) && !process.env.CI && !process.env.GITCMS_NO_TTY;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
function animated() {
|
|
40
|
+
return isInteractive() && !process.env.GITCMS_NO_ANIMATION;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
|
|
44
|
+
|
|
45
|
+
export class Screen {
|
|
46
|
+
constructor() {
|
|
47
|
+
// How many lines the sticky block currently occupies (0 = not drawn).
|
|
48
|
+
this.sticky = 0;
|
|
49
|
+
this.spinnerText = "";
|
|
50
|
+
this.spinnerFrame = 0;
|
|
51
|
+
this.spinnerTimer = null;
|
|
52
|
+
this.status = {
|
|
53
|
+
mode: "READY",
|
|
54
|
+
auth: "not logged in",
|
|
55
|
+
authed: false,
|
|
56
|
+
cwd: shortCwd(),
|
|
57
|
+
hint: "",
|
|
58
|
+
};
|
|
59
|
+
this.done = [false, false, false];
|
|
60
|
+
this.interactive = isInteractive();
|
|
61
|
+
// Set by a long-running command (dev) that needs to shut something down on
|
|
62
|
+
// Ctrl-C instead of being killed where it stands. See bin/gogitcms-editor.mjs.
|
|
63
|
+
this.onInterrupt = null;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
// ---- output -------------------------------------------------------------
|
|
67
|
+
|
|
68
|
+
// line writes one output line in the style of its class (see theme.mjs).
|
|
69
|
+
line(text = "", cls = "out") {
|
|
70
|
+
if (!this.interactive) {
|
|
71
|
+
console.log(text);
|
|
72
|
+
return;
|
|
73
|
+
}
|
|
74
|
+
this.clearSticky();
|
|
75
|
+
const style = classStyle(cls);
|
|
76
|
+
const body = style.spaced ? [...String(text)].join(" ") : String(text);
|
|
77
|
+
if (style.bold) getTerm().bold();
|
|
78
|
+
getTerm().colorRgbHex(style.color, body);
|
|
79
|
+
getTerm().styleReset();
|
|
80
|
+
getTerm()("\n");
|
|
81
|
+
this.drawSticky();
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
// stream writes lines with the design's pacing. Each entry is a string or
|
|
85
|
+
// { text, cls }.
|
|
86
|
+
async stream(lines) {
|
|
87
|
+
for (const entry of lines) {
|
|
88
|
+
const { text = "", cls = "out" } = typeof entry === "string" ? { text: entry } : entry;
|
|
89
|
+
this.line(text, cls);
|
|
90
|
+
if (!animated()) continue;
|
|
91
|
+
await sleep(text.trim() === "" ? DELAY.blank : DELAY[cls] || DELAY.default);
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
ok(text) {
|
|
96
|
+
this.line(text.startsWith("✓") ? text : `✓ ${text}`, "ok");
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
fail(text) {
|
|
100
|
+
this.line(text.startsWith("✗") ? text : `✗ ${text}`, "err");
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
// header prints the branded block and the step ticker: the top chrome of the
|
|
104
|
+
// design, drawn once per command rather than pinned.
|
|
105
|
+
header(subtitle = "SETUP") {
|
|
106
|
+
if (!this.interactive) {
|
|
107
|
+
console.log(`gogitcms-editor — ${subtitle.toLowerCase()}`);
|
|
108
|
+
return;
|
|
109
|
+
}
|
|
110
|
+
this.clearSticky();
|
|
111
|
+
getTerm().colorRgbHex(THEME.faint, "GO·GIT CMS ");
|
|
112
|
+
getTerm().colorRgbHex(THEME.border, "/ ");
|
|
113
|
+
getTerm().colorRgbHex(THEME.dim, subtitle);
|
|
114
|
+
const doneCount = this.done.filter(Boolean).length;
|
|
115
|
+
const progress = `${doneCount} / ${STEPS.length} complete`;
|
|
116
|
+
const pad = Math.max(1, getTerm().width - 13 - subtitle.length - progress.length);
|
|
117
|
+
getTerm()(" ".repeat(pad));
|
|
118
|
+
getTerm().colorRgbHex(THEME.faint, progress);
|
|
119
|
+
getTerm()("\n");
|
|
120
|
+
this.steps();
|
|
121
|
+
getTerm()("\n");
|
|
122
|
+
this.drawSticky();
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
// steps renders the 01/02/03 ticker, marking completed steps with a check and
|
|
126
|
+
// the current one as active.
|
|
127
|
+
steps() {
|
|
128
|
+
const current = this.done.findIndex((d) => !d);
|
|
129
|
+
STEPS.forEach((s, i) => {
|
|
130
|
+
const isDone = this.done[i];
|
|
131
|
+
const active = i === current;
|
|
132
|
+
const mark = isDone ? "✓" : s.n;
|
|
133
|
+
const color = isDone || active ? THEME.fg : THEME.dim;
|
|
134
|
+
if (isDone || active) getTerm().bold();
|
|
135
|
+
getTerm().colorRgbHex(color, `${mark} ${s.label}`);
|
|
136
|
+
getTerm().styleReset();
|
|
137
|
+
if (i < STEPS.length - 1) getTerm().colorRgbHex(THEME.border, " · ");
|
|
138
|
+
});
|
|
139
|
+
getTerm()("\n");
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
// ---- status -------------------------------------------------------------
|
|
143
|
+
|
|
144
|
+
setStatus(patch) {
|
|
145
|
+
Object.assign(this.status, patch);
|
|
146
|
+
if (this.interactive) {
|
|
147
|
+
this.clearSticky();
|
|
148
|
+
this.drawSticky();
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
setDone(index, value = true) {
|
|
153
|
+
this.done[index] = value;
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
// ---- spinner ------------------------------------------------------------
|
|
157
|
+
|
|
158
|
+
// startSpinner pins an animated line above the status line. Returns a stop
|
|
159
|
+
// function that clears it; callers usually follow with ok()/fail().
|
|
160
|
+
startSpinner(text) {
|
|
161
|
+
this.spinnerText = text;
|
|
162
|
+
if (!this.interactive) {
|
|
163
|
+
console.log(text);
|
|
164
|
+
return () => {};
|
|
165
|
+
}
|
|
166
|
+
this.clearSticky();
|
|
167
|
+
this.drawSticky();
|
|
168
|
+
if (animated()) {
|
|
169
|
+
this.spinnerTimer = setInterval(() => {
|
|
170
|
+
this.spinnerFrame = (this.spinnerFrame + 1) % SPINNER_FRAMES.length;
|
|
171
|
+
this.clearSticky();
|
|
172
|
+
this.drawSticky();
|
|
173
|
+
}, 90);
|
|
174
|
+
// A pinned spinner must never hold the process open on its own.
|
|
175
|
+
this.spinnerTimer.unref?.();
|
|
176
|
+
}
|
|
177
|
+
return () => this.stopSpinner();
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
stopSpinner() {
|
|
181
|
+
if (this.spinnerTimer) {
|
|
182
|
+
clearInterval(this.spinnerTimer);
|
|
183
|
+
this.spinnerTimer = null;
|
|
184
|
+
}
|
|
185
|
+
if (!this.spinnerText) return;
|
|
186
|
+
this.spinnerText = "";
|
|
187
|
+
if (this.interactive) {
|
|
188
|
+
this.clearSticky();
|
|
189
|
+
this.drawSticky();
|
|
190
|
+
}
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
// ---- sticky block -------------------------------------------------------
|
|
194
|
+
|
|
195
|
+
// drawSticky renders the spinner line (when active) and the status line at the
|
|
196
|
+
// bottom, leaving the cursor parked at the start of the status line so the
|
|
197
|
+
// next clearSticky knows exactly what to erase.
|
|
198
|
+
drawSticky() {
|
|
199
|
+
if (!this.interactive) return;
|
|
200
|
+
let lines = 0;
|
|
201
|
+
if (this.spinnerText) {
|
|
202
|
+
getTerm().colorRgbHex(THEME.dim, `${SPINNER_FRAMES[this.spinnerFrame]} ${this.spinnerText}`);
|
|
203
|
+
getTerm()("\n");
|
|
204
|
+
lines++;
|
|
205
|
+
}
|
|
206
|
+
this.statusLine();
|
|
207
|
+
lines++;
|
|
208
|
+
this.sticky = lines;
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
// clearSticky erases the sticky block and returns the cursor to its first
|
|
212
|
+
// line, which is where the next output line belongs.
|
|
213
|
+
clearSticky() {
|
|
214
|
+
if (!this.interactive || this.sticky === 0) return;
|
|
215
|
+
for (let i = 0; i < this.sticky; i++) {
|
|
216
|
+
getTerm().column(1);
|
|
217
|
+
getTerm().eraseLine();
|
|
218
|
+
if (i < this.sticky - 1) getTerm().up(1);
|
|
219
|
+
}
|
|
220
|
+
this.sticky = 0;
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
// statusLine draws the mode pill, auth state, working directory and next-step
|
|
224
|
+
// hint — the bottom bar of the design. No trailing newline: it is the last
|
|
225
|
+
// thing on screen.
|
|
226
|
+
statusLine() {
|
|
227
|
+
const s = this.status;
|
|
228
|
+
const pillBg =
|
|
229
|
+
s.mode === "DONE" ? THEME.ok : s.mode === "READY" ? THEME.pill : THEME.fg;
|
|
230
|
+
const pillFg = s.mode === "READY" ? THEME.fg : THEME.bg;
|
|
231
|
+
getTerm().bgColorRgbHex(pillBg);
|
|
232
|
+
getTerm().colorRgbHex(pillFg, ` ${s.mode} `);
|
|
233
|
+
getTerm().styleReset();
|
|
234
|
+
getTerm().colorRgbHex(s.authed ? THEME.ok : THEME.faint, " ● ");
|
|
235
|
+
getTerm().colorRgbHex(THEME.dim, s.auth);
|
|
236
|
+
getTerm().colorRgbHex(THEME.faint, ` ${s.cwd}`);
|
|
237
|
+
if (s.hint) {
|
|
238
|
+
const left = ` ${s.mode} ● ${s.auth} ${s.cwd}`.length;
|
|
239
|
+
const pad = Math.max(1, getTerm().width - left - s.hint.length - 1);
|
|
240
|
+
getTerm()(" ".repeat(pad));
|
|
241
|
+
getTerm().colorRgbHex(THEME.faint, s.hint);
|
|
242
|
+
}
|
|
243
|
+
getTerm().styleReset();
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
// done tears the sticky block down so the shell prompt returns to a clean
|
|
247
|
+
// line. Every command must call this before exiting.
|
|
248
|
+
finish() {
|
|
249
|
+
this.stopSpinner();
|
|
250
|
+
if (!this.interactive) return;
|
|
251
|
+
this.clearSticky();
|
|
252
|
+
getTerm().styleReset();
|
|
253
|
+
releaseInput();
|
|
254
|
+
}
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
// releaseInput hands stdin back to the shell. terminal-kit's widgets (menus,
|
|
258
|
+
// input fields, yes/no) grab input and never let go, which leaves the terminal
|
|
259
|
+
// in raw mode: the process then hangs after its last line — a grabbed stdin is
|
|
260
|
+
// an active handle, so the event loop never drains — and Ctrl-C arrives as a key
|
|
261
|
+
// event rather than SIGINT, so it cannot kill anything either. Every path that
|
|
262
|
+
// grabs input must end here.
|
|
263
|
+
export function releaseInput() {
|
|
264
|
+
if (!_term) return;
|
|
265
|
+
try {
|
|
266
|
+
_term.grabInput(false);
|
|
267
|
+
_term.hideCursor(false);
|
|
268
|
+
} catch {
|
|
269
|
+
// Restoring a terminal that was never grabbed is a no-op, not an error.
|
|
270
|
+
}
|
|
271
|
+
// grabInput(false) removes terminal-kit's listener but leaves stdin resumed;
|
|
272
|
+
// pausing it releases the last handle keeping the process alive.
|
|
273
|
+
if (process.stdin.isTTY) process.stdin.pause();
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
// shortCwd renders the working directory the way a shell prompt does.
|
|
277
|
+
function shortCwd() {
|
|
278
|
+
const cwd = process.cwd();
|
|
279
|
+
const home = process.env.HOME || "";
|
|
280
|
+
return home && cwd.startsWith(home) ? `~${cwd.slice(home.length)}` : cwd;
|
|
281
|
+
}
|
|
282
|
+
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
// The palette and line taxonomy from the terminal onboarding design. Colours are
|
|
2
|
+
// applied with terminal-kit's truecolor helpers; on a terminal without truecolor
|
|
3
|
+
// terminal-kit degrades them to the nearest palette entry on its own.
|
|
4
|
+
|
|
5
|
+
export const THEME = {
|
|
6
|
+
fg: "#ededed",
|
|
7
|
+
dim: "#808080",
|
|
8
|
+
faint: "#5c5c5c",
|
|
9
|
+
ok: "#5fce8a",
|
|
10
|
+
err: "#f08b82",
|
|
11
|
+
border: "#242424",
|
|
12
|
+
bg: "#0a0a0a",
|
|
13
|
+
pill: "#3d3d3d",
|
|
14
|
+
};
|
|
15
|
+
|
|
16
|
+
// Line classes, mirroring resolveLine() in the design. Each maps to a colour and
|
|
17
|
+
// whether it is emphasised; `code` is the big one-time code, `fileline` is a
|
|
18
|
+
// rendered config file body.
|
|
19
|
+
const CLASSES = {
|
|
20
|
+
out: { color: THEME.fg },
|
|
21
|
+
dim: { color: THEME.dim },
|
|
22
|
+
ok: { color: THEME.ok },
|
|
23
|
+
err: { color: THEME.err },
|
|
24
|
+
label: { color: THEME.dim, bold: true, caps: true },
|
|
25
|
+
code: { color: THEME.fg, bold: true, spaced: true },
|
|
26
|
+
filehdr: { color: THEME.dim, bold: true },
|
|
27
|
+
fileline: { color: THEME.fg },
|
|
28
|
+
in: { color: THEME.fg, bold: true },
|
|
29
|
+
};
|
|
30
|
+
|
|
31
|
+
export function classStyle(cls) {
|
|
32
|
+
return CLASSES[cls] || CLASSES.out;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
// The three onboarding steps, in the order the design walks them.
|
|
36
|
+
export const STEPS = [
|
|
37
|
+
{ n: "01", label: "auth", cmd: "gogitcms-editor login" },
|
|
38
|
+
{ n: "02", label: "config", cmd: "gogitcms-editor init" },
|
|
39
|
+
{ n: "03", label: "run", cmd: "gogitcms-editor dev" },
|
|
40
|
+
];
|