@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
@@ -43,3 +43,19 @@ export function showBuildStatus(
43
43
  ): boolean {
44
44
  return config?.storage === 'github' && config.skipDeploy === false
45
45
  }
46
+
47
+ /**
48
+ * Whether an item is on the public site: a type with no status, or an item with the published
49
+ * value. A missing status counts as the default. The edit form hides the build status for a draft
50
+ * that was never public in the page: its saves skip the deploy.
51
+ */
52
+ export function isPublicItem(
53
+ type: Pick<PlutoContentType, 'status'> | undefined,
54
+ item: Record<string, unknown>
55
+ ): boolean {
56
+ if (!type?.status) {
57
+ return true
58
+ }
59
+ const value = item[type.status.column ?? 'status'] ?? type.status.default
60
+ return value === (type.status.publishedValue || 'published')
61
+ }
@@ -0,0 +1,43 @@
1
+ import type { PackageManager } from './pages-workflow'
2
+
3
+ /** The state of GitHub Pages for the repo, read with the GitHub CLI of this computer. */
4
+ export interface PlutoPagesState {
5
+ /** `on`: Pages is on. `off`: GitHub answered 404. `unknown`: the CLI could not tell. */
6
+ state: 'on' | 'off' | 'unknown'
7
+ url?: string
8
+ /** `workflow`: the site comes from an Action. `legacy`: from a branch. */
9
+ source?: 'workflow' | 'legacy'
10
+ /** Why the state is unknown: no `gh`, `gh` not signed in, or another failure. */
11
+ reason?: 'no-gh' | 'signed-out' | 'error'
12
+ }
13
+
14
+ /** The answer of `GET /api/_pluto/github/deploy/status`. */
15
+ export interface PlutoDeployStatus {
16
+ repo: string
17
+ branch: string
18
+ /** True for local mode on this computer: the page can write the workflow and run `gh`. */
19
+ local: boolean
20
+ packageManager: PackageManager
21
+ workflow: {
22
+ path: string
23
+ /** Local mode only: the file is in the project. */
24
+ exists?: boolean
25
+ /** Local mode only: the file is the one that Pluto writes now. */
26
+ current?: boolean
27
+ /** The workflow to copy, for a setup by hand. */
28
+ text: string
29
+ }
30
+ /** Local mode only. */
31
+ pages?: PlutoPagesState
32
+ /** The default address of the Pages site. */
33
+ pagesUrl: string
34
+ }
35
+
36
+ /** The answer of `POST /api/_pluto/github/deploy/pages`. */
37
+ export interface PlutoPagesSetupResult {
38
+ status: PlutoDeployStatus
39
+ /** The workflow was written and committed. */
40
+ committed: boolean
41
+ /** Pages was turned on (or was on already) with the Actions source. */
42
+ pagesOn: boolean
43
+ }
@@ -0,0 +1,138 @@
1
+ /** The path of the GitHub Pages workflow that the Deploy page writes. */
2
+ export const PAGES_WORKFLOW_PATH = '.github/workflows/pluto-pages.yml'
3
+
4
+ export type PackageManager = 'npm' | 'pnpm' | 'yarn' | 'bun'
5
+
6
+ /** The lock file of each package manager, in the order the check reads them. */
7
+ const LOCK_FILES: [string, PackageManager][] = [
8
+ ['bun.lock', 'bun'],
9
+ ['bun.lockb', 'bun'],
10
+ ['pnpm-lock.yaml', 'pnpm'],
11
+ ['yarn.lock', 'yarn'],
12
+ ['package-lock.json', 'npm'],
13
+ ]
14
+
15
+ /** The package manager of a project, from the files at its root. Default npm. */
16
+ export function detectPackageManager(files: readonly string[]): PackageManager {
17
+ const names = new Set(files)
18
+ return LOCK_FILES.find(([file]) => names.has(file))?.[1] ?? 'npm'
19
+ }
20
+
21
+ /** A GitHub Actions expression: `${{ <expression> }}` in the workflow. */
22
+ function actionsExpression(expression: string): string {
23
+ return ['$', '{{ ', expression, ' }}'].join('')
24
+ }
25
+
26
+ /** The setup and install steps of each package manager, and the build command, as YAML lines. */
27
+ function buildSteps(manager: PackageManager): {
28
+ setup: string[]
29
+ build: string
30
+ } {
31
+ const node = [
32
+ ' - uses: actions/setup-node@v4',
33
+ ' with:',
34
+ ' node-version: 22',
35
+ ]
36
+ switch (manager) {
37
+ case 'bun':
38
+ return {
39
+ setup: [
40
+ ' - uses: oven-sh/setup-bun@v2',
41
+ ' - run: bun install --frozen-lockfile',
42
+ ],
43
+ build: 'bunx nuxt build --preset github_pages',
44
+ }
45
+ case 'pnpm':
46
+ return {
47
+ setup: [
48
+ ' - uses: pnpm/action-setup@v4',
49
+ ...node,
50
+ ' cache: pnpm',
51
+ ' - run: pnpm install --frozen-lockfile',
52
+ ],
53
+ build: 'pnpm exec nuxt build --preset github_pages',
54
+ }
55
+ case 'yarn':
56
+ return {
57
+ setup: [
58
+ ...node,
59
+ ' cache: yarn',
60
+ ' - run: yarn install --frozen-lockfile',
61
+ ],
62
+ build: 'yarn nuxt build --preset github_pages',
63
+ }
64
+ default:
65
+ return {
66
+ setup: [...node, ' cache: npm', ' - run: npm ci'],
67
+ build: 'npx nuxt build --preset github_pages',
68
+ }
69
+ }
70
+ }
71
+
72
+ /**
73
+ * The GitHub Actions workflow that builds the site as static files and deploys it to GitHub
74
+ * Pages, on each push to the content branch. The build reads the Markdown of the checkout, so it
75
+ * needs no secret. `PLUTO_BASE_URL` gets the sub path of a project site (`/<repo>`), or nothing
76
+ * for a user site or a custom domain.
77
+ */
78
+ export function renderPagesWorkflow(options: {
79
+ branch: string
80
+ packageManager: PackageManager
81
+ }): string {
82
+ const { setup, build } = buildSteps(options.packageManager)
83
+ return [
84
+ '# Builds the site as static files and deploys it to GitHub Pages, on each push.',
85
+ '# Pluto wrote this file. The build reads the Markdown of the repo: it needs no secret.',
86
+ 'name: Deploy to GitHub Pages',
87
+ '',
88
+ 'on:',
89
+ ' push:',
90
+ ` branches: [${JSON.stringify(options.branch)}]`,
91
+ ' workflow_dispatch:',
92
+ '',
93
+ 'permissions:',
94
+ ' contents: read',
95
+ ' pages: write',
96
+ ' id-token: write',
97
+ '',
98
+ 'concurrency:',
99
+ ' group: pages',
100
+ ' cancel-in-progress: true',
101
+ '',
102
+ 'jobs:',
103
+ ' build:',
104
+ ' runs-on: ubuntu-latest',
105
+ ' steps:',
106
+ ' - uses: actions/checkout@v4',
107
+ ' - id: pages',
108
+ ' uses: actions/configure-pages@v5',
109
+ ...setup,
110
+ ` - run: ${build}`,
111
+ ' env:',
112
+ ' # A project site lives under /<repo>. A user site or a custom domain has no path.',
113
+ ` PLUTO_BASE_URL: ${actionsExpression('steps.pages.outputs.base_path')}`,
114
+ ' - uses: actions/upload-pages-artifact@v3',
115
+ ' with:',
116
+ ' path: .output/public',
117
+ '',
118
+ ' deploy:',
119
+ ' needs: build',
120
+ ' runs-on: ubuntu-latest',
121
+ ' environment:',
122
+ ' name: github-pages',
123
+ ` url: ${actionsExpression('steps.deployment.outputs.page_url')}`,
124
+ ' steps:',
125
+ ' - id: deployment',
126
+ ' uses: actions/deploy-pages@v4',
127
+ '',
128
+ ].join('\n')
129
+ }
130
+
131
+ /** The default address of a GitHub Pages site: `https://<owner>.github.io/<repo>/`. */
132
+ export function defaultPagesUrl(repo: string): string {
133
+ const [owner = '', name = ''] = repo.split('/')
134
+ const host = `${owner.toLowerCase()}.github.io`
135
+ return name.toLowerCase() === host
136
+ ? `https://${host}/`
137
+ : `https://${host}/${name}/`
138
+ }
@@ -0,0 +1,97 @@
1
+ /** The GitHub role of a user who can sign in: admin, maintain, or write (push). */
2
+ export type PlutoGithubRepoRole = 'admin' | 'maintain' | 'write'
3
+
4
+ /** One person with write access to the content repo. */
5
+ export interface PlutoGithubRepoUser {
6
+ id: number
7
+ login: string
8
+ avatarUrl: string
9
+ /** The GitHub profile. */
10
+ url: string
11
+ role: PlutoGithubRepoRole
12
+ /** False when `ALLOWED_USERS` is set and does not have this login. */
13
+ canSignIn: boolean
14
+ }
15
+
16
+ /**
17
+ * The answer of `GET /api/_pluto/github/users`. `users` is empty with a `reason` when the list
18
+ * cannot load: `no-repo` (no repo is known), `no-app` (local mode with no GitHub App and no read
19
+ * token, so there is no token to ask GitHub) or `app-error` (local mode: the App gave no token; it
20
+ * is not installed on the repo, or its key is wrong).
21
+ */
22
+ export interface PlutoGithubUsersResponse {
23
+ repo: string
24
+ users: PlutoGithubRepoUser[]
25
+ /** True when `ALLOWED_USERS` limits the sign-in. */
26
+ restricted: boolean
27
+ reason?: 'no-repo' | 'no-app' | 'app-error'
28
+ }
29
+
30
+ /** The parts of a GitHub collaborator that Pluto reads. */
31
+ export interface GithubCollaborator {
32
+ id: number
33
+ login: string
34
+ avatar_url?: string
35
+ html_url?: string
36
+ role_name?: string
37
+ permissions?: {
38
+ admin?: boolean
39
+ maintain?: boolean
40
+ push?: boolean
41
+ }
42
+ }
43
+
44
+ /** The role of a collaborator, or `null` when it cannot push (triage, read). */
45
+ export function repoRoleOf(
46
+ collaborator: GithubCollaborator
47
+ ): PlutoGithubRepoRole | null {
48
+ const permissions = collaborator.permissions ?? {}
49
+ if (permissions.admin || collaborator.role_name === 'admin') {
50
+ return 'admin'
51
+ }
52
+ if (permissions.maintain || collaborator.role_name === 'maintain') {
53
+ return 'maintain'
54
+ }
55
+ if (permissions.push || collaborator.role_name === 'write') {
56
+ return 'write'
57
+ }
58
+ return null
59
+ }
60
+
61
+ const ROLE_ORDER: Record<PlutoGithubRepoRole, number> = {
62
+ admin: 0,
63
+ maintain: 1,
64
+ write: 2,
65
+ }
66
+
67
+ /**
68
+ * Turns the collaborators into the users of the page: only the ones who can push, admins first,
69
+ * then by login. `allowedUsers` holds lowercase logins; empty means every user can sign in.
70
+ */
71
+ export function toRepoUsers(
72
+ collaborators: GithubCollaborator[],
73
+ allowedUsers: readonly string[]
74
+ ): PlutoGithubRepoUser[] {
75
+ const users: PlutoGithubRepoUser[] = []
76
+ for (const collaborator of collaborators) {
77
+ const role = repoRoleOf(collaborator)
78
+ if (!role) {
79
+ continue
80
+ }
81
+ users.push({
82
+ id: collaborator.id,
83
+ login: collaborator.login,
84
+ avatarUrl: collaborator.avatar_url ?? '',
85
+ url: collaborator.html_url ?? `https://github.com/${collaborator.login}`,
86
+ role,
87
+ canSignIn:
88
+ allowedUsers.length === 0 ||
89
+ allowedUsers.includes(collaborator.login.toLowerCase()),
90
+ })
91
+ }
92
+ return users.sort(
93
+ (a, b) =>
94
+ ROLE_ORDER[a.role] - ROLE_ORDER[b.role] ||
95
+ a.login.localeCompare(b.login, 'en', { sensitivity: 'base' })
96
+ )
97
+ }
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@plutocms/core",
3
3
  "type": "module",
