@plutocms/core 0.47.1 → 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 (43) hide show
  1. package/CHANGELOG.md +11 -0
  2. package/FEATURES.md +15 -4
  3. package/app/components/Pluto/Admin/PlutoAdminBackendBadge.vue +84 -0
  4. package/app/components/Pluto/Admin/PlutoAdminNavbar.vue +7 -0
  5. package/app/components/Pluto/Github/PlutoGithubBackendDetails.vue +180 -0
  6. package/app/components/Pluto/Github/PlutoGithubBuildStatus.vue +17 -4
  7. package/app/components/Pluto/Github/PlutoGithubDeployPage.vue +472 -0
  8. package/app/components/Pluto/Github/PlutoGithubUsersPage.vue +214 -0
  9. package/app/components/Pluto/PlutoHtml.vue +8 -2
  10. package/app/composables/internal/async-component.ts +9 -0
  11. package/app/composables/pluto-extension.ts +4 -0
  12. package/app/composables/pluto-navbar.ts +25 -0
  13. package/app/composables/pluto-registry.ts +1 -0
  14. package/i18n/locales/en.json +164 -2
  15. package/i18n/locales/pt-BR.json +164 -2
  16. package/modules/pluto-github/index.ts +127 -51
  17. package/modules/pluto-github/options.ts +18 -5
  18. package/modules/pluto-github/runtime/app/composables/pluto-github-local-git.ts +50 -0
  19. package/modules/pluto-github/runtime/app/composables/pluto-github-session.ts +8 -1
  20. package/modules/pluto-github/runtime/app/plugins/pluto-github.ts +84 -1
  21. package/modules/pluto-github/runtime/server/adapter.ts +26 -7
  22. package/modules/pluto-github/runtime/server/api/deploy/pages.post.ts +5 -0
  23. package/modules/pluto-github/runtime/server/api/deploy/status.get.ts +5 -0
  24. package/modules/pluto-github/runtime/server/api/local/push.post.ts +5 -0
  25. package/modules/pluto-github/runtime/server/api/local/status.get.ts +5 -0
  26. package/modules/pluto-github/runtime/server/api/users.get.ts +5 -0
  27. package/modules/pluto-github/runtime/server/store/github.ts +4 -1
  28. package/modules/pluto-github/runtime/server/store/local-git.ts +145 -0
  29. package/modules/pluto-github/runtime/server/store/memory.ts +4 -1
  30. package/modules/pluto-github/runtime/server/utils/build-status.ts +16 -7
  31. package/modules/pluto-github/runtime/server/utils/deploy.ts +204 -0
  32. package/modules/pluto-github/runtime/server/utils/github-setup.ts +1 -3
  33. package/modules/pluto-github/runtime/server/utils/github-users.ts +120 -0
  34. package/modules/pluto-github/runtime/server/utils/local-git-routes.ts +95 -0
  35. package/modules/pluto-github/runtime/shared/build-status.ts +26 -0
  36. package/modules/pluto-github/runtime/shared/client-rules.ts +16 -0
  37. package/modules/pluto-github/runtime/shared/deploy.ts +43 -0
  38. package/modules/pluto-github/runtime/shared/pages-workflow.ts +138 -0
  39. package/modules/pluto-github/runtime/shared/repo-users.ts +97 -0
  40. package/package.json +1 -1
  41. package/shared/types/github.d.ts +2 -0
  42. package/shared/types/registry.ts +22 -0
  43. package/shared/utils/html-base.ts +23 -0
package/CHANGELOG.md CHANGED
@@ -1,6 +1,17 @@
1
1
  # Changelog
2
2
 
3
3
 
