diff --git a/packages/core/dev-test/backends/github/config.yml b/packages/core/dev-test/backends/github/config.yml index 2f908ce45..41b11b13d 100644 --- a/packages/core/dev-test/backends/github/config.yml +++ b/packages/core/dev-test/backends/github/config.yml @@ -2,7 +2,9 @@ backend: name: github branch: main repo: staticjscms/static-cms-github - + auth_scope: repo # this is needed to fork the private repo + open_authoring: true +publish_mode: editorial_workflow media_folder: assets/upload public_folder: /assets/upload media_library: diff --git a/packages/core/src/__tests__/backend.spec.ts b/packages/core/src/__tests__/backend.spec.ts index 690551060..f713a6b22 100644 --- a/packages/core/src/__tests__/backend.spec.ts +++ b/packages/core/src/__tests__/backend.spec.ts @@ -490,6 +490,7 @@ describe('Backend', () => { mediaFiles: [{ id: '1', draft: true }], status: WorkflowStatus.DRAFT, updatedOn: '20230-02-09T00:00:00.000Z', + openAuthoring: false, }); }); }); diff --git a/packages/core/src/actions/__tests__/config.spec.ts b/packages/core/src/actions/__tests__/config.spec.ts index 93345e7e5..394a0322d 100644 --- a/packages/core/src/actions/__tests__/config.spec.ts +++ b/packages/core/src/actions/__tests__/config.spec.ts @@ -889,7 +889,7 @@ describe('config', () => { ).toThrow("i18n locales 'en, de' are missing the default locale fr"); }); - it('should throw is default locale is missing from collection i18n config', () => { + it('should throw if default locale is missing from collection i18n config', () => { expect(() => applyDefaults( createMockConfig({ diff --git a/packages/core/src/actions/auth.ts b/packages/core/src/actions/auth.ts index 42a2ece13..707cd3279 100644 --- a/packages/core/src/actions/auth.ts +++ b/packages/core/src/actions/auth.ts @@ -2,6 +2,7 @@ import { currentBackend } from '../backend'; import { AUTH_FAILURE, AUTH_REQUEST, AUTH_REQUEST_DONE, AUTH_SUCCESS, LOGOUT } from '../constants'; import { invokeEvent } from '../lib/registry'; import { addSnackbar } from '../store/slices/snackbars'; +import { useOpenAuthoring } from './globalUI'; import type { AnyAction } from 'redux'; import type { ThunkDispatch } from 'redux-thunk'; @@ -57,6 +58,9 @@ export function authenticateUser() { return Promise.resolve(backend.currentUser()) .then(user => { if (user) { + if (user.useOpenAuthoring) { + dispatch(useOpenAuthoring()); + } dispatch(authenticate(user)); } else { dispatch(doneAuthenticating()); @@ -85,6 +89,9 @@ export function loginUser(credentials: Credentials) { return backend .authenticate(credentials) .then(user => { + if (user.useOpenAuthoring) { + dispatch(useOpenAuthoring()); + } dispatch(authenticate(user)); }) .catch((error: unknown) => { diff --git a/packages/core/src/actions/config.ts b/packages/core/src/actions/config.ts index 0f65a5021..cec5fe455 100644 --- a/packages/core/src/actions/config.ts +++ b/packages/core/src/actions/config.ts @@ -90,16 +90,17 @@ function setI18nField(field: T) { function getI18nDefaults( collectionOrFileI18n: boolean | Partial, - defaultI18n: I18nInfo, + { default_locale, locales = ['en'], structure = I18N_STRUCTURE_SINGLE_FILE }: Partial, ): I18nInfo { if (typeof collectionOrFileI18n === 'boolean') { - return defaultI18n; + return { default_locale, locales, structure }; } else { - const locales = collectionOrFileI18n.locales || defaultI18n.locales; - const defaultLocale = collectionOrFileI18n.default_locale || locales?.[0]; - const mergedI18n: I18nInfo = deepmerge(defaultI18n, collectionOrFileI18n); - mergedI18n.locales = locales ?? []; - mergedI18n.default_locale = defaultLocale; + const mergedI18n: I18nInfo = deepmerge( + { default_locale, locales, structure }, + collectionOrFileI18n, + ); + mergedI18n.locales = collectionOrFileI18n.locales ?? locales; + mergedI18n.default_locale = collectionOrFileI18n.default_locale || locales?.[0]; throwOnMissingDefaultLocale(mergedI18n); return mergedI18n; } diff --git a/packages/core/src/actions/globalUI.ts b/packages/core/src/actions/globalUI.ts index f636fadd7..1466b8dc7 100644 --- a/packages/core/src/actions/globalUI.ts +++ b/packages/core/src/actions/globalUI.ts @@ -1,8 +1,14 @@ /* eslint-disable import/prefer-default-export */ -import { THEME_CHANGE } from '../constants'; +import { THEME_CHANGE, USE_OPEN_AUTHORING } from '../constants'; + +export function useOpenAuthoring() { + return { + type: USE_OPEN_AUTHORING, + } as const; +} export function changeTheme(theme: string) { return { type: THEME_CHANGE, payload: theme } as const; } -export type GlobalUIAction = ReturnType; +export type GlobalUIAction = ReturnType; diff --git a/packages/core/src/backend.ts b/packages/core/src/backend.ts index 0b5defe29..4e973265e 100644 --- a/packages/core/src/backend.ts +++ b/packages/core/src/backend.ts @@ -1024,13 +1024,18 @@ export class Backend f?.name === slug); @@ -1210,10 +1230,10 @@ export class Backend { @@ -1223,8 +1243,8 @@ export class Backend { const { collection, slug } = parseContentKey(contentKey); const branch = branchFromContentKey(contentKey); const pullRequest = await this.getBranchPullRequest(branch); @@ -665,6 +665,7 @@ export default class API { .map(d => ({ path: d.path, newFile: d.newFile, id: '' })), updatedAt, pullRequestAuthor, + openAuthoring: false, }; } diff --git a/packages/core/src/backends/bitbucket/implementation.ts b/packages/core/src/backends/bitbucket/implementation.ts index fe4eb7e42..18cce8a2e 100644 --- a/packages/core/src/backends/bitbucket/implementation.ts +++ b/packages/core/src/backends/bitbucket/implementation.ts @@ -43,6 +43,7 @@ import type { DisplayURL, ImplementationFile, PersistOptions, + UnpublishedEntry, User, } from '@staticcms/core/interface'; import type { ApiRequest, AsyncLock, Cursor, FetchError } from '@staticcms/core/lib/util'; @@ -572,7 +573,7 @@ export default class BitbucketBackend implements BackendClass { id?: string; collection?: string; slug?: string; - }) { + }): Promise { if (id) { const data = await this.api!.retrieveUnpublishedEntryData(id); return data; diff --git a/packages/core/src/backends/git-gateway/implementation.tsx b/packages/core/src/backends/git-gateway/implementation.tsx index 15e1b038a..d435393da 100644 --- a/packages/core/src/backends/git-gateway/implementation.tsx +++ b/packages/core/src/backends/git-gateway/implementation.tsx @@ -36,6 +36,7 @@ import type { DisplayURLObject, ImplementationFile, PersistOptions, + UnpublishedEntry, User, } from '@staticcms/core/interface'; import type { ApiRequest, Cursor } from '@staticcms/core/lib/util'; @@ -563,7 +564,15 @@ export default class GitGateway implements BackendClass { return this.backend!.unpublishedEntries(); } - unpublishedEntry({ id, collection, slug }: { id?: string; collection?: string; slug?: string }) { + unpublishedEntry({ + id, + collection, + slug, + }: { + id?: string; + collection?: string; + slug?: string; + }): Promise { return this.backend!.unpublishedEntry({ id, collection, slug }); } diff --git a/packages/core/src/backends/github/API.ts b/packages/core/src/backends/github/API.ts index 11fc27bd6..10f619afe 100644 --- a/packages/core/src/backends/github/API.ts +++ b/packages/core/src/backends/github/API.ts @@ -33,7 +33,7 @@ import { } from '@staticcms/core/lib/util/APIUtils'; import { GitHubCommitStatusState, PullRequestState } from './types'; -import type { DataFile, PersistOptions } from '@staticcms/core/interface'; +import type { DataFile, PersistOptions, UnpublishedEntry } from '@staticcms/core/interface'; import type { ApiRequest, FetchError } from '@staticcms/core/lib/util'; import type AssetProxy from '@staticcms/core/valueObjects/AssetProxy'; import type { Semaphore } from 'semaphore'; @@ -77,6 +77,7 @@ export interface Config { token?: string; branch?: string; useOpenAuthoring?: boolean; + openAuthoringEnabled?: boolean; repo?: string; originRepo?: string; squashMerges: boolean; @@ -163,6 +164,7 @@ export default class API { token: string; branch: string; useOpenAuthoring?: boolean; + openAuthoringEnabled?: boolean; repo: string; originRepo: string; repoOwner: string; @@ -201,6 +203,7 @@ export default class API { this.mergeMethod = config.squashMerges ? 'squash' : 'merge'; this.cmsLabelPrefix = config.cmsLabelPrefix; this.initialWorkflowStatus = config.initialWorkflowStatus; + this.openAuthoringEnabled = config.openAuthoringEnabled; } static DEFAULT_COMMIT_MESSAGE = 'Automatically generated by Static CMS'; @@ -337,11 +340,27 @@ export default class API { } generateContentKey(collectionName: string, slug: string) { - return generateContentKey(collectionName, slug); + const contentKey = generateContentKey(collectionName, slug); + if (this.useOpenAuthoring) { + return `${this.repo}/${contentKey}`; + } + + return contentKey; + } + + getContentKeySlug(contentKey: string) { + let key = contentKey; + + const parts = contentKey.split(this.repoName); + if (parts.length > 1) { + key = parts[1]; + } + + return key.replace(/^\//g, '').replace(/^cms\//g, ''); } parseContentKey(contentKey: string) { - return parseContentKey(contentKey); + return parseContentKey(this.getContentKeySlug(contentKey)); } async readFile( @@ -497,6 +516,10 @@ export default class API { } async deleteFiles(paths: string[], message: string) { + if (this.useOpenAuthoring) { + return Promise.reject('Cannot delete published entries as an Open Authoring user!'); + } + const branchData = await this.getDefaultBranch(); const files = paths.map(path => ({ path, sha: null })); const changeTree = await this.updateTree(branchData.commit.sha, files); @@ -669,6 +692,13 @@ export default class API { branches.map(b => this.filterOpenAuthoringBranches(b)), ); branches = branchesWithFilter.filter(b => b.filter).map(b => b.branch); + } else if (this.openAuthoringEnabled) { + const cmsPullRequests = await this.getPullRequests( + undefined, + PullRequestState.Open, + () => true, + ); + branches = cmsPullRequests.map(pr => pr.head.ref); } else { const cmsPullRequests = await this.getPullRequests(undefined, PullRequestState.Open, pr => withCmsLabel(pr, this.cmsLabelPrefix), @@ -680,10 +710,9 @@ export default class API { } async getOpenAuthoringBranches() { - const cmsBranches = await this.requestAllPages( + return this.requestAllPages( `${this.repoURL}/git/refs/heads/cms/${this.repo}`, ).catch(() => [] as GitListMatchingRefsResponseItem[]); - return cmsBranches; } filterOpenAuthoringBranches = async (branch: string) => { @@ -723,9 +752,9 @@ export default class API { }, ); - return pullRequests.filter( - pr => pr.head.ref.startsWith(`${CMS_BRANCH_PREFIX}/`) && predicate(pr), - ); + return pullRequests.filter(pr => { + return pr.head.ref.startsWith(`${CMS_BRANCH_PREFIX}/`) && predicate(pr); + }); } deleteBranch(branchName: string) { @@ -745,6 +774,14 @@ export default class API { if (this.useOpenAuthoring) { const pullRequests = await this.getPullRequests(branch, PullRequestState.All, () => true); return this.getOpenAuthoringPullRequest(branch, pullRequests); + } else if (this.openAuthoringEnabled) { + const pullRequests = await this.getPullRequests(undefined, PullRequestState.Open, pr => { + return this.getContentKeySlug(pr.head.ref) === this.getContentKeySlug(branch); + }); + if (pullRequests.length <= 0) { + throw new EditorialWorkflowError('content is not under editorial workflow', true); + } + return pullRequests[0]; } else { const pullRequests = await this.getPullRequests(branch, PullRequestState.Open, pr => withCmsLabel(pr, this.cmsLabelPrefix), @@ -797,7 +834,7 @@ export default class API { return result; } - async retrieveUnpublishedEntryData(contentKey: string) { + async retrieveUnpublishedEntryData(contentKey: string): Promise { const { collection, slug } = this.parseContentKey(contentKey); const branch = branchFromContentKey(contentKey); const pullRequest = await this.getBranchPullRequest(branch); @@ -809,8 +846,11 @@ export default class API { const label = pullRequest.labels.find(l => isCMSLabel(l.name, this.cmsLabelPrefix)) as { name: string; }; - const status = labelToStatus(label.name, this.cmsLabelPrefix); + const status = label + ? labelToStatus(label.name, this.cmsLabelPrefix) + : WorkflowStatus.PENDING_REVIEW; const updatedAt = pullRequest.updated_at; + return { collection, slug, @@ -818,6 +858,8 @@ export default class API { diffs: diffs.map(d => ({ path: d.path, newFile: d.newFile, id: d.sha })), updatedAt, pullRequestAuthor, + openAuthoring: + !pullRequest.head.ref.includes(this.repo) && pullRequest.head.ref.includes(this.repoName), }; } diff --git a/packages/core/src/backends/github/AuthenticationPage.css b/packages/core/src/backends/github/AuthenticationPage.css new file mode 100644 index 000000000..c382aa072 --- /dev/null +++ b/packages/core/src/backends/github/AuthenticationPage.css @@ -0,0 +1,26 @@ +.CMS_Github_AuthenticationPage_fork-approve-container { + @apply flex + flex-col + flex-nowrap + justify-around + flex-grow-[0.2]; +} + +.CMS_Github_AuthenticationPage_fork-text { + @apply max-w-[600px] + w-full + px-2 + my-2 + justify-center + items-center + text-center; +} + +.CMS_Github_AuthenticationPage_fork-buttons { + @apply flex + flex-col + flex-nowrap + justify-around + items-center + gap-2; +} diff --git a/packages/core/src/backends/github/AuthenticationPage.tsx b/packages/core/src/backends/github/AuthenticationPage.tsx index 71de92282..653a49eff 100644 --- a/packages/core/src/backends/github/AuthenticationPage.tsx +++ b/packages/core/src/backends/github/AuthenticationPage.tsx @@ -1,12 +1,24 @@ import { Github as GithubIcon } from '@styled-icons/simple-icons/Github'; import React, { useCallback, useState } from 'react'; +import Button from '@staticcms/core/components/common/button/Button'; import Login from '@staticcms/core/components/login/Login'; import { NetlifyAuthenticator } from '@staticcms/core/lib/auth'; +import useCurrentBackend from '@staticcms/core/lib/hooks/useCurrentBackend'; import useTranslate from '@staticcms/core/lib/hooks/useTranslate'; +import { generateClassNames } from '@staticcms/core/lib/util/theming.util'; -import type { AuthenticationPageProps } from '@staticcms/core/interface'; +import type { AuthenticationPageProps, User } from '@staticcms/core/interface'; import type { FC, MouseEvent } from 'react'; +import type GitHub from './implementation'; + +import './AuthenticationPage.css'; + +const classes = generateClassNames('Github_AuthenticationPage', [ + 'fork-approve-container', + 'fork-text', + 'fork-buttons', +]); const GitHubAuthenticationPage: FC = ({ inProgress = false, @@ -19,6 +31,53 @@ const GitHubAuthenticationPage: FC = ({ const t = useTranslate(); const [loginError, setLoginError] = useState(null); + const [forkState, setForkState] = useState<{ + requestingFork?: boolean; + findingFork?: boolean; + approveFork?: () => void; + }>(); + + const { requestingFork = false, findingFork = false, approveFork } = forkState ?? {}; + + const backend = useCurrentBackend(); + + const getPermissionToFork = useCallback(() => { + return new Promise(resolve => { + setForkState({ + findingFork: true, + requestingFork: true, + approveFork: () => { + setForkState({ + findingFork: true, + requestingFork: false, + }); + resolve(true); + }, + }); + }); + }, []); + + const loginWithOpenAuthoring = useCallback( + (userData: User): Promise => { + if (backend?.backendName !== 'github') { + return Promise.resolve(); + } + + const githubBackend = backend.implementation as GitHub; + + setForkState({ findingFork: true }); + return githubBackend + .authenticateWithFork({ userData, getPermissionToFork }) + .then(() => { + setForkState({ findingFork: false }); + }) + .catch(() => { + setForkState({ findingFork: false }); + console.error('Cannot create fork'); + }); + }, + [backend?.backendName, backend?.implementation, getPermissionToFork], + ); const handleLogin = useCallback( (e: MouseEvent) => { @@ -30,18 +89,25 @@ const GitHubAuthenticationPage: FC = ({ }; const auth = new NetlifyAuthenticator(cfg); - const { auth_scope: authScope = '' } = config.backend; + const { auth_scope: authScope = '', open_authoring: openAuthoringEnabled } = config.backend; - const scope = authScope || 'repo'; + const scope = authScope || (openAuthoringEnabled ? 'public_repo' : 'repo'); auth.authenticate({ provider: 'github', scope }, (err, data) => { if (err) { setLoginError(err.toString()); - } else if (data) { + return; + } + + if (data) { + if (openAuthoringEnabled) { + return loginWithOpenAuthoring(data).then(() => onLogin(data)); + } + onLogin(data); } }); }, - [authEndpoint, base_url, config.backend, onLogin, siteId], + [authEndpoint, base_url, config.backend, loginWithOpenAuthoring, onLogin, siteId], ); return ( @@ -49,8 +115,18 @@ const GitHubAuthenticationPage: FC = ({ login={handleLogin} label={t('auth.loginWithGitHub')} icon={GithubIcon} - inProgress={inProgress} + inProgress={inProgress || findingFork || requestingFork} error={loginError} + buttonContent={ + requestingFork ? ( +
+