4
- "version": "0.47.0",
4
+ "version": "0.48.0",
5
5
  "description": "The base layer every Pluto app extends: the admin, the content model, and the Git CMS",
6
6
  "author": "Victor Ribeiro",
7
7
  "license": "MIT",
@@ -40,6 +40,8 @@ declare module '@nuxt/schema' {
40
40
  postType: boolean
41
41
  /** True when a save adds `[skip ci]`. The build status item shows only when this is false. */
42
42
  skipDeploy: boolean
43
+ /** A static build (`nuxt generate`, `github_pages`): no server, so no session request. */
44
+ static: boolean
43
45
  }
44
46
  }
45
47
  }
@@ -355,6 +355,26 @@ export interface PlutoSiteInfo extends PlutoEntry {
355
355
  url?: () => string | null | undefined
356
356
  }
357
357
 
358
+ /**
359
+ * The backend that stores the content: a Git repo or a database. The admin navbar shows the
360
+ * last registered one as a badge, with a popover. See the admin-shell skill.
361
+ */
362
+ export interface PlutoBackendInfo extends PlutoEntry {
363
+ /** `git`: Markdown files in a Git repo. `database`: rows in a database. */
364
+ kind: 'git' | 'database'
365
+ /** A translation key or literal: the name in the badge ("GitHub", "Supabase"). */
366
+ label: string
367
+ icon?: string
368
+ /** A number on the badge, for example the commits that are not pushed. 0 shows none. */
369
+ count?: () => number | null | undefined
370
+ /** A translation key or literal: the text next to the count ("{count} to push"). */
371
+ countLabel?: string
372
+ /** The body of the popover. Use an async component. */
373
+ details?: Component
374
+ /** Called when the popover opens, for example to read the state again. */
375
+ onOpen?: () => void
376
+ }
377
+
358
378
  export interface PlutoExtension {
359
379
  /** Layer name. Use the package's $meta.name, e.g. 'supabase-blog'. */
360
380
  id: string
@@ -373,6 +393,7 @@ export interface PlutoExtension {
373
393
  accountMenu?: PlutoAccountMenuItem[]
374
394
  adminBanners?: PlutoAdminBanner[]
375
395
  site?: PlutoSiteInfo
396
+ backend?: PlutoBackendInfo
376
397
  contentTypes?: PlutoContentTypeEntry[]
377
398
  contentFieldWidgets?: PlutoContentFieldWidget[]
378
399
  contentActions?: PlutoContentAction[]
@@ -409,6 +430,7 @@ export interface PlutoRegistry {
409
430
  accountMenu: PlutoRegistryBucket<PlutoAccountMenuItem>
410
431
  adminBanners: PlutoRegistryBucket<PlutoAdminBanner>
411
432
  siteInfo: PlutoRegistryBucket<PlutoSiteInfo>
433
+ backend: PlutoRegistryBucket<PlutoBackendInfo>
412
434
  contentTypes: PlutoRegistryBucket<PlutoContentTypeEntry>
413
435
  contentFieldWidgets: PlutoRegistryBucket<PlutoContentFieldWidget>
414
436
  contentActions: PlutoRegistryBucket<PlutoContentAction>
@@ -0,0 +1,23 @@
1
+ /**
2
+ * Puts the base path of the app before each site path (`/media/...`, `/posts/...`) in the `src`
3
+ * and `href` attributes of sanitized HTML. A site under a sub path, such as a project site of
4
+ * GitHub Pages (`/<repo>/`), needs it: the body of a post holds plain site paths, and the browser
5
+ * reads them from the root of the host.
6
+ *
7
+ * It changes nothing when the base is `/`, for a URL with a scheme or a protocol-relative URL
8
+ * (`//host`), for a relative path, an anchor or a query, and for a path that already starts with
9
+ * the base. The HTML comes from the public API, so each attribute value is in double quotes.
10
+ */
11
+ export function withBaseInHtml(html: string, baseURL: string): string {
12
+ if (!html || !baseURL || baseURL === '/') {
13
+ return html
14
+ }
15
+ const base = baseURL.endsWith('/') ? baseURL : `${baseURL}/`
16
+ return html.replace(
17
+ /(\s(?:src|href)=")(\/[^"]*)"/g,
18
+ (match, attribute: string, path: string) =>
19
+ path.startsWith('//') || path.startsWith(base)
20
+ ? match
21
+ : `${attribute}${base}${path.slice(1)}"`
22
+ )
23
+ }