@loopress/cli 0.29.0 → 0.31.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +1369 -360
- package/dist/commands/acf/push.js +2 -1
- package/dist/commands/composer/push.d.ts +1 -0
- package/dist/commands/composer/push.js +13 -0
- package/dist/commands/content/copy.d.ts +28 -0
- package/dist/commands/content/copy.js +97 -0
- package/dist/commands/cpt/diff.js +5 -0
- package/dist/commands/cpt/list.d.ts +2 -0
- package/dist/commands/cpt/list.js +4 -0
- package/dist/commands/cpt/pull.d.ts +2 -0
- package/dist/commands/cpt/pull.js +4 -0
- package/dist/commands/cpt/push.d.ts +2 -0
- package/dist/commands/cpt/push.js +4 -0
- package/dist/commands/cpt/rm.d.ts +2 -0
- package/dist/commands/cpt/rm.js +3 -0
- package/dist/commands/cpt/rollback.js +5 -0
- package/dist/commands/dev.js +14 -4
- package/dist/commands/diff.js +1 -1
- package/dist/commands/doctor.d.ts +2 -0
- package/dist/commands/doctor.js +57 -2
- package/dist/commands/form/push.js +4 -1
- package/dist/commands/meta/diff.d.ts +2 -0
- package/dist/commands/meta/diff.js +5 -0
- package/dist/commands/meta/list.d.ts +19 -0
- package/dist/commands/meta/list.js +21 -0
- package/dist/commands/meta/pull.d.ts +2 -0
- package/dist/commands/meta/pull.js +4 -0
- package/dist/commands/meta/push.d.ts +2 -0
- package/dist/commands/meta/push.js +4 -0
- package/dist/commands/meta/rm.d.ts +2 -0
- package/dist/commands/meta/rm.js +3 -0
- package/dist/commands/meta/rollback.d.ts +2 -0
- package/dist/commands/meta/rollback.js +5 -0
- package/dist/commands/option/list.js +2 -8
- package/dist/commands/option/push.js +4 -2
- package/dist/commands/page/push.js +3 -2
- package/dist/commands/pattern/diff.d.ts +2 -0
- package/dist/commands/pattern/diff.js +5 -0
- package/dist/commands/pattern/list.d.ts +2 -0
- package/dist/commands/pattern/list.js +3 -0
- package/dist/commands/pattern/pull.d.ts +2 -0
- package/dist/commands/pattern/pull.js +4 -0
- package/dist/commands/pattern/push.d.ts +2 -0
- package/dist/commands/pattern/push.js +4 -0
- package/dist/commands/pattern/rm.d.ts +2 -0
- package/dist/commands/pattern/rm.js +3 -0
- package/dist/commands/pattern/rollback.d.ts +2 -0
- package/dist/commands/pattern/rollback.js +5 -0
- package/dist/commands/plugin/audit.js +43 -25
- package/dist/commands/project/config.d.ts +1 -0
- package/dist/commands/project/config.js +20 -1
- package/dist/commands/project/list.d.ts +1 -1
- package/dist/commands/project/list.js +6 -6
- package/dist/commands/project/pull.js +11 -8
- package/dist/commands/project/push.js +23 -18
- package/dist/commands/project/remove.js +1 -1
- package/dist/commands/project/switch.d.ts +0 -1
- package/dist/commands/project/switch.js +16 -36
- package/dist/commands/promote.d.ts +1 -0
- package/dist/commands/promote.js +7 -3
- package/dist/commands/pull.d.ts +2 -1
- package/dist/commands/pull.js +31 -6
- package/dist/commands/push.d.ts +4 -2
- package/dist/commands/push.js +51 -19
- package/dist/commands/role/diff.d.ts +2 -0
- package/dist/commands/role/diff.js +5 -0
- package/dist/commands/role/list.d.ts +2 -0
- package/dist/commands/role/list.js +4 -0
- package/dist/commands/role/pull.d.ts +2 -0
- package/dist/commands/role/pull.js +4 -0
- package/dist/commands/role/push.d.ts +2 -0
- package/dist/commands/role/push.js +4 -0
- package/dist/commands/role/rm.d.ts +2 -0
- package/dist/commands/role/rm.js +3 -0
- package/dist/commands/role/rollback.d.ts +2 -0
- package/dist/commands/role/rollback.js +5 -0
- package/dist/commands/snippet/push.d.ts +1 -0
- package/dist/commands/snippet/push.js +24 -3
- package/dist/commands/status.d.ts +2 -3
- package/dist/commands/status.js +21 -53
- package/dist/commands/taxonomy/diff.d.ts +2 -0
- package/dist/commands/taxonomy/diff.js +5 -0
- package/dist/commands/taxonomy/list.d.ts +2 -0
- package/dist/commands/taxonomy/list.js +5 -0
- package/dist/commands/taxonomy/pull.d.ts +2 -0
- package/dist/commands/taxonomy/pull.js +4 -0
- package/dist/commands/taxonomy/push.d.ts +2 -0
- package/dist/commands/taxonomy/push.js +4 -0
- package/dist/commands/taxonomy/rm.d.ts +2 -0
- package/dist/commands/taxonomy/rm.js +3 -0
- package/dist/commands/taxonomy/rollback.d.ts +2 -0
- package/dist/commands/taxonomy/rollback.js +5 -0
- package/dist/commands/theme/template/push.d.ts +2 -0
- package/dist/commands/theme/template/push.js +26 -5
- package/dist/commands/theme/version/pull.js +6 -1
- package/dist/config/project-config.manager.d.ts +3 -3
- package/dist/config/project-config.manager.js +22 -31
- package/dist/hooks/finally.js +11 -3
- package/dist/lib/api-client.js +2 -1
- package/dist/lib/app-build.js +3 -1
- package/dist/lib/base.d.ts +2 -2
- package/dist/lib/base.js +33 -38
- package/dist/lib/declared-type-command.d.ts +35 -0
- package/dist/lib/declared-type-command.js +192 -0
- package/dist/lib/dev-watch.d.ts +1 -1
- package/dist/lib/dev-watch.js +7 -3
- package/dist/lib/diff-command.d.ts +2 -0
- package/dist/lib/diff-command.js +24 -3
- package/dist/lib/github-release.js +4 -0
- package/dist/lib/load-snippets.js +4 -0
- package/dist/lib/local-callback-server.js +3 -1
- package/dist/lib/php-files-command.d.ts +8 -0
- package/dist/lib/php-files-command.js +5 -0
- package/dist/lib/push-command.d.ts +5 -2
- package/dist/lib/push-command.js +96 -15
- package/dist/lib/put-or-create.js +3 -4
- package/dist/lib/resource-state.js +38 -85
- package/dist/lib/rollback-command.js +9 -2
- package/dist/lib/rollback-materialize.js +13 -24
- package/dist/lib/spinner.d.ts +2 -0
- package/dist/lib/spinner.js +72 -0
- package/dist/lib/temp-admin.d.ts +10 -1
- package/dist/lib/temp-admin.js +27 -2
- package/dist/lib/validate-local.js +40 -12
- package/dist/lib/wp-client.d.ts +1 -0
- package/dist/lib/wp-client.js +21 -2
- package/dist/lib/wp-site-diagnostic.js +3 -2
- package/dist/types/global-config.generated.d.ts +1 -5
- package/dist/types/project-config.generated.d.ts +28 -4
- package/dist/types/snippet.d.ts +1 -0
- package/dist/utils/composer.js +2 -1
- package/dist/utils/declared-type-format.d.ts +52 -0
- package/dist/utils/declared-type-format.js +233 -0
- package/dist/utils/resource-dirs.d.ts +9 -3
- package/dist/utils/resource-dirs.js +16 -4
- package/dist/utils/template-format.d.ts +1 -0
- package/dist/utils/theme-css.d.ts +19 -0
- package/dist/utils/theme-css.js +100 -0
- package/oclif.manifest.json +3294 -1213
- package/package.json +32 -16
- package/schemas/global-config.schema.json +2 -6
- package/schemas/project-config.schema.json +37 -7
- package/dist/commands/seo/diff.js +0 -5
- package/dist/commands/seo/list.d.ts +0 -18
- package/dist/commands/seo/list.js +0 -58
- package/dist/commands/seo/pull.d.ts +0 -24
- package/dist/commands/seo/pull.js +0 -89
- package/dist/commands/seo/push.d.ts +0 -29
- package/dist/commands/seo/push.js +0 -178
- package/dist/commands/seo/rollback.js +0 -5
- package/dist/utils/seo-format.d.ts +0 -29
- package/dist/utils/seo-format.js +0 -18
- /package/dist/commands/{seo → cpt}/diff.d.ts +0 -0
- /package/dist/commands/{seo → cpt}/rollback.d.ts +0 -0
package/dist/lib/diff-command.js
CHANGED
|
@@ -47,8 +47,16 @@ export class DiffCommand extends LoopressCommand {
|
|
|
47
47
|
// a tally, and the verdict, then returns the structured result. Exit code: 1 on drift, 2
|
|
48
48
|
// when a resource could not be compared (inconclusive), so the command doubles as a CI gate.
|
|
49
49
|
async report(targets, sides) {
|
|
50
|
-
this.out(`Comparing ${c('bold', sides.leftLabel)} ${c('dim', '→')} ${c('bold', sides.
|
|
50
|
+
this.out(`Comparing ${c('bold', sides.leftLabel)} ${c('dim', '→')} ${c('bold', sides.rightHeading)}\n`);
|
|
51
51
|
const results = await Promise.all(targets.map(async (target) => this.computeResource(target, sides)));
|
|
52
|
+
// Every resource failing the same way (site down, bad credentials) is one problem: say it
|
|
53
|
+
// once instead of once per resource.
|
|
54
|
+
const sharedError = sharedFailure(results.map((result) => result.json.error));
|
|
55
|
+
if (sharedError !== undefined) {
|
|
56
|
+
// Named, since some resources may have compared fine (one that needs no request).
|
|
57
|
+
const failedTitles = results.filter((result) => result.json.error !== undefined).map((result) => result.title);
|
|
58
|
+
this.out(c('red', `error (${failedTitles.join(', ')}): ${sharedError}`));
|
|
59
|
+
}
|
|
52
60
|
const resources = {};
|
|
53
61
|
let drift = false;
|
|
54
62
|
let failed = false;
|
|
@@ -60,8 +68,7 @@ export class DiffCommand extends LoopressCommand {
|
|
|
60
68
|
resources[result.resource] = result.json;
|
|
61
69
|
if (result.json.error !== undefined) {
|
|
62
70
|
failed = true;
|
|
63
|
-
|
|
64
|
-
this.out(`${c('bold', result.title)} ${errorLabel}`);
|
|
71
|
+
this.renderFailure(result.title, result.json.error, sharedError);
|
|
65
72
|
continue;
|
|
66
73
|
}
|
|
67
74
|
compared += 1;
|
|
@@ -91,6 +98,7 @@ export class DiffCommand extends LoopressCommand {
|
|
|
91
98
|
return {
|
|
92
99
|
againstWp: against ? this.wpForEnvironment(against) : undefined,
|
|
93
100
|
leftLabel: this.siteConfig.name,
|
|
101
|
+
rightHeading: against ?? 'local files',
|
|
94
102
|
rightLabel: against ?? 'local',
|
|
95
103
|
warn: (message) => {
|
|
96
104
|
this.warn(message);
|
|
@@ -120,6 +128,13 @@ export class DiffCommand extends LoopressCommand {
|
|
|
120
128
|
if (!this.jsonEnabled())
|
|
121
129
|
this.log(message);
|
|
122
130
|
}
|
|
131
|
+
renderFailure(title, error, sharedError) {
|
|
132
|
+
// Already printed once by `report` when every failure is the same.
|
|
133
|
+
if (sharedError !== undefined)
|
|
134
|
+
return;
|
|
135
|
+
const errorLabel = c('red', `error: ${error}`);
|
|
136
|
+
this.out(`${c('bold', title)} ${errorLabel}`);
|
|
137
|
+
}
|
|
123
138
|
renderSection(title, stateDiff) {
|
|
124
139
|
if (isEmptyDiff(stateDiff)) {
|
|
125
140
|
this.out(`${c('bold', title)} ${c('dim', 'in sync')}`);
|
|
@@ -180,3 +195,9 @@ function indentPatch(patch) {
|
|
|
180
195
|
.join('\n')
|
|
181
196
|
.trimEnd();
|
|
182
197
|
}
|
|
198
|
+
// The one error every failed resource shares, when more than one failed (see `report`).
|
|
199
|
+
function sharedFailure(errors) {
|
|
200
|
+
const failures = errors.filter((error) => error !== undefined);
|
|
201
|
+
const distinct = new Set(failures);
|
|
202
|
+
return failures.length > 1 && distinct.size === 1 ? failures[0] : undefined;
|
|
203
|
+
}
|
|
@@ -4,6 +4,7 @@ import { mkdtemp } from 'node:fs/promises';
|
|
|
4
4
|
import { tmpdir } from 'node:os';
|
|
5
5
|
import { join } from 'node:path';
|
|
6
6
|
import { pipeline } from 'node:stream/promises';
|
|
7
|
+
import { withSpinner } from './spinner.js';
|
|
7
8
|
const REPO = 'loopress/loopress';
|
|
8
9
|
const ASSET_NAME = 'loopress-full.zip';
|
|
9
10
|
/**
|
|
@@ -17,6 +18,9 @@ const ASSET_NAME = 'loopress-full.zip';
|
|
|
17
18
|
* "the one zip asset".
|
|
18
19
|
*/
|
|
19
20
|
export async function downloadLatestFullZip() {
|
|
21
|
+
return withSpinner('Downloading Loopress Full', download);
|
|
22
|
+
}
|
|
23
|
+
async function download() {
|
|
20
24
|
const releases = await got(`https://api.github.com/repos/${REPO}/releases`, {
|
|
21
25
|
headers: { accept: 'application/vnd.github+json', 'user-agent': 'loopress-cli' },
|
|
22
26
|
searchParams: { 'per_page': 30 },
|
|
@@ -26,6 +26,7 @@ async function loadSnippet(path, file, onSkip) {
|
|
|
26
26
|
}
|
|
27
27
|
let id;
|
|
28
28
|
let name;
|
|
29
|
+
let description;
|
|
29
30
|
let type;
|
|
30
31
|
let isActive = false;
|
|
31
32
|
let tags = [];
|
|
@@ -40,6 +41,8 @@ async function loadSnippet(path, file, onSkip) {
|
|
|
40
41
|
// `||`, not `??`: an empty-string name in the sidecar JSON counts as absent too.
|
|
41
42
|
// eslint-disable-next-line @typescript-eslint/prefer-nullish-coalescing
|
|
42
43
|
name = meta.name || undefined;
|
|
44
|
+
// Kept even when empty: a description cleared on purpose, not one to replace with "Imported from <path>".
|
|
45
|
+
description = meta.description;
|
|
43
46
|
type = parseType(meta.type) ?? undefined;
|
|
44
47
|
isActive = Boolean(meta.active);
|
|
45
48
|
tags = Array.isArray(meta.tags) ? meta.tags.map(String) : [];
|
|
@@ -58,6 +61,7 @@ async function loadSnippet(path, file, onSkip) {
|
|
|
58
61
|
return {
|
|
59
62
|
active: isActive,
|
|
60
63
|
code: content,
|
|
64
|
+
description,
|
|
61
65
|
id,
|
|
62
66
|
insertMethod: insertMethod ?? 'auto',
|
|
63
67
|
location: location ?? defaultLocationForType(resolvedType),
|
|
@@ -2,6 +2,7 @@ import { Buffer } from 'node:buffer';
|
|
|
2
2
|
import { randomBytes, timingSafeEqual } from 'node:crypto';
|
|
3
3
|
import { createServer } from 'node:http';
|
|
4
4
|
import { openBrowser } from './open-browser.js';
|
|
5
|
+
import { withSpinner } from './spinner.js';
|
|
5
6
|
/** Constant-time compare that also tolerates a length mismatch without throwing. */
|
|
6
7
|
function safeEqual(a, b) {
|
|
7
8
|
const bufferA = Buffer.from(a);
|
|
@@ -28,7 +29,7 @@ function safeEqual(a, b) {
|
|
|
28
29
|
export async function waitForLocalCallback(options) {
|
|
29
30
|
const timeoutMs = options.timeoutMs ?? 5 * 60 * 1000;
|
|
30
31
|
const state = randomBytes(32).toString('hex');
|
|
31
|
-
|
|
32
|
+
const callback = new Promise((resolve, reject) => {
|
|
32
33
|
function finish(res, page) {
|
|
33
34
|
// `Connection: close` (here and below): server.close() only refuses new connections, a
|
|
34
35
|
// kept-alive socket would otherwise still reach this handler after the shutdown.
|
|
@@ -100,6 +101,7 @@ export async function waitForLocalCallback(options) {
|
|
|
100
101
|
openBrowser(targetUrl);
|
|
101
102
|
});
|
|
102
103
|
});
|
|
104
|
+
return withSpinner('Waiting for the browser', async () => callback);
|
|
103
105
|
}
|
|
104
106
|
/**
|
|
105
107
|
* The page rewrites its own URL to `/` on load, so the callback query string (an Application
|
|
@@ -30,7 +30,15 @@ type RemotePhpFile = {
|
|
|
30
30
|
filename: string;
|
|
31
31
|
public?: boolean;
|
|
32
32
|
revision?: string;
|
|
33
|
+
runtimeError?: RuntimeError;
|
|
33
34
|
};
|
|
35
|
+
export type RuntimeError = {
|
|
36
|
+
at: string;
|
|
37
|
+
message: string;
|
|
38
|
+
method: string;
|
|
39
|
+
type: 'action' | 'cron' | 'filter';
|
|
40
|
+
};
|
|
41
|
+
export declare function formatRuntimeError(error: RuntimeError): string;
|
|
34
42
|
type PushResult = {
|
|
35
43
|
pruned: string[];
|
|
36
44
|
pushed: string[];
|
|
@@ -14,6 +14,9 @@ import { loadFiles as loadDirectoryFiles } from './load-files.js';
|
|
|
14
14
|
import { PushCommand } from './push-command.js';
|
|
15
15
|
import { getResourceStateProvider } from './resource-state.js';
|
|
16
16
|
import { isApplicative404 } from './wp-client.js';
|
|
17
|
+
export function formatRuntimeError(error) {
|
|
18
|
+
return `${error.method}() threw while running as ${error.type === 'action' ? 'an' : 'a'} ${error.type}, last at ${error.at}: ${error.message}`;
|
|
19
|
+
}
|
|
17
20
|
// Mirrors wordpress-plugin FileWriter::DECLARE_PATTERN / withGuard(): the server rejects both
|
|
18
21
|
// an absent declare(strict_types=1); and one that appears more than once (it needs a single
|
|
19
22
|
// unambiguous insertion point for the ABSPATH guard). Matching "exactly once" here, not just
|
|
@@ -262,6 +265,8 @@ export function resourceListCommand(spec) {
|
|
|
262
265
|
for (const file of files) {
|
|
263
266
|
const badges = [file.public ? '[PUBLIC]' : '', file.error ? `(${file.error})` : ''].filter(Boolean).join(' ');
|
|
264
267
|
this.log(badges ? ` ${file.filename} ${badges}` : ` ${file.filename}`);
|
|
268
|
+
if (file.runtimeError)
|
|
269
|
+
this.log(` ${formatRuntimeError(file.runtimeError)}`);
|
|
265
270
|
}
|
|
266
271
|
if (files.some((file) => file.public)) {
|
|
267
272
|
this.log('');
|
|
@@ -3,18 +3,20 @@ import { type ResourceState } from './diff-state.js';
|
|
|
3
3
|
import { type ResourceStateProvider } from './resource-state.js';
|
|
4
4
|
export declare abstract class PushCommand extends LoopressCommand {
|
|
5
5
|
static baseFlags: {
|
|
6
|
+
overwrite: import("@oclif/core/interfaces").BooleanFlag<boolean>;
|
|
6
7
|
'production-confirmed': import("@oclif/core/interfaces").BooleanFlag<boolean>;
|
|
7
8
|
env: import("@oclif/core/interfaces").OptionFlag<string | undefined, import("@oclif/core/interfaces").CustomOptions>;
|
|
8
9
|
};
|
|
9
10
|
protected failedCount: number;
|
|
10
11
|
private refusedByGuard;
|
|
11
|
-
protected captureBeforePushState(provider: ResourceStateProvider, dir: string): Promise<ResourceState | undefined>;
|
|
12
|
+
protected captureBeforePushState(provider: ResourceStateProvider, dir: string, writes?: (id: string) => boolean): Promise<ResourceState | undefined>;
|
|
12
13
|
catch(err: Error): Promise<void>;
|
|
13
14
|
protected guardProductionPush(): Promise<void>;
|
|
14
15
|
init(): Promise<void>;
|
|
15
|
-
protected previewRemote(provider: ResourceStateProvider, dir: string): Promise<
|
|
16
|
+
protected previewRemote(provider: ResourceStateProvider, dir: string): Promise<ResourceState>;
|
|
16
17
|
protected recordDeployment(status: 'failure' | 'success'): Promise<void>;
|
|
17
18
|
protected recordSuccess(): Promise<void>;
|
|
19
|
+
protected reportPushToSite(): Promise<void>;
|
|
18
20
|
protected reportTaskFailure(message: string, error: unknown, task?: {
|
|
19
21
|
output: string;
|
|
20
22
|
}): never;
|
|
@@ -22,4 +24,5 @@ export declare abstract class PushCommand extends LoopressCommand {
|
|
|
22
24
|
output: string;
|
|
23
25
|
}) => Promise<void>): Promise<void>;
|
|
24
26
|
protected writeAfterPushSnapshot(provider: ResourceStateProvider, dir: string, beforeState: ResourceState | undefined): Promise<void>;
|
|
27
|
+
private guardOutsideEdits;
|
|
25
28
|
}
|
package/dist/lib/push-command.js
CHANGED
|
@@ -1,17 +1,22 @@
|
|
|
1
|
+
import { confirm } from '@inquirer/prompts';
|
|
1
2
|
import { Flags } from '@oclif/core';
|
|
2
3
|
import got from 'got';
|
|
3
4
|
import { Listr } from 'listr2';
|
|
4
5
|
import { resolve } from 'node:path';
|
|
6
|
+
import { isDeepStrictEqual } from 'node:util';
|
|
5
7
|
import { authManager } from '../config/auth.manager.js';
|
|
6
8
|
import { API_URL } from './api-client.js';
|
|
7
9
|
import { LoopressCommand } from './base.js';
|
|
8
10
|
import { guardProductionPush } from './guard-production-push.js';
|
|
9
|
-
import {
|
|
11
|
+
import { isInteractive } from './interactive.js';
|
|
12
|
+
import { readSnapshot, recordToState, writeSnapshot } from './snapshot-store.js';
|
|
13
|
+
import { withoutSpinner, withSpinner } from './spinner.js';
|
|
10
14
|
export class PushCommand extends LoopressCommand {
|
|
11
15
|
// Passed by an aggregate command (`lps theme push`) that already confirmed production once,
|
|
12
16
|
// so its steps skip that prompt while still asking their own confirmations (uninstalls).
|
|
13
17
|
static baseFlags = {
|
|
14
18
|
...LoopressCommand.baseFlags,
|
|
19
|
+
overwrite: Flags.boolean({ description: 'Push even over changes made on the site outside Loopress since your last push' }),
|
|
15
20
|
'production-confirmed': Flags.boolean({ hidden: true }),
|
|
16
21
|
};
|
|
17
22
|
failedCount = 0;
|
|
@@ -25,13 +30,17 @@ export class PushCommand extends LoopressCommand {
|
|
|
25
30
|
// `writeAfterPushSnapshot` there is no restore point to complete.
|
|
26
31
|
//
|
|
27
32
|
// On a dry run, the same read is the preview's check against the site (see previewRemote).
|
|
28
|
-
|
|
33
|
+
//
|
|
34
|
+
// `writes` narrows the outside-edit check to the ids this push actually writes, for a push that
|
|
35
|
+
// skips some local items (`lps page push <slug>`, readonly options, `lps acf push --type`).
|
|
36
|
+
async captureBeforePushState(provider, dir, writes) {
|
|
29
37
|
if (this.dryRun) {
|
|
30
|
-
await this.previewRemote(provider, dir);
|
|
38
|
+
await this.guardOutsideEdits(provider, dir, await this.previewRemote(provider, dir), writes);
|
|
31
39
|
return undefined;
|
|
32
40
|
}
|
|
41
|
+
let remote;
|
|
33
42
|
try {
|
|
34
|
-
|
|
43
|
+
remote = await provider.remote(this.wp, (message) => {
|
|
35
44
|
this.warn(message);
|
|
36
45
|
}, dir);
|
|
37
46
|
}
|
|
@@ -39,6 +48,8 @@ export class PushCommand extends LoopressCommand {
|
|
|
39
48
|
this.warn(`Could not read remote state for a rollback snapshot: ${error.message}`);
|
|
40
49
|
return undefined;
|
|
41
50
|
}
|
|
51
|
+
await this.guardOutsideEdits(provider, dir, remote, writes);
|
|
52
|
+
return remote;
|
|
42
53
|
}
|
|
43
54
|
async catch(err) {
|
|
44
55
|
if (!this.dryRun && !this.refusedByGuard && this.siteConfig) {
|
|
@@ -67,7 +78,7 @@ export class PushCommand extends LoopressCommand {
|
|
|
67
78
|
// pre-apply check built on it, see what the real push will hit.
|
|
68
79
|
async previewRemote(provider, dir) {
|
|
69
80
|
try {
|
|
70
|
-
await provider.remote(this.wp, () => { }, dir);
|
|
81
|
+
return await provider.remote(this.wp, () => { }, dir);
|
|
71
82
|
}
|
|
72
83
|
catch (error) {
|
|
73
84
|
this.error(`The site would refuse this push: ${error.message}`);
|
|
@@ -78,19 +89,37 @@ export class PushCommand extends LoopressCommand {
|
|
|
78
89
|
if (!token)
|
|
79
90
|
return;
|
|
80
91
|
try {
|
|
81
|
-
await got.post(`${API_URL}/deployments`, {
|
|
92
|
+
await withSpinner('Recording the deployment', async () => got.post(`${API_URL}/deployments`, {
|
|
82
93
|
headers: { Authorization: `Bearer ${token}` },
|
|
83
94
|
json: { status, url: this.siteConfig.url },
|
|
84
95
|
timeout: { request: 3000 },
|
|
85
|
-
});
|
|
96
|
+
}));
|
|
86
97
|
}
|
|
87
98
|
catch {
|
|
88
99
|
// non-blocking: recording must never interrupt the push flow
|
|
89
100
|
}
|
|
90
101
|
}
|
|
91
102
|
async recordSuccess() {
|
|
92
|
-
if (
|
|
93
|
-
|
|
103
|
+
if (this.dryRun)
|
|
104
|
+
return;
|
|
105
|
+
await this.recordDeployment('success');
|
|
106
|
+
await this.reportPushToSite();
|
|
107
|
+
}
|
|
108
|
+
// Tells the plugin this push finished, for its Overview tab and the "managed by Loopress"
|
|
109
|
+
// warnings in wp-admin: one record per command (`this.id`, e.g. `acf:push`), since a single push
|
|
110
|
+
// sends one request per item. Best-effort like recordDeployment: a plugin older than the push
|
|
111
|
+
// log answers 404, one that doesn't know this command 400, neither may fail a push that landed.
|
|
112
|
+
async reportPushToSite() {
|
|
113
|
+
if (!this.id)
|
|
114
|
+
return;
|
|
115
|
+
try {
|
|
116
|
+
// Bounded: the site fingerprints the pushed resource before answering, which a large
|
|
117
|
+
// site can make slow, and a finished push must not wait on its own bookkeeping.
|
|
118
|
+
await this.wp.post('loopress/v1/pushes', { resource: this.id }, { timeoutMs: 5000 });
|
|
119
|
+
}
|
|
120
|
+
catch {
|
|
121
|
+
// non-blocking, see above
|
|
122
|
+
}
|
|
94
123
|
}
|
|
95
124
|
// Reports one failed Listr task: status lines go through `task.output` when running inside a
|
|
96
125
|
// Listr renderer (this.warn would race with the repaint), and fall back to `this.warn` when
|
|
@@ -111,17 +140,19 @@ export class PushCommand extends LoopressCommand {
|
|
|
111
140
|
// just the first. `label` feeds the "Push <label>" task title; `task` does the actual work
|
|
112
141
|
// and reports its own failure through `reportTaskFailure`.
|
|
113
142
|
async runPushTasks(items, label, task) {
|
|
114
|
-
await
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
143
|
+
await withoutSpinner(async () => {
|
|
144
|
+
await new Listr(items.map((item) => ({
|
|
145
|
+
task: async (_ctx, taskCtx) => task(item, taskCtx),
|
|
146
|
+
title: `Push ${label(item)}`,
|
|
147
|
+
})), { concurrent: false, exitOnError: false, renderer: this.jsonEnabled() ? 'silent' : 'default' }).run();
|
|
148
|
+
});
|
|
118
149
|
}
|
|
119
150
|
// Pairs `beforeState` (from captureBeforePushState, read before this push touched anything)
|
|
120
151
|
// with a fresh remote read taken right after the push, and writes both as one rollback
|
|
121
152
|
// snapshot. Reading the after-state from WordPress itself, rather than assuming it matches
|
|
122
153
|
// the local files that were pushed, is what keeps this correct for every resource's own
|
|
123
|
-
// partial-push semantics: a readonly option skipped by `option push`, a snippet or
|
|
124
|
-
//
|
|
154
|
+
// partial-push semantics: a readonly option skipped by `option push`, a snippet or form
|
|
155
|
+
// that gets a server-assigned id on create, a theme-styles push that only touches the
|
|
125
156
|
// active stylesheet, an API/hook push that leaves server-side files alone unless `--prune`.
|
|
126
157
|
// `lps <resource> rollback`'s drift check compares the environment's state at rollback time
|
|
127
158
|
// against exactly this after-state, so it has to be what actually landed, not what was sent.
|
|
@@ -144,4 +175,54 @@ export class PushCommand extends LoopressCommand {
|
|
|
144
175
|
this.warn(`Could not save a rollback snapshot after this push: ${error.message}`);
|
|
145
176
|
}
|
|
146
177
|
}
|
|
178
|
+
// Refuses to silently overwrite a change made on the site outside Loopress (wp-admin, another
|
|
179
|
+
// tool) since this machine's last push. Flags an id only when both hold:
|
|
180
|
+
// - the site no longer matches what that push left (the latest snapshot's `afterState`, the
|
|
181
|
+
// same reference rollback's drift check uses), and
|
|
182
|
+
// - this push would write a different value over it (local differs from the site), so a
|
|
183
|
+
// `pull` that already brought the change into the local files is no reason to stop.
|
|
184
|
+
// Only ids present locally count: a push is additive, it never touches a site-only id. And
|
|
185
|
+
// only ids the site still has with readable content: pushing over a deletion (`lps <resource>
|
|
186
|
+
// rm`, or wp-admin) loses nothing, and a file the site lists without its content (too large
|
|
187
|
+
// to send) can't be compared, so it would otherwise block every push.
|
|
188
|
+
// Ceiling: snapshots live in git-ignored `.loopress/`, so only the machine that pushed last
|
|
189
|
+
// is protected; a record kept on the site would cover teammates and CI.
|
|
190
|
+
async guardOutsideEdits(provider, dir, remote, writes) {
|
|
191
|
+
if (this.overwrite)
|
|
192
|
+
return;
|
|
193
|
+
let expected;
|
|
194
|
+
let local;
|
|
195
|
+
let pushedAt;
|
|
196
|
+
try {
|
|
197
|
+
const snapshot = await readSnapshot(resolve(process.cwd(), this.rootDir), provider.resource, this.siteConfig.name);
|
|
198
|
+
expected = recordToState(snapshot.afterState);
|
|
199
|
+
pushedAt = snapshot.createdAt;
|
|
200
|
+
local = await provider.local(dir, () => { });
|
|
201
|
+
}
|
|
202
|
+
catch {
|
|
203
|
+
// No earlier push from this machine, or unreadable local files the push reports itself.
|
|
204
|
+
return;
|
|
205
|
+
}
|
|
206
|
+
const overwritten = [...local.keys()].filter((id) => (writes?.(id) ?? true) &&
|
|
207
|
+
remote.get(id) !== undefined &&
|
|
208
|
+
!sameEntry(remote, expected, id) &&
|
|
209
|
+
!sameEntry(local, remote, id));
|
|
210
|
+
if (overwritten.length === 0)
|
|
211
|
+
return;
|
|
212
|
+
const message = `Changed on ${this.siteConfig.url} outside Loopress since your last push (${pushedAt}), this push would overwrite: ${overwritten.join(', ')}. ` +
|
|
213
|
+
`Review with \`lps diff --only ${provider.resource}\`, bring the site's version into your files, or pass --overwrite.`;
|
|
214
|
+
// A dry run only previews: say what the real push would stop on.
|
|
215
|
+
if (this.dryRun) {
|
|
216
|
+
this.warn(message);
|
|
217
|
+
return;
|
|
218
|
+
}
|
|
219
|
+
if (!isInteractive())
|
|
220
|
+
this.error(message);
|
|
221
|
+
this.warn(message);
|
|
222
|
+
if (!(await confirm({ default: false, message: 'Overwrite those changes?' })))
|
|
223
|
+
this.error('Aborted.');
|
|
224
|
+
}
|
|
225
|
+
}
|
|
226
|
+
function sameEntry(left, right, id) {
|
|
227
|
+
return left.has(id) === right.has(id) && isDeepStrictEqual(left.get(id), right.get(id));
|
|
147
228
|
}
|
|
@@ -1,8 +1,7 @@
|
|
|
1
1
|
import { isNotFoundError } from './wp-client.js';
|
|
2
|
-
// Update-or-create dance shared by every id-based resource (form,
|
|
3
|
-
//
|
|
4
|
-
//
|
|
5
|
-
// database reset.
|
|
2
|
+
// Update-or-create dance shared by every id-based resource (form, snippet, unlike ACF which
|
|
3
|
+
// is key-based and only ever POSTs): PUT to the known id, and only fall back to POST when the
|
|
4
|
+
// site doesn't recognize that id (404), e.g. a fresh install or a database reset.
|
|
6
5
|
export async function putOrCreate(wp, options) {
|
|
7
6
|
const { id, payload, postEndpoint, postPayload = payload, putEndpoint } = options;
|
|
8
7
|
if (id !== null) {
|
|
@@ -1,11 +1,11 @@
|
|
|
1
1
|
import { readFile } from 'node:fs/promises';
|
|
2
2
|
import { basename, join, relative, sep } from 'node:path';
|
|
3
3
|
import { ACF_OBJECT_TYPES, acfEndpoint, getAcfKey } from '../utils/acf-format.js';
|
|
4
|
+
import { CPT_SPEC, fileExtension, META_SPEC, parseDeclaredFile, PATTERN_SPEC, ROLE_SPEC, TAXONOMY_SPEC, } from '../utils/declared-type-format.js';
|
|
4
5
|
import { FORM_ENDPOINT, getFormId } from '../utils/form-format.js';
|
|
5
6
|
import { getMenuSlug, MENU_ENDPOINT, MENU_LOCATIONS_ENDPOINT } from '../utils/menu-format.js';
|
|
6
7
|
import { optionEndpoint, parseLocalOption } from '../utils/option-format.js';
|
|
7
8
|
import { formatPageProblems, PAGES_ENDPOINT, readLocalHtmlFiles, readLocalPages } from '../utils/page-format.js';
|
|
8
|
-
import { DEFAULT_POST_TYPES, SEO_REDIRECTS_ENDPOINT, SEO_SETTINGS_ENDPOINT, seoPostMetaEndpoint, } from '../utils/seo-format.js';
|
|
9
9
|
import { normalizeSnippet, SNIPPETS_ENDPOINT, stripPhpOpeningTag } from '../utils/snippet-format.js';
|
|
10
10
|
import { CHILD_THEME_ENDPOINT, parsePartFile, parseTemplateFile, TEMPLATE_SLUG_RULE } from '../utils/template-format.js';
|
|
11
11
|
import { canonicalGlobalStyles, getActiveThemeGlobalStyles, globalStylesEndpoint, THEME_STYLES_FILE_SUFFIX, } from '../utils/theme-styles-format.js';
|
|
@@ -30,17 +30,15 @@ function omit(object, keys) {
|
|
|
30
30
|
const ACF_VOLATILE_KEYS = ['modified'];
|
|
31
31
|
// A WPForms form is a WP post; `modified`/`modified_gmt` move on every edit.
|
|
32
32
|
const FORM_VOLATILE_KEYS = ['modified', 'modified_gmt'];
|
|
33
|
-
// A RankMath redirect's `hits` is a live visit counter.
|
|
34
|
-
const REDIRECT_VOLATILE_KEYS = ['hits'];
|
|
35
33
|
// ---- snippets -------------------------------------------------------------------------------
|
|
36
|
-
// `description` is dropped:
|
|
37
|
-
//
|
|
38
|
-
//
|
|
39
|
-
//
|
|
34
|
+
// `description` is dropped: a sidecar without one gets "Imported from <path>" on push, so
|
|
35
|
+
// comparing it would flag every such snippet as drifted. `id` becomes the map key. A PHP
|
|
36
|
+
// snippet's `code` has its leading `<?php` stripped on both sides: `snippet pull` re-adds it
|
|
37
|
+
// for editor friendliness, WordPress stores only the body. Other types keep it, it's content.
|
|
40
38
|
function canonicalSnippet(snippet) {
|
|
41
39
|
return {
|
|
42
40
|
active: snippet.active,
|
|
43
|
-
code: stripPhpOpeningTag(snippet.code),
|
|
41
|
+
code: snippet.type === 'php' ? stripPhpOpeningTag(snippet.code) : snippet.code,
|
|
44
42
|
insertMethod: snippet.insertMethod,
|
|
45
43
|
location: snippet.location,
|
|
46
44
|
name: snippet.name,
|
|
@@ -257,82 +255,6 @@ const partProvider = {
|
|
|
257
255
|
resource: 'part',
|
|
258
256
|
title: 'Template parts',
|
|
259
257
|
};
|
|
260
|
-
// ---- SEO --------------------------------------------------------------------------------
|
|
261
|
-
function canonicalRedirect(redirect) {
|
|
262
|
-
return omit(redirect, REDIRECT_VOLATILE_KEYS);
|
|
263
|
-
}
|
|
264
|
-
const seoProvider = {
|
|
265
|
-
dirKind: 'seo',
|
|
266
|
-
async local(dir, onWarn) {
|
|
267
|
-
const state = new Map();
|
|
268
|
-
// The provider settings blob (Yoast / RankMath) is compared whole. If a real payload turns
|
|
269
|
-
// out to carry install-specific noise (a plugin version, a license hash), add those keys
|
|
270
|
-
// to an omit() list here, the e2e round-trip is what will surface them.
|
|
271
|
-
const settingsPath = join(dir, 'settings.json');
|
|
272
|
-
try {
|
|
273
|
-
const settings = readJson(await readFile(settingsPath, 'utf8'));
|
|
274
|
-
state.set('settings', settings);
|
|
275
|
-
}
|
|
276
|
-
catch (error) {
|
|
277
|
-
if (error.code !== 'ENOENT') {
|
|
278
|
-
onWarn(`Skipping "${settingsPath}": ${error.message}`);
|
|
279
|
-
}
|
|
280
|
-
}
|
|
281
|
-
for (const postType of DEFAULT_POST_TYPES) {
|
|
282
|
-
const metaDir = join(dir, 'post-meta', postType);
|
|
283
|
-
const entries = await loadFiles(metaDir, {
|
|
284
|
-
extension: '.json',
|
|
285
|
-
onSkip: onWarn,
|
|
286
|
-
parse: (raw, filePath) => ({ file: basename(filePath, '.json'), value: readJson(raw) }),
|
|
287
|
-
});
|
|
288
|
-
for (const { file, value } of entries) {
|
|
289
|
-
const slug = typeof value.slug === 'string' && value.slug !== '' ? value.slug : `local:${file}`;
|
|
290
|
-
state.set(`post-meta/${postType}/${slug}`, value);
|
|
291
|
-
}
|
|
292
|
-
}
|
|
293
|
-
const redirects = await loadFiles(join(dir, 'redirects'), {
|
|
294
|
-
extension: '.json',
|
|
295
|
-
onSkip: onWarn,
|
|
296
|
-
parse: readJson,
|
|
297
|
-
});
|
|
298
|
-
for (const redirect of redirects) {
|
|
299
|
-
const id = Number(redirect.id);
|
|
300
|
-
if (Number.isSafeInteger(id) && id > 0)
|
|
301
|
-
state.set(`redirects/${id}`, canonicalRedirect(redirect));
|
|
302
|
-
}
|
|
303
|
-
return state;
|
|
304
|
-
},
|
|
305
|
-
async remote(wp, onWarn) {
|
|
306
|
-
const state = new Map();
|
|
307
|
-
// `revision` (#234) is dropped from both, same as optionsProvider.remote() does for
|
|
308
|
-
// RemoteOption.revision below: it's bookkeeping for the conditional-write precondition, not
|
|
309
|
-
// configuration, and comparing it here would flag drift whenever the mere act of reading
|
|
310
|
-
// changed nothing meaningful.
|
|
311
|
-
const { settings } = await wp.get(SEO_SETTINGS_ENDPOINT);
|
|
312
|
-
state.set('settings', settings);
|
|
313
|
-
for (const postType of DEFAULT_POST_TYPES) {
|
|
314
|
-
const posts = await wp.get(seoPostMetaEndpoint(postType));
|
|
315
|
-
for (const { meta, slug, title } of posts)
|
|
316
|
-
state.set(`post-meta/${postType}/${slug}`, { meta, slug, title });
|
|
317
|
-
}
|
|
318
|
-
// Redirects are a RankMath-only feature; on Yoast the endpoint 404s, same graceful skip
|
|
319
|
-
// as `seo pull`.
|
|
320
|
-
try {
|
|
321
|
-
const redirects = await wp.get(SEO_REDIRECTS_ENDPOINT);
|
|
322
|
-
for (const redirect of redirects) {
|
|
323
|
-
state.set(`redirects/${redirect.id}`, canonicalRedirect(redirect));
|
|
324
|
-
}
|
|
325
|
-
}
|
|
326
|
-
catch (error) {
|
|
327
|
-
if (!isNotFoundError(error))
|
|
328
|
-
throw error;
|
|
329
|
-
onWarn(`Skipping redirects: not supported by the active SEO plugin`);
|
|
330
|
-
}
|
|
331
|
-
return state;
|
|
332
|
-
},
|
|
333
|
-
resource: 'seo',
|
|
334
|
-
title: 'SEO',
|
|
335
|
-
};
|
|
336
258
|
// ---- Menus --------------------------------------------------------------------------------
|
|
337
259
|
// Every item's post_type/taxonomy target is already resolved to `object`/`objectSlug` by the
|
|
338
260
|
// REST layer (MenuService), never a raw `_menu_item_object_id`, so the exported tree is already
|
|
@@ -382,6 +304,33 @@ const menuProvider = {
|
|
|
382
304
|
resource: 'menu',
|
|
383
305
|
title: 'Menus',
|
|
384
306
|
};
|
|
307
|
+
// ---- Custom post types, taxonomies, post meta, roles and patterns ---------------------------------------
|
|
308
|
+
// Keyed by slug (the local file name), compared on the bare arguments: `revision` is push
|
|
309
|
+
// bookkeeping, never part of the file.
|
|
310
|
+
function declaredTypeProvider(spec, title) {
|
|
311
|
+
return {
|
|
312
|
+
dirKind: spec.dirKind,
|
|
313
|
+
async local(dir, onWarn) {
|
|
314
|
+
const entries = await loadFiles(dir, {
|
|
315
|
+
extension: fileExtension(spec),
|
|
316
|
+
onSkip: onWarn,
|
|
317
|
+
parse: (raw, filePath) => parseDeclaredFile(spec, filePath, raw),
|
|
318
|
+
});
|
|
319
|
+
return new Map(entries.map(({ args, slug }) => [slug, args]));
|
|
320
|
+
},
|
|
321
|
+
async remote(wp) {
|
|
322
|
+
const items = await wp.get(spec.endpoint);
|
|
323
|
+
return new Map(items.map(({ args, slug }) => [slug, args]));
|
|
324
|
+
},
|
|
325
|
+
resource: spec.cliName,
|
|
326
|
+
title,
|
|
327
|
+
};
|
|
328
|
+
}
|
|
329
|
+
const cptProvider = declaredTypeProvider(CPT_SPEC, 'Post types');
|
|
330
|
+
const taxonomyProvider = declaredTypeProvider(TAXONOMY_SPEC, 'Taxonomies');
|
|
331
|
+
const metaProvider = declaredTypeProvider(META_SPEC, 'Post meta');
|
|
332
|
+
const roleProvider = declaredTypeProvider(ROLE_SPEC, 'Roles');
|
|
333
|
+
const patternProvider = declaredTypeProvider(PATTERN_SPEC, 'Patterns');
|
|
385
334
|
// ---- Options ------------------------------------------------------------------------------
|
|
386
335
|
// Unlike every provider above, remote() has no "everything" endpoint to read: GET /options only
|
|
387
336
|
// lists names, never values (by design, see OptionsController), so both sides compare exactly
|
|
@@ -470,8 +419,12 @@ export const RESOURCE_STATE_PROVIDERS = [
|
|
|
470
419
|
templateProvider,
|
|
471
420
|
partProvider,
|
|
472
421
|
pageProvider,
|
|
473
|
-
seoProvider,
|
|
474
422
|
menuProvider,
|
|
423
|
+
cptProvider,
|
|
424
|
+
taxonomyProvider,
|
|
425
|
+
metaProvider,
|
|
426
|
+
roleProvider,
|
|
427
|
+
patternProvider,
|
|
475
428
|
optionsProvider,
|
|
476
429
|
themeStylesProvider,
|
|
477
430
|
];
|
|
@@ -126,8 +126,15 @@ export function resourceRollbackCommand(resource, options) {
|
|
|
126
126
|
// commands/snippet/push.ts's `static args`), not a --path flag. --env/--yes: the
|
|
127
127
|
// production guard and the drift confirmation above already covered what the delegated
|
|
128
128
|
// push's own guard would ask again, same reasoning as the top-level `lps push`
|
|
129
|
-
// delegating to each resource's push command in commands/push.ts.
|
|
130
|
-
|
|
129
|
+
// delegating to each resource's push command in commands/push.ts. --overwrite: the drift
|
|
130
|
+
// check above is the same question as the push's outside-edit check, already answered.
|
|
131
|
+
await this.config.runCommand(`${command.replaceAll(' ', ':')}:push`, [
|
|
132
|
+
tmpDir,
|
|
133
|
+
'--env',
|
|
134
|
+
this.siteConfig.name,
|
|
135
|
+
'--yes',
|
|
136
|
+
'--overwrite',
|
|
137
|
+
]);
|
|
131
138
|
}
|
|
132
139
|
finally {
|
|
133
140
|
// A cleanup failure here must never hide a real error from the push above: warn about
|
|
@@ -1,10 +1,10 @@
|
|
|
1
1
|
import { mkdir, writeFile } from 'node:fs/promises';
|
|
2
2
|
import { dirname, join } from 'node:path';
|
|
3
3
|
import { getAcfKey } from '../utils/acf-format.js';
|
|
4
|
+
import { CPT_SPEC, fileExtension, formatDeclaredFile, META_SPEC, PATTERN_SPEC, ROLE_SPEC, TAXONOMY_SPEC } from '../utils/declared-type-format.js';
|
|
4
5
|
import { getFormId, getFormTitle } from '../utils/form-format.js';
|
|
5
6
|
import { getMenuSlug } from '../utils/menu-format.js';
|
|
6
7
|
import { defaultReadonlyFor, optionFileName } from '../utils/option-format.js';
|
|
7
|
-
import { redirectFileBase } from '../utils/seo-format.js';
|
|
8
8
|
import { buildMetaFile, buildSnippetFile } from '../utils/snippet-format.js';
|
|
9
9
|
import { themeStylesFileName } from '../utils/theme-styles-format.js';
|
|
10
10
|
import { toSlug } from '../utils/to-slug.js';
|
|
@@ -51,28 +51,6 @@ async function phpFile(id, value, dir) {
|
|
|
51
51
|
await mkdir(dirname(filePath), { recursive: true });
|
|
52
52
|
await writeFile(filePath, String(value));
|
|
53
53
|
}
|
|
54
|
-
// ids are 'settings', `post-meta/<postType>/<slug>`, or `redirects/<id>` (see resource-state.ts's
|
|
55
|
-
// seoProvider). Each value round-trips to the exact file `seo pull` would have written.
|
|
56
|
-
async function seo(id, value, dir) {
|
|
57
|
-
if (id === 'settings') {
|
|
58
|
-
await writeJson(join(dir, 'settings.json'), value);
|
|
59
|
-
return;
|
|
60
|
-
}
|
|
61
|
-
if (id.startsWith('post-meta/')) {
|
|
62
|
-
const rest = id.slice('post-meta/'.length);
|
|
63
|
-
const separator = rest.indexOf('/');
|
|
64
|
-
const postType = rest.slice(0, separator);
|
|
65
|
-
const slug = rest.slice(separator + 1);
|
|
66
|
-
await writeJson(join(dir, 'post-meta', postType, `${slug}.json`), value);
|
|
67
|
-
return;
|
|
68
|
-
}
|
|
69
|
-
if (id.startsWith('redirects/')) {
|
|
70
|
-
const redirect = value;
|
|
71
|
-
await writeJson(join(dir, 'redirects', `${redirectFileBase(redirect)}.json`), redirect);
|
|
72
|
-
return;
|
|
73
|
-
}
|
|
74
|
-
throw new Error(`Unrecognized SEO snapshot id "${id}"`);
|
|
75
|
-
}
|
|
76
54
|
// ids are 'menu-locations' or `menu/<slug>` (see resource-state.ts's menuProvider).
|
|
77
55
|
async function menu(id, value, dir) {
|
|
78
56
|
if (id === 'menu-locations') {
|
|
@@ -97,15 +75,26 @@ async function option(id, value, dir) {
|
|
|
97
75
|
async function themeStyles(id, value, dir) {
|
|
98
76
|
await writeJson(join(dir, themeStylesFileName(id)), value);
|
|
99
77
|
}
|
|
78
|
+
// id is the slug, value the bare register_*() arguments (resource-state.ts's declaredTypeProvider),
|
|
79
|
+
// written in the spec's file format like `<resource> pull` does.
|
|
80
|
+
function declaredType(spec) {
|
|
81
|
+
return async (id, value, dir) => {
|
|
82
|
+
await writeFile(join(dir, `${id}${fileExtension(spec)}`), formatDeclaredFile(spec, value));
|
|
83
|
+
};
|
|
84
|
+
}
|
|
100
85
|
const MATERIALIZERS = {
|
|
101
86
|
acf,
|
|
102
87
|
api: phpFile,
|
|
88
|
+
cpt: declaredType(CPT_SPEC),
|
|
103
89
|
form,
|
|
104
90
|
hook: phpFile,
|
|
105
91
|
menu,
|
|
92
|
+
meta: declaredType(META_SPEC),
|
|
106
93
|
option,
|
|
107
|
-
|
|
94
|
+
pattern: declaredType(PATTERN_SPEC),
|
|
95
|
+
role: declaredType(ROLE_SPEC),
|
|
108
96
|
snippet,
|
|
97
|
+
taxonomy: declaredType(TAXONOMY_SPEC),
|
|
109
98
|
'theme-styles': themeStyles,
|
|
110
99
|
};
|
|
111
100
|
// Writes a snapshot's state back to `dir` in the exact on-disk layout `<resource> pull` would
|