@loopress/cli 0.16.0 → 0.18.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 +248 -29
- package/dist/commands/api/list.d.ts +9 -0
- package/dist/commands/api/list.js +26 -0
- package/dist/commands/api/pull.d.ts +13 -0
- package/dist/commands/api/pull.js +64 -0
- package/dist/commands/api/push.d.ts +15 -0
- package/dist/commands/api/push.js +76 -0
- package/dist/commands/form/list.d.ts +9 -0
- package/dist/commands/form/list.js +26 -0
- package/dist/commands/form/pull.d.ts +14 -0
- package/dist/commands/form/pull.js +79 -0
- package/dist/commands/form/push.d.ts +16 -0
- package/dist/commands/form/push.js +130 -0
- package/dist/commands/seo/list.d.ts +11 -0
- package/dist/commands/seo/list.js +60 -0
- package/dist/commands/seo/pull.d.ts +19 -0
- package/dist/commands/seo/pull.js +134 -0
- package/dist/commands/seo/push.d.ts +20 -0
- package/dist/commands/seo/push.js +171 -0
- package/dist/lib/base.d.ts +3 -0
- package/dist/lib/base.js +15 -0
- package/dist/types/project-config.generated.d.ts +12 -0
- package/dist/utils/form-format.d.ts +3 -0
- package/dist/utils/form-format.js +17 -0
- package/dist/utils/seo-format.d.ts +20 -0
- package/dist/utils/seo-format.js +10 -0
- package/oclif.manifest.json +463 -82
- package/package.json +21 -15
- package/schemas/project-config.schema.json +15 -0
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
import { LoopressCommand } from '../../lib/base.js';
|
|
2
|
+
export default class List extends LoopressCommand {
|
|
3
|
+
static description: string;
|
|
4
|
+
static examples: string[];
|
|
5
|
+
static flags: {
|
|
6
|
+
json: import("@oclif/core/interfaces").BooleanFlag<boolean>;
|
|
7
|
+
};
|
|
8
|
+
run(): Promise<void>;
|
|
9
|
+
}
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
import { Flags } from '@oclif/core';
|
|
2
|
+
import { LoopressCommand } from '../../lib/base.js';
|
|
3
|
+
import { FORM_ENDPOINT, getFormId, getFormTitle } from '../../utils/form-format.js';
|
|
4
|
+
export default class List extends LoopressCommand {
|
|
5
|
+
static description = 'List forms from WordPress';
|
|
6
|
+
static examples = ['$ lps form list'];
|
|
7
|
+
static flags = {
|
|
8
|
+
json: Flags.boolean({ char: 'j', description: 'Output in JSON format' }),
|
|
9
|
+
};
|
|
10
|
+
async run() {
|
|
11
|
+
const { flags } = await this.parse(List);
|
|
12
|
+
const forms = await this.wp.get(FORM_ENDPOINT);
|
|
13
|
+
if (flags.json) {
|
|
14
|
+
this.log(JSON.stringify(forms, null, 2));
|
|
15
|
+
return;
|
|
16
|
+
}
|
|
17
|
+
this.log(`Forms (${forms.length}):`);
|
|
18
|
+
if (forms.length === 0) {
|
|
19
|
+
this.log(' (none)');
|
|
20
|
+
return;
|
|
21
|
+
}
|
|
22
|
+
for (const form of forms) {
|
|
23
|
+
this.log(` ${getFormId(form) ?? '(no id)'}. ${getFormTitle(form)}`);
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
}
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
import { LoopressCommand } from '../../lib/base.js';
|
|
2
|
+
export default class Pull extends LoopressCommand {
|
|
3
|
+
static args: {
|
|
4
|
+
path: import("@oclif/core/interfaces").Arg<string | undefined, Record<string, unknown>>;
|
|
5
|
+
};
|
|
6
|
+
static description: string;
|
|
7
|
+
static examples: string[];
|
|
8
|
+
static flags: {
|
|
9
|
+
'dry-run': import("@oclif/core/interfaces").BooleanFlag<boolean>;
|
|
10
|
+
};
|
|
11
|
+
run(): Promise<void>;
|
|
12
|
+
private findOrphanedFiles;
|
|
13
|
+
}
|
|
14
|
+
export declare function slug(title: string): string;
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
import { Args } from '@oclif/core';
|
|
2
|
+
import { Listr } from 'listr2';
|
|
3
|
+
import { mkdir, readdir, rm, writeFile } from 'node:fs/promises';
|
|
4
|
+
import { extname, join } from 'node:path';
|
|
5
|
+
import slugify from 'slugify';
|
|
6
|
+
import { LoopressCommand } from '../../lib/base.js';
|
|
7
|
+
import { FORM_ENDPOINT, getFormId, getFormTitle } from '../../utils/form-format.js';
|
|
8
|
+
export default class Pull extends LoopressCommand {
|
|
9
|
+
static args = {
|
|
10
|
+
path: Args.string({ description: 'Path to forms directory (overrides project config)' }),
|
|
11
|
+
};
|
|
12
|
+
static description = 'Pull forms from WordPress';
|
|
13
|
+
static examples = ['$ lps form pull'];
|
|
14
|
+
static flags = {
|
|
15
|
+
...LoopressCommand.dryRunFlag,
|
|
16
|
+
};
|
|
17
|
+
async run() {
|
|
18
|
+
const { args } = await this.parse(Pull);
|
|
19
|
+
const { url } = this.siteConfig;
|
|
20
|
+
const path = this.resolveFormPath(args.path);
|
|
21
|
+
this.log(`Pulling forms from ${url}`);
|
|
22
|
+
this.log(`Forms path: ${path}`);
|
|
23
|
+
const remoteList = await this.wp.get(FORM_ENDPOINT);
|
|
24
|
+
const withId = remoteList.filter((form) => getFormId(form) !== null);
|
|
25
|
+
const skipped = remoteList.length - withId.length;
|
|
26
|
+
const orphans = await this.findOrphanedFiles(path, new Set(withId.map((form) => getFormId(form))));
|
|
27
|
+
if (this.dryRun) {
|
|
28
|
+
this.log(`[dry-run] Would pull ${withId.length} form${withId.length === 1 ? '' : 's'} to ${path}`);
|
|
29
|
+
if (orphans.length > 0) {
|
|
30
|
+
this.log(`[dry-run] Would remove ${orphans.length} local file${orphans.length === 1 ? '' : 's'} in ${path} no longer present on WordPress: ${orphans.join(', ')}`);
|
|
31
|
+
}
|
|
32
|
+
return;
|
|
33
|
+
}
|
|
34
|
+
if (withId.length > 0)
|
|
35
|
+
await mkdir(path, { recursive: true });
|
|
36
|
+
await new Listr(withId.map((form) => {
|
|
37
|
+
const id = getFormId(form);
|
|
38
|
+
const title = getFormTitle(form);
|
|
39
|
+
return {
|
|
40
|
+
async task(_ctx, task) {
|
|
41
|
+
await writeFile(join(path, `${id}-${slug(title)}.json`), JSON.stringify(form, null, 2) + '\n');
|
|
42
|
+
task.output = `Pulled: ${title}`;
|
|
43
|
+
},
|
|
44
|
+
title: `Pull ${title}`,
|
|
45
|
+
};
|
|
46
|
+
})).run();
|
|
47
|
+
for (const file of orphans)
|
|
48
|
+
await rm(join(path, file), { force: true });
|
|
49
|
+
if (orphans.length > 0) {
|
|
50
|
+
this.warn(`Removed ${orphans.length} local file${orphans.length === 1 ? '' : 's'} in ${path} no longer present on WordPress: ${orphans.join(', ')}`);
|
|
51
|
+
}
|
|
52
|
+
this.log(`Pulled ${withId.length} form${withId.length === 1 ? '' : 's'} to ${path}`);
|
|
53
|
+
if (skipped > 0) {
|
|
54
|
+
this.warn(`${skipped} form${skipped === 1 ? '' : 's'} skipped because they have no id`);
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
// Only matches files following the `<id>-<slug>.json` convention pull/push themselves
|
|
58
|
+
// produce, same principle as findOrphanedFiles in commands/snippet/pull.ts.
|
|
59
|
+
async findOrphanedFiles(dir, keepIds) {
|
|
60
|
+
let files;
|
|
61
|
+
try {
|
|
62
|
+
files = await readdir(dir);
|
|
63
|
+
}
|
|
64
|
+
catch (error) {
|
|
65
|
+
if (error.code === 'ENOENT')
|
|
66
|
+
return [];
|
|
67
|
+
throw error;
|
|
68
|
+
}
|
|
69
|
+
return files.filter((file) => {
|
|
70
|
+
if (extname(file) !== '.json')
|
|
71
|
+
return false;
|
|
72
|
+
const match = /^(\d+)-/.exec(file);
|
|
73
|
+
return match !== null && !keepIds.has(Number(match[1]));
|
|
74
|
+
});
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
export function slug(title) {
|
|
78
|
+
return slugify(title, { lower: true, strict: true }) || 'untitled';
|
|
79
|
+
}
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
import { PushCommand } from '../../lib/push-command.js';
|
|
2
|
+
export default class Push extends PushCommand {
|
|
3
|
+
static args: {
|
|
4
|
+
path: import("@oclif/core/interfaces").Arg<string | undefined, Record<string, unknown>>;
|
|
5
|
+
};
|
|
6
|
+
static description: string;
|
|
7
|
+
static examples: string[];
|
|
8
|
+
static flags: {
|
|
9
|
+
'dry-run': import("@oclif/core/interfaces").BooleanFlag<boolean>;
|
|
10
|
+
};
|
|
11
|
+
private failedCount;
|
|
12
|
+
run(): Promise<void>;
|
|
13
|
+
private ensureCanonicalFilename;
|
|
14
|
+
private loadFiles;
|
|
15
|
+
private pushForm;
|
|
16
|
+
}
|
|
@@ -0,0 +1,130 @@
|
|
|
1
|
+
import { Args } from '@oclif/core';
|
|
2
|
+
import { Listr } from 'listr2';
|
|
3
|
+
import { readdir, readFile, rename } from 'node:fs/promises';
|
|
4
|
+
import { dirname, extname, join } from 'node:path';
|
|
5
|
+
import { PushCommand } from '../../lib/push-command.js';
|
|
6
|
+
import { isNotFoundError } from '../../lib/wp-client.js';
|
|
7
|
+
import { FORM_ENDPOINT, getFormId, getFormTitle } from '../../utils/form-format.js';
|
|
8
|
+
import { slug } from './pull.js';
|
|
9
|
+
export default class Push extends PushCommand {
|
|
10
|
+
static args = {
|
|
11
|
+
path: Args.string({ description: 'Path to forms directory (overrides project config)' }),
|
|
12
|
+
};
|
|
13
|
+
static description = 'Push forms to WordPress. Local files created or updated remotely are renamed on disk to the `<id>-<slug>.json` convention.';
|
|
14
|
+
static examples = ['$ lps form push'];
|
|
15
|
+
static flags = {
|
|
16
|
+
...PushCommand.dryRunFlag,
|
|
17
|
+
};
|
|
18
|
+
failedCount = 0;
|
|
19
|
+
async run() {
|
|
20
|
+
const { args } = await this.parse(Push);
|
|
21
|
+
const { url } = this.siteConfig;
|
|
22
|
+
const path = this.resolveFormPath(args.path);
|
|
23
|
+
this.log(`Pushing forms to ${url}`);
|
|
24
|
+
this.log(`Forms path: ${path}`);
|
|
25
|
+
const files = await this.loadFiles(path);
|
|
26
|
+
this.log(`Found ${files.length} form${files.length === 1 ? '' : 's'} to push`);
|
|
27
|
+
await new Listr(files.map(({ data, filePath }) => ({
|
|
28
|
+
task: async (_ctx, task) => this.pushForm(filePath, data, task),
|
|
29
|
+
title: `Push ${getFormTitle(data)}`,
|
|
30
|
+
})), { concurrent: false, exitOnError: false }).run();
|
|
31
|
+
if (this.failedCount > 0) {
|
|
32
|
+
this.error(`${this.failedCount} form${this.failedCount === 1 ? '' : 's'} failed to push.`);
|
|
33
|
+
}
|
|
34
|
+
if (this.dryRun)
|
|
35
|
+
return;
|
|
36
|
+
await this.recordSuccess();
|
|
37
|
+
this.log('All forms pushed.');
|
|
38
|
+
}
|
|
39
|
+
// Renames the local file to the `<id>-<slug>.json` convention used by `form pull`
|
|
40
|
+
// whenever it doesn't already match (a hand-created file with no id, or a stale slug after
|
|
41
|
+
// a title change in the WordPress admin), same principle as ensureCanonicalFilename in
|
|
42
|
+
// commands/snippet/push.ts.
|
|
43
|
+
async ensureCanonicalFilename(filePath, id, title) {
|
|
44
|
+
const canonicalPath = join(dirname(filePath), `${id}-${slug(title)}.json`);
|
|
45
|
+
if (filePath !== canonicalPath)
|
|
46
|
+
await rename(filePath, canonicalPath);
|
|
47
|
+
}
|
|
48
|
+
// One file is read in isolation: a corrupted or hand-broken JSON file must only skip that
|
|
49
|
+
// form, not abort loading the rest of the directory, same principle as loadObjects() in
|
|
50
|
+
// commands/acf/push.ts.
|
|
51
|
+
async loadFiles(dir) {
|
|
52
|
+
let files;
|
|
53
|
+
try {
|
|
54
|
+
files = await readdir(dir);
|
|
55
|
+
}
|
|
56
|
+
catch (error) {
|
|
57
|
+
if (error.code === 'ENOENT')
|
|
58
|
+
return [];
|
|
59
|
+
throw error;
|
|
60
|
+
}
|
|
61
|
+
const forms = [];
|
|
62
|
+
for (const file of files) {
|
|
63
|
+
if (extname(file) !== '.json')
|
|
64
|
+
continue;
|
|
65
|
+
const filePath = join(dir, file);
|
|
66
|
+
let parsed;
|
|
67
|
+
try {
|
|
68
|
+
parsed = JSON.parse(await readFile(filePath, 'utf8'));
|
|
69
|
+
}
|
|
70
|
+
catch (error) {
|
|
71
|
+
this.warn(`Skipping "${filePath}": ${error.message}`);
|
|
72
|
+
continue;
|
|
73
|
+
}
|
|
74
|
+
if (typeof parsed !== 'object' || parsed === null) {
|
|
75
|
+
this.warn(`Skipping "${filePath}": not a JSON object`);
|
|
76
|
+
continue;
|
|
77
|
+
}
|
|
78
|
+
forms.push({ data: parsed, filePath });
|
|
79
|
+
}
|
|
80
|
+
return forms;
|
|
81
|
+
}
|
|
82
|
+
// Throwing on failure (rather than returning a boolean) is what lets Listr mark the task as
|
|
83
|
+
// failed (red cross) instead of completed; `exitOnError: false` on the task list still lets
|
|
84
|
+
// sibling forms push regardless. PUT-then-404-fallback-POST is the same dance as
|
|
85
|
+
// commands/snippet/push.ts, forms are id-based like snippets, not key-based like ACF.
|
|
86
|
+
async pushForm(filePath, data, task) {
|
|
87
|
+
const title = getFormTitle(data);
|
|
88
|
+
if (this.dryRun) {
|
|
89
|
+
if (task)
|
|
90
|
+
task.output = `[dry-run] Would push: ${title}`;
|
|
91
|
+
return;
|
|
92
|
+
}
|
|
93
|
+
try {
|
|
94
|
+
const id = getFormId(data);
|
|
95
|
+
if (id === null) {
|
|
96
|
+
const created = await this.wp.post(FORM_ENDPOINT, data);
|
|
97
|
+
const newId = getFormId(created);
|
|
98
|
+
if (newId !== null)
|
|
99
|
+
await this.ensureCanonicalFilename(filePath, newId, title);
|
|
100
|
+
}
|
|
101
|
+
else {
|
|
102
|
+
try {
|
|
103
|
+
await this.wp.put(`${FORM_ENDPOINT}/${id}`, data);
|
|
104
|
+
await this.ensureCanonicalFilename(filePath, id, title);
|
|
105
|
+
}
|
|
106
|
+
catch (error) {
|
|
107
|
+
// The id recorded locally doesn't exist on this site (e.g. a fresh install): create
|
|
108
|
+
// it instead of failing, and adopt whatever id the site assigns.
|
|
109
|
+
if (!isNotFoundError(error))
|
|
110
|
+
throw error;
|
|
111
|
+
const created = await this.wp.post(FORM_ENDPOINT, data);
|
|
112
|
+
const newId = getFormId(created);
|
|
113
|
+
if (newId !== null)
|
|
114
|
+
await this.ensureCanonicalFilename(filePath, newId, title);
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
if (task)
|
|
118
|
+
task.output = `Pushed: ${title}`;
|
|
119
|
+
}
|
|
120
|
+
catch (error) {
|
|
121
|
+
const message = `Failed to push ${title}: ${error.message}`;
|
|
122
|
+
if (task)
|
|
123
|
+
task.output = message;
|
|
124
|
+
else
|
|
125
|
+
this.warn(` ${message}`);
|
|
126
|
+
this.failedCount++;
|
|
127
|
+
throw error;
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
}
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
import { LoopressCommand } from '../../lib/base.js';
|
|
2
|
+
export default class List extends LoopressCommand {
|
|
3
|
+
static description: string;
|
|
4
|
+
static examples: string[];
|
|
5
|
+
static flags: {
|
|
6
|
+
json: import("@oclif/core/interfaces").BooleanFlag<boolean>;
|
|
7
|
+
'post-type': import("@oclif/core/interfaces").OptionFlag<string[] | undefined, import("@oclif/core/interfaces").CustomOptions>;
|
|
8
|
+
};
|
|
9
|
+
run(): Promise<void>;
|
|
10
|
+
private fetchRedirects;
|
|
11
|
+
}
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
import { Flags } from '@oclif/core';
|
|
2
|
+
import { LoopressCommand } from '../../lib/base.js';
|
|
3
|
+
import { DEFAULT_POST_TYPES, SEO_REDIRECTS_ENDPOINT, seoPostMetaEndpoint } from '../../utils/seo-format.js';
|
|
4
|
+
export default class List extends LoopressCommand {
|
|
5
|
+
static description = 'List posts with SEO meta, and redirects if supported by the active SEO plugin, on WordPress';
|
|
6
|
+
static examples = ['$ lps seo list', '$ lps seo list --post-type post'];
|
|
7
|
+
static flags = {
|
|
8
|
+
json: Flags.boolean({ char: 'j', description: 'Output in JSON format' }),
|
|
9
|
+
'post-type': Flags.string({ description: 'Limit to specific post types', multiple: true }),
|
|
10
|
+
};
|
|
11
|
+
async run() {
|
|
12
|
+
const { flags } = await this.parse(List);
|
|
13
|
+
const postTypes = flags['post-type'] && flags['post-type'].length > 0 ? flags['post-type'] : [...DEFAULT_POST_TYPES];
|
|
14
|
+
const byType = {};
|
|
15
|
+
for (const postType of postTypes) {
|
|
16
|
+
byType[postType] = await this.wp.get(seoPostMetaEndpoint(postType));
|
|
17
|
+
}
|
|
18
|
+
const { redirects, unsupportedReason } = await this.fetchRedirects();
|
|
19
|
+
if (flags.json) {
|
|
20
|
+
this.log(JSON.stringify({ postMeta: byType, redirects: redirects ?? undefined, redirectsUnsupported: unsupportedReason }, null, 2));
|
|
21
|
+
return;
|
|
22
|
+
}
|
|
23
|
+
for (const postType of postTypes) {
|
|
24
|
+
const posts = byType[postType];
|
|
25
|
+
this.log(`${postType} (${posts.length}):`);
|
|
26
|
+
if (posts.length === 0) {
|
|
27
|
+
this.log(' (none)');
|
|
28
|
+
this.log('');
|
|
29
|
+
continue;
|
|
30
|
+
}
|
|
31
|
+
for (const post of posts) {
|
|
32
|
+
this.log(` ${post.slug}. ${post.title}`);
|
|
33
|
+
}
|
|
34
|
+
this.log('');
|
|
35
|
+
}
|
|
36
|
+
if (unsupportedReason) {
|
|
37
|
+
this.log(`redirects: ${unsupportedReason}`);
|
|
38
|
+
return;
|
|
39
|
+
}
|
|
40
|
+
this.log(`redirects (${redirects.length}):`);
|
|
41
|
+
if (redirects.length === 0) {
|
|
42
|
+
this.log(' (none)');
|
|
43
|
+
return;
|
|
44
|
+
}
|
|
45
|
+
for (const redirect of redirects) {
|
|
46
|
+
this.log(` ${redirect.id}. [${redirect.status}] ${redirect.headerCode} -> ${redirect.urlTo}`);
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
// Redirects are only supported by some SeoProvider backends (RankMath, not Yoast); reported
|
|
50
|
+
// as a line in the listing rather than failing the whole command, since post meta above may
|
|
51
|
+
// well have succeeded.
|
|
52
|
+
async fetchRedirects() {
|
|
53
|
+
try {
|
|
54
|
+
return { redirects: await this.wp.get(SEO_REDIRECTS_ENDPOINT), unsupportedReason: undefined };
|
|
55
|
+
}
|
|
56
|
+
catch (error) {
|
|
57
|
+
return { redirects: null, unsupportedReason: error.message };
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
}
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
import { LoopressCommand } from '../../lib/base.js';
|
|
2
|
+
import { SeoRedirect } from '../../utils/seo-format.js';
|
|
3
|
+
export default class Pull extends LoopressCommand {
|
|
4
|
+
static args: {
|
|
5
|
+
path: import("@oclif/core/interfaces").Arg<string | undefined, Record<string, unknown>>;
|
|
6
|
+
};
|
|
7
|
+
static description: string;
|
|
8
|
+
static examples: string[];
|
|
9
|
+
static flags: {
|
|
10
|
+
'post-type': import("@oclif/core/interfaces").OptionFlag<string[] | undefined, import("@oclif/core/interfaces").CustomOptions>;
|
|
11
|
+
'dry-run': import("@oclif/core/interfaces").BooleanFlag<boolean>;
|
|
12
|
+
};
|
|
13
|
+
run(): Promise<void>;
|
|
14
|
+
private findOrphanedFiles;
|
|
15
|
+
private pullPostMeta;
|
|
16
|
+
private pullRedirects;
|
|
17
|
+
private pullSettings;
|
|
18
|
+
}
|
|
19
|
+
export declare function redirectFileBase(redirect: SeoRedirect): string;
|
|
@@ -0,0 +1,134 @@
|
|
|
1
|
+
import { Args, Flags } from '@oclif/core';
|
|
2
|
+
import { Listr } from 'listr2';
|
|
3
|
+
import { mkdir, readdir, rm, writeFile } from 'node:fs/promises';
|
|
4
|
+
import { extname, join } from 'node:path';
|
|
5
|
+
import slugify from 'slugify';
|
|
6
|
+
import { LoopressCommand } from '../../lib/base.js';
|
|
7
|
+
import { DEFAULT_POST_TYPES, SEO_REDIRECTS_ENDPOINT, SEO_SETTINGS_ENDPOINT, seoPostMetaEndpoint, } from '../../utils/seo-format.js';
|
|
8
|
+
export default class Pull extends LoopressCommand {
|
|
9
|
+
static args = {
|
|
10
|
+
path: Args.string({ description: 'Path to SEO directory (overrides project config)' }),
|
|
11
|
+
};
|
|
12
|
+
static description = 'Pull SEO settings, post meta, and (if supported) redirects from WordPress';
|
|
13
|
+
static examples = ['$ lps seo pull', '$ lps seo pull --post-type post --post-type page'];
|
|
14
|
+
static flags = {
|
|
15
|
+
...LoopressCommand.dryRunFlag,
|
|
16
|
+
'post-type': Flags.string({ description: 'Limit post meta to specific post types', multiple: true }),
|
|
17
|
+
};
|
|
18
|
+
async run() {
|
|
19
|
+
const { args, flags } = await this.parse(Pull);
|
|
20
|
+
const { url } = this.siteConfig;
|
|
21
|
+
const path = this.resolveSeoPath(args.path);
|
|
22
|
+
const postTypes = flags['post-type'] && flags['post-type'].length > 0 ? flags['post-type'] : [...DEFAULT_POST_TYPES];
|
|
23
|
+
this.log(`Pulling SEO configuration from ${url}`);
|
|
24
|
+
this.log(`SEO path: ${path}`);
|
|
25
|
+
await this.pullSettings(path);
|
|
26
|
+
for (const postType of postTypes) {
|
|
27
|
+
await this.pullPostMeta(postType, path);
|
|
28
|
+
}
|
|
29
|
+
await this.pullRedirects(path);
|
|
30
|
+
}
|
|
31
|
+
// Shared by post-meta (`<slug>.json`) and redirects (`<id>-<slug>.json`) directories: a local
|
|
32
|
+
// file whose identity is no longer in the current remote list belongs to something deleted on
|
|
33
|
+
// WordPress. Left on disk, it would silently come back to life on the next `seo push`.
|
|
34
|
+
async findOrphanedFiles(dir, keepKeys, numericIdPrefix) {
|
|
35
|
+
let files;
|
|
36
|
+
try {
|
|
37
|
+
files = await readdir(dir);
|
|
38
|
+
}
|
|
39
|
+
catch (error) {
|
|
40
|
+
if (error.code === 'ENOENT')
|
|
41
|
+
return [];
|
|
42
|
+
throw error;
|
|
43
|
+
}
|
|
44
|
+
return files.filter((file) => {
|
|
45
|
+
if (extname(file) !== '.json')
|
|
46
|
+
return false;
|
|
47
|
+
if (numericIdPrefix) {
|
|
48
|
+
const match = /^(\d+)-/.exec(file);
|
|
49
|
+
return match !== null && !keepKeys.has(match[1]);
|
|
50
|
+
}
|
|
51
|
+
return !keepKeys.has(file.slice(0, -'.json'.length));
|
|
52
|
+
});
|
|
53
|
+
}
|
|
54
|
+
async pullPostMeta(postType, basePath) {
|
|
55
|
+
const dir = join(basePath, 'post-meta', postType);
|
|
56
|
+
const remote = await this.wp.get(seoPostMetaEndpoint(postType));
|
|
57
|
+
const orphans = await this.findOrphanedFiles(dir, new Set(remote.map((post) => post.slug)), false);
|
|
58
|
+
if (this.dryRun) {
|
|
59
|
+
this.log(`[dry-run] Would pull ${remote.length} ${postType} post-meta file(s) to ${dir}`);
|
|
60
|
+
if (orphans.length > 0) {
|
|
61
|
+
this.log(`[dry-run] Would remove ${orphans.length} local file${orphans.length === 1 ? '' : 's'} in ${dir} no longer present on WordPress: ${orphans.join(', ')}`);
|
|
62
|
+
}
|
|
63
|
+
return;
|
|
64
|
+
}
|
|
65
|
+
if (remote.length > 0)
|
|
66
|
+
await mkdir(dir, { recursive: true });
|
|
67
|
+
await new Listr(remote.map((post) => ({
|
|
68
|
+
async task(_ctx, task) {
|
|
69
|
+
await writeFile(join(dir, `${post.slug}.json`), JSON.stringify(post, null, 2) + '\n');
|
|
70
|
+
task.output = `Pulled: ${post.slug}`;
|
|
71
|
+
},
|
|
72
|
+
title: `Pull ${post.slug}`,
|
|
73
|
+
}))).run();
|
|
74
|
+
for (const file of orphans)
|
|
75
|
+
await rm(join(dir, file), { force: true });
|
|
76
|
+
if (orphans.length > 0) {
|
|
77
|
+
this.warn(`Removed ${orphans.length} local file${orphans.length === 1 ? '' : 's'} in ${dir} no longer present on WordPress: ${orphans.join(', ')}`);
|
|
78
|
+
}
|
|
79
|
+
this.log(`Pulled ${remote.length} ${postType} post-meta file(s) to ${dir}`);
|
|
80
|
+
}
|
|
81
|
+
// Redirects are only supported by some SeoProvider backends (RankMath, not Yoast). Unlike
|
|
82
|
+
// push (which must fail loudly if the user has local redirect files that can't be synced),
|
|
83
|
+
// pull degrades gracefully here: the active plugin never supporting redirects isn't an error
|
|
84
|
+
// in the same sense a deleted-on-WordPress file is, there's simply nothing to pull.
|
|
85
|
+
async pullRedirects(basePath) {
|
|
86
|
+
const dir = join(basePath, 'redirects');
|
|
87
|
+
let remote;
|
|
88
|
+
try {
|
|
89
|
+
remote = await this.wp.get(SEO_REDIRECTS_ENDPOINT);
|
|
90
|
+
}
|
|
91
|
+
catch (error) {
|
|
92
|
+
this.warn(`Skipping redirects: ${error.message}`);
|
|
93
|
+
return;
|
|
94
|
+
}
|
|
95
|
+
const orphans = await this.findOrphanedFiles(dir, new Set(remote.map((redirect) => String(redirect.id))), true);
|
|
96
|
+
if (this.dryRun) {
|
|
97
|
+
this.log(`[dry-run] Would pull ${remote.length} redirect(s) to ${dir}`);
|
|
98
|
+
if (orphans.length > 0) {
|
|
99
|
+
this.log(`[dry-run] Would remove ${orphans.length} local file${orphans.length === 1 ? '' : 's'} in ${dir} no longer present on WordPress: ${orphans.join(', ')}`);
|
|
100
|
+
}
|
|
101
|
+
return;
|
|
102
|
+
}
|
|
103
|
+
if (remote.length > 0)
|
|
104
|
+
await mkdir(dir, { recursive: true });
|
|
105
|
+
await new Listr(remote.map((redirect) => ({
|
|
106
|
+
async task(_ctx, task) {
|
|
107
|
+
await writeFile(join(dir, `${redirectFileBase(redirect)}.json`), JSON.stringify(redirect, null, 2) + '\n');
|
|
108
|
+
task.output = `Pulled: redirect #${redirect.id}`;
|
|
109
|
+
},
|
|
110
|
+
title: `Pull redirect #${redirect.id}`,
|
|
111
|
+
}))).run();
|
|
112
|
+
for (const file of orphans)
|
|
113
|
+
await rm(join(dir, file), { force: true });
|
|
114
|
+
if (orphans.length > 0) {
|
|
115
|
+
this.warn(`Removed ${orphans.length} local file${orphans.length === 1 ? '' : 's'} in ${dir} no longer present on WordPress: ${orphans.join(', ')}`);
|
|
116
|
+
}
|
|
117
|
+
this.log(`Pulled ${remote.length} redirect(s) to ${dir}`);
|
|
118
|
+
}
|
|
119
|
+
async pullSettings(basePath) {
|
|
120
|
+
const file = join(basePath, 'settings.json');
|
|
121
|
+
const settings = await this.wp.get(SEO_SETTINGS_ENDPOINT);
|
|
122
|
+
if (this.dryRun) {
|
|
123
|
+
this.log(`[dry-run] Would pull settings to ${file}`);
|
|
124
|
+
return;
|
|
125
|
+
}
|
|
126
|
+
await mkdir(basePath, { recursive: true });
|
|
127
|
+
await writeFile(file, JSON.stringify(settings, null, 2) + '\n');
|
|
128
|
+
this.log(`Pulled settings to ${file}`);
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
export function redirectFileBase(redirect) {
|
|
132
|
+
const slug = slugify(redirect.urlTo || 'redirect', { lower: true, strict: true });
|
|
133
|
+
return `${redirect.id}-${slug || 'redirect'}`;
|
|
134
|
+
}
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
import { PushCommand } from '../../lib/push-command.js';
|
|
2
|
+
export default class Push extends PushCommand {
|
|
3
|
+
static args: {
|
|
4
|
+
path: import("@oclif/core/interfaces").Arg<string | undefined, Record<string, unknown>>;
|
|
5
|
+
};
|
|
6
|
+
static description: string;
|
|
7
|
+
static examples: string[];
|
|
8
|
+
static flags: {
|
|
9
|
+
'dry-run': import("@oclif/core/interfaces").BooleanFlag<boolean>;
|
|
10
|
+
};
|
|
11
|
+
private failedCount;
|
|
12
|
+
run(): Promise<void>;
|
|
13
|
+
private jsonFilesIn;
|
|
14
|
+
private pushPostMeta;
|
|
15
|
+
private pushPostMetaFile;
|
|
16
|
+
private pushRedirectFile;
|
|
17
|
+
private pushRedirects;
|
|
18
|
+
private pushSettings;
|
|
19
|
+
private renameToCanonical;
|
|
20
|
+
}
|