@loopress/cli 0.18.0 → 0.20.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 +260 -75
- package/dist/commands/acf/pull.d.ts +1 -1
- package/dist/commands/acf/pull.js +12 -25
- package/dist/commands/acf/push.d.ts +1 -1
- package/dist/commands/acf/push.js +18 -47
- package/dist/commands/api/publish.d.ts +9 -0
- package/dist/commands/api/publish.js +60 -0
- package/dist/commands/api/pull.d.ts +1 -1
- package/dist/commands/api/pull.js +11 -26
- package/dist/commands/api/push.d.ts +2 -1
- package/dist/commands/api/push.js +40 -27
- package/dist/commands/composer/init.js +11 -1
- package/dist/commands/composer/push.d.ts +1 -0
- package/dist/commands/composer/push.js +17 -4
- package/dist/commands/doctor.d.ts +8 -0
- package/dist/commands/doctor.js +79 -0
- package/dist/commands/form/pull.d.ts +1 -2
- package/dist/commands/form/pull.js +13 -32
- package/dist/commands/form/push.d.ts +1 -1
- package/dist/commands/form/push.js +6 -14
- package/dist/commands/init.d.ts +2 -0
- package/dist/commands/init.js +65 -33
- package/dist/commands/page/list.d.ts +9 -0
- package/dist/commands/page/list.js +26 -0
- package/dist/commands/page/pull.d.ts +13 -0
- package/dist/commands/page/pull.js +65 -0
- package/dist/commands/page/push.d.ts +16 -0
- package/dist/commands/page/push.js +145 -0
- package/dist/commands/plugin/push.d.ts +1 -1
- package/dist/commands/plugin/push.js +2 -14
- package/dist/commands/project/config.d.ts +1 -0
- package/dist/commands/project/config.js +32 -0
- package/dist/commands/project/push.d.ts +5 -0
- package/dist/commands/project/push.js +21 -11
- package/dist/commands/project/rotate.d.ts +7 -0
- package/dist/commands/project/rotate.js +20 -0
- package/dist/commands/seo/pull.d.ts +1 -3
- package/dist/commands/seo/pull.js +15 -43
- package/dist/commands/seo/push.d.ts +1 -1
- package/dist/commands/seo/push.js +4 -17
- package/dist/commands/snippet/publish.js +2 -2
- package/dist/commands/snippet/pull.d.ts +1 -4
- package/dist/commands/snippet/pull.js +12 -60
- package/dist/commands/snippet/push.d.ts +1 -1
- package/dist/commands/snippet/push.js +4 -13
- package/dist/commands/status.d.ts +4 -0
- package/dist/commands/status.js +25 -4
- package/dist/config/project-config.manager.js +2 -2
- package/dist/lib/api-client.d.ts +2 -1
- package/dist/lib/api-client.js +10 -3
- package/dist/lib/base.d.ts +12 -0
- package/dist/lib/base.js +100 -9
- package/dist/lib/bootstrap-full-install.d.ts +10 -0
- package/dist/lib/bootstrap-full-install.js +66 -0
- package/dist/lib/browser-launch.d.ts +8 -0
- package/dist/lib/browser-launch.js +20 -0
- package/dist/lib/find-orphaned-files.d.ts +7 -0
- package/dist/lib/find-orphaned-files.js +29 -0
- package/dist/lib/github-release.d.ts +11 -0
- package/dist/lib/github-release.js +37 -0
- package/dist/lib/interactive.d.ts +1 -0
- package/dist/lib/interactive.js +7 -0
- package/dist/lib/load-files.d.ts +5 -0
- package/dist/lib/load-files.js +31 -0
- package/dist/lib/plugin-detection.d.ts +8 -0
- package/dist/lib/plugin-detection.js +11 -0
- package/dist/lib/push-command.d.ts +7 -0
- package/dist/lib/push-command.js +40 -2
- package/dist/lib/rotate-app-password.d.ts +6 -0
- package/dist/lib/rotate-app-password.js +33 -0
- package/dist/lib/temp-admin.d.ts +19 -0
- package/dist/lib/temp-admin.js +46 -0
- package/dist/lib/wp-client.d.ts +10 -4
- package/dist/lib/wp-client.js +29 -12
- package/dist/types/project-config.generated.d.ts +4 -0
- package/dist/utils/composer.d.ts +3 -0
- package/dist/utils/page-format.d.ts +7 -0
- package/dist/utils/page-format.js +78 -0
- package/dist/utils/seo-format.d.ts +1 -0
- package/dist/utils/seo-format.js +5 -0
- package/dist/utils/snippet-format.d.ts +2 -0
- package/dist/utils/snippet-format.js +27 -1
- package/dist/utils/to-slug.d.ts +1 -0
- package/dist/utils/to-slug.js +7 -0
- package/oclif.manifest.json +688 -29
- package/package.json +16 -9
- package/schemas/project-config.schema.json +5 -0
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
import { WpClient } from './wp-client.js';
|
|
2
|
+
const APPLICATION_PASSWORDS_PATH = 'wp/v2/users/me/application-passwords';
|
|
3
|
+
export const APP_PASSWORD_MAX_AGE_MS = 90 * 24 * 60 * 60 * 1000;
|
|
4
|
+
export function isAppPasswordStale(addedAt) {
|
|
5
|
+
return Date.now() - new Date(addedAt).getTime() > APP_PASSWORD_MAX_AGE_MS;
|
|
6
|
+
}
|
|
7
|
+
// Create the new credential, confirm it actually authenticates on its own, only then revoke
|
|
8
|
+
// the old one. Reversing this order risks locking the user out if the new one is somehow bad.
|
|
9
|
+
export async function rotateAppPassword(env) {
|
|
10
|
+
const [user] = env.token.split(':');
|
|
11
|
+
const oldClient = new WpClient(env.url, env.token);
|
|
12
|
+
const old = await oldClient.get(`${APPLICATION_PASSWORDS_PATH}/introspect`);
|
|
13
|
+
const created = await oldClient.post(APPLICATION_PASSWORDS_PATH, { name: 'Loopress' });
|
|
14
|
+
if (!created.password) {
|
|
15
|
+
throw new Error(`WordPress did not return a password when creating the new application password on ${env.url}.`);
|
|
16
|
+
}
|
|
17
|
+
const newToken = `${user}:${created.password}`;
|
|
18
|
+
const newClient = new WpClient(env.url, newToken);
|
|
19
|
+
try {
|
|
20
|
+
await newClient.get(`${APPLICATION_PASSWORDS_PATH}/introspect`);
|
|
21
|
+
}
|
|
22
|
+
catch (error) {
|
|
23
|
+
// The new credential exists on WordPress but was never confirmed to work: clean it up with
|
|
24
|
+
// the still-valid old credentials instead of leaving an orphan behind. maybeAutoRotate
|
|
25
|
+
// retries on every subsequent run, so a persistent failure here would otherwise pile up
|
|
26
|
+
// one abandoned 'Loopress' entry per attempt. Best-effort: a failure here shouldn't hide
|
|
27
|
+
// the original, more informative error.
|
|
28
|
+
await oldClient.delete(`${APPLICATION_PASSWORDS_PATH}/${created.uuid}`).catch(() => { });
|
|
29
|
+
throw error;
|
|
30
|
+
}
|
|
31
|
+
await newClient.delete(`${APPLICATION_PASSWORDS_PATH}/${old.uuid}`);
|
|
32
|
+
return { ...env, addedAt: new Date().toISOString(), token: newToken };
|
|
33
|
+
}
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
import { WpClient } from './wp-client.js';
|
|
2
|
+
export interface TempAdmin {
|
|
3
|
+
id: number;
|
|
4
|
+
password: string;
|
|
5
|
+
username: string;
|
|
6
|
+
}
|
|
7
|
+
/**
|
|
8
|
+
* Creates a temporary WordPress administrator, the only way to obtain `install_plugins` when
|
|
9
|
+
* all the CLI has is an application password. `.invalid` is the RFC 2606 reserved TLD for
|
|
10
|
+
* addresses guaranteed not to be real, appropriate for an account that has no real mailbox.
|
|
11
|
+
*/
|
|
12
|
+
export declare function createTempAdmin(wp: WpClient): Promise<TempAdmin>;
|
|
13
|
+
/**
|
|
14
|
+
* Deletes the temporary admin, reassigning any content it might own to the real user behind
|
|
15
|
+
* the app password. Failure here throws rather than warns, naming the leftover account: a
|
|
16
|
+
* cleanup that silently fails would leave a live administrator with a random password on the
|
|
17
|
+
* target site, discoverable only by someone reading logs closely.
|
|
18
|
+
*/
|
|
19
|
+
export declare function deleteTempAdmin(wp: WpClient, admin: TempAdmin): Promise<void>;
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
import { randomBytes } from 'node:crypto';
|
|
2
|
+
import { isNotFoundError } from './wp-client.js';
|
|
3
|
+
// Only role that carries `install_plugins`/`activate_plugins`; there's nothing narrower to grant.
|
|
4
|
+
const TEMP_ADMIN_ROLE = 'administrator';
|
|
5
|
+
/**
|
|
6
|
+
* Creates a temporary WordPress administrator, the only way to obtain `install_plugins` when
|
|
7
|
+
* all the CLI has is an application password. `.invalid` is the RFC 2606 reserved TLD for
|
|
8
|
+
* addresses guaranteed not to be real, appropriate for an account that has no real mailbox.
|
|
9
|
+
*/
|
|
10
|
+
export async function createTempAdmin(wp) {
|
|
11
|
+
const username = `lps-temp-${Date.now().toString(36)}`;
|
|
12
|
+
const password = randomBytes(24).toString('base64url');
|
|
13
|
+
const user = await wp.post('wp/v2/users', {
|
|
14
|
+
email: `${username}@lps-temp.invalid`,
|
|
15
|
+
password,
|
|
16
|
+
roles: [TEMP_ADMIN_ROLE],
|
|
17
|
+
username,
|
|
18
|
+
});
|
|
19
|
+
return { id: user.id, password, username };
|
|
20
|
+
}
|
|
21
|
+
/**
|
|
22
|
+
* Deletes the temporary admin, reassigning any content it might own to the real user behind
|
|
23
|
+
* the app password. Failure here throws rather than warns, naming the leftover account: a
|
|
24
|
+
* cleanup that silently fails would leave a live administrator with a random password on the
|
|
25
|
+
* target site, discoverable only by someone reading logs closely.
|
|
26
|
+
*/
|
|
27
|
+
export async function deleteTempAdmin(wp, admin) {
|
|
28
|
+
const me = await wp.get('wp/v2/users/me');
|
|
29
|
+
try {
|
|
30
|
+
await wp.delete(`wp/v2/users/${admin.id}?reassign=${me.id}&force=true`);
|
|
31
|
+
}
|
|
32
|
+
catch (error) {
|
|
33
|
+
throw new Error(`Failed to remove the temporary admin account "${admin.username}" (id ${admin.id}) from the site. Remove it manually from wp-admin.`, { cause: error });
|
|
34
|
+
}
|
|
35
|
+
const stillExists = await wp
|
|
36
|
+
.get(`wp/v2/users/${admin.id}`)
|
|
37
|
+
.then(() => true)
|
|
38
|
+
.catch((error) => {
|
|
39
|
+
if (isNotFoundError(error))
|
|
40
|
+
return false;
|
|
41
|
+
throw error;
|
|
42
|
+
});
|
|
43
|
+
if (stillExists) {
|
|
44
|
+
throw new Error(`Temporary admin account "${admin.username}" (id ${admin.id}) still exists after deletion. Remove it manually from wp-admin.`);
|
|
45
|
+
}
|
|
46
|
+
}
|
package/dist/lib/wp-client.d.ts
CHANGED
|
@@ -7,10 +7,16 @@ export declare class WpClient {
|
|
|
7
7
|
private readonly siteUrl;
|
|
8
8
|
private readonly client;
|
|
9
9
|
constructor(siteUrl: string, token: string);
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
10
|
+
delete<T = unknown>(path: string, options?: RequestOptions): Promise<T>;
|
|
11
|
+
get<T>(path: string, options?: RequestOptions): Promise<T>;
|
|
12
|
+
post<T = unknown>(path: string, json?: Record<string, unknown>, options?: RequestOptions): Promise<T>;
|
|
13
|
+
put<T = unknown>(path: string, json?: Record<string, unknown>, options?: RequestOptions): Promise<T>;
|
|
13
14
|
private request;
|
|
14
15
|
}
|
|
16
|
+
interface RequestOptions {
|
|
17
|
+
timeoutMs?: number;
|
|
18
|
+
}
|
|
15
19
|
export declare function isNotFoundError(error: unknown): boolean;
|
|
16
|
-
export declare function
|
|
20
|
+
export declare function isTimeoutError(error: unknown): boolean;
|
|
21
|
+
export declare function formatWpError(error: unknown, url: string, timeoutMs?: number): string;
|
|
22
|
+
export {};
|
package/dist/lib/wp-client.js
CHANGED
|
@@ -15,22 +15,26 @@ export class WpClient {
|
|
|
15
15
|
timeout: { request: REQUEST_TIMEOUT_MS },
|
|
16
16
|
});
|
|
17
17
|
}
|
|
18
|
-
async
|
|
19
|
-
return this.request('
|
|
18
|
+
async delete(path, options) {
|
|
19
|
+
return this.request('delete', path, undefined, options);
|
|
20
20
|
}
|
|
21
|
-
async
|
|
22
|
-
return this.request('
|
|
21
|
+
async get(path, options) {
|
|
22
|
+
return this.request('get', path, undefined, options);
|
|
23
23
|
}
|
|
24
|
-
async
|
|
25
|
-
return this.request('
|
|
24
|
+
async post(path, json, options) {
|
|
25
|
+
return this.request('post', path, json, options);
|
|
26
26
|
}
|
|
27
|
-
async
|
|
27
|
+
async put(path, json, options) {
|
|
28
|
+
return this.request('put', path, json, options);
|
|
29
|
+
}
|
|
30
|
+
async request(method, path, json, options) {
|
|
31
|
+
const timeoutMs = options?.timeoutMs ?? REQUEST_TIMEOUT_MS;
|
|
28
32
|
try {
|
|
29
|
-
const response = await this.client(path, { json, method });
|
|
33
|
+
const response = await this.client(path, { json, method, timeout: { request: timeoutMs } });
|
|
30
34
|
return (response.body ? JSON.parse(response.body) : undefined);
|
|
31
35
|
}
|
|
32
36
|
catch (error) {
|
|
33
|
-
throw new Error(formatWpError(error, `${this.siteUrl}/wp-json/${path}
|
|
37
|
+
throw new Error(formatWpError(error, `${this.siteUrl}/wp-json/${path}`, timeoutMs), { cause: error });
|
|
34
38
|
}
|
|
35
39
|
}
|
|
36
40
|
}
|
|
@@ -38,7 +42,11 @@ export function isNotFoundError(error) {
|
|
|
38
42
|
const cause = error?.cause;
|
|
39
43
|
return cause?.response?.statusCode === 404;
|
|
40
44
|
}
|
|
41
|
-
export function
|
|
45
|
+
export function isTimeoutError(error) {
|
|
46
|
+
const cause = error?.cause;
|
|
47
|
+
return cause?.name === 'TimeoutError';
|
|
48
|
+
}
|
|
49
|
+
export function formatWpError(error, url, timeoutMs = REQUEST_TIMEOUT_MS) {
|
|
42
50
|
const err = error;
|
|
43
51
|
const status = err.response?.statusCode;
|
|
44
52
|
if (status === 401 || status === 403) {
|
|
@@ -52,7 +60,7 @@ export function formatWpError(error, url) {
|
|
|
52
60
|
return reason ? `Request failed (${status}) on ${url}: ${reason}` : `Request failed (${status}) on ${url}.`;
|
|
53
61
|
}
|
|
54
62
|
if (err.name === 'TimeoutError') {
|
|
55
|
-
return `Request timed out after ${
|
|
63
|
+
return `Request timed out after ${timeoutMs / 1000}s on ${url}. Is the site reachable?`;
|
|
56
64
|
}
|
|
57
65
|
return `Request to ${url} failed: ${err.message ?? String(error)}`;
|
|
58
66
|
}
|
|
@@ -60,13 +68,22 @@ export function formatWpError(error, url) {
|
|
|
60
68
|
// core response (e.g. an uncaught fatal formatted by WordPress itself) uses `{"message": "..."}`.
|
|
61
69
|
// Surfacing this is what makes a deliberately clear server-side error (e.g. "Multiple snippet
|
|
62
70
|
// plugins are active...") actually reach the user instead of a bare, unhelpful status code.
|
|
71
|
+
//
|
|
72
|
+
// Some controllers (ComposerController::sync(), notably) pair a short, generic `error` (e.g.
|
|
73
|
+
// "Sync failed.") with the real detail in a separate `output` field (the raw Composer trace),
|
|
74
|
+
// specifically so a caller that only reads `error` doesn't see it — which is exactly what this
|
|
75
|
+
// function used to do, hiding the one piece of text that actually explains the failure.
|
|
63
76
|
function extractServerErrorMessage(body) {
|
|
64
77
|
if (!body)
|
|
65
78
|
return undefined;
|
|
66
79
|
try {
|
|
67
80
|
const parsed = JSON.parse(body);
|
|
68
81
|
const reason = parsed.error ?? parsed.message;
|
|
69
|
-
|
|
82
|
+
const summary = typeof reason === 'string' && reason.trim() ? reason : undefined;
|
|
83
|
+
const detail = typeof parsed.output === 'string' && parsed.output.trim() ? parsed.output.trim() : undefined;
|
|
84
|
+
if (summary && detail)
|
|
85
|
+
return `${summary}\n${detail}`;
|
|
86
|
+
return summary ?? detail;
|
|
70
87
|
}
|
|
71
88
|
catch {
|
|
72
89
|
return undefined;
|
|
@@ -23,6 +23,10 @@ export interface LoopressProjectConfiguration {
|
|
|
23
23
|
* Directory where forms (WPForms today) are read from and written to, relative to rootDir.
|
|
24
24
|
*/
|
|
25
25
|
formDir?: string;
|
|
26
|
+
/**
|
|
27
|
+
* Directory where WordPress pages are read from and written to, relative to rootDir.
|
|
28
|
+
*/
|
|
29
|
+
pageDir?: string;
|
|
26
30
|
/**
|
|
27
31
|
* Directory where custom API route files are read from and written to, relative to rootDir.
|
|
28
32
|
*/
|
package/dist/utils/composer.d.ts
CHANGED
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
export declare const PAGE_ENDPOINT = "wp/v2/pages";
|
|
2
|
+
export declare const PAGE_LIST_QUERY = "per_page=100";
|
|
3
|
+
export declare function getPageId(data: Record<string, unknown>): null | number;
|
|
4
|
+
export declare function getPageTitle(data: Record<string, unknown>): string;
|
|
5
|
+
export declare function pageFileBase(id: number, title: string): string;
|
|
6
|
+
export declare function pickPageMeta(data: Record<string, unknown>): Record<string, unknown>;
|
|
7
|
+
export declare function getPageContent(data: Record<string, unknown>): string;
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
import { toSlug } from './to-slug.js';
|
|
2
|
+
// WordPress core's own REST endpoint (`wp/v2/pages`), unlike acf/form/seo/snippet there is no
|
|
3
|
+
// Loopress-plugin controller behind this one, same principle as `wp/v2/plugins` in plugin push/pull.
|
|
4
|
+
export const PAGE_ENDPOINT = 'wp/v2/pages';
|
|
5
|
+
// ponytail: fixed cap, not a paged loop; fine for typical page counts, revisit with real
|
|
6
|
+
// pagination (X-WP-TotalPages) if a site ever has more than 100 pages.
|
|
7
|
+
export const PAGE_LIST_QUERY = 'per_page=100';
|
|
8
|
+
export function getPageId(data) {
|
|
9
|
+
const id = Number(data.id);
|
|
10
|
+
return Number.isInteger(id) && id > 0 ? id : null;
|
|
11
|
+
}
|
|
12
|
+
// `title`/`content`/`excerpt` come back as `{raw, rendered}` from `?context=edit` (what `page
|
|
13
|
+
// pull` requests) but round-trip fine as plain strings too for a hand-created file, same
|
|
14
|
+
// looseness as getFormTitle in form-format.ts.
|
|
15
|
+
export function getPageTitle(data) {
|
|
16
|
+
const { title } = data;
|
|
17
|
+
if (typeof title === 'string' && title.trim() !== '')
|
|
18
|
+
return title;
|
|
19
|
+
if (title && typeof title === 'object') {
|
|
20
|
+
const { raw, rendered } = title;
|
|
21
|
+
if (typeof raw === 'string' && raw.trim() !== '')
|
|
22
|
+
return raw;
|
|
23
|
+
if (typeof rendered === 'string' && rendered.trim() !== '')
|
|
24
|
+
return rendered;
|
|
25
|
+
}
|
|
26
|
+
return '(untitled)';
|
|
27
|
+
}
|
|
28
|
+
export function pageFileBase(id, title) {
|
|
29
|
+
return `${id}-${toSlug(title, 'untitled')}`;
|
|
30
|
+
}
|
|
31
|
+
// The fields WordPress core actually accepts on write to `wp/v2/pages` (confirmed via
|
|
32
|
+
// `OPTIONS wp/v2/pages`), plus `id` for local identity. A GET response carries a lot more:
|
|
33
|
+
// `guid`, `link`, `modified`/`modified_gmt`, `permalink_template`, `generated_slug`,
|
|
34
|
+
// `class_list`, `type`, `_links`, all of it readonly/computed by WordPress, most of it
|
|
35
|
+
// domain- or revision-specific. `_links` alone is the bulk of the file and, like
|
|
36
|
+
// `modified`/`modified_gmt`, changes on every edit regardless of whether the page's actual
|
|
37
|
+
// content changed, which would make every `page pull` produce a spurious git diff if kept.
|
|
38
|
+
const PAGE_META_FIELDS = [
|
|
39
|
+
'id',
|
|
40
|
+
'date',
|
|
41
|
+
'date_gmt',
|
|
42
|
+
'slug',
|
|
43
|
+
'status',
|
|
44
|
+
'password',
|
|
45
|
+
'parent',
|
|
46
|
+
'title',
|
|
47
|
+
'author',
|
|
48
|
+
'excerpt',
|
|
49
|
+
'featured_media',
|
|
50
|
+
'comment_status',
|
|
51
|
+
'ping_status',
|
|
52
|
+
'menu_order',
|
|
53
|
+
'meta',
|
|
54
|
+
'template',
|
|
55
|
+
];
|
|
56
|
+
export function pickPageMeta(data) {
|
|
57
|
+
const meta = {};
|
|
58
|
+
for (const field of PAGE_META_FIELDS) {
|
|
59
|
+
if (field in data)
|
|
60
|
+
meta[field] = data[field];
|
|
61
|
+
}
|
|
62
|
+
return meta;
|
|
63
|
+
}
|
|
64
|
+
// `content` comes back as `{raw, rendered}` from `?context=edit` (what `page pull` requests);
|
|
65
|
+
// a hand-created file only has the plain string. This is the one field split out into its own
|
|
66
|
+
// `.html` file by pull/push: raw Gutenberg block markup escaped inside a JSON string is neither
|
|
67
|
+
// readable nor safely hand-editable, same reasoning as the code/meta split in snippet-format.ts.
|
|
68
|
+
export function getPageContent(data) {
|
|
69
|
+
const { content } = data;
|
|
70
|
+
if (typeof content === 'string')
|
|
71
|
+
return content;
|
|
72
|
+
if (content && typeof content === 'object') {
|
|
73
|
+
const { raw } = content;
|
|
74
|
+
if (typeof raw === 'string')
|
|
75
|
+
return raw;
|
|
76
|
+
}
|
|
77
|
+
return '';
|
|
78
|
+
}
|
package/dist/utils/seo-format.js
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { toSlug } from './to-slug.js';
|
|
1
2
|
// Post types synced by `lps seo pull` when --post-type isn't given.
|
|
2
3
|
export const DEFAULT_POST_TYPES = ['post', 'page'];
|
|
3
4
|
export const SEO_SETTINGS_ENDPOINT = 'loopress/v1/seo/settings';
|
|
@@ -8,3 +9,7 @@ export function seoRedirectEndpoint(id) {
|
|
|
8
9
|
export function seoPostMetaEndpoint(postType) {
|
|
9
10
|
return `loopress/v1/seo/post-meta/${postType}`;
|
|
10
11
|
}
|
|
12
|
+
// The `<id>-<slug>.json` on-disk convention shared by `seo pull` and `seo push`.
|
|
13
|
+
export function redirectFileBase(redirect) {
|
|
14
|
+
return `${redirect.id}-${toSlug(redirect.urlTo, 'redirect')}`;
|
|
15
|
+
}
|
|
@@ -20,4 +20,6 @@ export declare function parseLocation(raw: unknown): null | SnippetLocation;
|
|
|
20
20
|
export declare function parseInsertMethod(raw: unknown): null | SnippetInsertMethod;
|
|
21
21
|
export declare function defaultLocationForType(type: SnippetType): SnippetLocation;
|
|
22
22
|
export declare function stripPhpOpeningTag(code: string): string;
|
|
23
|
+
export declare function buildSnippetFile(snippet: NormalizedSnippet): string;
|
|
24
|
+
export declare function buildMetaFile(snippet: NormalizedSnippet): string;
|
|
23
25
|
export declare function normalizeSnippet(data: Record<string, unknown>): NormalizedSnippet;
|
|
@@ -34,10 +34,36 @@ function resolvePriority(raw) {
|
|
|
34
34
|
}
|
|
35
35
|
// Snippet files on disk keep the <?php opening tag so they're valid, syntax-highlighted PHP files.
|
|
36
36
|
// The WordPress plugin stores just the executable body, so it's stripped before push and restored
|
|
37
|
-
// on pull (see buildSnippetFile
|
|
37
|
+
// on pull (see buildSnippetFile below).
|
|
38
38
|
export function stripPhpOpeningTag(code) {
|
|
39
39
|
return code.replace(/^<\?php\s*/i, '');
|
|
40
40
|
}
|
|
41
|
+
export function buildSnippetFile(snippet) {
|
|
42
|
+
if (snippet.type === 'php' && !snippet.code.trimStart().startsWith('<?')) {
|
|
43
|
+
return `<?php\n\n${snippet.code}`;
|
|
44
|
+
}
|
|
45
|
+
return snippet.code;
|
|
46
|
+
}
|
|
47
|
+
export function buildMetaFile(snippet) {
|
|
48
|
+
const meta = {
|
|
49
|
+
id: snippet.id,
|
|
50
|
+
name: snippet.name,
|
|
51
|
+
type: snippet.type,
|
|
52
|
+
active: snippet.active,
|
|
53
|
+
location: snippet.location,
|
|
54
|
+
};
|
|
55
|
+
if (snippet.description)
|
|
56
|
+
meta.description = snippet.description;
|
|
57
|
+
if (snippet.tags.length > 0)
|
|
58
|
+
meta.tags = snippet.tags;
|
|
59
|
+
if (snippet.insertMethod === 'shortcode')
|
|
60
|
+
meta.insertMethod = snippet.insertMethod;
|
|
61
|
+
if (snippet.priority !== 10)
|
|
62
|
+
meta.priority = snippet.priority;
|
|
63
|
+
if (snippet.shortcodeAttributes.length > 0)
|
|
64
|
+
meta.shortcodeAttributes = snippet.shortcodeAttributes;
|
|
65
|
+
return JSON.stringify(meta, null, 2) + '\n';
|
|
66
|
+
}
|
|
41
67
|
// Defensive coercion for whatever JSON the `loopress/v1/snippets` endpoint returns, in case a
|
|
42
68
|
// field is missing or malformed server-side.
|
|
43
69
|
export function normalizeSnippet(data) {
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export declare function toSlug(value: string, fallback?: string): string;
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
import slugify from 'slugify';
|
|
2
|
+
// The one slugification used everywhere a name becomes part of a filename or an api slug.
|
|
3
|
+
// `fallback` covers values that slugify to nothing (empty, punctuation-only); callers that
|
|
4
|
+
// can genuinely live with an empty slug just omit it.
|
|
5
|
+
export function toSlug(value, fallback = '') {
|
|
6
|
+
return slugify(value, { lower: true, strict: true }) || fallback;
|
|
7
|
+
}
|