@plutocms/core 0.47.0 → 0.48.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 (45) hide show
  1. package/CHANGELOG.md +18 -0
  2. package/FEATURES.md +15 -4
  3. package/README.md +5 -5
  4. package/app/components/Pluto/Admin/PlutoAdminBackendBadge.vue +84 -0
  5. package/app/components/Pluto/Admin/PlutoAdminNavbar.vue +7 -0
  6. package/app/components/Pluto/Github/PlutoGithubBackendDetails.vue +180 -0
  7. package/app/components/Pluto/Github/PlutoGithubBuildStatus.vue +17 -4
  8. package/app/components/Pluto/Github/PlutoGithubDeployPage.vue +472 -0
  9. package/app/components/Pluto/Github/PlutoGithubSetupPanel.vue +1 -1
  10. package/app/components/Pluto/Github/PlutoGithubUsersPage.vue +214 -0
  11. package/app/components/Pluto/PlutoHtml.vue +8 -2
  12. package/app/composables/internal/async-component.ts +9 -0
  13. package/app/composables/pluto-extension.ts +4 -0
  14. package/app/composables/pluto-navbar.ts +25 -0
  15. package/app/composables/pluto-registry.ts +1 -0
  16. package/i18n/locales/en.json +164 -2
  17. package/i18n/locales/pt-BR.json +164 -2
  18. package/modules/pluto-github/index.ts +127 -51
  19. package/modules/pluto-github/options.ts +18 -5
  20. package/modules/pluto-github/runtime/app/composables/pluto-github-local-git.ts +50 -0
  21. package/modules/pluto-github/runtime/app/composables/pluto-github-session.ts +8 -1
  22. package/modules/pluto-github/runtime/app/plugins/pluto-github.ts +84 -1
  23. package/modules/pluto-github/runtime/server/adapter.ts +26 -7
  24. package/modules/pluto-github/runtime/server/api/deploy/pages.post.ts +5 -0
  25. package/modules/pluto-github/runtime/server/api/deploy/status.get.ts +5 -0
  26. package/modules/pluto-github/runtime/server/api/local/push.post.ts +5 -0
  27. package/modules/pluto-github/runtime/server/api/local/status.get.ts +5 -0
  28. package/modules/pluto-github/runtime/server/api/users.get.ts +5 -0
  29. package/modules/pluto-github/runtime/server/store/github.ts +4 -1
  30. package/modules/pluto-github/runtime/server/store/local-git.ts +145 -0
  31. package/modules/pluto-github/runtime/server/store/memory.ts +4 -1
  32. package/modules/pluto-github/runtime/server/utils/build-status.ts +16 -7
  33. package/modules/pluto-github/runtime/server/utils/deploy.ts +204 -0
  34. package/modules/pluto-github/runtime/server/utils/github-setup.ts +1 -3
  35. package/modules/pluto-github/runtime/server/utils/github-users.ts +120 -0
  36. package/modules/pluto-github/runtime/server/utils/local-git-routes.ts +95 -0
  37. package/modules/pluto-github/runtime/shared/build-status.ts +26 -0
  38. package/modules/pluto-github/runtime/shared/client-rules.ts +16 -0
  39. package/modules/pluto-github/runtime/shared/deploy.ts +43 -0
  40. package/modules/pluto-github/runtime/shared/pages-workflow.ts +138 -0
  41. package/modules/pluto-github/runtime/shared/repo-users.ts +97 -0
  42. package/package.json +1 -1
  43. package/shared/types/github.d.ts +2 -0
  44. package/shared/types/registry.ts +22 -0
  45. package/shared/utils/html-base.ts +23 -0
@@ -55,6 +55,16 @@ export default defineNuxtModule<PlutoGithubOptions>({
55
55
  return meta?.name === 'supabase' || meta?.plutoBackend === true
56
56
  }) || modules.includes('@nuxtjs/supabase')
57
57
 