{t('workflow.openAuthoring.forkRequired')}

+
+ +
+
+ ) : null + } /> ); }; diff --git a/packages/core/src/backends/github/implementation.tsx b/packages/core/src/backends/github/implementation.tsx index b486ef0b1..de2b57bbc 100644 --- a/packages/core/src/backends/github/implementation.tsx +++ b/packages/core/src/backends/github/implementation.tsx @@ -32,6 +32,7 @@ import type { DisplayURL, ImplementationFile, PersistOptions, + UnpublishedEntry, UnpublishedEntryMediaFile, User, } from '@staticcms/core/interface'; @@ -158,7 +159,35 @@ export default class GitHub implements BackendClass { } restoreUser(user: User) { - return this.authenticate(user); + return this.openAuthoringEnabled + ? this.authenticateWithFork({ userData: user, getPermissionToFork: () => true }).then(() => + this.authenticate(user), + ) + : this.authenticate(user); + } + + async pollUntilForkExists({ repo, token }: { repo: string; token: string }) { + const pollDelay = 250; // milliseconds + let repoExists = false; + while (!repoExists) { + repoExists = await fetch(`${this.apiRoot}/repos/${repo}`, { + headers: { Authorization: `token ${token}` }, + }) + .then(() => true) + .catch(err => { + if (err && err.status === 404) { + console.info('This 404 was expected and handled appropriately.'); + return false; + } else { + return Promise.reject(err); + } + }); + // wait between polls + if (!repoExists) { + await new Promise(resolve => setTimeout(resolve, pollDelay)); + } + } + return Promise.resolve(); } async currentUser({ token }: { token: string }) { @@ -196,6 +225,65 @@ export default class GitHub implements BackendClass { return this._userIsOriginMaintainerPromises[username]; } + async forkExists({ token }: { token: string }) { + try { + const currentUser = await this.currentUser({ token }); + const repoName = this.originRepo.split('/')[1]; + const repo = await fetch(`${this.apiRoot}/repos/${currentUser.login}/${repoName}`, { + method: 'GET', + headers: { + Authorization: `token ${token}`, + }, + }).then(res => res.json()); + + // https://developer.github.com/v3/repos/#get + // The parent and source objects are present when the repository is a fork. + // parent is the repository this repository was forked from, source is the ultimate source for the network. + const forkExists = + repo.fork === true && + repo.parent && + repo.parent.full_name.toLowerCase() === this.originRepo.toLowerCase(); + return forkExists; + } catch { + return false; + } + } + + async authenticateWithFork({ + userData, + getPermissionToFork, + }: { + userData: User; + getPermissionToFork: () => Promise | boolean; + }) { + if (!this.openAuthoringEnabled) { + throw new Error('Cannot authenticate with fork; Open Authoring is turned off.'); + } + const token = userData.token as string; + + // Origin maintainers should be able to use the CMS normally. If alwaysFork + // is enabled we always fork (and avoid the origin maintainer check) + if (!this.alwaysForkEnabled && (await this.userIsOriginMaintainer({ token }))) { + this.repo = this.originRepo; + this.useOpenAuthoring = false; + return Promise.resolve(); + } + + if (!(await this.forkExists({ token }))) { + await getPermissionToFork(); + } + + const fork = await fetch(`${this.apiRoot}/repos/${this.originRepo}/forks`, { + method: 'POST', + headers: { + Authorization: `token ${token}`, + }, + }).then(res => res.json()); + this.useOpenAuthoring = true; + this.repo = fork.full_name; + return this.pollUntilForkExists({ repo: fork.full_name, token }); + } + async authenticate(state: Credentials) { this.token = state.token as string; const apiCtor = API; @@ -208,6 +296,7 @@ export default class GitHub implements BackendClass { squashMerges: this.squashMerges, cmsLabelPrefix: this.cmsLabelPrefix, useOpenAuthoring: this.useOpenAuthoring, + openAuthoringEnabled: this.openAuthoringEnabled, initialWorkflowStatus: this.options.initialWorkflowStatus, }); const user = await this.api!.user(); @@ -231,7 +320,7 @@ export default class GitHub implements BackendClass { } // Authorized user - return { ...user, token: state.token as string }; + return { ...user, token: state.token as string, useOpenAuthoring: this.useOpenAuthoring }; } logout() { @@ -327,7 +416,7 @@ export default class GitHub implements BackendClass { } entriesByFiles(files: ImplementationFile[]) { - const repoURL = this.api!.repoURL; + const repoURL = this.useOpenAuthoring ? this.api!.originRepoURL : this.api!.repoURL; const readFile = (path: string, id: string | null | undefined) => this.api!.readFile(path, id, { repoURL }).catch(() => '') as Promise; @@ -485,14 +574,12 @@ export default class GitHub implements BackendClass { id?: string; collection?: string; slug?: string; - }) { + }): Promise { if (id) { - const data = await this.api!.retrieveUnpublishedEntryData(id); - return data; + return this.api!.retrieveUnpublishedEntryData(id); } else if (collection && slug) { const entryId = this.api!.generateContentKey(collection, slug); - const data = await this.api!.retrieveUnpublishedEntryData(entryId); - return data; + return this.api!.retrieveUnpublishedEntryData(entryId); } else { throw new Error('Missing unpublished entry id or collection and slug'); } diff --git a/packages/core/src/backends/gitlab/API.ts b/packages/core/src/backends/gitlab/API.ts index d1f93abfa..e03cd0d59 100644 --- a/packages/core/src/backends/gitlab/API.ts +++ b/packages/core/src/backends/gitlab/API.ts @@ -31,7 +31,7 @@ import { } from '@staticcms/core/lib/util/APIUtils'; import type { WorkflowStatus } from '@staticcms/core/constants/publishModes'; -import type { DataFile, PersistOptions } from '@staticcms/core/interface'; +import type { DataFile, PersistOptions, UnpublishedEntry } from '@staticcms/core/interface'; import type { ApiRequest, FetchError } from '@staticcms/core/lib/util'; import type AssetProxy from '@staticcms/core/valueObjects/AssetProxy'; @@ -667,7 +667,7 @@ export default class API { return mergeRequests[0]; } - async retrieveUnpublishedEntryData(contentKey: string) { + async retrieveUnpublishedEntryData(contentKey: string): Promise { const { collection, slug } = parseContentKey(contentKey); const branch = branchFromContentKey(contentKey); const mergeRequest = await this.getBranchMergeRequest(branch); @@ -690,6 +690,7 @@ export default class API { diffs: diffsWithIds, updatedAt, pullRequestAuthor, + openAuthoring: false, }; } diff --git a/packages/core/src/backends/gitlab/implementation.ts b/packages/core/src/backends/gitlab/implementation.ts index 882b39e2c..60ff93712 100644 --- a/packages/core/src/backends/gitlab/implementation.ts +++ b/packages/core/src/backends/gitlab/implementation.ts @@ -37,6 +37,7 @@ import type { DisplayURL, ImplementationFile, PersistOptions, + UnpublishedEntry, UnpublishedEntryMediaFile, User, } from '@staticcms/core/interface'; @@ -56,6 +57,7 @@ export default class GitLab implements BackendClass { }; repo: string; branch: string; + useOpenAuthoring?: boolean; apiRoot: string; token: string | null; squashMerges: boolean; @@ -352,14 +354,12 @@ export default class GitLab implements BackendClass { id?: string; collection?: string; slug?: string; - }) { + }): Promise { if (id) { - const data = await this.api!.retrieveUnpublishedEntryData(id); - return data; + return this.api!.retrieveUnpublishedEntryData(id); } else if (collection && slug) { const entryId = generateContentKey(collection, slug); - const data = await this.api!.retrieveUnpublishedEntryData(entryId); - return data; + return this.api!.retrieveUnpublishedEntryData(entryId); } else { throw new Error('Missing unpublished entry id or collection and slug'); } diff --git a/packages/core/src/backends/test/implementation.ts b/packages/core/src/backends/test/implementation.ts index 79fff9997..13825c5f1 100644 --- a/packages/core/src/backends/test/implementation.ts +++ b/packages/core/src/backends/test/implementation.ts @@ -26,6 +26,7 @@ import type { ImplementationFile, ImplementationMediaFile, PersistOptions, + UnpublishedEntry, User, } from '@staticcms/core/interface'; @@ -379,7 +380,15 @@ export default class TestBackend implements BackendClass { return Promise.resolve(Object.keys(window.repoFilesUnpublished)); } - unpublishedEntry({ id, collection, slug }: { id?: string; collection?: string; slug?: string }) { + unpublishedEntry({ + id, + collection, + slug, + }: { + id?: string; + collection?: string; + slug?: string; + }): Promise { if (id) { const parts = id.split('/'); collection = parts[0]; @@ -392,7 +401,10 @@ export default class TestBackend implements BackendClass { ); } - return Promise.resolve(entry); + return Promise.resolve({ + ...entry, + openAuthoring: false, + }); } async unpublishedEntryDataFile(collection: string, slug: string, path: string) { diff --git a/packages/core/src/components/App.tsx b/packages/core/src/components/App.tsx index baf4f56fb..e0e3c76c6 100644 --- a/packages/core/src/components/App.tsx +++ b/packages/core/src/components/App.tsx @@ -15,6 +15,7 @@ import TopBarProgress from 'react-topbar-progress-indicator'; import { loginUser as loginUserAction } from '@staticcms/core/actions/auth'; import { discardDraft } from '@staticcms/core/actions/entries'; import { currentBackend } from '@staticcms/core/backend'; +import { loadUnpublishedEntries } from '../actions/editorialWorkflow'; import { changeTheme } from '../actions/globalUI'; import useDefaultPath from '../lib/hooks/useDefaultPath'; import useTranslate from '../lib/hooks/useTranslate'; @@ -162,6 +163,14 @@ const App: FC = ({ dispatch(discardDraft()); }, [dispatch, pathname, searchParams]); + useEffect(() => { + if (!user || !useWorkflow) { + return; + } + + dispatch(loadUnpublishedEntries(collections)); + }, [collections, dispatch, useWorkflow, user]); + const [prevUser, setPrevUser] = useState(user); useEffect(() => { if (!prevUser && user) { diff --git a/packages/core/src/components/entry-editor/EditorInterface.tsx b/packages/core/src/components/entry-editor/EditorInterface.tsx index 25b469c93..57bc22e46 100644 --- a/packages/core/src/components/entry-editor/EditorInterface.tsx +++ b/packages/core/src/components/entry-editor/EditorInterface.tsx @@ -16,7 +16,10 @@ import { import { customPathFromSlug } from '@staticcms/core/lib/util/nested.util'; import { generateClassNames } from '@staticcms/core/lib/util/theming.util'; import { selectConfig, selectUseWorkflow } from '@staticcms/core/reducers/selectors/config'; -import { selectIsFetching } from '@staticcms/core/reducers/selectors/globalUI'; +import { + selectIsFetching, + selectUseOpenAuthoring, +} from '@staticcms/core/reducers/selectors/globalUI'; import { useAppSelector } from '@staticcms/core/store/hooks'; import MainView from '../MainView'; import EditorToolbar from './EditorToolbar'; @@ -149,6 +152,7 @@ const EditorInterface: FC = ({ }) => { const config = useAppSelector(selectConfig); const useWorkflow = useAppSelector(selectUseWorkflow); + const useOpenAuthoring = useAppSelector(selectUseOpenAuthoring); const isSmallScreen = useIsSmallScreen(); @@ -160,6 +164,10 @@ const EditorInterface: FC = ({ ), [entry.isDeleting, entry.isPersisting, isLoading, isPublishing, isUpdatingStatus], ); + const editorDisabled = useMemo( + () => Boolean(disabled || entry.openAuthoring), + [disabled, entry.openAuthoring], + ); const { locales, default_locale } = useMemo(() => getI18nInfo(collection), [collection]) ?? {}; const translatedLocales = useMemo( @@ -321,7 +329,7 @@ const EditorInterface: FC = ({ canChangeLocale={i18nEnabled && !i18nActive} onLocaleChange={handleLocaleChange} slug={slug} - disabled={disabled} + disabled={editorDisabled} /> ), @@ -338,7 +346,7 @@ const EditorInterface: FC = ({ i18nEnabled, handleLocaleChange, slug, - disabled, + editorDisabled, ], ); @@ -366,7 +374,7 @@ const EditorInterface: FC = ({ canChangeLocale context={!isSmallScreen ? 'i18nSplit' : undefined} hideBorder - disabled={disabled} + disabled={editorDisabled} /> )), @@ -381,7 +389,7 @@ const EditorInterface: FC = ({ handleLocaleChange, isSmallScreen, submitted, - disabled, + editorDisabled, ], ); @@ -505,7 +513,6 @@ const EditorInterface: FC = ({ navbarActions={ handleOnPersist({ createNew: true })} onPersistAndDuplicate={() => handleOnPersist({ createNew: true, duplicate: true })} @@ -553,6 +560,7 @@ const EditorInterface: FC = ({ disabled={disabled} onChangeStatus={onChangeStatus} isLoading={isLoading} + useOpenAuthoring={useOpenAuthoring} mobile /> diff --git a/packages/core/src/components/entry-editor/EditorToolbar.css b/packages/core/src/components/entry-editor/EditorToolbar.css index 5661f6a2e..b01dfdc0d 100644 --- a/packages/core/src/components/entry-editor/EditorToolbar.css +++ b/packages/core/src/components/entry-editor/EditorToolbar.css @@ -43,5 +43,6 @@ .CMS_EditorToolbar_workflow-controls { @apply hidden md:flex - gap-2; + gap-2 + items-center; } diff --git a/packages/core/src/components/entry-editor/EditorToolbar.tsx b/packages/core/src/components/entry-editor/EditorToolbar.tsx index c07af0b7f..45e23365b 100644 --- a/packages/core/src/components/entry-editor/EditorToolbar.tsx +++ b/packages/core/src/components/entry-editor/EditorToolbar.tsx @@ -10,13 +10,17 @@ import { Publish as PublishIcon } from '@styled-icons/material/Publish'; import { Unpublished as UnpublishedIcon } from '@styled-icons/material/Unpublished'; import React, { useCallback, useMemo } from 'react'; +import { loadUnpublishedEntry } from '@staticcms/core/actions/editorialWorkflow'; import { deleteLocalBackup, loadEntry } from '@staticcms/core/actions/entries'; import useTranslate from '@staticcms/core/lib/hooks/useTranslate'; import classNames from '@staticcms/core/lib/util/classNames.util'; import { selectAllowDeletion, selectAllowPublish } from '@staticcms/core/lib/util/collection.util'; import { generateClassNames } from '@staticcms/core/lib/util/theming.util'; import { selectUseWorkflow } from '@staticcms/core/reducers/selectors/config'; -import { selectIsFetching } from '@staticcms/core/reducers/selectors/globalUI'; +import { + selectIsFetching, + selectUseOpenAuthoring, +} from '@staticcms/core/reducers/selectors/globalUI'; import { useAppDispatch, useAppSelector } from '@staticcms/core/store/hooks'; import IconButton from '../common/button/IconButton'; import confirm from '../common/confirm/Confirm'; @@ -46,13 +50,13 @@ export const classes = generateClassNames('EditorToolbar', [ export interface EditorToolbarProps { isPersisting?: boolean; - isDeleting?: boolean; onPersist: (opts?: EditorPersistOptions) => Promise; onPersistAndNew: () => Promise; onPersistAndDuplicate: () => Promise; onDelete: () => Promise; onDuplicate: () => void; hasChanged: boolean; + hasUnpublishedChanges: boolean; collection: CollectionWithDefaults; isNewEntry: boolean; isModification?: boolean; @@ -72,7 +76,6 @@ export interface EditorToolbarProps { currentStatus: WorkflowStatus | undefined; isUpdatingStatus: boolean; onChangeStatus: (status: WorkflowStatus) => void; - hasUnpublishedChanges: boolean; isPublishing: boolean; onPublish: (opts?: EditorPersistOptions) => Promise; onUnPublish: () => Promise; @@ -120,12 +123,17 @@ const EditorToolbar: FC = ({ }) => { const t = useTranslate(); + const useOpenAuthoring = useAppSelector(selectUseOpenAuthoring); + const canCreate = useMemo( () => ('folder' in collection && collection.create) ?? false, [collection], ); const canDelete = useMemo(() => selectAllowDeletion(collection), [collection]); - const canPublish = useMemo(() => selectAllowPublish(collection, slug), [collection, slug]); + const canPublish = useMemo( + () => selectAllowPublish(collection, slug) && !useOpenAuthoring, + [collection, slug, useOpenAuthoring], + ); const isPublished = useMemo(() => !isNewEntry && !hasChanged, [hasChanged, isNewEntry]); const isLoading = useAppSelector(selectIsFetching); @@ -148,10 +156,14 @@ const EditorToolbar: FC = ({ }) ) { await dispatch(deleteLocalBackup(collection, slug)); - await dispatch(loadEntry(collection, slug)); + if (useWorkflow) { + await dispatch(loadUnpublishedEntry(collection, slug)); + } else { + await dispatch(loadEntry(collection, slug)); + } onDiscardDraft(); } - }, [collection, dispatch, onDiscardDraft, slug]); + }, [collection, dispatch, onDiscardDraft, slug, useWorkflow]); const handlePublishClick = useCallback(() => { if (useWorkflow) { @@ -358,7 +370,9 @@ const EditorToolbar: FC = ({ ) : null} - {canDelete && (!useWorkflow || workflowDeleteLabel) ? ( + {canDelete && + (!useOpenAuthoring || hasUnpublishedChanges) && + (!useWorkflow || workflowDeleteLabel) ? ( = ({ disabled={disabled} onChangeStatus={onChangeStatus} isLoading={isLoading} + useOpenAuthoring={useOpenAuthoring} /> ) : null} - {publishLabel ? ( + {!useOpenAuthoring && publishLabel ? ( = ({ @@ -37,6 +40,7 @@ const EditorWorkflowToolbarButtons: FC = ({ disabled, isLoading, mobile, + useOpenAuthoring, }) => { const t = useTranslate(); @@ -49,6 +53,15 @@ const EditorWorkflowToolbarButtons: FC = ({ [t], ); + const statusToPillColor: Record = useMemo( + () => ({ + [WorkflowStatus.DRAFT]: 'info', + [WorkflowStatus.PENDING_REVIEW]: 'warning', + [WorkflowStatus.PENDING_PUBLISH]: 'success', + }), + [], + ); + const handleSave = useCallback(() => { if (!hasChanged) { return; @@ -60,48 +73,65 @@ const EditorWorkflowToolbarButtons: FC = ({ return ( <> {currentStatus ? ( - - - onChangeStatus(WorkflowStatus.DRAFT)} - startIcon={currentStatus === WorkflowStatus.DRAFT ? CheckIcon : undefined} - contentClassName={ - currentStatus !== WorkflowStatus.DRAFT ? classes['not-checked'] : '' - } - > - {statusToTranslation[WorkflowStatus.DRAFT]} - - onChangeStatus(WorkflowStatus.PENDING_REVIEW)} - startIcon={currentStatus === WorkflowStatus.PENDING_REVIEW ? CheckIcon : undefined} - contentClassName={ - currentStatus !== WorkflowStatus.PENDING_REVIEW ? classes['not-checked'] : '' - } - > - {statusToTranslation[WorkflowStatus.PENDING_REVIEW]} - - onChangeStatus(WorkflowStatus.PENDING_PUBLISH)} - startIcon={currentStatus === WorkflowStatus.PENDING_PUBLISH ? CheckIcon : undefined} - contentClassName={ - currentStatus !== WorkflowStatus.PENDING_PUBLISH ? classes['not-checked'] : '' - } - > - {statusToTranslation[WorkflowStatus.PENDING_PUBLISH]} - - - + useOpenAuthoring ? ( + <> + + {statusToTranslation[currentStatus]} + + {currentStatus === WorkflowStatus.DRAFT ? ( + + ) : null} + + ) : ( + + + onChangeStatus(WorkflowStatus.DRAFT)} + startIcon={currentStatus === WorkflowStatus.DRAFT ? CheckIcon : undefined} + contentClassName={ + currentStatus !== WorkflowStatus.DRAFT ? classes['not-checked'] : '' + } + > + {statusToTranslation[WorkflowStatus.DRAFT]} + + onChangeStatus(WorkflowStatus.PENDING_REVIEW)} + startIcon={currentStatus === WorkflowStatus.PENDING_REVIEW ? CheckIcon : undefined} + contentClassName={ + currentStatus !== WorkflowStatus.PENDING_REVIEW ? classes['not-checked'] : '' + } + > + {statusToTranslation[WorkflowStatus.PENDING_REVIEW]} + + onChangeStatus(WorkflowStatus.PENDING_PUBLISH)} + startIcon={currentStatus === WorkflowStatus.PENDING_PUBLISH ? CheckIcon : undefined} + contentClassName={ + currentStatus !== WorkflowStatus.PENDING_PUBLISH ? classes['not-checked'] : '' + } + > + {statusToTranslation[WorkflowStatus.PENDING_PUBLISH]} + + + + ) ) : mobile ? (
) : null} diff --git a/packages/core/src/components/login/Login.tsx b/packages/core/src/components/login/Login.tsx index c90ce8a92..77201ff97 100644 --- a/packages/core/src/components/login/Login.tsx +++ b/packages/core/src/components/login/Login.tsx @@ -29,6 +29,7 @@ export interface LoginProps { label?: string; error?: ReactNode; disabled?: boolean; + buttonContent?: ReactNode; } const Login: FC = ({ @@ -38,6 +39,7 @@ const Login: FC = ({ label, error, disabled = false, + buttonContent, }) => { const t = useTranslate(); @@ -72,15 +74,19 @@ const Login: FC = ({
{error}
) : null} - + {buttonContent ? ( + buttonContent + ) : ( + + )} {config?.site_url && } ); diff --git a/packages/core/src/components/workflow/Dashboard.tsx b/packages/core/src/components/workflow/Dashboard.tsx index 53f98f6f3..a962329fa 100644 --- a/packages/core/src/components/workflow/Dashboard.tsx +++ b/packages/core/src/components/workflow/Dashboard.tsx @@ -14,7 +14,8 @@ import useTranslate from '@staticcms/core/lib/hooks/useTranslate'; import classNames from '@staticcms/core/lib/util/classNames.util'; import { PointerSensor } from '@staticcms/core/lib/util/dnd.util'; import { generateClassNames } from '@staticcms/core/lib/util/theming.util'; -import { useAppDispatch } from '@staticcms/core/store/hooks'; +import { selectUseOpenAuthoring } from '@staticcms/core/reducers/selectors/globalUI'; +import { useAppDispatch, useAppSelector } from '@staticcms/core/store/hooks'; import MainView from '../MainView'; import ActiveWorkflowCard from './ActiveWorkflowCard'; import WorkflowColumn from './WorkflowColumn'; @@ -45,6 +46,8 @@ const Dashboard: FC = () => { const dispatch = useAppDispatch(); + const useOpenAuthoring = useAppSelector(selectUseOpenAuthoring); + const { boardSections, entriesById, setBoardSections } = useWorkflowBoardSections(); const inReviewEntries = useWorkflowEntriesByCollection(WorkflowStatus.PENDING_REVIEW); @@ -147,14 +150,17 @@ const Dashboard: FC = () => { sensors={sensors} >
- {(Object.keys(boardSections) as WorkflowStatus[]).map(status => ( - - ))} + {(Object.keys(boardSections) as WorkflowStatus[]) + .filter(ws => !useOpenAuthoring || ws !== WorkflowStatus.PENDING_PUBLISH) + .map(status => ( + + ))}
{activeEntry ? : null} diff --git a/packages/core/src/components/workflow/WorkflowCard.tsx b/packages/core/src/components/workflow/WorkflowCard.tsx index c7f0460a1..2b3af5731 100644 --- a/packages/core/src/components/workflow/WorkflowCard.tsx +++ b/packages/core/src/components/workflow/WorkflowCard.tsx @@ -39,14 +39,16 @@ const classes = generateClassNames('WorkflowCard', [ export interface WorkflowCardProps { entry: Entry; + useOpenAuthoring: boolean; } -const WorkflowCard: FC = ({ entry }) => { +const WorkflowCard: FC = ({ entry, useOpenAuthoring }) => { const t = useTranslate(); const dispatch = useAppDispatch(); const { isDragging, setNodeRef, listeners } = useDraggable({ id: `${entry.collection}|${entry.slug}`, + disabled: useOpenAuthoring, }); const collection = useAppSelector(selectCollection(entry.collection)); diff --git a/packages/core/src/components/workflow/WorkflowColumn.tsx b/packages/core/src/components/workflow/WorkflowColumn.tsx index 705684af8..22f26eb9a 100644 --- a/packages/core/src/components/workflow/WorkflowColumn.tsx +++ b/packages/core/src/components/workflow/WorkflowColumn.tsx @@ -27,13 +27,20 @@ interface WorkflowColumnProps { entries: Entry[]; status: WorkflowStatus; dragging: boolean; + useOpenAuthoring: boolean; } -const WorkflowColumn: FC = ({ entries, status, dragging }) => { +const WorkflowColumn: FC = ({ + entries, + status, + dragging, + useOpenAuthoring, +}) => { const t = useTranslate(); const { isOver, setNodeRef } = useDroppable({ id: status, + disabled: useOpenAuthoring, }); return ( @@ -47,7 +54,11 @@ const WorkflowColumn: FC = ({ entries, status, dragging })
{entries.map(e => ( - + ))}
diff --git a/packages/core/src/constants.ts b/packages/core/src/constants.ts index b67464073..3daeccfdf 100644 --- a/packages/core/src/constants.ts +++ b/packages/core/src/constants.ts @@ -122,6 +122,7 @@ export const WAIT_UNTIL_ACTION = 'WAIT_UNTIL_ACTION'; // Global UI export const THEME_CHANGE = 'THEME_CHANGE'; +export const USE_OPEN_AUTHORING = 'USE_OPEN_AUTHORING'; /* * Editorial Workflow diff --git a/packages/core/src/formats/YamlFormatter.ts b/packages/core/src/formats/YamlFormatter.ts index 1b525e2e9..34f7faa80 100644 --- a/packages/core/src/formats/YamlFormatter.ts +++ b/packages/core/src/formats/YamlFormatter.ts @@ -33,7 +33,7 @@ class YamlFormatter extends FileFormatter { } toFile(data: object, sortedKeys: string[] = [], comments: Record = {}) { - const doc = new yaml.Document(); + const doc = new yaml.Document({ aliasDuplicateObjects: false }); const contents = doc.createNode(data) as YAMLMap; addComments(contents.items as Pair[], comments); diff --git a/packages/core/src/interface.ts b/packages/core/src/interface.ts index d8b322c97..a8281aded 100644 --- a/packages/core/src/interface.ts +++ b/packages/core/src/interface.ts @@ -121,6 +121,7 @@ export interface Entry { updatedOn: string; status?: WorkflowStatus; newRecord?: boolean; + openAuthoring?: boolean; isFetching?: boolean; isPersisting?: boolean; isDeleting?: boolean; @@ -542,6 +543,7 @@ export type User = Credentials & { login?: string; name?: string; avatar_url?: string; + useOpenAuthoring?: boolean; }; export interface ImplementationFile { @@ -993,6 +995,7 @@ export interface Backend { delete?: string; uploadMedia?: string; deleteMedia?: string; + openAuthoring?: string; }; use_large_media_transforms_in_media_library?: boolean; @@ -1389,6 +1392,7 @@ export interface UnpublishedEntry { status: WorkflowStatus; diffs: UnpublishedEntryDiff[]; updatedAt: string; + openAuthoring: boolean; } export interface UnpublishedEntryDiff { diff --git a/packages/core/src/lib/formatters.ts b/packages/core/src/lib/formatters.ts index 68df89585..011b18653 100644 --- a/packages/core/src/lib/formatters.ts +++ b/packages/core/src/lib/formatters.ts @@ -30,6 +30,7 @@ const commitMessageTemplates = { delete: 'Delete {{collection}} “{{slug}}”', uploadMedia: 'Upload “{{path}}”', deleteMedia: 'Delete “{{path}}”', + openAuthoring: '{{message}}', } as const; const variableRegex = /\{\{([^}]+)\}\}/g; @@ -46,10 +47,11 @@ export function commitMessageFormatter( type: keyof typeof commitMessageTemplates, config: ConfigWithDefaults, { slug, path, collection, authorLogin, authorName }: Options, + isOpenAuthoring?: boolean, ) { const templates = { ...commitMessageTemplates, ...(config.backend.commit_messages || {}) }; - return templates[type].replace(variableRegex, (_, variable) => { + const commitMessage = templates[type].replace(variableRegex, (_, variable) => { switch (variable) { case 'slug': return slug || ''; @@ -68,6 +70,26 @@ export function commitMessageFormatter( return ''; } }); + + if (!isOpenAuthoring) { + return commitMessage; + } + + const message = templates.openAuthoring?.replace(variableRegex, (_, variable: string) => { + switch (variable) { + case 'message': + return commitMessage; + case 'author-login': + return authorLogin || ''; + case 'author-name': + return authorName || ''; + default: + console.warn(`Ignoring unknown variable “${variable}” in open authoring message template.`); + return ''; + } + }); + + return message; } export function prepareSlug(slug: string) { diff --git a/packages/core/src/lib/hooks/index.ts b/packages/core/src/lib/hooks/index.ts index 4d4a538c4..dd0261fd7 100644 --- a/packages/core/src/lib/hooks/index.ts +++ b/packages/core/src/lib/hooks/index.ts @@ -9,3 +9,4 @@ export { default as useMediaInsert } from './useMediaInsert'; export { default as useMediaPersist } from './useMediaPersist'; export { default as useUUID } from './useUUID'; export { default as useTranslate } from './useTranslate'; +export { default as useCurrentBackend } from './useCurrentBackend'; diff --git a/packages/core/src/lib/hooks/useCurrentBackend.ts b/packages/core/src/lib/hooks/useCurrentBackend.ts new file mode 100644 index 000000000..8fd32128a --- /dev/null +++ b/packages/core/src/lib/hooks/useCurrentBackend.ts @@ -0,0 +1,19 @@ +import { useMemo } from 'react'; + +import { currentBackend } from '@staticcms/core/backend'; +import { selectConfig } from '@staticcms/core/reducers/selectors/config'; +import { useAppSelector } from '@staticcms/core/store/hooks'; + +import type { Backend } from '@staticcms/core/backend'; + +export default function useCurrentBackend(): Backend | undefined { + const config = useAppSelector(selectConfig); + + return useMemo(() => { + if (!config) { + return undefined; + } + + return currentBackend(config); + }, [config]); +} diff --git a/packages/core/src/lib/i18n.ts b/packages/core/src/lib/i18n.ts index 25d399a3c..f5279edb1 100644 --- a/packages/core/src/lib/i18n.ts +++ b/packages/core/src/lib/i18n.ts @@ -43,7 +43,7 @@ export function getI18nInfo( if (!hasI18n(collection) || typeof collection.i18n !== 'object') { return null; } - return collection.i18n; + return collection.i18n as I18nInfo; } export function getI18nFilesDepth( @@ -397,12 +397,10 @@ export function groupEntries( }, ); - const groupedEntries = Object.values(grouped).reduce((acc, values) => { + return Object.values(grouped).reduce((acc, values) => { const entryValue = mergeValues(collection, structure, default_locale, values); return [...acc, entryValue]; }, [] as Entry[]); - - return groupedEntries; } export function getI18nDataFiles( diff --git a/packages/core/src/lib/util/__tests__/nested.util.spec.ts b/packages/core/src/lib/util/__tests__/nested.util.spec.ts index 85b584938..313b44bfe 100644 --- a/packages/core/src/lib/util/__tests__/nested.util.spec.ts +++ b/packages/core/src/lib/util/__tests__/nested.util.spec.ts @@ -39,6 +39,7 @@ const entries: Entry[] = [ raw: '---\ntitle: An Author\n---\nAuthor details go here!.\n', slug: 'authors/author-1/index', updatedOn: '', + openAuthoring: false, }, { author: '', @@ -54,6 +55,7 @@ const entries: Entry[] = [ raw: '---\ntitle: Authors\n---\n', slug: 'authors/index', updatedOn: '', + openAuthoring: false, }, { author: '', @@ -72,6 +74,7 @@ const entries: Entry[] = [ raw: '---\ntitle: Hello World\n---\nCoffee is a small tree or shrub that grows in the forest understory in its wild form, and traditionally was grown commercially under other trees that provided shade. The forest-like structure of shade coffee farms provides habitat for a great number of migratory and resident species.\n', slug: 'posts/hello-world/index', updatedOn: '', + openAuthoring: false, }, { author: '', @@ -87,6 +90,7 @@ const entries: Entry[] = [ raw: '---\ntitle: Posts\n---\n', slug: 'posts/index', updatedOn: '', + openAuthoring: false, }, { author: '', @@ -105,6 +109,7 @@ const entries: Entry[] = [ raw: '---\ntitle: Hello World News\n---\nCoffee is a small tree or shrub that grows in the forest understory in its wild form, and traditionally was grown commercially under other trees that provided shade. The forest-like structure of shade coffee farms provides habitat for a great number of migratory and resident species.\n', slug: 'posts/news/hello-world-news/index', updatedOn: '', + openAuthoring: false, }, { author: '', @@ -120,6 +125,7 @@ const entries: Entry[] = [ raw: '---\ntitle: News Articles\n---\n', slug: 'posts/news/index', updatedOn: '', + openAuthoring: false, }, { author: '', @@ -135,6 +141,7 @@ const entries: Entry[] = [ raw: '---\ntitle: Pages\n---\n', slug: 'index', updatedOn: '', + openAuthoring: false, }, ]; diff --git a/packages/core/src/locales/en/index.ts b/packages/core/src/locales/en/index.ts index 323cfae34..f42d15174 100644 --- a/packages/core/src/locales/en/index.ts +++ b/packages/core/src/locales/en/index.ts @@ -409,6 +409,12 @@ const en: BaseLocalePhrasesRoot = { pending_publish: 'Ready', currentEntries: '%{smart_count} entry |||| %{smart_count} entries', }, + openAuthoring: { + forkRequired: + "Open Authoring is enabled. We need to use a fork on your github account. (If a fork already exists, we'll use that.)", + forkRepo: 'Fork the repo', + markReadyForReview: 'Mark Ready for Review', + }, }, }; diff --git a/packages/core/src/reducers/entryDraft.ts b/packages/core/src/reducers/entryDraft.ts index c260bdf88..47d164a8c 100644 --- a/packages/core/src/reducers/entryDraft.ts +++ b/packages/core/src/reducers/entryDraft.ts @@ -2,7 +2,6 @@ import cloneDeep from 'lodash/cloneDeep'; import isEqual from 'lodash/isEqual'; import { v4 as uuid } from 'uuid'; -import {} from '../actions/editorialWorkflow'; import { ADD_DRAFT_ENTRY_MEDIA_FILE, DRAFT_CHANGE_FIELD, diff --git a/packages/core/src/reducers/globalUI.ts b/packages/core/src/reducers/globalUI.ts index c85d1720e..eefca3323 100644 --- a/packages/core/src/reducers/globalUI.ts +++ b/packages/core/src/reducers/globalUI.ts @@ -1,10 +1,11 @@ -import { THEME_CHANGE } from '../constants'; +import { THEME_CHANGE, USE_OPEN_AUTHORING } from '../constants'; import { isNotNullish } from '../lib/util/null.util'; import type { GlobalUIAction } from '../actions/globalUI'; export type GlobalUIState = { isFetching: boolean; + useOpenAuthoring: boolean; theme: string; }; @@ -23,6 +24,7 @@ function loadColorTheme(): string { const defaultState: GlobalUIState = { isFetching: false, + useOpenAuthoring: false, theme: loadColorTheme(), }; @@ -44,6 +46,11 @@ const globalUI = (state: GlobalUIState = defaultState, action: GlobalUIAction): } switch (action.type) { + case USE_OPEN_AUTHORING: + return { + ...state, + useOpenAuthoring: true, + }; case THEME_CHANGE: localStorage.setItem('color-theme', action.payload.toLowerCase()); return { diff --git a/packages/core/src/reducers/selectors/globalUI.ts b/packages/core/src/reducers/selectors/globalUI.ts index 8dfdd699b..01c78553a 100644 --- a/packages/core/src/reducers/selectors/globalUI.ts +++ b/packages/core/src/reducers/selectors/globalUI.ts @@ -7,3 +7,7 @@ export function selectIsFetching(state: RootState) { export function selectTheme(state: RootState) { return state.globalUI.theme; } + +export function selectUseOpenAuthoring(state: RootState) { + return state.globalUI.useOpenAuthoring; +} diff --git a/packages/core/src/styles/main.css b/packages/core/src/styles/main.css index e0153664a..efa3b866e 100644 --- a/packages/core/src/styles/main.css +++ b/packages/core/src/styles/main.css @@ -125,6 +125,7 @@ --tw-ring-color: color-mix(in srgb, var(--primary-light) 50%, transparent); &:hover { + color: var(--primary-contrast-color); background: var(--primary-dark); } diff --git a/packages/core/src/valueObjects/createEntry.ts b/packages/core/src/valueObjects/createEntry.ts index eb5c55680..485186a44 100644 --- a/packages/core/src/valueObjects/createEntry.ts +++ b/packages/core/src/valueObjects/createEntry.ts @@ -20,6 +20,7 @@ interface Options { // eslint-disable-next-line @typescript-eslint/no-explicit-any [locale: string]: any; }; + openAuthoring?: boolean; } export default function createEntry( @@ -43,6 +44,7 @@ export default function createEntry( status: options.status || undefined, i18n: options.i18n || {}, meta: options.meta || undefined, + openAuthoring: options.openAuthoring, }; return returnObj; diff --git a/packages/core/test/data/entry.mock.ts b/packages/core/test/data/entry.mock.ts index 3f6e42e7c..e3d53d01f 100644 --- a/packages/core/test/data/entry.mock.ts +++ b/packages/core/test/data/entry.mock.ts @@ -15,6 +15,7 @@ export const createMockEntry = ( mediaFiles: [], author: 'Some Person', updatedOn: '20230-02-09T00:00:00.000Z', + openAuthoring: false, ...options, }); @@ -31,6 +32,7 @@ export const createMockExpandedEntry = ( mediaFiles: [], author: 'Some Person', updatedOn: '20230-02-09T00:00:00.000Z', + openAuthoring: false, ...options, }); @@ -45,5 +47,6 @@ export const createMockUnpublishedEntry = ( { id: 'netlify.png', path: 'netlify.png', newFile: true }, ], updatedAt: '20230-02-09T00:00:00.000Z', + openAuthoring: false, ...options, }); diff --git a/packages/docs/content/community.json b/packages/docs/content/community.json index 6fa4defc5..46654546c 100644 --- a/packages/docs/content/community.json +++ b/packages/docs/content/community.json @@ -1,6 +1,6 @@ { "title": "Help us build the CMS of the future", - "subtitle": "Get support, give support and find out what's new through the channels below.", + "subtitle": "Get support, give support and find out what is new through the channels below.", "sections": [ { "title": "Contributing", diff --git a/packages/docs/content/docs/add-to-your-site-bundling.mdx b/packages/docs/content/docs/add-to-your-site-bundling.mdx index 05811ee65..1ef742c69 100644 --- a/packages/docs/content/docs/add-to-your-site-bundling.mdx +++ b/packages/docs/content/docs/add-to-your-site-bundling.mdx @@ -4,7 +4,7 @@ title: Bundling weight: 5 --- -This tutorial guides you through the steps for adding Static CMS via a package manager to a site that's built with a common [static site generator](https://www.staticgen.com/). If you want to start form a template, the [Next Template](/docs/start-with-a-template) provides a great example of bundling in action. +This tutorial guides you through the steps for adding Static CMS via a package manager to a site that is built with a common [static site generator](https://www.staticgen.com/). If you want to start form a template, the [Next Template](/docs/start-with-a-template) provides a great example of bundling in action. ## Installation @@ -58,11 +58,11 @@ Make sure the file containing the CMS object will be built as a page, with `@sta ## Configuration -Configuration is different for every site, so we'll break it down into parts. Add all the code snippets in this section to your `admin/config.js` file (which is passed into the `CMS.init({ config })` call). +Configuration is different for every site, so we will break it down into parts. Add all the code snippets in this section to your `admin/config.js` file (which is passed into the `CMS.init({ config })` call). ### Backend -We're using [Netlify](https://www.netlify.com) for our hosting and authentication in this tutorial, so backend configuration is fairly straightforward. +We are using [Netlify](https://www.netlify.com) for our hosting and authentication in this tutorial, so backend configuration is fairly straightforward. For GitHub and GitLab repositories, you can start your Static CMS `config` file with these lines: @@ -84,11 +84,11 @@ backend: _(For Bitbucket repositories, use the [Bitbucket backend](/docs/bitbucket-backend) instructions instead.)_ -The configuration above specifies your backend protocol and your publication branch. Git Gateway is an open source API that acts as a proxy between authenticated users of your site and your site repo. (We'll get to the details of that in the [Authentication section](#authentication) below.) If you leave out the `branch` declaration, it defaults to `main`. +The configuration above specifies your backend protocol and your publication branch. Git Gateway is an open source API that acts as a proxy between authenticated users of your site and your site repo. (We will get to the details of that in the [Authentication section](#authentication) below.) If you leave out the `branch` declaration, it defaults to `main`. ### Editorial Workflow -By default, saving a post in the CMS interface pushes a commit directly to the publication branch specified in `backend`. However, you also have the option to enable the [Editorial Workflow](/docs/configuration-options/#publish-mode), which adds an interface for drafting, reviewing, and approving posts. To do this, add the following line to your `config.yml`: +By default, saving a post in the CMS interface pushes a commit directly to the publication branch specified in `backend`. However, you also have the option to enable the [Editorial Workflow](/docs/editorial-workflow), which adds an interface for drafting, reviewing, and approving posts. To do this, add the following line to your `config.yml`: ```yaml @@ -117,9 +117,9 @@ media_folder: 'images/uploads' # Media files will be stored in the repo under im -If you're creating a new folder for uploaded media, you'll need to know where your static site generator expects static files. You can refer to the paths outlined above in [App File Structure](#app-file-structure), and put your media folder in the same location where you put the `admin` folder. +If you are creating a new folder for uploaded media, you will need to know where your static site generator expects static files. You can refer to the paths outlined above in [App File Structure](#app-file-structure), and put your media folder in the same location where you put the `admin` folder. -Note that the`media_folder` file path is relative to the project root, so the example above would work for Jekyll, GitBook, or any other generator that stores static files at the project root. However, it would not work for Hugo, Hexo, Middleman or others that store static files in a subfolder. Here's an example that could work for a Hugo site: +Note that the`media_folder` file path is relative to the project root, so the example above would work for Jekyll, GitBook, or any other generator that stores static files at the project root. However, it would not work for Hugo, Hexo, Middleman or others that store static files in a subfolder. Here is an example that could work for a Hugo site: ```js @@ -135,7 +135,7 @@ public_folder: '/images/uploads' # The src attribute for uploaded media will beg -The configuration above adds a new setting, `public_folder`. While `media_folder` specifies where uploaded files are saved in the repo, `public_folder` indicates where they are found in the published site. Image `src` attributes use this path, which is relative to the file where it's called. For this reason, we usually start the path at the site root, using the opening `/`. +The configuration above adds a new setting, `public_folder`. While `media_folder` specifies where uploaded files are saved in the repo, `public_folder` indicates where they are found in the published site. Image `src` attributes use this path, which is relative to the file where it is called. For this reason, we usually start the path at the site root, using the opening `/`. _If `public_folder` is not set, Static CMS defaults to the same value as `media_folder`, adding an opening `/` if one is not included._ @@ -143,7 +143,7 @@ _If `public_folder` is not set, Static CMS defaults to the same value as `media_ Collections define the structure for the different content types on your static site. Since every site is different, the `collections` settings differ greatly from one site to the next. -Let's say your site has a blog, with the posts stored in `_posts/blog`, and files saved in a date-title format, like `1999-12-31-lets-party.md`. Each post begins with settings in yaml-formatted front matter, like so: +Let us say your site has a blog, with the posts stored in `_posts/blog`, and files saved in a date-title format, like `1999-12-31-lets-party.md`. Each post begins with settings in yaml-formatted front matter, like so: ```yaml --- @@ -166,7 +166,7 @@ collections: [ label: 'Blog', // Used in the UI folder: '_posts/blog', // The path to the folder where the documents are stored create: true, // Allow users to create new documents in this collection - slug: '{{year}}-{{month}}-{{day}}-{{slug}}', // Filename template, e.g., YYYY-MM-DD-title.md + slug: '{{year}}-{{month}}-{{day}}-{{slug}}', // Filename template, e.g., yyyy-MM-dd-title.md fields: [ // The fields for each document, usually in front matter { label: 'Layout', name: 'layout', widget: 'hidden', default: 'blog' }, { label: 'Title', name: 'title', widget: 'string' }, @@ -185,7 +185,7 @@ collections: label: 'Blog' # Used in the UI folder: '_posts/blog' # The path to the folder where the documents are stored create: true # Allow users to create new documents in this collection - slug: '{{year}}-{{month}}-{{day}}-{{slug}}' # Filename template, e.g., YYYY-MM-DD-title.md + slug: '{{year}}-{{month}}-{{day}}-{{slug}}' # Filename template, e.g., yyyy-MM-dd-title.md fields: # The fields for each document, usually in front matter - { label: 'Layout', name: 'layout', widget: 'hidden', default: 'blog' } - { label: 'Title', name: 'title', widget: 'string' } @@ -197,7 +197,7 @@ collections: -Let's break that down: +Let us break that down: | Field | Description | | ------ | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | @@ -254,7 +254,7 @@ collections: ## Authentication -Now that you have your Static CMS files in place and configured, all that's left is to enable authentication. We're using the [Netlify](https://www.netlify.com/) platform here because it's one of the quickest ways to get started, but you can learn about other authentication options in the [Backends](/docs/backends-overview) doc. +Now that you have your Static CMS files in place and configured, all that is left is to enable authentication. We are using the [Netlify](https://www.netlify.com/) platform here because it is one of the quickest ways to get started, but you can learn about other authentication options in the [Backends](/docs/backends-overview) doc. ### Setup on Netlify @@ -265,9 +265,9 @@ Netlify offers a built-in authentication service called Identity. In order to us Netlify's Identity and Git Gateway services allow you to manage CMS admin users for your site without requiring them to have an account with your Git host or commit access on your repo. From your site dashboard on Netlify: 1. Go to **Settings > Identity**, and select **Enable Identity service**. -2. Under **Registration preferences**, select **Open** or **Invite only**. In most cases, you want only invited users to access your CMS, but if you're just experimenting, you can leave it open for convenience. +2. Under **Registration preferences**, select **Open** or **Invite only**. In most cases, you want only invited users to access your CMS, but if you are just experimenting, you can leave it open for convenience. 3. If you'd like to allow one-click login with services like Google and GitHub, check the boxes next to the services you'd like to use, under **External providers**. -4. Scroll down to **Services > Git Gateway**, and click **Enable Git Gateway**. This authenticates with your Git host and generates an API access token. In this case, we're leaving the **Roles** field blank, which means any logged in user may access Static CMS. For information on changing this, check the [Netlify Identity documentation](https://www.netlify.com/docs/identity/). +4. Scroll down to **Services > Git Gateway**, and click **Enable Git Gateway**. This authenticates with your Git host and generates an API access token. In this case, we are leaving the **Roles** field blank, which means any logged in user may access Static CMS. For information on changing this, check the [Netlify Identity documentation](https://www.netlify.com/docs/identity/). ### Add the Netlify Identity Widget @@ -277,7 +277,7 @@ With the backend set to handle authentication, now you need a frontend interface ``` -Add this to the `` of your CMS index page at `/admin/index.html`, as well as the `` of your site's main index page. Depending on how your site generator is set up, this may mean you need to add it to the default template, or to a "partial" or "include" template. If you can find where the site stylesheet is linked, that's probably the right place. Alternatively, you can include the script in your site using Netlify's [Script Injection](https://www.netlify.com/docs/inject-analytics-snippets/) feature. +Add this to the `` of your CMS index page at `/admin/index.html`, as well as the `` of your site's main index page. Depending on how your site generator is set up, this may mean you need to add it to the default template, or to a "partial" or "include" template. If you can find where the site stylesheet is linked, that is probably the right place. Alternatively, you can include the script in your site using Netlify's [Script Injection](https://www.netlify.com/docs/inject-analytics-snippets/) feature. When a user logs in with the Netlify Identity widget, an access token directs to the site homepage. In order to complete the login and get back to Static CMS, redirect the user back to the `/admin/` path. To do this, add the following script before the closing `body` tag of your site's main index page: @@ -305,6 +305,6 @@ If you set your registration preference to "Invite only," invite yourself (and a If you left your site registration open, or for return visits after confirming an email invitation, access your site's CMS at `yoursite.com/admin/`. -**Note:** No matter where you access Static CMS — whether running locally, in a staging environment, or in your published site — it always fetches and commits files in your hosted repository (for example, on GitHub), on the branch you configured in your Static CMS `config` file. This means that content fetched in the admin UI matches the content in the repository, which may be different from your locally running site. It also means that content saved using the admin UI saves directly to the hosted repository, even if you're running the UI locally or in staging. +**Note:** No matter where you access Static CMS — whether running locally, in a staging environment, or in your published site — it always fetches and commits files in your hosted repository (for example, on GitHub), on the branch you configured in your Static CMS `config` file. This means that content fetched in the admin UI matches the content in the repository, which may be different from your locally running site. It also means that content saved using the admin UI saves directly to the hosted repository, even if you are running the UI locally or in staging. Happy posting! diff --git a/packages/docs/content/docs/add-to-your-site-cdn.mdx b/packages/docs/content/docs/add-to-your-site-cdn.mdx index 87176b3bd..5deaeec51 100644 --- a/packages/docs/content/docs/add-to-your-site-cdn.mdx +++ b/packages/docs/content/docs/add-to-your-site-cdn.mdx @@ -4,11 +4,11 @@ title: CDN Hosting weight: 4 --- -This tutorial guides you through the steps for adding Static CMS via a public CDN to a site that's built with a common [static site generator](https://www.staticgen.com/), like Jekyll, Next, Hugo, Hexo, or Gatsby. Alternatively, you can [start from a template](/docs/start-with-a-template) or dive right into [configuration options](/docs/configuration-options). +This tutorial guides you through the steps for adding Static CMS via a public CDN to a site that is built with a common [static site generator](https://www.staticgen.com/), like Jekyll, Next, Hugo, Hexo, or Gatsby. Alternatively, you can [start from a template](/docs/start-with-a-template) or dive right into [configuration options](/docs/configuration-options). ## App File Structure -A static `admin` folder contains all Static CMS files, stored at the root of your published site. Where you store this folder in the source files depends on your static site generator. Here's the static file location for a few of the most popular static site generators: +A static `admin` folder contains all Static CMS files, stored at the root of your published site. Where you store this folder in the source files depends on your static site generator. Here is the static file location for a few of the most popular static site generators: | These generators | store static files in | | ------------------------------------------------------- | --------------------- | @@ -25,9 +25,9 @@ A static `admin` folder contains all Static CMS files, stored at the root of you | preact-cli | `/src/static` | | Docusaurus | `/static` | -If your generator isn't listed here, you can check its documentation, or as a shortcut, look in your project for a `css` or `images` folder. The contents of folders like that are usually processed as static files, so it's likely you can store your `admin` folder next to those. (When you've found the location, feel free to add it to these docs by [filing a pull request](https://github.com/StaticJsCMS/static-cms/blob/main/CONTRIBUTING.md#pull-requests)!) +If your generator is not listed here, you can check its documentation, or as a shortcut, look in your project for a `css` or `images` folder. The contents of folders like that are usually processed as static files, so it is likely you can store your `admin` folder next to those. (When you have found the location, feel free to add it to these docs by [filing a pull request](https://github.com/StaticJsCMS/static-cms/blob/main/CONTRIBUTING.md#pull-requests)!) -Inside the `admin` folder, you'll create two files: +Inside the `admin` folder, you will create two files: ```bash admin @@ -35,7 +35,7 @@ admin └ config.yml ``` -The first file, `admin/index.html`, is the entry point for the Static CMS admin interface. This means that users navigate to `yoursite.com/admin/` to access it. On the code side, it's a basic HTML starter page that loads the Static CMS JavaScript file from a public CDN and initializes it. The second file, `admin/config.yml`, is the heart of your Static CMS installation, and a bit more complex. The [Configuration](#configuration) section covers the details. +The first file, `admin/index.html`, is the entry point for the Static CMS admin interface. This means that users navigate to `yoursite.com/admin/` to access it. On the code side, it is a basic HTML starter page that loads the Static CMS JavaScript file from a public CDN and initializes it. The second file, `admin/config.yml`, is the heart of your Static CMS installation, and a bit more complex. The [Configuration](#configuration) section covers the details. In this example, we pull the `admin/index.html` file from a public CDN. @@ -62,11 +62,11 @@ In the code above the `script` is loaded from the `unpkg` CDN. Should there be a ## Configuration -Configuration is different for every site, so we'll break it down into parts. Add all the code snippets in this section to your `admin/config.yml` file. Alternatively, you can use a javascript file (`admin/config.js`) instead of a yaml file. Simply import the javascript config and pass it into your `CMS.init({ config })` call. +Configuration is different for every site, so we will break it down into parts. Add all the code snippets in this section to your `admin/config.yml` file. Alternatively, you can use a javascript file (`admin/config.js`) instead of a yaml file. Simply import the javascript config and pass it into your `CMS.init({ config })` call. ### Backend -We're using [Netlify](https://www.netlify.com) for our hosting and authentication in this tutorial, so backend configuration is fairly straightforward. +We are using [Netlify](https://www.netlify.com) for our hosting and authentication in this tutorial, so backend configuration is fairly straightforward. For GitHub repositories, you can start your Static CMS `config` file with these lines: @@ -88,11 +88,11 @@ backend: { _(For GitLab repositories, use [GitLab backend](/docs/gitlab-backend) and for Bitbucket repositories, use [Bitbucket backend](/docs/bitbucket-backend).)_ -The configuration above specifies your backend protocol and your publication branch. Git Gateway is an open source API that acts as a proxy between authenticated users of your site and your site repo. (We'll get to the details of that in the [Authentication section](#authentication) below.) If you leave out the `branch` declaration, it defaults to `main`. +The configuration above specifies your backend protocol and your publication branch. Git Gateway is an open source API that acts as a proxy between authenticated users of your site and your site repo. (We will get to the details of that in the [Authentication section](#authentication) below.) If you leave out the `branch` declaration, it defaults to `main`. ### Editorial Workflow -By default, saving a post in the CMS interface pushes a commit directly to the publication branch specified in `backend`. However, you also have the option to enable the [Editorial Workflow](/docs/configuration-options/#publish-mode), which adds an interface for drafting, reviewing, and approving posts. To do this, add the following line to your `config.yml`: +By default, saving a post in the CMS interface pushes a commit directly to the publication branch specified in `backend`. However, you also have the option to enable the [Editorial Workflow](/docs/editorial-workflow), which adds an interface for drafting, reviewing, and approving posts. To do this, add the following line to your `config.yml`: ```yaml @@ -121,9 +121,9 @@ media_folder: 'images/uploads', // Media files will be stored in the repo under -If you're creating a new folder for uploaded media, you'll need to know where your static site generator expects static files. You can refer to the paths outlined above in [App File Structure](#app-file-structure), and put your media folder in the same location where you put the `admin` folder. +If you are creating a new folder for uploaded media, you will need to know where your static site generator expects static files. You can refer to the paths outlined above in [App File Structure](#app-file-structure), and put your media folder in the same location where you put the `admin` folder. -Note that the `media_folder` file path is relative to the project root, so the example above would work for Jekyll, GitBook, or any other generator that stores static files at the project root. However, it would not work for Hugo, Next, Hexo, Middleman or others that store static files in a subfolder. Here's an example that could work for a Hugo site: +Note that the `media_folder` file path is relative to the project root, so the example above would work for Jekyll, GitBook, or any other generator that stores static files at the project root. However, it would not work for Hugo, Next, Hexo, Middleman or others that store static files in a subfolder. Here is an example that could work for a Hugo site: ```yaml @@ -139,7 +139,7 @@ public_folder: '/images/uploads', // The src attribute for uploaded media will b -The configuration above adds a new setting, `public_folder`. While `media_folder` specifies where uploaded files are saved in the repo, `public_folder` indicates where they are found in the published site. Image `src` attributes use this path, which is relative to the file where it's called. For this reason, we usually start the path at the site root, using the opening `/`. +The configuration above adds a new setting, `public_folder`. While `media_folder` specifies where uploaded files are saved in the repo, `public_folder` indicates where they are found in the published site. Image `src` attributes use this path, which is relative to the file where it is called. For this reason, we usually start the path at the site root, using the opening `/`. _If `public_folder` is not set, Static CMS defaults to the same value as `media_folder`, adding an opening `/` if one is not included._ @@ -147,7 +147,7 @@ _If `public_folder` is not set, Static CMS defaults to the same value as `media_ Collections define the structure for the different content types on your static site. Since every site is different, the `collections` settings differ greatly from one site to the next. -Let's say your site has a blog, with the posts stored in `_posts/blog`, and files saved in a date-title format, like `1999-12-31-lets-party.md`. Each post begins with settings in yaml-formatted front matter, like so: +Let us say your site has a blog, with the posts stored in `_posts/blog`, and files saved in a date-title format, like `1999-12-31-lets-party.md`. Each post begins with settings in yaml-formatted front matter, like so: ```yaml --- @@ -169,7 +169,7 @@ collections: label: 'Blog' # Used in the UI folder: '_posts/blog' # The path to the folder where the documents are stored create: true # Allow users to create new documents in this collection - slug: '{{year}}-{{month}}-{{day}}-{{slug}}' # Filename template, e.g., YYYY-MM-DD-title.md + slug: '{{year}}-{{month}}-{{day}}-{{slug}}' # Filename template, e.g., yyyy-MM-dd-title.md fields: # The fields for each document, usually in front matter - { label: 'Layout', name: 'layout', widget: 'hidden', default: 'blog' } - { label: 'Title', name: 'title', widget: 'string' } @@ -186,7 +186,7 @@ collections: [ label: 'Blog', // Used in the UI folder: '_posts/blog', // The path to the folder where the documents are stored create: true, // Allow users to create new documents in this collection - slug: '{{year}}-{{month}}-{{day}}-{{slug}}', // Filename template, e.g., YYYY-MM-DD-title.md + slug: '{{year}}-{{month}}-{{day}}-{{slug}}', // Filename template, e.g., yyyy-MM-dd-title.md fields: [ // The fields for each document, usually in front matter { label: 'Layout', name: 'layout', widget: 'hidden', default: 'blog' }, @@ -202,7 +202,7 @@ collections: [ -Let's break that down: +Let us break that down: | Field | Description | | ------ | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | @@ -259,7 +259,7 @@ collections: [ ## Authentication -Now that you have your Static CMS files in place and configured, all that's left is to enable authentication. We're using the [Netlify](https://www.netlify.com/) platform here because it's one of the quickest ways to get started, but you can learn about other authentication options in the [Backends](/docs/backends-overview) doc. +Now that you have your Static CMS files in place and configured, all that is left is to enable authentication. We are using the [Netlify](https://www.netlify.com/) platform here because it is one of the quickest ways to get started, but you can learn about other authentication options in the [Backends](/docs/backends-overview) doc. ### Setup on Netlify @@ -270,9 +270,9 @@ Netlify offers a built-in authentication service called Identity. In order to us Netlify's Identity and Git Gateway services allow you to manage CMS admin users for your site without requiring them to have an account with your Git host or commit access on your repo. From your site dashboard on Netlify: 1. Go to **Settings > Identity**, and select **Enable Identity service**. -2. Under **Registration preferences**, select **Open** or **Invite only**. In most cases, you want only invited users to access your CMS, but if you're just experimenting, you can leave it open for convenience. +2. Under **Registration preferences**, select **Open** or **Invite only**. In most cases, you want only invited users to access your CMS, but if you are just experimenting, you can leave it open for convenience. 3. If you'd like to allow one-click login with services like Google and GitHub, check the boxes next to the services you'd like to use, under **External providers**. -4. Scroll down to **Services > Git Gateway**, and click **Enable Git Gateway**. This authenticates with your Git host and generates an API access token. In this case, we're leaving the **Roles** field blank, which means any logged in user may access Static CMS. For information on changing this, check the [Netlify Identity documentation](https://www.netlify.com/docs/identity/). +4. Scroll down to **Services > Git Gateway**, and click **Enable Git Gateway**. This authenticates with your Git host and generates an API access token. In this case, we are leaving the **Roles** field blank, which means any logged in user may access Static CMS. For information on changing this, check the [Netlify Identity documentation](https://www.netlify.com/docs/identity/). ### Add the Netlify Identity Widget @@ -282,7 +282,7 @@ With the backend set to handle authentication, now you need a frontend interface ``` -Add this to the `` of your CMS index page at `/admin/index.html`, as well as the `` of your site's main index page. Depending on how your site generator is set up, this may mean you need to add it to the default template, or to a "partial" or "include" template. If you can find where the site stylesheet is linked, that's probably the right place. Alternatively, you can include the script in your site using Netlify's [Script Injection](https://www.netlify.com/docs/inject-analytics-snippets/) feature. +Add this to the `` of your CMS index page at `/admin/index.html`, as well as the `` of your site's main index page. Depending on how your site generator is set up, this may mean you need to add it to the default template, or to a "partial" or "include" template. If you can find where the site stylesheet is linked, that is probably the right place. Alternatively, you can include the script in your site using Netlify's [Script Injection](https://www.netlify.com/docs/inject-analytics-snippets/) feature. When a user logs in with the Netlify Identity widget, an access token directs to the site homepage. In order to complete the login and get back to Static CMS, redirect the user back to the `/admin/` path. To do this, add the following script before the closing `body` tag of your site's main index page: @@ -310,6 +310,6 @@ If you set your registration preference to "Invite only," invite yourself (and a If you left your site registration open, or for return visits after confirming an email invitation, access your site's CMS at `yoursite.com/admin/`. -**Note:** No matter where you access Static CMS — whether running locally, in a staging environment, or in your published site — it always fetches and commits files in your hosted repository (for example, on GitHub), on the branch you configured in your Static CMS `config` file. This means that content fetched in the admin UI matches the content in the repository, which may be different from your locally running site. It also means that content saved using the admin UI saves directly to the hosted repository, even if you're running the UI locally or in staging. +**Note:** No matter where you access Static CMS — whether running locally, in a staging environment, or in your published site — it always fetches and commits files in your hosted repository (for example, on GitHub), on the branch you configured in your Static CMS `config` file. This means that content fetched in the admin UI matches the content in the repository, which may be different from your locally running site. It also means that content saved using the admin UI saves directly to the hosted repository, even if you are running the UI locally or in staging. Happy posting! diff --git a/packages/docs/content/docs/add-to-your-site.mdx b/packages/docs/content/docs/add-to-your-site.mdx index 271cee5eb..686412ce4 100644 --- a/packages/docs/content/docs/add-to-your-site.mdx +++ b/packages/docs/content/docs/add-to-your-site.mdx @@ -10,7 +10,7 @@ You can add Static CMS to your site in two different ways: ## CDN hosting -Adding Static CMS via a public CDN to a site that's built with a common static site generator, like Jekyll, Hugo, Hexo, or Gatsby. Alternatively, is a quick and easy way to get started. You can start from a [template](/docs/start-with-a-template) or use [this guide](/docs/add-to-your-site-cdn) to get started. +Adding Static CMS via a public CDN to a site that is built with a common static site generator, like Jekyll, Hugo, Hexo, or Gatsby. Alternatively, is a quick and easy way to get started. You can start from a [template](/docs/start-with-a-template) or use [this guide](/docs/add-to-your-site-cdn) to get started. ## Bundling diff --git a/packages/docs/content/docs/beta-features.mdx b/packages/docs/content/docs/beta-features.mdx index 89d5a5179..66d1bf64f 100644 --- a/packages/docs/content/docs/beta-features.mdx +++ b/packages/docs/content/docs/beta-features.mdx @@ -9,191 +9,22 @@ weight: 200 fix the features as well as update the docs. Use at your own risk. -Static CMS runs new functionality in an open beta format from time to time. That means that this functionality is totally available for use, an it might be ready for primetime, but it could break or change without notice. +Static CMS runs new functionality in an open beta format from time to time. That means that this functionality is fully available for use, and it might be ready for primetime, but it could break or change without notice. **Use these features at your own risk.** -## Folder Collections Path +## Editorial Workflow -By default Static CMS stores folder collection content under the folder specified in the collection setting. You can now specify an additional `path` template (similar to the `slug` template) to control the content destination. +By default, all entries created or edited in Static CMS are committed directly into the main repository branch. -This allows saving content in subfolders, e.g. configuring `path: '{{year}}/{{slug}}'` will save the content under `posts/2019/post-title.md`. +The publish_mode option allows you to enable "Editorial Workflow" mode for more control over the content publishing phases. All unpublished entries will be arranged in a board according to their status, and they can be further reviewed and edited before going live. -See [Folder Collections Path](/docs/collection-types#folder-collections-path) for more information. +See [Editorial Workflow](/docs/editorial-workflow) for more information. -## Nested Collections +## Open Authoring -Nested collections is a beta feature that allows a folder collection to show a nested structure of entries and edit the locations of the entries. This feature is useful when you have a complex folder structure and may not want to create separate collections for every directory. +When using the [GitHub backend](/docs/github-backend), you can use Static CMS to accept contributions from GitHub users without giving them access to your repository. When they make changes in the CMS, the CMS forks your repository for them behind the scenes, and all the changes are made to the fork. When the contributor is ready to submit their changes, they can set their draft as ready for review in the CMS. This triggers a pull request to your repository, which you can merge using the GitHub UI. -See [Nested Collections](/docs/collection-types#nested-collections) for more information. +At the same time, any contributors who _do_ have write access to the repository can continue to use Static CMS normally. -## Image widget file size limit - -You can set a limit to as what the maximum file size of a file is that users can upload directly into a image field. - -See [Media Library](/docs/configuration-options#media-library) for more information. - -## Media Library Folders - -Adds support for viewing subfolders and creating new subfolders in the media library, under your configured `media_folder`. - -See [Media Library](/docs/configuration-options#media-library) for more information. - -## Summary string template transformations - -You can apply transformations on fields in a summary string template using filter notation syntax. - -Example config: - - -```yaml -collections: - - name: 'posts' - label: 'Posts' - folder: '_posts' - summary: "{{title | upper}} - {{date | date('YYYY-MM-DD')}} - {{body | truncate(20, '***')}}" - fields: - - { label: 'Title', name: 'title', widget: 'string' } - - { label: 'Publish Date', name: 'date', widget: 'datetime' } - - { label: 'Body', name: 'body', widget: 'markdown' } -``` - -```js -collections: [ - { - name: 'posts', - label: 'Posts', - folder: '_posts', - summary: "{{title | upper}} - {{date | date('YYYY-MM-DD')}} - {{body | truncate(20, '***')}}", - fields: [ - { label: 'Title', name: 'title', widget: 'string' }, - { label: 'Publish Date', name: 'date', widget: 'datetime' }, - { label: 'Body', name: 'body', widget: 'markdown' }, - ], - }, -], -``` - - - -The above config will transform the title field to uppercase and format the date field using `YYYY-MM-DD` format. -Available transformations are `upper`, `lower`, `date('')`, `default('defaultValue')`, `ternary('valueForTrue','valueForFalse')` and `truncate()`/`truncate(, '')` - -## Registering to CMS Events - -You can execute a function when a specific CMS event occurs. Supported events are `mounted`, `login`, `logout`, `change`, `preSave` and `postSave`. - -### Mounted - -The `mounted` event handler fires once the CMS is fully loaded. - -```javascript -CMS.registerEventListener({ - name: 'mounted', - handler: () => { - // your code here - }, -}); -``` - -### Login - -The `login` event handler fires when a user logs into the CMS. - -```javascript -CMS.registerEventListener({ - name: 'login', - handler: ({ author: { login, name } }) => { - // your code here - }, -}); -``` - -### Logout - -The `logout` event handler fires when a user logs out of the CMS. - -```javascript -CMS.registerEventListener({ - name: 'logout', - handler: () => { - // your code here - }, -}); -``` - -### Pre Save - -The `preSave` event handler fires before the changes have been saved to your git backend, and can be used to modify the entry data like so: - -```javascript -CMS.registerEventListener({ - name: 'preSave', - collection: 'posts', - handler: ({ data: { entry } }) => { - return { - ...entry.data, - title: 'new title', - }; - }, -}); -``` - -### Post Save - -The `postSave` event handler fires after the changes have been saved to your git backend. - -```javascript -CMS.registerEventListener({ - name: 'postSave', - collection: 'posts', - handler: ({ data: { entry } }) => { - // your code here - }, -}); -``` - -### Change - -The `change` event handler must provide a field name, and can be used to modify the entry data like so: - -```javascript -CMS.registerEventListener({ - name: 'change', - collection: 'posts', - field: 'path.to.my.field', - handler: ({ data, collection, field }) => { - const currentValue = data.path.to.my.field; - - return { - ...data, - path: { - ...data.path, - to: { - ...data.path.to, - my: { - ...data.path.to.my, - field: `new${currentValue}` - } - } - } - }; - }, -}); -``` - -## i18n Support - -Static CMS can provide a side by side interface for authoring content in multiple languages. Configuring Static CMS for i18n support requires top level configuration, collection level configuration and field level configuration. - -## Gitea Backend - -For repositories stored on Gitea, the gitea backend allows CMS users to log in directly with their Gitea account. Note that all users must have push access to your content repository for this to work. - -See [Gitea Backend](/docs/gitea-backend) for more information. - -## Large Media Support - -Netlify Large Media allows you to store your media files outside of your git backend. This is helpful if you are trying to store large media files. - -See [Netlify Large Media](/docs/netlify-large-media) for more information. +See [Open Authoring](/docs/open-authoring) for more information. diff --git a/packages/docs/content/docs/cms-events.mdx b/packages/docs/content/docs/cms-events.mdx new file mode 100644 index 000000000..f5d699799 --- /dev/null +++ b/packages/docs/content/docs/cms-events.mdx @@ -0,0 +1,119 @@ +--- +group: Customization +title: Events Hooks +weight: 110 +--- + +You can execute a function when a specific event occurs within Static CMSD. + +Supported events are: + +| Name | Description | +| ----------- | ----------------------------------------------------------------------------------------------------------------------- | +| mounted | Event fires once Static CMS is fully loaded | +| login | Event fires when a user logs into Static CMS | +| logout | Event fires when a user logs out of Static CMS | +| change | Event fires when a user changes the value of a field in the editor | +| preSave | Event fires before the changes have been saved to your git backend | +| postSave | Event fires after the changes have been saved to your git backend | +| prePublish | _**Editorial Workflow ONLY**_. Event fires before the entry is "published", before the PR is merged into default branch | +| postPublish | _**Editorial Workflow ONLY**_. Event fires after the entry is "published", after the PR is merged into default branch | + +## Mounted Event + +The `mounted` event handler fires once Static CMS is fully loaded. + +```javascript +CMS.registerEventListener({ + name: 'mounted', + handler: () => { + // your code here + }, +}); +``` + +## Login Event + +The `login` event handler fires when a user logs into Static CMS. + +```javascript +CMS.registerEventListener({ + name: 'login', + handler: ({ author: { login, name } }) => { + // your code here + }, +}); +``` + +## Logout Event + +The `logout` event handler fires when a user logs out of Static CMS. + +```javascript +CMS.registerEventListener({ + name: 'logout', + handler: () => { + // your code here + }, +}); +``` + +## Change Event + +The `change` event handler fires when a user changes the value of a field in the editor. Event listeners for `change` must provide a field name, and can be used to modify the entry data like so: + +```javascript +CMS.registerEventListener({ + name: 'change', + collection: 'posts', + field: 'path.to.my.field', + handler: ({ data, collection, field }) => { + const currentValue = data.path.to.my.field; + + return { + ...data, + path: { + ...data.path, + to: { + ...data.path.to, + my: { + ...data.path.to.my, + field: `new${currentValue}`, + }, + }, + }, + }; + }, +}); +``` + +## Pre Save Event + +The `preSave` event handler fires before the changes have been saved to your git backend, and can be used to modify the entry data like so: + +```javascript +CMS.registerEventListener({ + name: 'preSave', + collection: 'posts', + handler: ({ data: { entry } }) => { + return { + ...entry.data, + title: 'new title', + }; + }, +}); +``` + +## Post Save Event + +The `postSave` event handler fires after the changes have been saved to your git backend. + +```javascript +CMS.registerEventListener({ + name: 'postSave', + collection: 'posts', + handler: ({ data: { entry } }) => { + // your code here + }, +}); +``` diff --git a/packages/docs/content/docs/collection-overview.mdx b/packages/docs/content/docs/collection-overview.mdx index ecfd3d9d1..8fa990616 100644 --- a/packages/docs/content/docs/collection-overview.mdx +++ b/packages/docs/content/docs/collection-overview.mdx @@ -67,7 +67,7 @@ You may also specify a custom `extension` not included in the list above, as lon - `yml` or `yaml`: parses and saves files as YAML-formatted data files; saves with `yml` extension by default - `json`: parses and saves files as JSON-formatted data files; saves with `json` extension by default - `toml`: parses and saves files as TOML-formatted data files; saves with `toml` extension by default -- `frontmatter`: parses files and saves files with data frontmatter followed by an unparsed body text (edited using a `body` field); saves with `md` extension by default; default for collections that can't be inferred. Collections with `frontmatter` format (either inferred or explicitly set) can parse files with frontmatter in YAML or JSON format. However, they will be saved with YAML frontmatter. +- `frontmatter`: parses files and saves files with data frontmatter followed by an unparsed body text (edited using a `body` field); saves with `md` extension by default; default for collections that cannot be inferred. Collections with `frontmatter` format (either inferred or explicitly set) can parse files with frontmatter in YAML or JSON format. However, they will be saved with YAML frontmatter. - `yaml-frontmatter`: same as the `frontmatter` format above, except frontmatter will be both parsed and saved only as YAML, followed by unparsed body text. The default delimiter for this option is `---`. - `json-frontmatter`: same as the `frontmatter` format above, except frontmatter will be both parsed and saved as JSON, followed by unparsed body text. The default delimiter for this option is `{` `}`. - `toml-frontmatter`: same as the `frontmatter` format above, except frontmatter will be both parsed and saved only as TOML, followed by unparsed body text. The default delimiter for this option is `+++`. @@ -220,6 +220,55 @@ summary: 'Version: {{version}} - {{title}}', +### Template Transformations + +You can apply transformations on fields in a summary string template using filter notation syntax. + +Example config: + + +```yaml +collections: + - name: 'posts' + label: 'Posts' + folder: '_posts' + summary: "{{title | upper}} - {{date | date('yyyy-MM-dd')}} - {{body | truncate(20, '***')}}" + fields: + - { label: 'Title', name: 'title', widget: 'string' } + - { label: 'Publish Date', name: 'date', widget: 'datetime' } + - { label: 'Body', name: 'body', widget: 'markdown' } +``` + +```js +collections: [ + { + name: 'posts', + label: 'Posts', + folder: '_posts', + summary: "{{title | upper}} - {{date | date('yyyy-MM-dd')}} - {{body | truncate(20, '***')}}", + fields: [ + { label: 'Title', name: 'title', widget: 'string' }, + { label: 'Publish Date', name: 'date', widget: 'datetime' }, + { label: 'Body', name: 'body', widget: 'markdown' }, + ], + }, +], +``` + + + +The above config will transform the title field to uppercase and format the date field using `yyyy-MM-dd` format. +Available transformations are: + +| Name | Format | Description | +| -------- | ---------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------- | +| upper | `upper` | Transforms the value to uppercase | +| lower | `lower` | Transforms the value to lowercase | +| date | `date('')` | Formats a date string in the provided format. Accepts [date-fns tokens](https://date-fns.org/docs/format) | +| default | `default('defaultValue')` | Provides default value if no field value | +| ternary | `ternary('valueForTrue','valueForFalse')` |
  • If field has value, show `valueForTrue`
  • If field does not have a value, show `valueForFalse`
| +| truncate | `truncate()`
`truncate(, '')` | Truncates text to a specified length. An optional replacement string for the omitted text can be provided as a second parameter | + ## Sortable Fields The `sortable_fields` setting is an optional object with the following options: @@ -231,7 +280,7 @@ The `sortable_fields` setting is an optional object with the following options: Defaults to inferring `title`, `date`, `author` and `description` fields and will also show `Update On` sort field in git based backends. -When `author` field can't be inferred commit author will be used. +When `author` field cannot be inferred commit author will be used. ```yaml diff --git a/packages/docs/content/docs/collection-types.mdx b/packages/docs/content/docs/collection-types.mdx index d2f71906a..6a1078e51 100644 --- a/packages/docs/content/docs/collection-types.mdx +++ b/packages/docs/content/docs/collection-types.mdx @@ -471,7 +471,7 @@ collections: [ -### Folder Collections Path +### Folder Collections Path By default Static CMS stores folder collection content under the folder specified in the collection setting. @@ -577,9 +577,9 @@ Supports all of the [`slug` templates](/docs/configuration-options#slug) and: - `{{media_folder}}` The global `media_folder`. - `{{public_folder}}` The global `public_folder`. -### Nested Collections +### Nested Collections -Nested collections is a beta feature that allows a folder collection to show a nested structure of entries and edit the locations of the entries. This feature is useful when you have a complex folder structure and may not want to create separate collections for every directory. +Nested collections allow a folder collection to show a nested structure of entries and edit the locations of the entries. This feature is useful when you have a complex folder structure and may not want to create separate collections for every directory. Example configuration: diff --git a/packages/docs/content/docs/configuration-options.mdx b/packages/docs/content/docs/configuration-options.mdx index af89305bf..735ec013a 100644 --- a/packages/docs/content/docs/configuration-options.mdx +++ b/packages/docs/content/docs/configuration-options.mdx @@ -25,7 +25,7 @@ _This setting is required._ The `backend` option specifies how to access the content for your site, including authentication. Full details and code samples can be found in [Backends](/docs/backends-overview). -**Note**: no matter where you access Static CMS — whether running locally, in a staging environment, or in your published site — it will always fetch and commit files in your hosted repository (for example, on GitHub), on the branch you configured in your Static CMS config file. This means that content fetched in the admin UI will match the content in the repository, which may be different from your locally running site. It also means that content saved using the admin UI will save directly to the hosted repository, even if you're running the UI locally or in staging. If you want to have your local CMS write to a local repository, [try the local_backend setting](/docs/local-backend). +**Note**: no matter where you access Static CMS — whether running locally, in a staging environment, or in your published site — it will always fetch and commit files in your hosted repository (for example, on GitHub), on the branch you configured in your Static CMS config file. This means that content fetched in the admin UI will match the content in the repository, which may be different from your locally running site. It also means that content saved using the admin UI will save directly to the hosted repository, even if you are running the UI locally or in staging. If you want to have your local CMS write to a local repository, [try the local_backend setting](/docs/local-backend). ### Commit Message Templates @@ -44,6 +44,7 @@ backend: delete: Delete {{collection}} "{{slug}}" uploadMedia: Upload "{{path}}" deleteMedia: Delete "{{path}}" + openAuthoring: "{{message}}" ``` ```js @@ -54,6 +55,7 @@ backend: { delete: 'Delete {{collection}} "{{slug}}"', uploadMedia: 'Upload "{{path}}"', deleteMedia: 'Delete "{{path}}"', + openAuthoring: '"{{message}}"' }, }, ``` @@ -86,28 +88,9 @@ Template tags produce the following output: By default, all entries created or edited in Static CMS are committed directly into the main repository branch. -The `publish_mode` option allows you to enable "Editorial Workflow" mode for more control over the content publishing phases. All unpublished entries will be arranged in a board according to their status, and they can be further reviewed and edited before going live. +The `publish_mode` option allows you to enable "Editorial Workflow" mode for more control over the content publishing phases. All unpublished entries will be arranged on a dashboard according to their status, and they can be further reviewed and edited before going live. -You can enable the Editorial Workflow with the following line in your `config.yml` file: - - -```yaml -publish_mode: editorial_workflow -``` - -```js -publish_mode: 'editorial_workflow'; -``` - - - -From a technical perspective, the workflow translates editor UI actions into common Git commands: - -| Actions in Netlify UI | Perform these Git actions | -| ------------------------- | ----------------------------------------------------------------------------------------------------------------- | -| Save draft | Commits to a new branch (named according to the pattern `cms/collectionName/entrySlug`), and opens a pull request | -| Edit draft | Pushes another commit to the draft branch/pull request | -| Approve and publish draft | Merges pull request and deletes branch | +See [Editorial Workflow](/docs/editorial-workflow) for more information. ## Media and Public Folders @@ -149,16 +132,16 @@ Based on the settings above, if a user used an image widget field called `avatar This setting can be set to an absolute URL e.g. `https://netlify.com/media` should you wish, however in general this is not advisable as content should have relative paths to other content. -## Media Library +## Media Library The `media_library` settings allows customization of the media library. ### Options -| Name | Type | Default | Description | -| -------------- | ------- | -------- | ---------------------------------------------------------------------------------------------------- | -| max_file_size | number | `512000` | _Optional_. The max size, in bytes, of files that can be uploaded to the media library | -| folder_support | boolean | `false` | _Optional_. Enables directory navigation and folder creation in your media library | +| Name | Type | Default | Description | +| -------------- | ------- | -------- | -------------------------------------------------------------------------------------- | +| max_file_size | number | `512000` | _Optional_. The max size, in bytes, of files that can be uploaded to the media library | +| folder_support | boolean | `false` | _Optional_. Enables directory navigation and folder creation in your media library | ### Example diff --git a/packages/docs/content/docs/contributor-guide.mdx b/packages/docs/content/docs/contributor-guide.mdx index 741e31bb7..5b146fbd5 100644 --- a/packages/docs/content/docs/contributor-guide.mdx +++ b/packages/docs/content/docs/contributor-guide.mdx @@ -4,7 +4,7 @@ title: Contributor Guide weight: 20 --- -We're hoping that Static CMS will do for the [Jamstack](https://www.jamstack.org) what WordPress did for dynamic sites back in the day. We know we can't do that without building a thriving community of contributors and users, and we'd love to have you join us. +We are hoping that Static CMS will do for the [Jamstack](https://www.jamstack.org) what WordPress did for dynamic sites back in the day. We know we cannot do that without building a thriving community of contributors and users, and we would love to have you join us. ## Getting started with contributing Being a developer is not a requirement for contributing to Static CMS, you only need the desire, a web browser, and a [GitHub account](https://github.com/join). The GitHub repo has a step-by-step [guide](https://github.com/StaticJsCMS/static-cms/blob/main/CONTRIBUTING.md) to get started with the code. @@ -18,12 +18,12 @@ The GitHub website allows you to submit issues, work with files, search for cont A [style guide](/docs/writing-style-guide/) is available to help provide context around grammar, code styling, syntax, etc. ## Filing issues -If you have a GitHub account, you can file an [issue](https://github.com/StaticJsCMS/static-cms/issues) (aka bug report) against the Static CMS docs. Even if you're not able to, or don't know how to, fix the issue (see [Improve existing content](#improve-existing-content)), it helps to start the conversation. +If you have a GitHub account, you can file an [issue](https://github.com/StaticJsCMS/static-cms/issues) (aka bug report) against the Static CMS docs. Even if you are not able to, or do not know how to, fix the issue (see [Improve existing content](#improve-existing-content)), it helps to start the conversation. When filing an issue, it is important to remember the [Code of Conduct](https://github.com/StaticJsCMS/static-cms/blob/main/CODE_OF_CONDUCT.md). ## Improve existing content -If you are able to offer up a change to existing content, it is welcome. Once you've forked the repo, and changed the content, you would file a pull request (PR). The repo [Contributing file](https://github.com/StaticJsCMS/static-cms/blob/main/CONTRIBUTING.md) lays out the correct format for PRs. +If you are able to offer up a change to existing content, it is welcome. Once you have forked the repo, and changed the content, you would file a pull request (PR). The repo [Contributing file](https://github.com/StaticJsCMS/static-cms/blob/main/CONTRIBUTING.md) lays out the correct format for PRs. ## Other places to get involved Here are some links with more information about getting involved: diff --git a/packages/docs/content/docs/decap-migration-guide.mdx b/packages/docs/content/docs/decap-migration-guide.mdx new file mode 100644 index 000000000..12dc2fdfa --- /dev/null +++ b/packages/docs/content/docs/decap-migration-guide.mdx @@ -0,0 +1,408 @@ +--- +group: Migration +title: Decap / Netlify Migration Guide +weight: 190 +--- + + + This page is a work in progress! It will likely change before v4.0.0 goes live. + + +Static CMS is a fork of [Decap](https://github.com/decaporg/decap-cms) (previously Netlify CMS). Many changes have been made, some big, some small. + +In this guide, we will walk you through the steps of migrating from Decap or Netlify to Static CMS. + +## How to Migrate + +Start by replacing Decap / Netlify with Static CMS, then address the changes below. + +### CDN + +Decap (_remove_): + +```html + +``` + +Netlify (_remove_): + +```html + +``` + +Static CMS (_add_): + +```html + +``` + +### Bundling + +```bash +# Uninstall Decap +npm uninstall decap-cms-app +npm uninstall decap-cms-core + +# Uninstall Netlify +npm uninstall netlify-cms-app +npm uninstall netlify-cms-core + +# Install Static CMS +npm install @staticcms/core +``` + +#### Change your imports + +Decap (_remove_): + +```js +import CMS from 'decap-cms-app'; +``` + +Netlify (_remove_): + +```js +import CMS from 'netlify-cms-app'; +``` + +Static CMS (_add_): + +```js +import CMS from '@staticcms/core'; +``` + +## Changes + +### React 18 + +React `18.2.0` is now the minimum supported React version. If you are using Static CMS through a CDN, this comes bundled. + +### Static CMS Styles + +Static CMS bundles its styles separately from the main javascript file, so you will need to import them separately. + +**CDN**: + +```html + +``` + +**Bundling**: + +```js +import '@staticcms/core/dist/main.css'; +``` + +### Backends + +The Azure backend has been removed. All other backends are still supported. + +However, the Gitlab, Client-Side Implicit Grant has been removed as a method of authentication. + +### Dates + +[Moment](https://momentjs.com/) has been dropped as the date library used. Instead we are now using [date-fns](https://date-fns.org/). Date formats in your configuration will need to be updated. See [format docs](https://date-fns.org/docs/format). + +### Initializing Static CMS + +CMS must be explicitly initiated by calling `CMS.init()`. Passing a config to `CMS.init()` will now completely override `config.yml` (they are now exclusive), instead of being merged with `config.yml` + +### Markdown Editor + +A [new markdown editor](/docs/widget-markdown) has been added. It comes with a new [shortcode](/docs/widget-markdown#shortcodes) system, old editor components no longer work. + +### Sortable Fields + +The `sortable_fields` configuration option has been slightly changed, as we now allow a [default sorting option](/docs/collection-overview#default-sort). + +**Decap / Netlify**: + +```yaml +sortable_fields: + - field1 + - field2 +``` + +**Static CMS**: + + +```yaml +sortable_fields: + fields: + - field1 + - field2 +``` + +```js +sortable_fields: { + fields: ['field1', 'field2']; +} +``` + + + +### View Filters + +The `view_filters` configuration option has been slightly changed, as we now allow a [default filter option](/docs/collection-overview#view-filters). Also each filter now requires a unique name. + +**Decap / Netlify**: + +```yaml +view_filters: + - label: "Alice's and Bob's Posts" + field: author + pattern: 'Alice|Bob' + - label: 'Posts published in 2020' + field: date + pattern: '2020' + - label: Drafts + field: draft + pattern: true +``` + +**Static CMS**: + + +```yaml +view_filters: + fields: + - name: alice-and-bob + label: "Alice's and Bob's Posts" + field: author + pattern: 'Alice|Bob' + - name: posts-2020 + label: 'Posts published in 2020' + field: date + pattern: '2020' + - name: drafts + label: Drafts + field: draft + pattern: true +``` + +```js +view_filters: { + fields: [ + { + name: 'alice-and-bob', + label: "Alice's and Bob's Posts", + field: 'author', + pattern: 'Alice|Bob', + }, + { + name: 'posts-2020', + label: 'Posts published in 2020', + field: 'date', + pattern: '2020', + }, + { + name: 'drafts', + label: 'Drafts', + field: 'draft', + pattern: true, + }, + ]; +} +``` + + + +### View Groups + +The `view_groups` configuration option has been slightly changed, as we now allow a [default grouping option](/docs/collection-overview#view-groups). Also each group now requires a unique name. + +**Decap / Netlify**: + +```yaml +view_groups: + - label: Year + field: date + # groups items based on the value matched by the pattern + pattern: \d{4} + - label: Drafts + field: draft +``` + +**Static CMS**: + + +```yaml +view_groups: + groups: + - name: by-year + label: Year + field: date + # groups items based on the value matched by the pattern + pattern: \d{4} + - name: drafts + label: Drafts + field: draft +``` + +```js +view_groups: { + groups: [ + { + name: "by-year", + label: "Year", + field: "date", + pattern: "\\d{4} + }, + { + name: "drafts", + label: "Drafts", + field: "draft" + } + ] +} +``` + + + +### List Widget + +Support in the List Widget for the `field` property has been dropped. A single field in the `fields` property [achieves the same behavior](/docs/widget-list). + +### Custom Widgets + +Custom widget creation has changed. `createClass` has been removed. Custom widgets should all be [functional components](https://reactjs.org/docs/components-and-props.html#function-and-class-components) now. + +There have also been changes to how custom widgets are registered and the properties passed to the controls and previews. See [custom widgets](/docs/custom-widgets) for full details. + +### Custom Previews + +Custom preview creation has changed. `createClass` has been removed. Custom previews should all be [functional components](https://reactjs.org/docs/components-and-props.html#function-and-class-components) now. + +There have also been changes to the properties passed to custom previews. See [custom previews](/docs/custom-previews) for full details. + +### External integrations + +The following external integrations have been removed: + +- Algolia +- AssetStore +- Cloudinary +- Uploadcare + +### Deprecated Features + +- All deprecated features were removed + - `date` widget has been removed + - `datetime` widget + - `dateFormat` has been removed (Use `date_format` instead) + - `timeFormat` has been removed (Use `time_format` instead) + +### Media Library + +The `getAsset` method has been removed, the new `useMediaAsset` hook should be used instead. See [Interacting With The Media Library](/docs/custom-widgets#interacting-with-the-media-library). + +### Beta Features + +The following beta features from Decap / Netlify have been dropped: + +- GraphQL support for GitHub and GitLab +- Remark plugins (new markdown editor has its own plugin system) +- Dynamic Default Values +- Custom Mount Element + +#### Nested Collections + +[Nested Collections](/docs/collection-types#nested-collections) have some breaking config changes. The `meta` config has been dropped and its `path` property has been moved into the `nested` prop. You can also no longer specify the widget type for the path. + +**Old Config** + + +```yaml +collections: + - name: pages + label: Pages + label_singular: 'Page' + folder: content/pages + create: true + nested: + depth: 100 + summary: '{{title}}' + fields: + - label: Title + name: title + widget: string + - label: Body + name: body + widget: markdown + meta: { path: { widget: string, label: 'Path', index_file: 'index' } } +``` + +```js +{ + collections: [ + { + name: 'pages', + label: 'Pages', + label_singular: 'Page', + folder: 'content/pages', + create: true, + nested: { + depth: 100, + summary: '{{title}}', + }, + fields: [ + { + label: 'Title', + name: 'title', + widget: 'string', + }, + { + label: 'Body', + name: 'body', + widget: 'markdown', + }, + ], + meta: { + path: { + widget: 'string', + label: 'Path', + index_file: 'index', + }, + }, + }, + ]; +} +``` + + + +## Platform Changes + +### Gatsby + +If you are using Gatsby you will need to change out your CMS plugin. + +```bash +# Uninstall Decap / Netlify plugin +npm uninstall gatsby-plugin-netlify-cms + +# Install Static CMS plugin +npm install gatsby-plugin-static-cms +``` + +## Local Development Changes + +If you are using the local backend you will need to switch the proxy server package you are using. + +Decap (_remove_): + +```bash +npx decap-server +``` + +Netlify (_remove_): + +```bash +npx netlify-cms-proxy-server +``` + +Static CMS (_add_): + +```bash +npx @staticcms/proxy-server +``` diff --git a/packages/docs/content/docs/editorial-workflow.mdx b/packages/docs/content/docs/editorial-workflow.mdx new file mode 100644 index 000000000..025c547c9 --- /dev/null +++ b/packages/docs/content/docs/editorial-workflow.mdx @@ -0,0 +1,35 @@ +--- +group: Workflow +weight: 10 +title: Editorial Workflow +beta: true +--- + + + Editorial Workflow currently does not work for the Gitea backend. Support coming soon. + + +By default, all entries created or edited in Static CMS are committed directly into the main repository branch. + +The `publish_mode` option allows you to enable "Editorial Workflow" mode for more control over the content publishing phases. The unpublished entries will be arranged on a dashboard, in Static CMS, according to their status (Draft, Ready for Review, Ready To Publish). This allows for quick access to unpublished entries, allowing them to be reviewed and edited before going live. + +You can enable the Editorial Workflow with the following line in your `config.yml` file: + + +```yaml +publish_mode: editorial_workflow +``` + +```js +publish_mode: 'editorial_workflow'; +``` + + + +From a technical perspective, the workflow translates editor UI actions into common Git commands: + +| Actions in Netlify UI | Perform these Git actions | +| ------------------------- | ----------------------------------------------------------------------------------------------------------------------- | +| Save draft | Commits to a new branch (named according to the pattern `cms/collectionName/entrySlug`), and opens a pull/merge request | +| Edit draft | Pushes another commit to the draft branch and pull/merge request | +| Approve and publish draft | Merges pull/merge request and deletes branch | diff --git a/packages/docs/content/docs/gitea-backend.mdx b/packages/docs/content/docs/gitea-backend.mdx index 617b84b2d..e920ad86b 100644 --- a/packages/docs/content/docs/gitea-backend.mdx +++ b/packages/docs/content/docs/gitea-backend.mdx @@ -2,7 +2,6 @@ title: Gitea group: Backends weight: 45 -beta: true --- - **Name**: `gitea` diff --git a/packages/docs/content/docs/i18n-support.mdx b/packages/docs/content/docs/i18n-support.mdx index 7d46f42fc..249db80a5 100644 --- a/packages/docs/content/docs/i18n-support.mdx +++ b/packages/docs/content/docs/i18n-support.mdx @@ -1,7 +1,6 @@ --- group: Collections title: i18n Support -beta: true weight: 30 --- diff --git a/packages/docs/content/docs/local-backend.mdx b/packages/docs/content/docs/local-backend.mdx index 545ab8022..1cc776909 100644 --- a/packages/docs/content/docs/local-backend.mdx +++ b/packages/docs/content/docs/local-backend.mdx @@ -36,7 +36,7 @@ local_backend: true, ## Usage 1. Run `npx @staticcms/proxy-server` from the root directory of the above repository. - - If the default port (8081) is in use, the proxy server won't start and you will see an error message. In this case, follow [these steps](#configure-the-@staticcms/proxy-server-port-number) before proceeding. + - If the default port (8081) is in use, the proxy server will not start and you will see an error message. In this case, follow [these steps](#configure-the-@staticcms/proxy-server-port-number) before proceeding. 2. Start your local development server (e.g. run `gatsby develop`). 3. Open `http://localhost:/admin` to verify that your can administer your content locally. Replace `` with the port of your local development server. For example Gatsby's default port is `8000` diff --git a/packages/docs/content/docs/migration-guide-v3.mdx b/packages/docs/content/docs/migration-guide-v3.mdx deleted file mode 100644 index 9c5cb351f..000000000 --- a/packages/docs/content/docs/migration-guide-v3.mdx +++ /dev/null @@ -1,89 +0,0 @@ ---- -group: Migration -title: How to Upgrade to v3 -weight: 101 ---- - -Static CMS v3 introduces: -- Mobile support -- Depedent fields (see [Field Conditions](/docs/widgets#field-conditions) for more information) - -In this guide, we will walk you through the steps for upgrading to Static CMS v3. - -Please [report any issues](https://github.com/StaticJsCMS/static-cms/issues/new) you encounter while upgrading to Static CMS v3. - -## Installing - -To install the latest version of Static CMS: - -```bash -npm install @staticcms/core@^3.0.0 -``` - -Or if you're using yarn: - -```bash -yarn add @staticcms/core@^3.0.0 -``` - -If you are using a CDN to load Static CMS, simply change your URLs: - -```html - -``` - -```html - -``` - -## Gitea Backend Update - -While still remaining in beta, the Gitea backend has been evolving. This update switches the authentication mechanism to PKCE auth and improves performance when dealing with multiple file commits. - -To use Gitea with Static CMS v3, you need to update your Gitea instance to at least `v1.20`. You will also need to update your config to match the setup for PKCE authentication. See [Gitea authentication](/docs/gitea-backend#authentication). - -## CMS Events - -CMS Events have undergone a significant refactor in this update, including adding a new `change` event. You may need to update your config as follows to continue to use them. The `preSave` and `postSave` events along with the new `change` event, now require a `collection` be provided during registration, with an optional `file` if you are targeting a [file collection](/docs/collection-types#file-collections). All events now can handle async handlers as well. - -**Old setup** - -```js -CMS.registerEventListener({ - name: 'preSave', - handler: ({ entry }) => { - return { - ...entry, - data: { - ...entry.data, - title: 'new title', - }, - }; - }, -}); -``` - -**New Setup** - -```js -CMS.registerEventListener({ - name: 'preSave', - collection: 'posts', - handler: ({ data: { entry } }) => { - return { - ...entry.data, - title: 'new title', - }; - }, -}); -``` - -See [CMS Events](/docs/beta-features#registering-to-cms-events) for more details. - -## Other Breaking Changes - -- The following Widget Control component properties have been removed: - - `hidden` - - `mediaPaths` - Use [useMediaInsert](/docs/custom-widgets#interacting-with-the-media-library) instead. - - `openMediaLibrary` - Use [useMediaInsert](/docs/custom-widgets#interacting-with-the-media-library) instead. - - `removeInsertedMedia` - Use [useMediaInsert](/docs/custom-widgets#interacting-with-the-media-library) instead. diff --git a/packages/docs/content/docs/migration-guide-v4.mdx b/packages/docs/content/docs/migration-guide-v4.mdx new file mode 100644 index 000000000..11669d112 --- /dev/null +++ b/packages/docs/content/docs/migration-guide-v4.mdx @@ -0,0 +1,267 @@ +--- +group: Migration +title: How to Upgrade to v4 +weight: 101 +--- + + + This page is a work in progress! It will likely change before v4.0.0 goes live. + + +Static CMS v4 introduces: + +- [Custom themes](/docs/custom-theme) +- [Editorial Workflow](/docs/editorial-workflow) +- [Open Authoring](/docs/open-authoring) (Github backend only) + +In this guide, we will walk you through the steps for upgrading to Static CMS v4. + +Please [report any issues](https://github.com/StaticJsCMS/static-cms/issues/new) you encounter while upgrading to Static CMS v4. + +## Installing + +To install the latest version of Static CMS: + +```bash +npm install @staticcms/core@^4.0.0 +``` + +Or if you are using yarn: + +```bash +yarn add @staticcms/core@^4.0.0 +``` + +If you are using a CDN to load Static CMS, simply change your URLs: + +```html + +``` + +```html + +``` + +## View Filters + +The `view_filters` configuration option has been slightly changed, as we now allow a [default filter option](/docs/collection-overview#view-filters). Also each filter now requires a unique name. + +**Old setup** + + +```yaml +view_filters: + - label: "Alice's and Bob's Posts" + field: author + pattern: 'Alice|Bob' + - label: 'Posts published in 2020' + field: date + pattern: '2020' + - label: Drafts + field: draft + pattern: true +``` + +```js +view_filters: [ + { + label: "Alice's and Bob's Posts", + field: 'author', + pattern: 'Alice|Bob', + }, + { + label: 'Posts published in 2020', + field: 'date', + pattern: '2020', + }, + { + label: 'Drafts', + field: 'draft', + pattern: true, + }, +]; +``` + + + +**New setup** + + +```yaml +view_filters: + fields: + - name: alice-and-bob + label: "Alice's and Bob's Posts" + field: author + pattern: 'Alice|Bob' + - name: posts-2020 + label: 'Posts published in 2020' + field: date + pattern: '2020' + - name: drafts + label: Drafts + field: draft + pattern: true +``` + +```js +view_filters: { + fields: [ + { + name: 'alice-and-bob', + label: "Alice's and Bob's Posts", + field: 'author', + pattern: 'Alice|Bob', + }, + { + name: 'posts-2020', + label: 'Posts published in 2020', + field: 'date', + pattern: '2020', + }, + { + name: 'drafts', + label: 'Drafts', + field: 'draft', + pattern: true, + }, + ]; +} +``` + + + +## View Groups + +The `view_groups` configuration option has been slightly changed, as we now allow a [default grouping option](/docs/collection-overview#view-groups). Also each group now requires a unique name. + +**Old setup** + + +```yaml +view_groups: + - label: Year + field: date + # groups items based on the value matched by the pattern + pattern: \d{4} + - label: Drafts + field: draft +``` + +```js +view_groups: [ + { + label: "Year", + field: "date", + pattern: "\\d{4} + }, + { + label: "Drafts", + field: "draft" + } +] +``` + + + +**New setup** + + +```yaml +view_groups: + groups: + - name: by-year + label: Year + field: date + # groups items based on the value matched by the pattern + pattern: \d{4} + - name: drafts + label: Drafts + field: draft +``` + +```js +view_groups: { + groups: [ + { + name: "by-year", + label: "Year", + field: "date", + pattern: "\\d{4} + }, + { + name: "drafts", + label: "Drafts", + field: "draft" + } + ] +} +``` + + + +## Theme + +The `theme` prop has been removed from: + +- Custom widget [control components](/docs/custom-widgets#control-component) and [preview components](/docs/custom-widgets#preview-component) +- [Custom previews](/docs/custom-previews#editor-preview) +- [Custom collection card previews](/docs/custom-previews#collection-card-preview) +- [Custom collection field previews](/docs/custom-previews#collection-field-preview) +- [Shortcode control components](/docs/widget-markdown#shortcodes) + +The new [useTheme hook](/docs/custom-theme#usetheme-hook) should be instead to get the colors of the current theme. + +## Date Template Transformation + +The date template transformation now uses [date-fns tokens](https://date-fns.org/docs/format) instead of momentjs. + +## List / Object Filter Rules + +Previously, when using [Filtered Folder Collections](/docs/collection-types#filtered-folder-collections), specifying a `list` field, Static CMS would search the values of the list to find a match. Now the default behavior is to match the JSON formatted version of the list's value. To match values inside the list, simply add `.*` to the end of your filter field. + +Object fields are also now matched against the JSON formatted version of their values. + +**Old setup** + + +```yaml +filter: + field: list_field + value: some_value +``` + +```js +filter: { + field: 'list_field', + value: 'some_value' +} +``` + + + +**New setup** + + +```yaml +filter: + field: list_field.* + value: some_value +``` + +```js +filter: { + field: 'list_field.*', + value: 'some_value' +} +``` + + + +## i18n Config + +For i18n, the setting `defaultLocale` has been renamed to `default_locale`. + +## Type Changes (TypeScript) + +The `StringOrTextField` type has been split into `StringField` and `TextField`. diff --git a/packages/docs/content/docs/open-authoring.mdx b/packages/docs/content/docs/open-authoring.mdx new file mode 100644 index 000000000..a804496b7 --- /dev/null +++ b/packages/docs/content/docs/open-authoring.mdx @@ -0,0 +1,103 @@ +--- +group: Workflow +weight: 20 +title: Open Authoring +beta: true +--- + +When using the [GitHub backend](/docs/github-backend), you can use Static CMS to accept contributions from GitHub users without giving them access to your repository. When they make changes in the CMS, the CMS forks your repository for them behind the scenes, and all the changes are made to the fork. When the contributor is ready to submit their changes, they can set their draft as ready for review in the CMS. This triggers a pull request to your repository, which makes it appear in the Dashboard for maintainers. + +At the same time, any contributors who _do_ have write access to the repository can continue to use Static CMS normally. + +## Requirements + +- You must use [the GitHub backend](/docs/github-backend). + + **Note that the [Git Gateway backend](/docs/git-gateway-backend) does _not_ support Open Authoring, even when the underlying repo is on GitHub.** + +- For private GitHub repos the user must have `read` access on the repo, and you must explicitly set the auth_scope to `repo`, for example: + + +```yaml +backend: + name: github + repo: owner-name/private-repo-name # path to private repo + auth_scope: repo # this is needed to fork the private repo + open_authoring: true +``` + +```js +backend: { + name: "github", + repo: "owner-name/private-repo-name", // path to private repo + auth_scope: "repo", // this is needed to fork the private repo + open_authoring: true +} +``` + + + +## Enabling Open Authoring + +1. [Enable the editorial workflow](/docs/editorial-workflow) by setting `publish_mode` to `editorial_workflow` in your `config.yml`. +2. Set `open_authoring` to `true` in the `backend` section of your `config.yml`, as follows: + + + ```yaml + backend: + name: github + repo: owner-name/repo-name # Path to your GitHub repository + open_authoring: true + ``` + + ```js + backend: { + name: "github", + repo: "owner-name/repo-name", // Path to your GitHub repository + open_authoring: true + } + ``` + + + +## Usage + +When a user logs into Static CMS who does not have write access to your repo, the CMS asks for permission to create a fork of your repo (or uses their existing fork, if they already have one). They are then presented with the normal CMS interface. The published content shown is from the original repo, so it stays up-to-date as changes are made. + +On the editorial workflow screen, the normal three columns are replaced by two columns instead — `Draft` and `Ready to Review`. + +When they make changes to content in the CMS, the changes are made to a branch on their fork. In the editorial workflow screen, they see only their own pending changes. Once they are ready to submit their changes, they can move the card into the "Ready To Review" column to create a pull request. When the entry is published (by a repository maintainer via their Static CMS UI), Static CMS deletes the branch, closes the PR and removes the card from the user's editorial workflow screen. Open Authoring users cannot publish entries through the CMS. + +Users who _do_ have write access to the original repository continue to use the CMS normally. + +## Alternative for external contributors with Git Gateway + +[As noted above](#requirements), Open Authoring does not work with the Git Gateway backend. However, you can use Git Gateway on a site with Netlify Identity that has [open registration](https://www.netlify.com/docs/identity/#adding-identity-users). This lets users create accounts on your site and log into the CMS. There are a few differences, including the following: + +- Users do not need to know about GitHub or create a GitHub account. Instead, they use Netlify Identity accounts that are created on your site and managed by you. +- The CMS applies users' changes directly to your repo, not to a fork. (If you use the editorial workflow, you can use features like [GitHub's protected branches](https://help.github.com/en/articles/about-protected-branches) or [Netlify's locked deploys](https://www.netlify.com/docs/locked-deploys/) to prevent users from publishing directly to your site from the CMS.) +- There is no distinction between users with write access to the repo and users without — all editorial workflow entries are visible from within the CMS and can be published with the CMS. + +## Linking to specific entries in the CMS + +Open authoring often includes some sort of "Edit this page" link on the live site. Static CMS supports this via the **edit** path: + +```js +/#/edit/{collectionName}/{entryName} +``` + +For the entry named "general" in the "settings" file collection + +```html +https://www.example.com/path-to-cms/#/edit/settings/general +``` + +For blog post "test.md" in the "posts" folder collection + +```html +https://www.example.com/path-to-cms/#/edit/posts/test +``` + +- **`collectionName`**: the name of the collection as entered in the CMS config. +- **`entryName`** _(for [file collections](/docs/collection-types/#file-collections)_: the `name` of the entry from the CMS config. +- **`entryName`** _(for [folder collections](/docs/collection-types/#folder-collections)_: the filename, sans extension (the slug). diff --git a/packages/docs/content/docs/start-with-a-template.mdx b/packages/docs/content/docs/start-with-a-template.mdx index 570d94898..621a112fe 100644 --- a/packages/docs/content/docs/start-with-a-template.mdx +++ b/packages/docs/content/docs/start-with-a-template.mdx @@ -41,15 +41,15 @@ You can add Static CMS [to an existing site](/docs/add-to-your-site/), but the q After clicking one of those buttons, authenticate with GitHub and choose a repository name. Netlify then automatically creates a clone of the repository in your GitHub account. Next, it builds and deploys the new site on Netlify, bringing you to the site dashboard after completing the build. -**Note for GitLab and Bitbucket users:** Static CMS supports GitLab and Bitbucket repositories, but won't work with the Deploy to Netlify buttons above without additional configuration (See [GitLab](/docs/gitlab-backend) or [Bitbucket](/docs/bitbucket-backend) respectively). +**Note for GitLab and Bitbucket users:** Static CMS supports GitLab and Bitbucket repositories, but will not work with the Deploy to Netlify buttons above without additional configuration (See [GitLab](/docs/gitlab-backend) or [Bitbucket](/docs/bitbucket-backend) respectively). ## Access Static CMS 1. The template deploy process sends you an invitation to your new site, sent from `no-reply@netlify.com`. - ![Sample email subject line: You've been invited to join radiologist-amanda-53841.netlify.com](/img/email-subject.webp) + ![Sample email subject line: You have been invited to join radiologist-amanda-53841.netlify.com](/img/email-subject.webp) 2. Wait for the deployment to complete, then click the link to accept the invite. Your site will open with a prompt to create a password. !["Complete your signup" modal on the Kaldi coffee site](/img/create-password.webp) -3. Enter a password, sign in, and you'll go to Static CMS. (For future visits, you can go straight to `/admin/`.) +3. Enter a password, sign in, and you will go to Static CMS. (For future visits, you can go straight to `/admin/`.) Try adding and editing posts, or changing the content of the Products page. When you save, the changes are pushed immediately to your Git repository, triggering a build on Netlify, and updating the content on your site. Check out the configuration code by visiting your site repo. diff --git a/packages/docs/content/docs/test-backend.mdx b/packages/docs/content/docs/test-backend.mdx index a131b2d33..4d37df45a 100644 --- a/packages/docs/content/docs/test-backend.mdx +++ b/packages/docs/content/docs/test-backend.mdx @@ -8,7 +8,7 @@ weight: 60 You can use the `test-repo` backend to try out Static CMS without connecting to a Git repo. With this backend, you can write and publish content normally, but any changes will disappear when you reload the page. This backend powers the Static CMS [demo site](https://demo.staticcms.org/). -**Note:** The `test-repo` backend can't access your local file system, nor does it connect to a Git repo, thus you won't see any existing files while using it. +**Note:** The `test-repo` backend cannot access your local file system, nor does it connect to a Git repo, thus you will not see any existing files while using it. To enable this backend, set your backend name to `test-repo` in your Static CMS `config` file. diff --git a/packages/docs/content/docs/widget-datetime.mdx b/packages/docs/content/docs/widget-datetime.mdx index 9b3dd3b2e..458e85c4a 100644 --- a/packages/docs/content/docs/widget-datetime.mdx +++ b/packages/docs/content/docs/widget-datetime.mdx @@ -17,9 +17,9 @@ For common options, see [Common widget options](/docs/widgets#common-widget-opti | Name | Type | Default | Description | | ----------- | ---------------------- | ------------------------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | | default | string | `Current Date and Time` | _Optional_. The default value for the field. Accepts a datetime string, or an empty string to accept blank input. | -| format | string | `yyyy-MM-dd'T'HH:mm:ss.SSSXXX` | _Optional_. Sets storage format. Accepts [date-fns tokens](https://date-fns.org/v2.29.3/docs/format) | -| date_format | string
\| boolean | `true` | _Optional_. Sets date display format in UI.
  • `string` - Accepts [date-fns tokens](https://date-fns.org/v2.29.3/docs/format)
  • `true` - Uses default locale format
  • `false` - If `time_format` is `true` or a string, then date picker is hidden
| -| time_format | string
\| boolean | `true` | _Optional_. Sets time display format in UI.
  • `string` - Accepts [date-fns tokens](https://date-fns.org/v2.29.3/docs/format)
  • `true` - Uses default locale format
  • `false` - Hides the time picker
| +| format | string | `yyyy-MM-dd'T'HH:mm:ss.SSSXXX` | _Optional_. Sets storage format. Accepts [date-fns tokens](https://date-fns.org/docs/format) | +| date_format | string
\| boolean | `true` | _Optional_. Sets date display format in UI.
  • `string` - Accepts [date-fns tokens](https://date-fns.org/docs/format)
  • `true` - Uses default locale format
  • `false` - If `time_format` is `true` or a string, then date picker is hidden
| +| time_format | string
\| boolean | `true` | _Optional_. Sets time display format in UI.
  • `string` - Accepts [date-fns tokens](https://date-fns.org/docs/format)
  • `true` - Uses default locale format
  • `false` - Hides the time picker
| | picker_utc | boolean | `false` | _Optional_.
  • `true` - The datetime picker will display times in UTC
  • `false` - The datetime picker will display times in the user's local timezone
When using date-only formats, it can be helpful to set this to `true` so users in all timezones will see the same date in the datetime picker | ## Examples diff --git a/packages/docs/content/docs/widget-list.mdx b/packages/docs/content/docs/widget-list.mdx index d35fb226b..ea2924969 100644 --- a/packages/docs/content/docs/widget-list.mdx +++ b/packages/docs/content/docs/widget-list.mdx @@ -19,7 +19,7 @@ For common options, see [Common widget options](/docs/widgets#common-widget-opti | default | string | `[ ]` | _Optional_. The default values for fields. Also accepts an array of items | | allow_add | boolean | `true` | _Optional_. `false` - Hides the button to add additional items. Ignored if both `fields` and `types` are not defined | | collapsed | boolean | `true` | _Optional_. `true` - The list and entries collapse by default. Ignored if both `fields` and `types` are not defined | -| summary | string | | _Optional_. The label displayed on collapsed entries. _Ignored for single field lists._ | +| summary | string | | _Optional_. The label displayed on collapsed entries. Can use [Template Transformations](/docs/collection-overview#template-transformations). _Ignored for single field lists._ | | label_singular | string | `label` | _Optional_. The text to show on the add button | | fields | list of widgets | [] | _Optional_. A nested list of multiple widget fields to be included in each repeatable iteration | | min | number | | _Optional_. Minimum number of items in the list | diff --git a/packages/docs/content/docs/widget-markdown.mdx b/packages/docs/content/docs/widget-markdown.mdx index feed97119..6b6f71851 100644 --- a/packages/docs/content/docs/widget-markdown.mdx +++ b/packages/docs/content/docs/widget-markdown.mdx @@ -10,7 +10,7 @@ weight: 19 The markdown widget provides a full fledged text editor allowing users to format text with features such as headings and blockquotes. Users can change their editing view with a handy toggle button. -_Please note:_ If you want to use your markdown editor to fill a markdown file contents after its frontmatter, you'll have to name the field `body` so Static CMS recognizes it and saves the file accordingly. +_Please note:_ If you want to use your markdown editor to fill a markdown file contents after its frontmatter, you will have to name the field `body` so Static CMS recognizes it and saves the file accordingly. ## Widget Options diff --git a/packages/docs/content/docs/widget-object.mdx b/packages/docs/content/docs/widget-object.mdx index 6e146ba87..2e5057911 100644 --- a/packages/docs/content/docs/widget-object.mdx +++ b/packages/docs/content/docs/widget-object.mdx @@ -14,11 +14,11 @@ The object widget allows you to group multiple widgets together, nested under a For common options, see [Common widget options](/docs/widgets#common-widget-options). -| Name | Type | Default | Description | -| --------- | ------- | ------- | ------------------------------------------------------------ | -| fields | boolean | `false` | A nested list of widget fields to include in your widget | -| collapsed | boolean | `false` | _Optional_. Collapse the widget's content by default | -| summary | string | `value` | _Optional_. The label displayed when the object is collapsed | +| Name | Type | Default | Description | +| --------- | ------- | ------- | ---------------------------------------------------------------------------------------------------------------------------------------------------- | +| fields | boolean | `false` | A nested list of widget fields to include in your widget | +| collapsed | boolean | `false` | _Optional_. Collapse the widget's content by default | +| summary | string | `value` | _Optional_. The label displayed when the object is collapsed. Can use [Template Transformations](/docs/collection-overview#template-transformations) | _Please note:_ A default value cannot be set directly on an object widget. Instead you can set defaults within each sub-field's configuration @@ -42,7 +42,7 @@ fields: label: 'Birthdate' widget: 'date' default: '' - format: 'MM/DD/YYYY' + format: 'MM/dd/yyyy' - name: 'address' label: 'Address' widget: 'object' @@ -81,7 +81,7 @@ fields: [ label: 'Birthdate', widget: 'date', default: '', - format: 'MM/DD/YYYY' + format: 'MM/dd/yyyy' }, { name: 'address', diff --git a/packages/docs/content/docs/widget-relation.mdx b/packages/docs/content/docs/widget-relation.mdx index 18103bc79..8512d5330 100644 --- a/packages/docs/content/docs/widget-relation.mdx +++ b/packages/docs/content/docs/widget-relation.mdx @@ -8,7 +8,7 @@ weight: 22 - **UI:** Text input with search result dropdown - **Data type:** Data type of the value pulled from the related collection item -The relation widget allows you to reference items from another collection. It provides a search input with a list of entries from the collection you're referencing, and the list automatically updates with matched entries based on what you've typed. +The relation widget allows you to reference items from another collection. It provides a search input with a list of entries from the collection you are referencing, and the list automatically updates with matched entries based on what you have typed. ## Widget Options diff --git a/packages/docs/content/docs/widgets.mdx b/packages/docs/content/docs/widgets.mdx index 7749dc4f9..c27ca29a9 100644 --- a/packages/docs/content/docs/widgets.mdx +++ b/packages/docs/content/docs/widgets.mdx @@ -45,7 +45,7 @@ The following options are available on all fields: | required | boolean | `true` | _Optional_. Specify as `false` to make a field optional | | hint | string | | _Optional_. Adds helper text directly below a widget. Useful for including instructions. Accepts markdown for bold, italic, strikethrough, and links. | | pattern | list of strings | | _Optional_. Adds field validation by specifying a list with a [regex pattern](https://regexr.com/) and an error message; more extensive validation can be achieved with [custom widgets](/docs/custom-widgets/#advanced-field-validation) | -| i18n | boolean
\| 'translate'
\| 'duplicate'
\| 'none' | | _Optional_.
  • `translate` - Allows translation of the field
  • `duplicate` - Duplicates the value from the default locale
  • `true` - Accept parent values as default
  • `none` or `false` - Exclude field from translations
| +| i18n | boolean
\| 'translate'
\| 'duplicate'
\| 'none' | | _Optional_.
  • `translate` - Allows translation of the field
  • `duplicate` - Duplicates the value from the default locale
  • `true` - Accept parent values as default
  • `none` or `false` - Exclude field from translations
| | condition | FilterRule
\| List of FilterRules | | _Optional_. See [Field Conditions](#field-conditions) | ## Example Widget diff --git a/packages/docs/content/docs/writing-style-guide.mdx b/packages/docs/content/docs/writing-style-guide.mdx index c3bd638cd..b69916acf 100644 --- a/packages/docs/content/docs/writing-style-guide.mdx +++ b/packages/docs/content/docs/writing-style-guide.mdx @@ -168,13 +168,13 @@ _____ > Do: View the fields. -> Don't: With this next command, we'll view the fields. +> Don't: With this next command, we will view the fields. ### Address the reader as "you" > Do: You can create a Deployment by … -> Don't: We'll create a Deployment by … +> Don't: We will create a Deployment by … _____ > Do: In the preceding output, you can see… @@ -201,7 +201,7 @@ Exception: Use "etc." for et cetera. ### Avoid using "we" -Using "we" in a sentence can be confusing, because the reader might not know whether they're part of the "we" you're describing. +Using "we" in a sentence can be confusing, because the reader might not know whether they are part of the "we" you are describing. > Do: Version 1.4 includes … diff --git a/packages/docs/content/homepage.json b/packages/docs/content/homepage.json index afc44375d..bb7bb634a 100644 --- a/packages/docs/content/homepage.json +++ b/packages/docs/content/homepage.json @@ -21,7 +21,7 @@ ], "call_to_action": { "title": "Getting started is simple and free.", - "subtitle": "Choose a template that's pre-configured with a static site generator and deploys to a global CDN in one click.", + "subtitle": "Choose a template that is pre-configured with a static site generator and deploys to a global CDN in one click.", "button_text": "Get Started", "url": "/docs/start-with-a-template/" }, @@ -37,14 +37,14 @@ "description": "The web-based app includes rich-text editing, real-time previews, and drag-and-drop media uploads." }, { - "image": "/img/your-content-your-way.webp", - "title": "Your content, your way", - "description": "Static CMS can integrate with most major static site generators and git repository providers." + "image": "/img/intuitive-workflow-for-content-teams.svg", + "title": "Intuitive workflow for content teams", + "description": "Writers and editors can easily manage content from draft to review to publish across any number of custom content types." }, { "image": "/img/instant-access-without-github-account.svg", "title": "Instant access without GitHub account", - "description": "With Git Gateway, you can add CMS access for any team member — even if they don't have a GitHub account." + "description": "With Git Gateway, you can add CMS access for any team member — even if they do not have a GitHub account." } ] } diff --git a/packages/docs/content/menu.json b/packages/docs/content/menu.json index f90e89b83..4e99701d0 100644 --- a/packages/docs/content/menu.json +++ b/packages/docs/content/menu.json @@ -13,6 +13,10 @@ "name": "Backends", "title": "Backends" }, + { + "name": "Workflow", + "title": "Workflow" + }, { "name": "Collections", "title": "Collections" diff --git a/packages/docs/next.config.js b/packages/docs/next.config.js index 49c772a81..303c711e7 100644 --- a/packages/docs/next.config.js +++ b/packages/docs/next.config.js @@ -10,11 +10,6 @@ const withBundleAnalyzer = require('@next/bundle-analyzer')({ const redirects = [ { source: '/docs', destination: '/docs/intro', permanent: true }, { source: '/chat', destination: 'https://discord.gg/ZWJM9pBMjj', permanent: true }, - { - source: '/docs/editorial-workflow', - destination: '/docs/configuration/#publish-mode', - permanent: true, - }, ]; /** @type {import('next').NextConfig} */ diff --git a/packages/docs/public/img/intuitive-workflow-for-content-teams.svg b/packages/docs/public/img/intuitive-workflow-for-content-teams.svg new file mode 100644 index 000000000..b8e9fc6a3 --- /dev/null +++ b/packages/docs/public/img/intuitive-workflow-for-content-teams.svg @@ -0,0 +1 @@ +workflowDRAFT SIN REVIEWREADY \ No newline at end of file diff --git a/packages/docs/src/components/docs/table_of_contents/DocsTableOfContents.tsx b/packages/docs/src/components/docs/table_of_contents/DocsTableOfContents.tsx index 9c7ead483..9027d31a5 100644 --- a/packages/docs/src/components/docs/table_of_contents/DocsTableOfContents.tsx +++ b/packages/docs/src/components/docs/table_of_contents/DocsTableOfContents.tsx @@ -147,10 +147,15 @@ const StyledNav = styled('nav')( align-self: flex-start; position: sticky; top: 0; - max-height: calc(100vh - 72px); - overflow-y: auto; + max-height: calc(100vh - 88px); + overflow-y: hidden; top: 16px; + &:hover { + overflow-y: auto; + padding-right: 0; + } + ${theme.breakpoints.between('md', 'lg')} { top: 24px; }