4
+ ## [0.48.0](https://github.com/plutocms/pluto/compare/v0.47.1...v0.48.0) (2026-11-02)
5
+
6
+
7
+ ### Features
8
+
9
+ * a Deploy tab in Settings puts the Git CMS site on Vercel, Netlify, a Node host, Cloudflare or GitHub Pages ([#373](https://github.com/plutocms/pluto/issues/373)) ([f8a7a27](https://github.com/plutocms/pluto/commit/f8a7a276e3019f0dc5aff933cb4e703ad85c343f))
10
+ * a draft save of the Git CMS skips the deploy, and the build status follows the last deploy ([#371](https://github.com/plutocms/pluto/issues/371)) ([800b2a5](https://github.com/plutocms/pluto/commit/800b2a557ad7b7678133a5f04fe000c86aaa9e19))
11
+ * a read-only Users page for the Git CMS lists the people with write access to the repo ([#369](https://github.com/plutocms/pluto/issues/369)) ([0412158](https://github.com/plutocms/pluto/commit/0412158ff698074ccff505c3e0630bc4721e0cf4))
12
+ * the admin navbar shows the backend (Git or a database), and local mode counts the commits to push ([#368](https://github.com/plutocms/pluto/issues/368)) ([b71c4b5](https://github.com/plutocms/pluto/commit/b71c4b5cbbc728ddf6214fa35cbbe8190f64e164))
13
+ * the Git CMS builds a static site (nuxt generate, GitHub Pages) from the files of the checkout ([#372](https://github.com/plutocms/pluto/issues/372)) ([f5de17e](https://github.com/plutocms/pluto/commit/f5de17e85a5ec6e54d15eb40e8d12719caf94ee1))
14
+
4
15
  ## [0.47.1](https://github.com/plutocms/pluto/compare/v0.47.0...v0.47.1) (2026-11-02)
5
16
 
6
17
 
package/FEATURES.md CHANGED
@@ -97,18 +97,29 @@ policies shared by every project here.
97
97
  (`excerpt` in a list, `html` for one item, through `adapter.toPublic`), and `PlutoMarkdown` (an alias of the core `PlutoHtml`, which shows the sanitized `html` of any backend). The GitHub App is created from the site (App Manifest flow): the setup routes
98
98
  under `/api/_pluto/github/setup/` (`status`, `github/start`, `github/callback`), a result page with the
99
99
  env block, a `.env` write in development, a setup check on `/admin/login`, a **GitHub** tab in Settings,
100
- and a private key in PEM, `\n` or base64 form. The edit form shows the build status of the head
101
- commit (check runs, commit statuses, deployments) next to Save, for GitHub storage with
100
+ and a private key in PEM, `\n` or base64 form. With `commit.skipDeploy` off, a draft save gets
101
+ `[skip ci]`, so only a change of published content deploys. The edit form shows the build status of the
102
+ newest commit that did not skip CI (check runs, commit statuses, deployments) next to Save, for GitHub storage with
102
103
  `commit.skipDeploy` off: the route `GET /api/_pluto/github/build-status`, a pure reducer, a polling loop,
103
104
  and an Info popover with links to the Action or the build. A content type with `liveOnSave` shows
104
105
  "Live" at once after a save and the static build as a small spinner. The file adapter has
105
106
  `readFile` (safe relative paths) and `_frontmatter` on `get`, and core has the auto-imported
106
- `markPublicRead(event)` for layers. @.claude/skills/git-cms/SKILL.md
107
+ `markPublicRead(event)` for layers. The backend badge shows local mode or the GitHub repo. In local
108
+ mode it counts the commits that are not pushed and has a Push button (`/api/_pluto/github/local/status`
109
+ and `push`, dev local request only). A read-only **Users** page (`/admin/users`) lists the people
110
+ with write access to the repo (`GET /api/_pluto/github/users`), their GitHub role, what it gives in
111
+ Pluto, and who `ALLOWED_USERS` blocks. A static build (`nuxt generate`, or the `github_pages`
112
+ preset) prerenders the site from the files of the checkout with no token, adds no server route,
113
+ keeps the admin out, and takes a sub path from `PLUTO_BASE_URL` (`PlutoHtml` puts it before the
114
+ site paths of a post body). A **Deploy** tab in Settings (`/admin/settings/deploy`) puts the site
115
+ on a host: import links for Vercel and Netlify, a Node host guide, Cloudflare Pages build settings,
116
+ and GitHub Pages (in local mode it writes and commits the workflow and turns Pages on with the
117
+ GitHub CLI). @.claude/skills/git-cms/SKILL.md
107
118
  - Lazy admin components: core registers its field widgets and dashboard widgets with
108
119
  `defineAsyncComponent`, shows a skeleton per field, widget and settings panel while a chunk
109
120
  loads, and warns in dev about a registered component that is not async.
110
121
  @.claude/skills/extension-registry/SKILL.md
111
- - Admin shell: core owns the `/admin` parent route, the `/admin/login` page, the route guard, the admin banners, the navbar and the user menu. A backend layer registers an auth driver, account menu items, site info and link actions. The admin is for staff only: a signed-in user with no capability goes to `/admin/no-access`. In production, an app with no auth driver (the backend is missing or did not load) sends every admin path to `/admin/not-configured`; in dev the admin opens. The bar shows on `/admin/**` for staff, and on public pages for staff only. A route hides the bar with the page meta `plutoAdminBar: false`, and `isPlutoPreviewPath` marks the preview path. The bar height is the token `--pluto-admin-bar-height`, and `<html>` gets `data-pluto-admin-bar` while the bar shows. A hint cookie puts the bar frame in the server HTML of a public page for staff (the actions and the user menu load after the mount). Content types with `adminBar` get "New <type>" and "Edit <type>" buttons (`usePlutoEditLink`). With two or more types, one "New" menu lists them, grouped by `navGroup`. A `navGroup` also puts related types under one sidebar item (registry bucket `navGroups`, core group `pages`). @.claude/skills/admin-shell/SKILL.md
122
+ - Admin shell: core owns the `/admin` parent route, the `/admin/login` page, the route guard, the admin banners, the navbar and the user menu. A backend layer registers an auth driver, account menu items, site info, link actions and the backend badge of the navbar ("Git · Local", "Git · GitHub", "Database · Supabase", with a popover; `usePlutoBackend`). The admin is for staff only: a signed-in user with no capability goes to `/admin/no-access`. In production, an app with no auth driver (the backend is missing or did not load) sends every admin path to `/admin/not-configured`; in dev the admin opens. The bar shows on `/admin/**` for staff, and on public pages for staff only. A route hides the bar with the page meta `plutoAdminBar: false`, and `isPlutoPreviewPath` marks the preview path. The bar height is the token `--pluto-admin-bar-height`, and `<html>` gets `data-pluto-admin-bar` while the bar shows. A hint cookie puts the bar frame in the server HTML of a public page for staff (the actions and the user menu load after the mount). Content types with `adminBar` get "New <type>" and "Edit <type>" buttons (`usePlutoEditLink`). With two or more types, one "New" menu lists them, grouped by `navGroup`. A `navGroup` also puts related types under one sidebar item (registry bucket `navGroups`, core group `pages`). @.claude/skills/admin-shell/SKILL.md
112
123
  - Local layers helper: `plutoLayer(name)` from `@plutocms/core/layers` gives a Nuxt `extends` entry.
113
124
  With `PLUTO_LOCAL_ROOT`, it uses the local checkouts of the Pluto layers. @.claude/skills/local-layers/SKILL.md
114
125
  - Pluto logo: `PlutoLogo` draws the logo inline. The wordmark takes the text color, so the logo works
@@ -0,0 +1,84 @@
1
+ <script setup lang="ts">
2
+ // The backend badge of the admin navbar: "Git · GitHub", "Git · Local",
3
+ // "Database · Supabase". It tells the editor where the content goes. A click
4
+ // opens a popover with the details of the backend (its own component). A
5
+ // count (for example the commits that are not pushed) shows as a small pill.
6
+ // The backend comes from the `backend` field of `definePlutoExtension`. See
7
+ // the admin-shell skill.
8
+ const { t } = usePlutoI18n()
9
+ const { backend } = usePlutoBackend()
10
+ const open = ref(false)
11
+
12
+ const kindLabel = computed(() =>
13
+ backend.value?.kind === 'database'
14
+ ? t('pluto.backend.kind.database')
15
+ : t('pluto.backend.kind.git')
16
+ )
17
+ const kindIcon = computed(() =>
18
+ backend.value?.kind === 'database'
19
+ ? 'i-lucide-database'
20
+ : 'i-lucide-git-branch'
21
+ )
22
+ const buttonLabel = computed(() => {
23
+ const name = `${kindLabel.value} · ${backend.value?.label ?? ''}`
24
+ return backend.value?.countLabel
25
+ ? `${t('pluto.backend.open', { name })} (${backend.value.countLabel})`
26
+ : t('pluto.backend.open', { name })
27
+ })
28
+
29
+ watch(open, (value) => {
30
+ if (value) {
31
+ backend.value?.onOpen?.()
32
+ }
33
+ })
34
+ </script>
35
+
36
+ <template>
37
+ <UPopover
38
+ v-if="backend"
39
+ v-model:open="open"
40
+ :content="{ align: 'start', sideOffset: 4 }"
41
+ >
42
+ <PlutoNavbarHit :label="buttonLabel" size="sm">
43
+ <span class="flex items-center gap-x-1.5">
44
+ <UIcon :name="kindIcon" class="size-4 shrink-0" />
45
+ <span class="hidden md:inline">{{ kindLabel }}</span>
46
+ <span class="text-muted hidden md:inline">·</span>
47
+ <span class="text-muted hidden max-w-32 truncate md:inline">
48
+ {{ backend.label }}
49
+ </span>
50
+ <span
51
+ v-if="backend.count"
52
+ :title="backend.countLabel"
53
+ class="bg-warning text-inverted rounded-full px-1.5 text-[0.6875rem]/4 font-semibold tabular-nums"
54
+ >
55
+ {{ backend.count }}
56
+ </span>
57
+ </span>
58
+ </PlutoNavbarHit>
59
+
60
+ <template #content>
61
+ <div class="w-80 max-w-[calc(100vw-2rem)] p-4 text-sm">
62
+ <div class="mb-3 flex items-center gap-x-2">
63
+ <UIcon
64
+ :name="backend.icon || kindIcon"
65
+ class="text-highlighted size-5 shrink-0"
66
+ />
67
+ <div class="min-w-0">
68
+ <p class="text-highlighted truncate font-semibold">
69
+ {{ backend.label }}
70
+ </p>
71
+ <p class="text-muted text-xs">
72
+ {{
73
+ backend.kind === 'database'
74
+ ? t('pluto.backend.databaseMode')
75
+ : t('pluto.backend.gitMode')
76
+ }}
77
+ </p>
78
+ </div>
79
+ </div>
80
+ <component :is="backend.details" v-if="backend.details" />
81
+ </div>
82
+ </template>
83
+ </UPopover>
84
+ </template>
@@ -85,6 +85,13 @@ const siteDomain = computed(() => domainFromUrl(site.url.value) || host)
85
85
  -->
86
86
  <div class="-mx-1 h-full min-w-0 overflow-hidden px-1">
87
87
  <ul class="flex h-full min-w-0 items-center gap-x-1 text-sm">
88
+ <!-- Where the content goes: Git or a database. Admin pages only. -->
89
+ <ClientOnly>
90
+ <li v-if="isAdmin" class="h-full shrink-0">
91
+ <PlutoAdminBackendBadge />
92
+ </li>
93
+ </ClientOnly>
94
+
88
95
  <PlutoNavbarButton
89
96
  v-if="isAdmin"
90
97
  :label="t('pluto.navbar.viewSite')"
@@ -0,0 +1,180 @@
1
+ <script setup lang="ts">
2
+ // The popover body of the backend badge (`backend.details`) for the Git CMS.
3
+ // Local mode (`fs` store): the saves are commits in the local repo, the
4
+ // commits that are not pushed, and a Push button. GitHub mode: the repo and
5
+ // the branch that each save commits to, with links to GitHub.
6
+ import { usePlutoGithubLocalGit } from '../../../../modules/pluto-github/runtime/app/composables/pluto-github-local-git'
7
+ import { usePlutoGithubSession } from '../../../../modules/pluto-github/runtime/app/composables/pluto-github-session'
8
+
9
+ const { t } = usePlutoI18n()
10
+ const errorText = usePlutoErrorText()
11
+ const config = useRuntimeConfig().public.plutoGithub as
12
+ | {
13
+ storage?: string
14
+ repo?: string
15
+ branch?: string
16
+ }
17
+ | undefined
18
+ const local = config?.storage === 'fs'
19
+ const repo = config?.repo || ''
20
+ const branch = config?.branch || 'main'
21
+ const session = usePlutoGithubSession()
22
+ const git = usePlutoGithubLocalGit()
23
+ const pushError = ref('')
24
+ const pushed = ref(false)
25
+
26
+ const repoUrl = computed(() => (repo ? `https://github.com/${repo}` : ''))
27
+ const commitsUrl = computed(() =>
28
+ repo ? `https://github.com/${repo}/commits/${encodeURIComponent(branch)}` : ''
29
+ )
30
+ const visibility = computed(() => {
31
+ const value = session.repo.value?.private
32
+ if (value === undefined) {
33
+ return ''
34
+ }
35
+ return value
36
+ ? t('pluto.github.backend.private')
37
+ : t('pluto.github.backend.public')
38
+ })
39
+
40
+ // What the local repo needs, in one line. `null` while the first read runs.
41
+ const localState = computed(() => {
42
+ const status = git.status.value
43
+ if (!status) {
44
+ return null
45
+ }
46
+ if (!status.repo) {
47
+ return status.reason === 'no-git'
48
+ ? t('pluto.github.local.noGit')
49
+ : t('pluto.github.local.notARepo')
50
+ }
51
+ if (!status.branch) {
52
+ return t('pluto.github.local.detached')
53
+ }
54
+ if (!status.remote) {
55
+ return t('pluto.github.local.noRemote')
56
+ }
57
+ return status.ahead
58
+ ? t('pluto.github.local.ahead', status.ahead)
59
+ : t('pluto.github.local.upToDate')
60
+ })
61
+ const canPush = computed(() => {
62
+ const status = git.status.value
63
+ return Boolean(
64
+ status?.repo && status.branch && status.remote && status.ahead > 0
65
+ )
66
+ })
67
+
68
+ async function push() {
69
+ pushError.value = ''
70
+ pushed.value = false
71
+ try {
72
+ await git.push()
73
+ pushed.value = true
74
+ } catch (error) {
75
+ pushError.value = errorText(error, 'pluto.github.local.errors.failed')
76
+ }
77
+ }
78
+ </script>
79
+
80
+ <template>
81
+ <div class="space-y-3">
82
+ <template v-if="local">
83
+ <p class="text-muted">{{ t('pluto.github.local.description') }}</p>
84
+
85
+ <dl class="grid grid-cols-[auto_1fr] gap-x-3 gap-y-1">
86
+ <template v-if="git.status.value?.repo && git.status.value.branch">
87
+ <dt class="text-muted">{{ t('pluto.github.backend.branch') }}</dt>
88
+ <dd class="text-highlighted truncate font-mono text-xs/5">
89
+ {{ git.status.value.branch }}
90
+ </dd>
91
+ </template>
92
+ <template v-if="repo">
93
+ <dt class="text-muted">{{ t('pluto.github.backend.repo') }}</dt>
94
+ <dd class="truncate">
95
+ <ULink :to="repoUrl" target="_blank" class="font-mono text-xs/5">
96
+ {{ repo }}
97
+ </ULink>
98
+ </dd>
99
+ </template>
100
+ </dl>
101
+
102
+ <div
103
+ class="border-default flex items-center justify-between gap-3 rounded-md border px-3 py-2"
104
+ >
105
+ <p v-if="localState" class="text-highlighted min-w-0">
106
+ {{ localState }}
107
+ </p>
108
+ <USkeleton v-else class="h-4 w-32" />
109
+ <UButton
110
+ v-if="canPush"
111
+ :label="t('pluto.github.local.push')"
112
+ :loading="git.pushing.value"
113
+ icon="i-lucide-upload"
114
+ size="xs"
115
+ @click="push"
116
+ />
117
+ </div>
118
+
119
+ <p
120
+ v-if="pushed && !canPush"
121
+ class="text-success flex items-center gap-1.5"
122
+ role="status"
123
+ >
124
+ <UIcon name="i-lucide-check" class="size-4" />
125
+ {{ t('pluto.github.local.pushed') }}
126
+ </p>
127
+ <p v-if="pushError" class="text-error" role="alert">
128
+ {{ pushError }}
129
+ </p>
130
+ <p v-else-if="canPush" class="text-muted text-xs">
131
+ {{ t('pluto.github.local.pushHint') }}
132
+ </p>
133
+ </template>
134
+
135
+ <template v-else>
136
+ <p class="text-muted">
137
+ {{ t('pluto.github.backend.description') }}
138
+ </p>
139
+ <dl class="grid grid-cols-[auto_1fr] gap-x-3 gap-y-1">
140
+ <dt class="text-muted">{{ t('pluto.github.backend.repo') }}</dt>
141
+ <dd class="flex min-w-0 items-center gap-2">
142
+ <span class="text-highlighted truncate font-mono text-xs/5">
143
+ {{ repo }}
144
+ </span>
145
+ <UBadge
146
+ v-if="visibility"
147
+ :label="visibility"
148
+ color="neutral"
149
+ size="sm"
150
+ variant="subtle"
151
+ />
152
+ </dd>
153
+ <dt class="text-muted">{{ t('pluto.github.backend.branch') }}</dt>
154
+ <dd class="text-highlighted truncate font-mono text-xs/5">
155
+ {{ branch }}
156
+ </dd>
157
+ </dl>
158
+ <div v-if="repoUrl" class="flex flex-wrap gap-2">
159
+ <UButton
160
+ :label="t('pluto.github.backend.openRepo')"
161
+ :to="repoUrl"
162
+ color="neutral"
163
+ icon="i-pluto-github"
164
+ size="xs"
165
+ target="_blank"
166
+ variant="outline"
167
+ />
168
+ <UButton
169
+ :label="t('pluto.github.backend.commits')"
170
+ :to="commitsUrl"
171
+ color="neutral"
172
+ icon="i-lucide-history"
173
+ size="xs"
174
+ target="_blank"
175
+ variant="outline"
176
+ />
177
+ </div>
178
+ </template>
179
+ </div>
180
+ </template>
@@ -1,6 +1,8 @@
1
1
  <script setup lang="ts">
2
2
  // A toolbar item of the edit form (`contentToolbarItems`). It shows the build
3
- // of the head commit of the content branch: a spinner while the site builds,
3
+ // of the newest commit of the content branch that did not skip CI (a draft
4
+ // save skips it), so a draft that was never public in this page shows nothing:
5
+ // a spinner while the site builds,
4
6
  // a check mark when it is live, and an error icon when the build failed. The
5
7
  // Info button opens the details, with a link to each Action or build.
6
8
  // The CMS plugin registers it only for GitHub storage with `skipDeploy` off.
@@ -12,22 +14,33 @@ import type {
12
14
  PlutoBuildState,
13
15
  } from '../../../../modules/pluto-github/runtime/shared/build-status'
14
16
  import { usePlutoGithubBuildStatus } from '../../../../modules/pluto-github/runtime/app/composables/pluto-github-build-status'
17
+ import { isPublicItem } from '../../../../modules/pluto-github/runtime/shared/client-rules'
15
18
  import { savedAgo } from '../../../composables/internal/content-dirty'
16
19
 
17
20
  const { t, locale } = usePlutoI18n()
18
21
  const form = usePlutoContentForm()
19
22
  const { status, check, afterSave } = usePlutoGithubBuildStatus()
20
23
 
24
+ // True when the item was public in this page: published when the page opened, or after a save.
25
+ // A draft save skips the deploy, so a draft that was never public has no build to show.
26
+ const everPublic = ref(
27
+ form ? isPublicItem(form.type.value, form.item.value) : true
28
+ )
29
+
21
30
  // Read when the edit page opens, and after each save. A new item has no commit yet:
22
31
  // the form goes to the edit page after the first save, and this item mounts again there.
23
32
  onMounted(() => {
24
- if (form?.editing.value) {
33
+ if (form?.editing.value && everPublic.value) {
25
34
  check()
26
35
  }
27
36
  })
28
37
  // True after a save in this page. With `liveOnSave` the change is live from then on.
29
38
  const savedHere = ref(false)
30
- form?.onSaved(() => {
39
+ form?.onSaved((saved) => {
40
+ everPublic.value ||= isPublicItem(form.type.value, saved)
41
+ if (!everPublic.value) {
42
+ return
43
+ }
31
44
  savedHere.value = true
32
45
  afterSave()
33
46
  })
@@ -130,7 +143,7 @@ const commitTime = computed(() => ago(status.value?.committedAt))
130
143
  </script>
131
144
 
132
145
  <template>
133
- <div v-if="summary" class="flex items-center gap-1">
146
+ <div v-if="summary && everPublic" class="flex items-center gap-1">
134
147
  <!-- A live region: a screen reader hears the change. -->
135
148
  <div
136
149
  role="status"