58
+ // A static build (`nuxt generate`, or a static Nitro preset such as `github_pages`): the pages
59
+ // are prerendered from the files of the checkout. The output has no server, so the module adds
60
+ // no sign-in, admin or write route.
61
+ // `_generate` is the internal flag of `nuxt generate`. It is not in the public types.
62
+ const generate = (nuxt.options as { _generate?: boolean })._generate
63
+ const isStatic =
64
+ !nuxt.options.dev &&
65
+ (Boolean(generate || nuxt.options.nitro?.static) ||
66
+ isStaticPreset(nuxt.options.nitro?.preset ?? process.env.NITRO_PRESET))
67
+
58
68
  const { enabled, resolved, warnings, errors } = resolvePlutoGithubOptions(
59
69
  options,
60
70
  {
@@ -65,6 +75,7 @@ export default defineNuxtModule<PlutoGithubOptions>({
65
75
  gitOrigin: nuxt.options.dev
66
76
  ? readGitOrigin(nuxt.options.rootDir)
67
77
  : undefined,
78
+ static: isStatic,
68
79
  }
69
80
  )
70
81
 
@@ -98,7 +109,8 @@ export default defineNuxtModule<PlutoGithubOptions>({
98
109
  Object.assign(secrets, {
99
110
  apiUrl: resolved.github.apiUrl,
100
111
  skipDeploy: resolved.commit.skipDeploy,
101
- localCommit: resolved.commit.local,
112
+ // A static build only reads.
113
+ localCommit: resolved.commit.local && !isStatic,
102
114
  headTtl: resolved.cache.headTtl,
103
115
  cacheStorage: resolved.cache.storage,
104
116
  postType: resolved.postType,
@@ -118,6 +130,8 @@ export default defineNuxtModule<PlutoGithubOptions>({
118
130
  dir: resolved.dir,
119
131
  postType: resolved.postType,
120
132
  skipDeploy: resolved.commit.skipDeploy,
133
+ // A static build has no server: the client asks for no session.
134
+ static: isStatic,
121
135
  }
122
136
  // The media library of core uses the GitHub driver (runtime/server/media.ts).
123
137
  const media = nuxt.options.runtimeConfig.public.plutoMedia as
@@ -129,31 +143,55 @@ export default defineNuxtModule<PlutoGithubOptions>({
129
143
 
130
144
  const resolver = createResolver(import.meta.url)
131
145
  const server = (file: string) => resolver.resolve('./runtime/server', file)
146
+ // A route of the server build. A static build has no server, so it adds none.
147
+ const addRoute = (route: string, method: 'get' | 'post', file: string) => {
148
+ if (!isStatic) {
149
+ addServerHandler({ route, method, handler: server(file) })
150
+ }
151
+ }
132
152
 
133
- // Server: the sign-in routes, and the Nitro plugin that
134
- // registers the store, the adapter and the post type.
135
- addServerHandler({
136
- route: '/api/_pluto/auth/github/login',
137
- method: 'get',
138
- handler: server('api/auth/github/login.get'),
139
- })
140
- addServerHandler({
141
- route: '/api/_pluto/auth/github/callback',
142
- method: 'get',
143
- handler: server('api/auth/github/callback.get'),
144
- })
145
- addServerHandler({
146
- route: '/api/_pluto/auth/logout',
147
- method: 'post',
148
- handler: server('api/auth/logout.post'),
149
- })
150
- addServerHandler({
151
- route: '/api/_pluto/auth/session',
152
- method: 'get',
153
- handler: server('api/auth/session.get'),
154
- })
153
+ // The Nitro plugin registers the store, the adapter and the post type. A static build needs
154
+ // it too: the public API reads the files while the pages prerender.
155
155
  addServerPlugin(server('plugins/pluto-github'))
156
156
 
157
+ if (isStatic) {
158
+ // A project site of GitHub Pages lives under `/<repo>/`. The workflow gives the path in
159
+ // `PLUTO_BASE_URL`, and the module writes it in the build config and the app config.
160
+ // `NUXT_APP_BASE_URL` does not work here: the prerenderer reads it at run time too, the
161
+ // paths then do not match, and every page answers a redirect.
162
+ const basePath = process.env.PLUTO_BASE_URL?.trim()
163
+ if (basePath) {
164
+ const baseURL = normalizeBasePath(basePath)
165
+ nuxt.options.app.baseURL = baseURL
166
+ nuxt.options.runtimeConfig.app = {
167
+ ...nuxt.options.runtimeConfig.app,
168
+ baseURL,
169
+ }
170
+ }
171
+ // The admin, the API routes of the admin and the preview pages stay out of the output.
172
+ nuxt.options.nitro.prerender ||= {}
173
+ nuxt.options.nitro.prerender.ignore = [
174
+ ...(nuxt.options.nitro.prerender.ignore ?? []),
175
+ '/admin',
176
+ '/api/_pluto',
177
+ '/_pluto',
178
+ ]
179
+ }
180
+
181
+ // Server: the sign-in routes.
182
+ addRoute(
183
+ '/api/_pluto/auth/github/login',
184
+ 'get',
185
+ 'api/auth/github/login.get'
186
+ )
187
+ addRoute(
188
+ '/api/_pluto/auth/github/callback',
189
+ 'get',
190
+ 'api/auth/github/callback.get'
191
+ )
192
+ addRoute('/api/_pluto/auth/logout', 'post', 'api/auth/logout.post')
193
+ addRoute('/api/_pluto/auth/session', 'get', 'api/auth/session.get')
194
+
157
195
  // Client: the extension plugin and the session composable. Core owns the
158
196
  // admin routes, the login page and the route guard.
159
197
  addPlugin({ src: resolver.resolve('./runtime/app/plugins/pluto-github') })
@@ -183,10 +221,27 @@ export default defineNuxtModule<PlutoGithubOptions>({
183
221
  const settingsFile = resolver.resolve(
184
222
  '../../app/components/Pluto/Github/PlutoGithubSettingsPage.vue'
185
223
  )
224
+ // The Deploy tab of Settings (`/admin/settings/deploy`): the hosts, and GitHub Pages.
225
+ const deployFile = resolver.resolve(
226
+ '../../app/components/Pluto/Github/PlutoGithubDeployPage.vue'
227
+ )
228
+ // The Users page (`/admin/users`): the people with write access to the repo, read only.
229
+ const usersFile = resolver.resolve(
230
+ '../../app/components/Pluto/Github/PlutoGithubUsersPage.vue'
231
+ )
186
232
  extendPages((pages) => {
187
- const settings = pages
188
- .find((page) => page.path === '/admin')
189
- ?.children?.find((page) => page.path === 'settings')
233
+ const admin = pages.find((page) => page.path === '/admin')
234
+ if (admin) {
235
+ admin.children ||= []
236
+ admin.children.push({
237
+ name: 'admin-users',
238
+ path: 'users',
239
+ file: usersFile,
240
+ })
241
+ } else {
242
+ logger.warn('Could not find the admin page. The Users page is off.')
243
+ }
244
+ const settings = admin?.children?.find((page) => page.path === 'settings')
190
245
  if (settings) {
191
246
  settings.children ||= []
192
247
  settings.children.push({
@@ -194,6 +249,11 @@ export default defineNuxtModule<PlutoGithubOptions>({
194
249
  path: 'github',
195
250
  file: settingsFile,
196
251
  })
252
+ settings.children.push({
253
+ name: 'admin-settings-deploy',
254
+ path: 'deploy',
255
+ file: deployFile,
256
+ })
197
257
  } else {
198
258
  logger.warn('Could not find the Settings page. The GitHub tab is off.')
199
259
  }
@@ -201,37 +261,36 @@ export default defineNuxtModule<PlutoGithubOptions>({
201
261
 
202
262
  // Setup routes: the checks and the App Manifest flow. See the git-cms skill.
203
263
  // They are static, so they win over the `:type/:slug` route below.
204
- addServerHandler({
205
- route: '/api/_pluto/github/setup/status',
206
- method: 'get',
207
- handler: server('api/setup/status.get'),
208
- })
209
- addServerHandler({
210
- route: '/api/_pluto/github/setup/start',
211
- method: 'get',
212
- handler: server('api/setup/start.get'),
213
- })
214
- addServerHandler({
215
- route: '/api/_pluto/github/setup/callback',
216
- method: 'get',
217
- handler: server('api/setup/callback.get'),
218
- })
264
+ addRoute('/api/_pluto/github/setup/status', 'get', 'api/setup/status.get')
265
+ addRoute('/api/_pluto/github/setup/start', 'get', 'api/setup/start.get')
266
+ addRoute(
267
+ '/api/_pluto/github/setup/callback',
268
+ 'get',
269
+ 'api/setup/callback.get'
270
+ )
219
271
 
220
272
  // The build status of the head commit. It needs a signed-in user.
221
- addServerHandler({
222
- route: '/api/_pluto/github/build-status',
223
- method: 'get',
224
- handler: server('api/build-status.get'),
225
- })
273
+ addRoute('/api/_pluto/github/build-status', 'get', 'api/build-status.get')
274
+
275
+ // The Deploy tab: the facts of the hosts (signed-in user), and the GitHub Pages setup (local
276
+ // mode on this computer only; the handler checks it).
277
+ addRoute('/api/_pluto/github/deploy/status', 'get', 'api/deploy/status.get')
278
+ addRoute('/api/_pluto/github/deploy/pages', 'post', 'api/deploy/pages.post')
279
+
280
+ // The people with write access to the repo, for the Users page. It needs a signed-in user.
281
+ addRoute('/api/_pluto/github/users', 'get', 'api/users.get')
282
+
283
+ // Local mode: the Git state of the project and the Push button of the backend badge.
284
+ // A dev build with the `fs` store only. The handlers also check the request.
285
+ if (nuxt.options.dev && resolved.storage === 'fs') {
286
+ addRoute('/api/_pluto/github/local/status', 'get', 'api/local/status.get')
287
+ addRoute('/api/_pluto/github/local/push', 'post', 'api/local/push.post')
288
+ }
226
289
 
227
290
  // The public reads of the posts go through the public API of core (`/api/pluto/v1/content/post`).
228
291
 
229
292
  // The media files that are in the repo but not in a deploy yet. See the media skill.
230
- addServerHandler({
231
- route: '/media/**',
232
- method: 'get',
233
- handler: server('routes/media.get'),
234
- })
293
+ addRoute('/media/**', 'get', 'routes/media.get')
235
294
 
236
295
  logger.info(
237
296
  `Standalone CMS on. Storage: ${resolved.storage}. Dir: ${resolved.dir}.`
@@ -245,3 +304,20 @@ function toCamelCase(name: string): string {
245
304
  .toLowerCase()
246
305
  .replace(/_([a-z])/g, (_match, char: string) => char.toUpperCase())
247
306
  }
307
+
308
+ /** The Nitro presets that write only static files: `static`, `github_pages`, `*_static`. */
309
+ export function isStaticPreset(preset: unknown): boolean {
310
+ if (typeof preset !== 'string') {
311
+ return false
312
+ }
313
+ const name = preset.toLowerCase().replaceAll('-', '_')
314
+ return (
315
+ name === 'static' || name === 'github_pages' || name.endsWith('_static')
316
+ )
317
+ }
318
+
319
+ /** `repo`, `/repo` and `/repo/` become `/repo/`. An empty path becomes `/`. */
320
+ export function normalizeBasePath(path: string): string {
321
+ const segments = path.split('/').filter(Boolean)
322
+ return segments.length > 0 ? `/${segments.join('/')}/` : '/'
323
+ }
@@ -7,12 +7,15 @@ export interface PlutoGithubOptions {
7
7
  * `false` turns it off.
8
8
  */
9
9
  enabled?: boolean | 'auto'
10
- /** Where the posts live. `fs` is valid in dev only. Default `fs` in dev, `github` in production. */
10
+ /**
11
+ * Where the posts live. `fs` is valid in dev and in a static build only. Default `fs` in dev and
12
+ * in a static build (`nuxt generate`), `github` in a server build.
13
+ */
11
14
  storage?: 'github' | 'fs'
12
15
  github?: {
13
16
  /**
14
17
  * The repo as `owner/name`. Required when storage is `github`. Default: the env var
15
- * `NUXT_PLUTO_GITHUB_REPO` or `PLUTO_GITHUB_REPO`, then Vercel's repo, then (dev only)
18
+ * `NUXT_PLUTO_GITHUB_REPO` or `PLUTO_GITHUB_REPO`, then Vercel's repo, then Netlify's repo, then (dev only)
16
19
  * the `origin` remote of the local git repo.
17
20
  */
18
21
  repo?: string
@@ -72,6 +75,11 @@ export interface PlutoGithubResolveContext {
72
75
  env?: Record<string, string | undefined>
73
76
  /** The URL of the `origin` git remote. The module reads it in dev only. */
74
77
  gitOrigin?: string
78
+ /**
79
+ * A static build (`nuxt generate`): the site reads the content from the files of the checkout
80
+ * while it prerenders. The `fs` store is the default and is valid. No sign-in, no admin.
81
+ */
82
+ static?: boolean
75
83
  }
76
84
 
77
85
  export interface PlutoGithubResolveResult {
@@ -137,7 +145,7 @@ export function resolvePlutoGithubOptions(
137
145
  options.storage ??
138
146
  (fromEnv('PLUTO_GITHUB_STORAGE') as
139
147
  ResolvedPlutoGithubOptions['storage'] | undefined) ??
140
- (ctx.dev ? 'fs' : 'github'),
148
+ (ctx.dev || ctx.static ? 'fs' : 'github'),
141
149
  github: {
142
150
  repo: options.github?.repo || detectRepo(ctx, fromEnv),
143
151
  branch: options.github?.branch ?? 'main',
@@ -185,9 +193,9 @@ export function resolvePlutoGithubOptions(
185
193
  errors.push(
186
194
  `plutoGithub.storage must be "github" or "fs". Got "${String(resolved.storage)}".`
187
195
  )
188
- } else if (resolved.storage === 'fs' && !ctx.dev) {
196
+ } else if (resolved.storage === 'fs' && !ctx.dev && !ctx.static) {
189
197
  errors.push(
190
- 'plutoGithub.storage "fs" works in dev only. Use "github" for a build.'
198
+ 'plutoGithub.storage "fs" works in dev and in a static build (nuxt generate) only. Use "github" for a server build.'
191
199
  )
192
200
  } else if (resolved.storage === 'github' && !resolved.github.repo) {
193
201
  errors.push(
@@ -238,6 +246,11 @@ function detectRepo(
238
246
  if (owner && slug) {
239
247
  return `${owner}/${slug}`
240
248
  }
249
+ // Netlify gives the URL of the repo to the build.
250
+ const netlify = parseGithubRepoUrl(fromEnv('REPOSITORY_URL') ?? '')
251
+ if (netlify) {
252
+ return netlify
253
+ }
241
254
  return ctx.dev && ctx.gitOrigin
242
255
  ? (parseGithubRepoUrl(ctx.gitOrigin) ?? '')
243
256
  : ''
@@ -0,0 +1,50 @@
1
+ import type { LocalGitStatus } from '../../server/store/local-git'
2
+ import { computed } from 'vue'
3
+ import { useState } from '#imports'
4
+
5
+ export type { LocalGitStatus }
6
+
7
+ export const CMS_LOCAL_STATUS_URL = '/api/_pluto/github/local/status'
8
+ export const CMS_LOCAL_PUSH_URL = '/api/_pluto/github/local/push'
9
+
10
+ /**
11
+ * The local Git repo of the project in local mode (`fs` store, dev): the branch and the commits
12
+ * that are not pushed. The state is shared under one key. It is `null` until the first read, and
13
+ * after a failed read. The CMS plugin reads it again on each admin navigation, when the tab shows
14
+ * again, and every 15 seconds while an admin page shows.
15
+ */
16
+ export function usePlutoGithubLocalGit() {
17
+ const status = useState<LocalGitStatus | null>(
18
+ 'pluto-github-local-git',
19
+ () => null
20
+ )
21
+ const pushing = useState('pluto-github-local-git-pushing', () => false)
22
+
23
+ async function refresh(): Promise<void> {
24
+ try {
25
+ status.value = await $fetch<LocalGitStatus>(CMS_LOCAL_STATUS_URL)
26
+ } catch {
27
+ status.value = null
28
+ }
29
+ }
30
+
31
+ /** Pushes the branch. Throws the fetch error, so the caller can show it. */
32
+ async function push(): Promise<void> {
33
+ pushing.value = true
34
+ try {
35
+ status.value = await $fetch<LocalGitStatus>(CMS_LOCAL_PUSH_URL, {
36
+ method: 'POST',
37
+ })
38
+ } finally {
39
+ pushing.value = false
40
+ }
41
+ }
42
+
43
+ return {
44
+ status,
45
+ pushing,
46
+ ahead: computed(() => (status.value?.repo ? status.value.ahead : 0)),
47
+ refresh,
48
+ push,
49
+ }
50
+ }
@@ -1,5 +1,5 @@
1
1
  import { computed } from 'vue'
2
- import { useFetch, useRequestFetch } from '#imports'
2
+ import { useFetch, useRequestFetch, useRuntimeConfig } from '#imports'
3
3
 
4
4
  export const CMS_SESSION_URL = '/api/_pluto/auth/session'
5
5
  export const CMS_LOGOUT_URL = '/api/_pluto/auth/logout'
@@ -26,11 +26,18 @@ export interface PlutoGithubSessionResponse {
26
26
  * The browser fetches it after hydration (`server: false`). The server never
27
27
  * reads it, so a public page that a CDN caches never holds a user. The admin
28
28
  * is client-only, so it loses nothing. The response has no token.
29
+ *
30
+ * A static build (`public.plutoGithub.static`) has no server, so it never asks: nobody is
31
+ * signed in there.
29
32
  */
30
33
  export function usePlutoGithubSession() {
34
+ const isStatic =
35
+ (useRuntimeConfig().public.plutoGithub as { static?: boolean } | undefined)
36
+ ?.static === true
31
37
  const asyncData = useFetch<PlutoGithubSessionResponse>(CMS_SESSION_URL, {
32
38
  key: 'pluto-github-session',
33
39
  server: false,
40
+ immediate: !isStatic,
34
41
  $fetch: useRequestFetch(),
35
42
  default: () => ({ user: null }),
36
43
  })
@@ -1,7 +1,8 @@
1
1
  import type { PlutoGithubSessionResponse } from '../composables/pluto-github-session'
2
2
  import { defineAsyncComponent } from 'vue'
3
- import { defineNuxtPlugin, useRuntimeConfig } from '#imports'
3
+ import { defineNuxtPlugin, useRouter, useRuntimeConfig } from '#imports'
4
4
  import PlutoGithubSignIn from '../../../../../app/components/Pluto/Github/PlutoGithubSignIn.vue'
5
+ import { isAdminPath } from '../../../../../app/composables/internal/admin-shell'
5
6
  import { definePlutoExtension } from '../../../../../app/composables/pluto-extension'
6
7
  import { usePlutoRegistry } from '../../../../../app/composables/pluto-registry'
7
8
  import {
@@ -10,11 +11,21 @@ import {
10
11
  showPublicRepoWarning,
11
12
  } from '../../shared/client-rules'
12
13
  import { plutoGithubPostType } from '../../shared/post-type'
14
+ import { usePlutoGithubLocalGit } from '../composables/pluto-github-local-git'
13
15
  import {
14
16
  CMS_SESSION_URL,
15
17
  usePlutoGithubSession,
16
18
  } from '../composables/pluto-github-session'
17
19
 
20
+ // The popover of the backend badge. It loads when the popover opens.
21
+ const PlutoGithubBackendDetails = defineAsyncComponent(
22
+ () =>
23
+ import('../../../../../app/components/Pluto/Github/PlutoGithubBackendDetails.vue')
24
+ )
25
+
26
+ /** How often the badge reads the local repo again while an admin page shows. */
27
+ const LOCAL_GIT_INTERVAL = 15_000
28
+
18
29
  // The dashboard loads each widget on demand, with a skeleton.
19
30
  const PlutoGithubPublicRepoWarning = defineAsyncComponent(
20
31
  () =>
@@ -51,6 +62,16 @@ export default defineNuxtPlugin(() => {
51
62
  | undefined
52
63
  )
53
64
 
65
+ // Local mode: the saves are commits in the local repo. The badge counts the commits that are
66
+ // not pushed. The status route exists in this mode only.
67
+ const local =
68
+ (useRuntimeConfig().public.plutoGithub as { storage?: string } | undefined)
69
+ ?.storage === 'fs'
70
+ const localGit = usePlutoGithubLocalGit()
71
+ if (local && import.meta.client) {
72
+ watchLocalGit(localGit.refresh)
73
+ }
74
+
54
75
  const { read, write, delete: remove } = plutoGithubPostType.capabilities ?? {}
55
76
  const capabilities = (
56
77
  postType
@@ -105,6 +126,35 @@ export default defineNuxtPlugin(() => {
105
126
  signInTitle: 'pluto.github.login.title',
106
127
  signInDescription: 'pluto.github.login.description',
107
128
  },
129
+ // The Users item of the menu, between Media (900) and Settings (1000). The page
130
+ // (`/admin/users`, added by the module) lists the people with write access to the repo.
131
+ nav: [
132
+ {
133
+ id: 'users',
134
+ order: 950,
135
+ label: 'pluto.github.users.title',
136
+ icon: 'i-lucide-users',
137
+ to: '/admin/users',
138
+ },
139
+ ],
140
+
141
+ // The backend badge of the navbar: local files with Git, or a GitHub repo.
142
+ backend: {
143
+ id: 'git',
144
+ kind: 'git',
145
+ label: local
146
+ ? 'pluto.github.backend.local'
147
+ : 'pluto.github.backend.github',
148
+ icon: local ? 'i-lucide-hard-drive' : 'i-pluto-github',
149
+ count: () => (local ? localGit.ahead.value : 0),
150
+ countLabel: 'pluto.github.local.aheadShort',
151
+ details: PlutoGithubBackendDetails,
152
+ onOpen: () => {
153
+ if (local) {
154
+ localGit.refresh()
155
+ }
156
+ },
157
+ },
108
158
  // The GitHub tab of Settings: the setup check and the Create button. The module
109
159
  // adds its route (`/admin/settings/github`) with `extendPages`.
110
160
  settingsPages: [
@@ -115,6 +165,14 @@ export default defineNuxtPlugin(() => {
115
165
  icon: 'pluto:github',
116
166
  to: '/admin/settings/github',
117
167
  },
168
+ // The Deploy tab: put the site on a host. The module adds its route too.
169
+ {
170
+ id: 'deploy',
171
+ order: 160,
172
+ label: 'pluto.settings.tabs.deploy',
173
+ icon: 'i-lucide-rocket',
174
+ to: '/admin/settings/deploy',
175
+ },
118
176
  ],
119
177
  // The content type gives the Posts nav item, the list and the form.
120
178
  contentTypes: postType ? [{ id: 'post', type: plutoGithubPostType }] : [],
@@ -169,3 +227,28 @@ export default defineNuxtPlugin(() => {
169
227
  },
170
228
  })
171
229
  })
230
+
231
+ /**
232
+ * Reads the local repo on each admin navigation, when the tab shows again, and every
233
+ * `LOCAL_GIT_INTERVAL` while an admin page shows. A save commits on the server, so the count
234
+ * follows within that time, or at once on the next navigation.
235
+ */
236
+ let watchingLocalGit = false
237
+
238
+ function watchLocalGit(refresh: () => Promise<void>): void {
239
+ if (watchingLocalGit) {
240
+ return
241
+ }
242
+ watchingLocalGit = true
243
+ const router = useRouter()
244
+ const onAdmin = () => isAdminPath(router.currentRoute.value.path)
245
+ const tick = () => {
246
+ if (onAdmin() && document.visibilityState === 'visible') {
247
+ refresh()
248
+ }
249
+ }
250
+ router.afterEach(tick)
251
+ document.addEventListener('visibilitychange', tick)
252
+ setInterval(tick, LOCAL_GIT_INTERVAL)
253
+ tick()
254
+ }
@@ -52,6 +52,10 @@ export interface MarkdownContentAdapterOptions {
52
52
  /** Reads the caller of a request. `null` means anonymous. */
53
53
  getAccess: (event: H3Event) => Promise<PlutoGithubAccess | null>
54
54
  commit?: {
55
+ /**
56
+ * `true`: every commit gets `[skip ci]`. `false`: only a commit that changes no published
57
+ * content gets it (a draft that stays a draft), so a draft save starts no deploy.
58
+ */
55
59
  skipDeploy?: boolean
56
60
  }
57
61
  /** The cache that holds parsed files by blob id. A memory cache by default. */
@@ -183,7 +187,7 @@ export function createMarkdownContentAdapter(
183
187
  const cache = options.cache ?? createGithubCache({ headTtl: 0 })
184
188
  const retries = options.retries ?? 2
185
189
  const now = options.now ?? (() => new Date())
186
- const skipSuffix = options.commit?.skipDeploy ? ' [skip ci]' : ''
190
+ const skipAll = options.commit?.skipDeploy === true
187
191
 
188
192
  // --- Errors ---------------------------------------------------------------
189
193
 
@@ -469,7 +473,7 @@ export function createMarkdownContentAdapter(
469
473
  function isPublished(
470
474
  type: PlutoContentType,
471
475
  layout: Layout,
472
- entry: Entry
476
+ entry: Pick<Entry, 'frontmatter'>
473
477
  ): boolean {
474
478
  if (!layout.statusColumn) {
475
479
  return true
@@ -535,8 +539,17 @@ export function createMarkdownContentAdapter(
535
539
 
536
540
  // --- Writes ---------------------------------------------------------------
537
541
 
538
- function messageOf(type: PlutoContentType, verb: string): string {
539
- return `content(${type.name}): ${verb}${skipSuffix}`
542
+ /**
543
+ * The commit message. `publicChange` is false when the commit changes no published content:
544
+ * the item is a draft before and after. Such a commit gets `[skip ci]`, so it starts no deploy.
545
+ */
546
+ function messageOf(
547
+ type: PlutoContentType,
548
+ verb: string,
549
+ publicChange: boolean
550
+ ): string {
551
+ const skip = skipAll || !publicChange ? ' [skip ci]' : ''
552
+ return `content(${type.name}): ${verb}${skip}`
540
553
  }
541
554
 
542
555
  /** Runs a plan against a fresh head. Retries when only unrelated files changed. */
@@ -859,6 +872,8 @@ export function createMarkdownContentAdapter(
859
872
 
860
873
  const frontmatter: Record<string, unknown> = {}
861
874
  applyValues(type, layout, frontmatter, values, 'create')
875
+ // A published item (or a type with no status) is public: its commit starts a deploy.
876
+ const published = isPublished(type, layout, { frontmatter })
862
877
  if (
863
878
  layout.statusColumn &&
864
879
  frontmatter[layout.statusColumn] === layout.publishedValue
@@ -874,7 +889,7 @@ export function createMarkdownContentAdapter(
874
889
  throw slugTakenError(slug)
875
890
  }
876
891
  return {
877
- message: messageOf(type, `create "${slug}"`),
892
+ message: messageOf(type, `create "${slug}"`, published),
878
893
  additions: [{ path, contents: text }],
879
894
  deletions: [],
880
895
  targets: [{ path, oid: null }],
@@ -982,7 +997,7 @@ export function createMarkdownContentAdapter(
982
997
  verb = `${nowPublished ? 'publish' : 'unpublish'} "${oldSlug}"`
983
998
  }
984
999
  return {
985
- message: messageOf(type, verb),
1000
+ message: messageOf(type, verb, wasPublished || nowPublished),
986
1001
  additions: [{ path: newPath, contents: text }],
987
1002
  deletions: newPath === oldPath ? [] : [{ path: oldPath }],
988
1003
  targets,
@@ -1018,7 +1033,11 @@ export function createMarkdownContentAdapter(
1018
1033
  throw notFoundError()
1019
1034
  }
1020
1035
  return {
1021
- message: messageOf(type, `delete "${slug}"`),
1036
+ message: messageOf(
1037
+ type,
1038
+ `delete "${slug}"`,
1039
+ isPublished(type, layout, current)
1040
+ ),
1022
1041
  additions: [],
1023
1042
  deletions: [{ path }],
1024
1043
  targets: [{ path, oid: current.oid }],
@@ -0,0 +1,5 @@
1
+ import { defineEventHandler } from 'h3'
2
+ import { handleSetupPages } from '../../utils/deploy'
3
+
4
+ /** Route: POST /api/_pluto/github/deploy/pages. Local mode only: the Pages workflow and gh. */
5
+ export default defineEventHandler(handleSetupPages)
@@ -0,0 +1,5 @@
1
+ import { defineEventHandler } from 'h3'
2
+ import { handleDeployStatus } from '../../utils/deploy'
3
+
4
+ /** Route: GET /api/_pluto/github/deploy/status. The facts of the Deploy page. */
5
+ export default defineEventHandler(handleDeployStatus)
@@ -0,0 +1,5 @@
1
+ import { defineEventHandler } from 'h3'
2
+ import { handleLocalGitPush } from '../../utils/local-git-routes'
3
+
4
+ /** Route: POST /api/_pluto/github/local/push. Local mode only. */
5
+ export default defineEventHandler(handleLocalGitPush)
@@ -0,0 +1,5 @@
1
+ import { defineEventHandler } from 'h3'
2
+ import { handleLocalGitStatus } from '../../utils/local-git-routes'
3
+
4
+ /** Route: GET /api/_pluto/github/local/status. Local mode only. */
5
+ export default defineEventHandler(handleLocalGitStatus)
@@ -0,0 +1,5 @@
1
+ import { defineEventHandler } from 'h3'
2
+ import { handleGithubUsers } from '../utils/github-users'
3
+
4
+ /** Route: GET /api/_pluto/github/users. The people with write access to the repo. */
5
+ export default defineEventHandler(handleGithubUsers)
@@ -323,7 +323,10 @@ export function createGithubStore(
323
323
  )
324
324
  return new Uint8Array(result.data)
325
325
  } catch (error) {
326
- if (error instanceof PlutoGithubStoreError && error.code === 'not-found') {
326
+ if (
327
+ error instanceof PlutoGithubStoreError &&
328
+ error.code === 'not-found'
329
+ ) {
327
330
  return null
328
331
  }
329
332
  throw error