diff --git a/docs/docs/cmd/spo/page/page-publish.mdx b/docs/docs/cmd/spo/page/page-publish.mdx new file mode 100644 index 00000000000..f2666d33214 --- /dev/null +++ b/docs/docs/cmd/spo/page/page-publish.mdx @@ -0,0 +1,41 @@ +import Global from '/docs/cmd/_global.mdx'; + +# spo page publish + +Publishes a modern page + +## Usage + +```sh +m365 spo page publish [options] +``` + +## Options + +```md definition-list +`-u, --webUrl ` +: URL of the site where the page is located. + +`-n, --name ` +: Name of the page. +``` + + + +## Examples + +Publish a modern page + +```sh +m365 spo page publish --webUrl https://contoso.sharepoint.com/sites/Marketing --name "Style guide.aspx" +``` + +Publish a modern page in a subfolder + +```sh +m365 spo page publish --webUrl https://contoso.sharepoint.com/sites/Marketing --name "/Styles/Guide.aspx" +``` + +## Response + +The command won't return a response on success. diff --git a/docs/src/config/sidebars.ts b/docs/src/config/sidebars.ts index c995ffe5bdb..f2186eb7991 100644 --- a/docs/src/config/sidebars.ts +++ b/docs/src/config/sidebars.ts @@ -3157,6 +3157,11 @@ const sidebars: SidebarsConfig = { label: 'page list', id: 'cmd/spo/page/page-list' }, + { + type: 'doc', + label: 'page publish', + id: 'cmd/spo/page/page-publish' + }, { type: 'doc', label: 'page remove', diff --git a/src/m365/spo/commands.ts b/src/m365/spo/commands.ts index e7323a44bb6..990c4988471 100644 --- a/src/m365/spo/commands.ts +++ b/src/m365/spo/commands.ts @@ -203,6 +203,7 @@ export default { PAGE_COPY: `${prefix} page copy`, PAGE_GET: `${prefix} page get`, PAGE_LIST: `${prefix} page list`, + PAGE_PUBLISH: `${prefix} page publish`, PAGE_REMOVE: `${prefix} page remove`, PAGE_SET: `${prefix} page set`, PAGE_CLIENTSIDEWEBPART_ADD: `${prefix} page clientsidewebpart add`, diff --git a/src/m365/spo/commands/page/page-publish.spec.ts b/src/m365/spo/commands/page/page-publish.spec.ts new file mode 100644 index 00000000000..0c6db15f965 --- /dev/null +++ b/src/m365/spo/commands/page/page-publish.spec.ts @@ -0,0 +1,178 @@ +import assert from 'assert'; +import sinon from 'sinon'; +import { z } from 'zod'; +import auth from '../../../../Auth.js'; +import { cli } from '../../../../cli/cli.js'; +import { CommandInfo } from '../../../../cli/CommandInfo.js'; +import { Logger } from '../../../../cli/Logger.js'; +import { CommandError } from '../../../../Command.js'; +import request from '../../../../request.js'; +import { telemetry } from '../../../../telemetry.js'; +import { pid } from '../../../../utils/pid.js'; +import { session } from '../../../../utils/session.js'; +import { sinonUtil } from '../../../../utils/sinonUtil.js'; +import commands from '../../commands.js'; +import command from './page-publish.js'; +import { urlUtil } from '../../../../utils/urlUtil.js'; +import { formatting } from '../../../../utils/formatting.js'; + +describe(commands.PAGE_PUBLISH, () => { + let log: string[]; + let logger: Logger; + let loggerLogSpy: sinon.SinonSpy; + let commandInfo: CommandInfo; + let postStub: sinon.SinonStub; + let commandOptionsSchema: z.ZodTypeAny; + + const webUrl = 'https://contoso.sharepoint.com/sites/Marketing'; + const serverRelativeUrl = urlUtil.getServerRelativeSiteUrl(webUrl); + const pageName = 'HR.aspx'; + + before(() => { + sinon.stub(auth, 'restoreAuth').resolves(); + sinon.stub(telemetry, 'trackEvent').returns(); + sinon.stub(pid, 'getProcessName').returns(''); + sinon.stub(session, 'getId').returns(''); + auth.connection.active = true; + commandInfo = cli.getCommandInfo(command); + commandOptionsSchema = commandInfo.command.getSchemaToParse()!; + }); + + beforeEach(() => { + log = []; + logger = { + log: async (msg: string) => { + log.push(msg); + }, + logRaw: async (msg: string) => { + log.push(msg); + }, + logToStderr: async (msg: string) => { + log.push(msg); + } + }; + loggerLogSpy = sinon.spy(logger, 'log'); + + const serverRelativePageUrl = `${serverRelativeUrl}/SitePages/${pageName}`; + postStub = sinon.stub(request, 'post').callsFake(async (opts) => { + if (opts.url === `${webUrl}/_api/web/GetFileByServerRelativePath(DecodedUrl='${formatting.encodeQueryParameter(serverRelativePageUrl)}')/Publish()`) { + return; + } + + throw 'Invalid request: ' + opts.url; + }); + }); + + afterEach(() => { + sinonUtil.restore([ + request.post + ]); + }); + + after(() => { + sinon.restore(); + auth.connection.active = false; + }); + + it('has correct name', () => { + assert.strictEqual(command.name, commands.PAGE_PUBLISH); + }); + + it('has a description', () => { + assert.notStrictEqual(command.description, null); + }); + + it('logs no command output', async () => { + await command.action(logger, + { + options: { + webUrl: webUrl, + name: pageName + } + }); + + assert(loggerLogSpy.notCalled); + }); + + it('correctly publishes page', async () => { + await command.action(logger, + { + options: { + webUrl: webUrl, + name: pageName + } + }); + + assert(postStub.calledOnce); + }); + + it('correctly publishes a page when extension is not specified', async () => { + await command.action(logger, + { + options: { + webUrl: webUrl, + name: pageName.substring(0, pageName.lastIndexOf('.')), + verbose: true + } + }); + + assert(postStub.calledOnce); + }); + + it('correctly publishes a nested page', async () => { + const pageUrl = '/folder1/folder2/' + pageName; + postStub.restore(); + + postStub = sinon.stub(request, 'post').callsFake(async (opts) => { + if (opts.url === `${webUrl}/_api/web/GetFileByServerRelativePath(DecodedUrl='${formatting.encodeQueryParameter(serverRelativeUrl + '/SitePages' + pageUrl)}')/Publish()`) { + return; + } + + throw 'Invalid request: ' + opts.url; + }); + + await command.action(logger, + { + options: { + webUrl: webUrl, + name: pageUrl + } + }); + + assert(postStub.calledOnce); + }); + + it('correctly handles API error', async () => { + postStub.restore(); + const errorMessage = 'The file /sites/Marketing/SitePages/My-new-page.aspx does not exist.'; + + sinon.stub(request, 'post').rejects({ + error: { + 'odata.error': { + message: { + lang: 'en-US', + value: errorMessage + } + } + } + }); + + await assert.rejects(command.action(logger, { options: { webUrl: webUrl, name: pageName } }), + new CommandError(errorMessage)); + }); + + it('fails validation if webUrl is not a valid SharePoint URL', async () => { + const actual = commandOptionsSchema.safeParse({ webUrl: 'foo' }); + assert.strictEqual(actual.success, false); + }); + + it('passes validation when the webUrl is a valid SharePoint URL and name is specified', async () => { + const actual = commandOptionsSchema.safeParse({ webUrl: webUrl, name: pageName }); + assert.strictEqual(actual.success, true); + }); + + it('passes validation when name has no extension', async () => { + const actual = commandOptionsSchema.safeParse({ webUrl: webUrl, name: 'page' }); + assert.strictEqual(actual.success, true); + }); +}); diff --git a/src/m365/spo/commands/page/page-publish.ts b/src/m365/spo/commands/page/page-publish.ts new file mode 100644 index 00000000000..dcb499524ac --- /dev/null +++ b/src/m365/spo/commands/page/page-publish.ts @@ -0,0 +1,69 @@ +import { z } from 'zod'; +import { zod } from '../../../../utils/zod.js'; +import { Logger } from '../../../../cli/Logger.js'; +import { globalOptionsZod } from '../../../../Command.js'; +import request, { CliRequestOptions } from '../../../../request.js'; +import { formatting } from '../../../../utils/formatting.js'; +import { urlUtil } from '../../../../utils/urlUtil.js'; +import { validation } from '../../../../utils/validation.js'; +import SpoCommand from '../../../base/SpoCommand.js'; +import commands from '../../commands.js'; + +const options = globalOptionsZod + .extend({ + webUrl: zod.alias('u', z.string() + .refine(url => validation.isValidSharePointUrl(url) === true, url => ({ + message: `'${url}' is not a valid SharePoint Online site URL.` + })) + ), + name: zod.alias('n', z.string()) + }) + .strict(); +declare type Options = z.infer; + +interface CommandArgs { + options: Options; +} + +class SpoPagePublishCommand extends SpoCommand { + public get name(): string { + return commands.PAGE_PUBLISH; + } + + public get description(): string { + return 'Publishes a modern page'; + } + + public get schema(): z.ZodTypeAny { + return options; + } + + public async commandAction(logger: Logger, args: CommandArgs): Promise { + try { + // Remove leading slashes from the page name (page can be nested in folders) + let pageName: string = urlUtil.removeLeadingSlashes(args.options.name); + if (!pageName.toLowerCase().endsWith('.aspx')) { + pageName += '.aspx'; + } + + if (this.verbose) { + await logger.logToStderr(`Publishing page ${pageName}...`); + } + + const filePath = `${urlUtil.getServerRelativeSiteUrl(args.options.webUrl)}/SitePages/${pageName}`; + const requestOptions: CliRequestOptions = { + url: `${args.options.webUrl}/_api/web/GetFileByServerRelativePath(DecodedUrl='${formatting.encodeQueryParameter(filePath)}')/Publish()`, + headers: { + accept: 'application/json;odata=nometadata' + } + }; + + await request.post(requestOptions); + } + catch (err: any) { + this.handleRejectedODataJsonPromise(err); + } + } +} + +export default new SpoPagePublishCommand();