@loopress/cli 0.15.0 → 0.17.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.
@@ -0,0 +1,47 @@
1
+ import { confirm } from '@inquirer/prompts';
2
+ import { existsSync } from 'node:fs';
3
+ import { writeFile } from 'node:fs/promises';
4
+ import { join } from 'node:path';
5
+ import { LoopressCommand } from '../../lib/base.js';
6
+ const WPACKAGIST_REPOSITORY = { type: 'composer', url: 'https://wpackagist.org' };
7
+ const INSTALLERS_PACKAGE = 'composer/installers';
8
+ const INSTALLERS_CONSTRAINT = '^2.0';
9
+ // The server runs Composer with `--working-dir` set to wp-content/loopress/ (see
10
+ // LoopressEnvironment::getDxDir), not the WordPress root, so installer-paths must climb out
11
+ // of that directory to land plugins/themes in their usual wp-content/ locations.
12
+ const INSTALLER_PATHS = {
13
+ '../plugins/{$name}/': ['type:wordpress-plugin'],
14
+ '../themes/{$name}/': ['type:wordpress-theme'],
15
+ };
16
+ export default class ComposerInit extends LoopressCommand {
17
+ static description = 'Create a composer.json wired to WPackagist for installing WordPress.org plugins and themes';
18
+ static examples = ['$ lps composer init', '$ lps composer init --dry-run'];
19
+ static flags = {
20
+ ...LoopressCommand.dryRunFlag,
21
+ };
22
+ async run() {
23
+ const composerJsonPath = join(process.cwd(), this.rootDir, 'composer.json');
24
+ if (existsSync(composerJsonPath)) {
25
+ const overwrite = await confirm({ default: false, message: 'composer.json already exists. Overwrite?' });
26
+ if (!overwrite) {
27
+ this.log('Aborted.');
28
+ return;
29
+ }
30
+ }
31
+ const composerJson = {
32
+ extra: { 'installer-paths': INSTALLER_PATHS },
33
+ name: 'loopress/site-dependencies',
34
+ repositories: [WPACKAGIST_REPOSITORY],
35
+ require: {
36
+ [INSTALLERS_PACKAGE]: INSTALLERS_CONSTRAINT,
37
+ },
38
+ };
39
+ if (this.dryRun) {
40
+ this.log(`[dry-run] Would write composer.json to ${composerJsonPath}`);
41
+ return;
42
+ }
43
+ await writeFile(composerJsonPath, JSON.stringify(composerJson, null, 2) + '\n', 'utf8');
44
+ this.log(`Wrote composer.json to ${composerJsonPath}`);
45
+ this.log('Add plugins with `wpackagist-plugin/<slug>` and a theme with `wpackagist-theme/<slug>` in require, then run `lps composer push`.');
46
+ }
47
+ }
@@ -2,21 +2,24 @@ import { writeFile } from 'node:fs/promises';
2
2
  import { join } from 'node:path';
3
3
  import { LoopressCommand } from '../../lib/base.js';
4
4
  export default class ComposerPull extends LoopressCommand {
5
- static description = 'Pull composer.lock from WordPress';
5
+ static description = 'Pull composer.json and composer.lock from WordPress';
6
6
  static examples = ['$ lps composer pull', '$ lps composer pull --dry-run'];
7
7
  static flags = {
8
8
  ...LoopressCommand.dryRunFlag,
9
9
  };
10
10
  async run() {
11
11
  const { url } = this.siteConfig;
12
- this.log(`Pulling composer.lock from ${url}`);
12
+ this.log(`Pulling composer.json and composer.lock from ${url}`);
13
+ const { composerJson } = await this.wp.get('loopress/v1/composer/json');
13
14
  const { composerLock } = await this.wp.get('loopress/v1/composer/lock');
14
15
  if (this.dryRun) {
15
- this.log('[dry-run] Would write composer.lock');
16
+ this.log('[dry-run] Would write composer.json and composer.lock');
16
17
  return;
17
18
  }
19
+ const composerJsonPath = join(process.cwd(), this.rootDir, 'composer.json');
18
20
  const lockPath = join(process.cwd(), this.rootDir, 'composer.lock');
21
+ await writeFile(composerJsonPath, composerJson, 'utf8');
19
22
  await writeFile(lockPath, composerLock, 'utf8');
20
- this.log(`Wrote composer.lock`);
23
+ this.log(`Wrote composer.json and composer.lock`);
21
24
  }
22
25
  }
