@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.
@@ -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
+ }
@@ -14,6 +14,9 @@ export declare abstract class LoopressCommand extends Command {
14
14
  protected get wp(): WpClient;
15
15
  init(): Promise<void>;
16
16
  protected resolveAcfPath(override?: string): string;
17
+ protected resolveApiPath(override?: string): string;
18
+ protected resolveFormPath(override?: string): string;
19
+ protected resolveSeoPath(override?: string): string;
17
20
  protected resolveSnippetsPath(override?: string): string;
18
21
  private resolveEnvironment;
19
22
  }
package/dist/lib/base.js CHANGED
@@ -40,6 +40,21 @@ export class LoopressCommand extends Command {
40
40
  return override;
41
41
  return join(this.rootDir, this.localConfig.acfDir ?? 'acf');
42
42
  }
43
+ resolveApiPath(override) {
44
+ if (override)
45
+ return override;
46
+ return join(this.rootDir, this.localConfig.apiDir ?? 'api');
47
+ }
48
+ resolveFormPath(override) {
49
+ if (override)
50
+ return override;
51
+ return join(this.rootDir, this.localConfig.formDir ?? 'forms');
52
+ }
53
+ resolveSeoPath(override) {
54
+ if (override)
55
+ return override;
56
+ return join(this.rootDir, this.localConfig.seoDir ?? 'seo');
57
+ }
43
58
  resolveSnippetsPath(override) {
44
59
  if (override)
45
60
  return override;
@@ -15,6 +15,18 @@ export interface LoopressProjectConfiguration {
15
15
  * Directory where ACF field groups, post types, taxonomies and options pages are read from and written to, relative to rootDir.
16
16
  */
17
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;
22
+ /**
23
+ * Directory where forms (WPForms today) are read from and written to, relative to rootDir.
24
+ */
25
+ formDir?: string;
26
+ /**
27
+ * Directory where custom API route files are read from and written to, relative to rootDir.
28
+ */
29
+ apiDir?: string;
18
30
  /**
19
31
  * Project identifier from the global Loopress config. When set, takes precedence over the currently active project in the global config.
20
32
  */
@@ -0,0 +1,3 @@
1
+ export declare const FORM_ENDPOINT = "loopress/v1/forms";
2
+ export declare function getFormId(data: Record<string, unknown>): null | number;
3
+ export declare function getFormTitle(data: Record<string, unknown>): string;
@@ -0,0 +1,17 @@
1
+ export const FORM_ENDPOINT = 'loopress/v1/forms';
2
+ // Deliberately loose, same reasoning as getAcfKey in acf-format.ts: a form plugin's own data
3
+ // format (WPForms today: fields, settings, notifications, confirmations, providers, meta; other
4
+ // WordPress form plugins may be supported later, same shape as snippet-format.ts's Code
5
+ // Snippets/WPCode split) is large, deeply nested, and versioned by whichever plugin release
6
+ // wrote it. We only need `id` for filenames/identity and `settings.form_title` for display
7
+ // (the field WPForms itself uses; a future provider may differ), everything else round-trips
8
+ // through pull/push untouched.
9
+ export function getFormId(data) {
10
+ const id = Number(data.id);
11
+ return Number.isInteger(id) && id > 0 ? id : null;
12
+ }
13
+ export function getFormTitle(data) {
14
+ const settings = data.settings;
15
+ const title = settings?.form_title;
16
+ return typeof title === 'string' && title.trim() !== '' ? title : '(untitled)';
17
+ }
@@ -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
+ }