@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.
- package/CHANGELOG.md +11 -0
- package/FEATURES.md +15 -4
- package/app/components/Pluto/Admin/PlutoAdminBackendBadge.vue +84 -0
- package/app/components/Pluto/Admin/PlutoAdminNavbar.vue +7 -0
- package/app/components/Pluto/Github/PlutoGithubBackendDetails.vue +180 -0
- package/app/components/Pluto/Github/PlutoGithubBuildStatus.vue +17 -4
- package/app/components/Pluto/Github/PlutoGithubDeployPage.vue +472 -0
- package/app/components/Pluto/Github/PlutoGithubUsersPage.vue +214 -0
- package/app/components/Pluto/PlutoHtml.vue +8 -2
- package/app/composables/internal/async-component.ts +9 -0
- package/app/composables/pluto-extension.ts +4 -0
- package/app/composables/pluto-navbar.ts +25 -0
- package/app/composables/pluto-registry.ts +1 -0
- package/i18n/locales/en.json +164 -2
- package/i18n/locales/pt-BR.json +164 -2
- package/modules/pluto-github/index.ts +127 -51
- package/modules/pluto-github/options.ts +18 -5
- package/modules/pluto-github/runtime/app/composables/pluto-github-local-git.ts +50 -0
- package/modules/pluto-github/runtime/app/composables/pluto-github-session.ts +8 -1
- package/modules/pluto-github/runtime/app/plugins/pluto-github.ts +84 -1
- package/modules/pluto-github/runtime/server/adapter.ts +26 -7
- package/modules/pluto-github/runtime/server/api/deploy/pages.post.ts +5 -0
- package/modules/pluto-github/runtime/server/api/deploy/status.get.ts +5 -0
- package/modules/pluto-github/runtime/server/api/local/push.post.ts +5 -0
- package/modules/pluto-github/runtime/server/api/local/status.get.ts +5 -0
- package/modules/pluto-github/runtime/server/api/users.get.ts +5 -0
- package/modules/pluto-github/runtime/server/store/github.ts +4 -1
- package/modules/pluto-github/runtime/server/store/local-git.ts +145 -0
- package/modules/pluto-github/runtime/server/store/memory.ts +4 -1
- package/modules/pluto-github/runtime/server/utils/build-status.ts +16 -7
- package/modules/pluto-github/runtime/server/utils/deploy.ts +204 -0
- package/modules/pluto-github/runtime/server/utils/github-setup.ts +1 -3
- package/modules/pluto-github/runtime/server/utils/github-users.ts +120 -0
- package/modules/pluto-github/runtime/server/utils/local-git-routes.ts +95 -0
- package/modules/pluto-github/runtime/shared/build-status.ts +26 -0
- package/modules/pluto-github/runtime/shared/client-rules.ts +16 -0
- package/modules/pluto-github/runtime/shared/deploy.ts +43 -0
- package/modules/pluto-github/runtime/shared/pages-workflow.ts +138 -0
- package/modules/pluto-github/runtime/shared/repo-users.ts +97 -0
- package/package.json +1 -1
- package/shared/types/github.d.ts +2 -0
- package/shared/types/registry.ts +22 -0
- 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
package/shared/types/github.d.ts
CHANGED
|
@@ -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
|
}
|
package/shared/types/registry.ts
CHANGED
|
@@ -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
|
+
}
|