@@ -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
+ }
@@ -0,0 +1,171 @@
1
+ import { Args } from '@oclif/core';
2
+ import { Listr } from 'listr2';
3
+ import { readdir, readFile, rm, writeFile } 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 { SEO_REDIRECTS_ENDPOINT, SEO_SETTINGS_ENDPOINT, seoPostMetaEndpoint, seoRedirectEndpoint, } from '../../utils/seo-format.js';
8
+ import { redirectFileBase } from './pull.js';
9
+ export default class Push extends PushCommand {
10
+ static args = {
11
+ path: Args.string({ description: 'Path to SEO directory (overrides project config)' }),
12
+ };
13
+ static description = 'Push SEO settings, post meta, and redirects to WordPress. Local redirect files created remotely are renamed on disk to the `<id>-<slug>` convention. Fails clearly per file if the active SEO plugin does not support redirects.';
14
+ static examples = ['$ lps seo 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.resolveSeoPath(args.path);
23
+ this.log(`Pushing SEO configuration to ${url}`);
24
+ this.log(`SEO path: ${path}`);
25
+ await this.pushSettings(path);
26
+ await this.pushPostMeta(path);
27
+ await this.pushRedirects(path);
28
+ if (this.failedCount > 0) {
29
+ this.error(`${this.failedCount} SEO item${this.failedCount === 1 ? '' : 's'} failed to push.`);
30
+ }
31
+ if (this.dryRun)
32
+ return;
33
+ await this.recordSuccess();
34
+ this.log('All SEO configuration pushed.');
35
+ }
36
+ async jsonFilesIn(dir) {
37
+ try {
38
+ return (await readdir(dir)).filter((file) => extname(file) === '.json');
39
+ }
40
+ catch (error) {
41
+ if (error.code === 'ENOENT')
42
+ return [];
43
+ throw error;
44
+ }
45
+ }
46
+ async pushPostMeta(basePath) {
47
+ const root = join(basePath, 'post-meta');
48
+ let postTypeDirs;
49
+ try {
50
+ postTypeDirs = (await readdir(root, { withFileTypes: true })).filter((entry) => entry.isDirectory()).map((entry) => entry.name);
51
+ }
52
+ catch (error) {
53
+ if (error.code === 'ENOENT')
54
+ return;
55
+ throw error;
56
+ }
57
+ for (const postType of postTypeDirs) {
58
+ const dir = join(root, postType);
59
+ const files = await this.jsonFilesIn(dir);
60
+ if (files.length === 0)
61
+ continue;
62
+ this.log(`Found ${files.length} ${postType} post-meta file${files.length === 1 ? '' : 's'} to push`);
63
+ await new Listr(files.map((file) => ({
64
+ task: async (_ctx, task) => this.pushPostMetaFile(postType, join(dir, file), task),
65
+ title: `Push ${file}`,
66
+ })), { concurrent: false, exitOnError: false }).run();
67
+ }
68
+ }
69
+ async pushPostMetaFile(postType, filePath, task) {
70
+ if (this.dryRun) {
71
+ if (task)
72
+ task.output = `[dry-run] Would push: ${filePath}`;
73
+ return;
74
+ }
75
+ try {
76
+ const post = JSON.parse(await readFile(filePath, 'utf8'));
77
+ await this.wp.post(seoPostMetaEndpoint(postType), { meta: post.meta, slug: post.slug });
78
+ if (task)
79
+ task.output = `Pushed: ${post.slug}`;
80
+ }
81
+ catch (error) {
82
+ const message = `Failed to push ${filePath}: ${error.message}`;
83
+ if (task)
84
+ task.output = message;
85
+ else
86
+ this.warn(` ${message}`);
87
+ this.failedCount++;
88
+ throw error;
89
+ }
90
+ }
91
+ async pushRedirectFile(filePath, task) {
92
+ if (this.dryRun) {
93
+ if (task)
94
+ task.output = `[dry-run] Would push: ${filePath}`;
95
+ return;
96
+ }
97
+ try {
98
+ const redirect = JSON.parse(await readFile(filePath, 'utf8'));
99
+ const payload = { headerCode: redirect.headerCode, sources: redirect.sources, status: redirect.status, urlTo: redirect.urlTo };
100
+ if (redirect.id) {
101
+ try {
102
+ await this.wp.put(seoRedirectEndpoint(redirect.id), payload);
103
+ if (task)
104
+ task.output = `Pushed: redirect #${redirect.id}`;
105
+ return;
106
+ }
107
+ catch (error) {
108
+ // The id recorded locally doesn't exist on this site (e.g. a fresh install): create it
109
+ // instead of failing, and adopt whatever id the site assigns (same fallback as snippet push).
110
+ if (!isNotFoundError(error))
111
+ throw error;
112
+ }
113
+ }
114
+ const created = await this.wp.post(SEO_REDIRECTS_ENDPOINT, payload);
115
+ await this.renameToCanonical(filePath, created);
116
+ if (task)
117
+ task.output = `Pushed: redirect #${created.id}`;
118
+ }
119
+ catch (error) {
120
+ const message = `Failed to push ${filePath}: ${error.message}`;
121
+ if (task)
122
+ task.output = message;
123
+ else
124
+ this.warn(` ${message}`);
125
+ this.failedCount++;
126
+ throw error;
127
+ }
128
+ }
129
+ async pushRedirects(basePath) {
130
+ const dir = join(basePath, 'redirects');
131
+ const files = await this.jsonFilesIn(dir);
132
+ if (files.length === 0)
133
+ return;
134
+ this.log(`Found ${files.length} redirect${files.length === 1 ? '' : 's'} to push`);
135
+ await new Listr(files.map((file) => ({
136
+ task: async (_ctx, task) => this.pushRedirectFile(join(dir, file), task),
137
+ title: `Push ${file}`,
138
+ })), { concurrent: false, exitOnError: false }).run();
139
+ }
140
+ async pushSettings(basePath) {
141
+ const file = join(basePath, 'settings.json');
142
+ let raw;
143
+ try {
144
+ raw = await readFile(file, 'utf8');
145
+ }
146
+ catch (error) {
147
+ if (error.code === 'ENOENT')
148
+ return;
149
+ throw error;
150
+ }
151
+ if (this.dryRun) {
152
+ this.log(`[dry-run] Would push: ${file}`);
153
+ return;
154
+ }
155
+ try {
156
+ await this.wp.put(SEO_SETTINGS_ENDPOINT, JSON.parse(raw));
157
+ this.log(`Pushed: ${file}`);
158
+ }
159
+ catch (error) {
160
+ this.failedCount++;
161
+ this.warn(`Failed to push ${file}: ${error.message}`);
162
+ }
163
+ }
164
+ async renameToCanonical(filePath, redirect) {
165
+ const dir = dirname(filePath);
166
+ const canonicalPath = join(dir, `${redirectFileBase(redirect)}.json`);
167
+ await writeFile(canonicalPath, JSON.stringify(redirect, null, 2) + '\n');
168
+ if (canonicalPath !== filePath)
169
+ await rm(filePath, { force: true });
170
+ }
171
+ }
package/dist/help.js CHANGED
@@ -7,7 +7,7 @@ const BANNER = `
7
7
  @@@@ ++++ ▗▖ ▗▄▖ ▗▄▖ ▗▄▄▖ ▗▄▄▖ ▗▄▄▄▖ ▗▄▄▖ ▗▄▄▖
8
8
  @@@@ ++++ ▐▌ ▐▌ ▐▌▐▌ ▐▌▐▌ ▐▌▐▌ ▐▌▐▌ ▐▌ ▐▌
9
9
  @@@@ ++++ ▐▌ ▐▌ ▐▌▐▌ ▐▌▐▛▀▘ ▐▛▀▚▖▐▛▀▀▘ ▝▀▚▖ ▝▀▚▖
10
- @@@@ ++++ ▐▙▄▄▖▝▚▄▞▘▝▚▄▞▘▐▌ ▐▌ ▐▌▐▙▄▄▖▗▄▄▞▘▗▄▄▞▘ (BETA)
10
+ @@@@ ++++ ▐▙▄▄▖▝▚▄▞▘▝▚▄▞▘▐▌ ▐▌ ▐▌▐▙▄▄▖▗▄▄▞▘▗▄▄▞▘ (ALPHA)
11
11
  @@@@ ++++
12
12
  @@@@ ++++
13
13
  @@@@@@@@@@@*++
@@ -13,6 +13,8 @@ export declare abstract class LoopressCommand extends Command {
13
13
  protected get rootDir(): string;
14
14
  protected get wp(): WpClient;
15
15
  init(): Promise<void>;
16
+ protected resolveAcfPath(override?: string): string;
17
+ protected resolveSeoPath(override?: string): string;
16
18
  protected resolveSnippetsPath(override?: string): string;
17
19
  private resolveEnvironment;
18
20
  }
package/dist/lib/base.js CHANGED
@@ -35,6 +35,16 @@ export class LoopressCommand extends Command {
35
35
  this.localConfig = await readLocalConfig();
36
36
  this.siteConfig = this.resolveEnvironment();
37
37
  }
38
+ resolveAcfPath(override) {
39
+ if (override)
40
+ return override;
41
+ return join(this.rootDir, this.localConfig.acfDir ?? 'acf');
42
+ }
43
+ resolveSeoPath(override) {
44
+ if (override)
45
+ return override;
46
+ return join(this.rootDir, this.localConfig.seoDir ?? 'seo');
47
+ }
38
48
  resolveSnippetsPath(override) {
39
49
  if (override)
40
50
  return override;
@@ -11,6 +11,14 @@ export interface LoopressProjectConfiguration {
11
11
  * Directory where code snippets are read from and written to, relative to rootDir.
12
12
  */
13
13
  snippetsDir?: string;
14
+ /**
15
+ * Directory where ACF field groups, post types, taxonomies and options pages are read from and written to, relative to rootDir.
16
+ */
17
+ acfDir?: string;
18
+ /**
19
+ * Directory where SEO settings, post meta, and redirects (RankMath and Yoast SEO, whichever is active) are read from and written to, relative to rootDir.
20
+ */
21
+ seoDir?: string;
14
22
  /**
15
23
  * Project identifier from the global Loopress config. When set, takes precedence over the currently active project in the global config.
16
24
  */
@@ -0,0 +1,4 @@
1
+ export declare const ACF_OBJECT_TYPES: readonly ["field-groups", "post-types", "taxonomies", "options-pages"];
2
+ export type AcfObjectType = (typeof ACF_OBJECT_TYPES)[number];
3
+ export declare function acfEndpoint(type: AcfObjectType): string;
4
+ export declare function getAcfKey(data: Record<string, unknown>): null | string;
@@ -0,0 +1,12 @@
1
+ export const ACF_OBJECT_TYPES = ['field-groups', 'post-types', 'taxonomies', 'options-pages'];
2
+ export function acfEndpoint(type) {
3
+ return `loopress/v1/acf/${type}`;
4
+ }
5
+ // Deliberately loose: ACF's own export JSON is large, deeply nested, and versioned by ACF
6
+ // itself (new settings appear across ACF releases). We only need `key` for filenames/identity;
7
+ // everything else round-trips through pull/push untouched, so there's no generated type for it
8
+ // (see the plan for why: shadowing ACF's own schema would be an ongoing losing battle, and the
9
+ // shared schema:types compiler options (additionalProperties: false) would be actively wrong here).
10
+ export function getAcfKey(data) {
11
+ return typeof data.key === 'string' && data.key.trim() !== '' ? data.key : null;
12
+ }
@@ -1,4 +1,12 @@
1
1
  export interface ComposerJson {
2
+ extra?: {
3
+ 'installer-paths'?: Record<string, string[]>;
4
+ };
5
+ name?: string;
6
+ repositories?: Array<{
7
+ type: string;
8
+ url: string;
9
+ }>;
2
10
  require?: Record<string, string>;
3
11
  'require-dev'?: Record<string, string>;
4
12
  }
@@ -1,7 +1,9 @@
1
1
  // Loopress must never manage itself: pulling it into loopress.json would make a later
2
2
  // `plugin push` try to reinstall it from WordPress.org, where it doesn't exist, potentially
3
- // clobbering the plugin's own directory in the process.
4
- const LOOPRESS_PLUGIN_SLUG = 'loopress';
3
+ // clobbering the plugin's own directory in the process. Checked against every slug this
4
+ // plugin has ever shipped under (pre-rename "loopress", and the current "loopress-full" /
5
+ // "loopress-light" editions), since a given site could be running any of them.
6
+ const LOOPRESS_PLUGIN_SLUGS = new Set(['loopress', 'loopress-full', 'loopress-light']);
5
7
  export function mergePluginManifest(existing, incoming) {
6
8
  const merged = { ...existing, ...incoming };
7
9
  const added = Object.keys(incoming).filter((s) => !(s in existing));
@@ -25,7 +27,7 @@ export function parseInstalledPlugins(raw) {
25
27
  slug: slugFromPluginFile(item.plugin),
26
28
  version: item.version,
27
29
  }))
28
- .filter((plugin) => plugin.slug !== LOOPRESS_PLUGIN_SLUG);
30
+ .filter((plugin) => !LOOPRESS_PLUGIN_SLUGS.has(plugin.slug));
29
31
  }
30
32
  export function diffPlugins(manifest, installed) {
31
33
  const installedMap = new Map(installed.map((p) => [p.slug, p]));
@@ -0,0 +1,20 @@
1
+ export declare const DEFAULT_POST_TYPES: readonly ["post", "page"];
2
+ export declare const SEO_SETTINGS_ENDPOINT = "loopress/v1/seo/settings";
3
+ export declare const SEO_REDIRECTS_ENDPOINT = "loopress/v1/seo/redirects";
4
+ export declare function seoRedirectEndpoint(id: number): string;
5
+ export declare function seoPostMetaEndpoint(postType: string): string;
6
+ export interface SeoPostMeta {
7
+ meta: Record<string, unknown>;
8
+ slug: string;
9
+ title: string;
10
+ }
11
+ export interface SeoRedirect {
12
+ createdAt: null | string;
13
+ headerCode: number;
14
+ hits: number;
15
+ id: number;
16
+ sources: unknown;
17
+ status: string;
18
+ updatedAt: null | string;
19
+ urlTo: string;
20
+ }
@@ -0,0 +1,10 @@
1
+ // Post types synced by `lps seo pull` when --post-type isn't given.
2
+ export const DEFAULT_POST_TYPES = ['post', 'page'];
3
+ export const SEO_SETTINGS_ENDPOINT = 'loopress/v1/seo/settings';
4
+ export const SEO_REDIRECTS_ENDPOINT = 'loopress/v1/seo/redirects';
5
+ export function seoRedirectEndpoint(id) {
6
+ return `${SEO_REDIRECTS_ENDPOINT}/${id}`;
7
+ }
8
+ export function seoPostMetaEndpoint(postType) {
9
+ return `loopress/v1/seo/post-meta/${postType}`;
10
+ }