@loopress/cli 0.24.0 → 0.25.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 +716 -98
- package/dist/commands/acf/push.d.ts +1 -0
- package/dist/commands/acf/push.js +25 -2
- package/dist/commands/acf/rollback.d.ts +2 -0
- package/dist/commands/acf/rollback.js +5 -0
- package/dist/commands/api/rm.d.ts +2 -0
- package/dist/commands/api/rm.js +2 -0
- package/dist/commands/api/rollback.d.ts +2 -0
- package/dist/commands/api/rollback.js +5 -0
- package/dist/commands/composer/push.d.ts +3 -0
- package/dist/commands/composer/push.js +24 -7
- package/dist/commands/diff.js +1 -1
- package/dist/commands/doctor.d.ts +17 -1
- package/dist/commands/doctor.js +55 -38
- package/dist/commands/form/pull.js +4 -1
- package/dist/commands/form/push.d.ts +3 -0
- package/dist/commands/form/push.js +39 -6
- package/dist/commands/form/rollback.d.ts +2 -0
- package/dist/commands/form/rollback.js +5 -0
- package/dist/commands/hook/rm.d.ts +2 -0
- package/dist/commands/hook/rm.js +2 -0
- package/dist/commands/hook/rollback.d.ts +2 -0
- package/dist/commands/hook/rollback.js +5 -0
- package/dist/commands/init.js +1 -0
- package/dist/commands/login.js +3 -2
- package/dist/commands/menu/diff.d.ts +2 -0
- package/dist/commands/menu/diff.js +5 -0
- package/dist/commands/menu/list.d.ts +13 -0
- package/dist/commands/menu/list.js +32 -0
- package/dist/commands/menu/pull.d.ts +15 -0
- package/dist/commands/menu/pull.js +72 -0
- package/dist/commands/menu/push.d.ts +17 -0
- package/dist/commands/menu/push.js +120 -0
- package/dist/commands/menu/rollback.d.ts +2 -0
- package/dist/commands/menu/rollback.js +5 -0
- package/dist/commands/option/add.js +1 -1
- package/dist/commands/option/push.d.ts +1 -0
- package/dist/commands/option/push.js +26 -1
- package/dist/commands/option/rollback.d.ts +2 -0
- package/dist/commands/option/rollback.js +5 -0
- package/dist/commands/plugin/push.d.ts +1 -0
- package/dist/commands/plugin/push.js +14 -0
- package/dist/commands/promote.d.ts +8 -1
- package/dist/commands/promote.js +2 -0
- package/dist/commands/pull.d.ts +11 -1
- package/dist/commands/pull.js +9 -2
- package/dist/commands/push.d.ts +11 -1
- package/dist/commands/push.js +9 -2
- package/dist/commands/seo/pull.js +6 -2
- package/dist/commands/seo/push.d.ts +3 -0
- package/dist/commands/seo/push.js +49 -7
- package/dist/commands/seo/rollback.d.ts +2 -0
- package/dist/commands/seo/rollback.js +5 -0
- package/dist/commands/snippet/push.d.ts +1 -0
- package/dist/commands/snippet/push.js +31 -1
- package/dist/commands/snippet/rollback.d.ts +2 -0
- package/dist/commands/snippet/rollback.js +5 -0
- package/dist/commands/theme-styles/diff.d.ts +2 -0
- package/dist/commands/theme-styles/diff.js +5 -0
- package/dist/commands/theme-styles/pull.d.ts +12 -0
- package/dist/commands/theme-styles/pull.js +31 -0
- package/dist/commands/theme-styles/push.d.ts +12 -0
- package/dist/commands/theme-styles/push.js +45 -0
- package/dist/commands/theme-styles/rollback.d.ts +2 -0
- package/dist/commands/theme-styles/rollback.js +5 -0
- package/dist/hooks/finally.js +4 -1
- package/dist/lib/app-manifest.js +4 -27
- package/dist/lib/base.d.ts +2 -0
- package/dist/lib/base.js +6 -0
- package/dist/lib/find-orphaned-files.js +5 -47
- package/dist/lib/load-files.js +4 -42
- package/dist/lib/local-callback-server.d.ts +14 -1
- package/dist/lib/local-callback-server.js +40 -1
- package/dist/lib/php-files-command.d.ts +17 -3
- package/dist/lib/php-files-command.js +196 -37
- package/dist/lib/push-command.d.ts +4 -0
- package/dist/lib/push-command.js +49 -0
- package/dist/lib/readdir-tolerant.d.ts +1 -0
- package/dist/lib/readdir-tolerant.js +12 -0
- package/dist/lib/resource-state.js +92 -3
- package/dist/lib/rollback-command.d.ts +21 -0
- package/dist/lib/rollback-command.js +180 -0
- package/dist/lib/rollback-materialize.d.ts +1 -0
- package/dist/lib/rollback-materialize.js +121 -0
- package/dist/lib/sentry.d.ts +3 -0
- package/dist/lib/sentry.js +30 -0
- package/dist/lib/snapshot-store.d.ts +30 -0
- package/dist/lib/snapshot-store.js +133 -0
- package/dist/lib/validate-local.js +2 -1
- package/dist/lib/wp-authorize-flow.d.ts +4 -0
- package/dist/lib/wp-authorize-flow.js +10 -8
- package/dist/types/project-config.generated.d.ts +8 -0
- package/dist/utils/acf-format.d.ts +1 -0
- package/dist/utils/acf-format.js +6 -0
- package/dist/utils/form-format.d.ts +4 -0
- package/dist/utils/form-format.js +3 -7
- package/dist/utils/menu-format.d.ts +24 -0
- package/dist/utils/menu-format.js +8 -0
- package/dist/utils/option-format.d.ts +2 -1
- package/dist/utils/option-format.js +22 -4
- package/dist/utils/plugin-sync.d.ts +6 -0
- package/dist/utils/resource-dirs.d.ts +2 -0
- package/dist/utils/resource-dirs.js +4 -0
- package/dist/utils/seo-format.d.ts +8 -0
- package/dist/utils/seo-format.js +3 -0
- package/dist/utils/snippet-format.d.ts +1 -0
- package/dist/utils/snippet-format.js +1 -0
- package/dist/utils/theme-styles-format.d.ts +26 -0
- package/dist/utils/theme-styles-format.js +55 -0
- package/oclif.manifest.json +1837 -386
- package/package.json +5 -3
- package/schemas/project-config.schema.json +10 -0
|
@@ -0,0 +1,133 @@
|
|
|
1
|
+
import { mkdir, readdir, readFile, rm, writeFile } from 'node:fs/promises';
|
|
2
|
+
import { join } from 'node:path';
|
|
3
|
+
// How many snapshots `writeSnapshot` keeps per resource before pruning the oldest, so the
|
|
4
|
+
// cache directory doesn't grow without bound across a project's lifetime.
|
|
5
|
+
export const SNAPSHOT_RETENTION = 10;
|
|
6
|
+
// `ResourceState` (a Map, for O(1) lookups during comparison) has no direct JSON
|
|
7
|
+
// representation; snapshots round-trip through a plain object instead. Keys are sorted so two
|
|
8
|
+
// snapshots of the same state serialize identically (stable diffs, deterministic tests).
|
|
9
|
+
export function stateToRecord(state) {
|
|
10
|
+
return Object.fromEntries([...state].sort(([a], [b]) => a.localeCompare(b)));
|
|
11
|
+
}
|
|
12
|
+
export function recordToState(record) {
|
|
13
|
+
return new Map(Object.entries(record));
|
|
14
|
+
}
|
|
15
|
+
// Environment names are free text (whatever `lps project config` was given), not guaranteed to
|
|
16
|
+
// be filesystem-safe path segments; anything outside a conservative safe set becomes '-'.
|
|
17
|
+
function sanitizeForPath(name) {
|
|
18
|
+
return name.replaceAll(/[^\w-]/g, '-');
|
|
19
|
+
}
|
|
20
|
+
// Scoped by environment, not just resource: a project with multiple environments (staging,
|
|
21
|
+
// production, ...) sharing one rootDir must never let a staging push's snapshot answer a
|
|
22
|
+
// production rollback, or a staging push's retention prune a production restore point.
|
|
23
|
+
function snapshotsDir(rootDir, resource, environment) {
|
|
24
|
+
return join(rootDir, '.loopress', 'snapshots', resource, sanitizeForPath(environment));
|
|
25
|
+
}
|
|
26
|
+
function snapshotPath(rootDir, resource, environment, id) {
|
|
27
|
+
return join(snapshotsDir(rootDir, resource, environment), `${id}.json`);
|
|
28
|
+
}
|
|
29
|
+
function isEnoent(error) {
|
|
30
|
+
return error.code === 'ENOENT';
|
|
31
|
+
}
|
|
32
|
+
function isEexist(error) {
|
|
33
|
+
return error.code === 'EEXIST';
|
|
34
|
+
}
|
|
35
|
+
// Snapshot ids are the millisecond timestamp they were written at: sortable, unique enough for
|
|
36
|
+
// a single-machine CLI run, and directly usable as `--to <id>`.
|
|
37
|
+
function isSnapshotFilename(name) {
|
|
38
|
+
return /^\d+\.json$/.test(name);
|
|
39
|
+
}
|
|
40
|
+
// Writes a snapshot for `resource`/`environment`, then prunes down to `retain` (oldest first),
|
|
41
|
+
// so a project that pushes often never accumulates an unbounded `.loopress/snapshots/`
|
|
42
|
+
// directory. Returns the new snapshot's id.
|
|
43
|
+
//
|
|
44
|
+
// Ids are millisecond timestamps; two pushes of the same resource/environment landing in the
|
|
45
|
+
// same millisecond (only realistic from concurrent processes, never a single sequential CLI
|
|
46
|
+
// run) would otherwise silently overwrite one restore point with another. `wx` (exclusive
|
|
47
|
+
// create) turns that into a detected collision instead, retried against the next millisecond.
|
|
48
|
+
export async function writeSnapshot(options) {
|
|
49
|
+
const { afterState, beforeState, environment, resource, retain = SNAPSHOT_RETENTION, rootDir } = options;
|
|
50
|
+
const dir = snapshotsDir(rootDir, resource, environment);
|
|
51
|
+
await mkdir(dir, { recursive: true });
|
|
52
|
+
const snapshot = {
|
|
53
|
+
afterState: stateToRecord(afterState),
|
|
54
|
+
beforeState: stateToRecord(beforeState),
|
|
55
|
+
createdAt: new Date().toISOString(),
|
|
56
|
+
environment,
|
|
57
|
+
resource,
|
|
58
|
+
};
|
|
59
|
+
const content = JSON.stringify(snapshot, null, 2) + '\n';
|
|
60
|
+
let id = Date.now();
|
|
61
|
+
for (;;) {
|
|
62
|
+
try {
|
|
63
|
+
await writeFile(join(dir, `${id}.json`), content, { flag: 'wx' });
|
|
64
|
+
break;
|
|
65
|
+
}
|
|
66
|
+
catch (error) {
|
|
67
|
+
if (!isEexist(error))
|
|
68
|
+
throw error;
|
|
69
|
+
id += 1;
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
await pruneSnapshots(rootDir, resource, environment, retain);
|
|
73
|
+
return String(id);
|
|
74
|
+
}
|
|
75
|
+
// Newest first. A missing snapshots directory (never pushed, or pruned to nothing) reads as
|
|
76
|
+
// "no snapshots", not an error, the same ENOENT tolerance every resource-state provider gives
|
|
77
|
+
// a never-pulled local directory.
|
|
78
|
+
export async function listSnapshots(rootDir, resource, environment) {
|
|
79
|
+
const dir = snapshotsDir(rootDir, resource, environment);
|
|
80
|
+
let entries;
|
|
81
|
+
try {
|
|
82
|
+
entries = await readdir(dir);
|
|
83
|
+
}
|
|
84
|
+
catch (error) {
|
|
85
|
+
if (isEnoent(error))
|
|
86
|
+
return [];
|
|
87
|
+
throw error;
|
|
88
|
+
}
|
|
89
|
+
const summaries = [];
|
|
90
|
+
const snapshotFiles = entries.filter((entry) => isSnapshotFilename(entry));
|
|
91
|
+
for (const entry of snapshotFiles) {
|
|
92
|
+
const id = entry.slice(0, -'.json'.length);
|
|
93
|
+
try {
|
|
94
|
+
const parsed = JSON.parse(await readFile(join(dir, entry), 'utf8'));
|
|
95
|
+
summaries.push({ createdAt: parsed.createdAt, environment: parsed.environment, id, resource });
|
|
96
|
+
}
|
|
97
|
+
catch {
|
|
98
|
+
// A corrupted or partially-written snapshot file (e.g. an interrupted write) is skipped
|
|
99
|
+
// rather than failing the whole listing; it stays on disk for a human to inspect.
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
return summaries.sort((a, b) => Number(b.id) - Number(a.id));
|
|
103
|
+
}
|
|
104
|
+
// `id` undefined reads the most recent snapshot for this environment (the common
|
|
105
|
+
// `lps <resource> rollback` case). Throws a message ready to show the user directly: no
|
|
106
|
+
// snapshots at all for this environment, or that specific id missing (renamed, deleted, or
|
|
107
|
+
// written under a different resource/environment).
|
|
108
|
+
export async function readSnapshot(rootDir, resource, environment, id) {
|
|
109
|
+
let targetId = id;
|
|
110
|
+
if (targetId === undefined) {
|
|
111
|
+
const [latest] = await listSnapshots(rootDir, resource, environment);
|
|
112
|
+
if (!latest) {
|
|
113
|
+
throw new Error(`No snapshots found for "${resource}" on "${environment}". A snapshot is written automatically by \`lps ${resource} push\`.`);
|
|
114
|
+
}
|
|
115
|
+
targetId = latest.id;
|
|
116
|
+
}
|
|
117
|
+
let raw;
|
|
118
|
+
try {
|
|
119
|
+
raw = await readFile(snapshotPath(rootDir, resource, environment, targetId), 'utf8');
|
|
120
|
+
}
|
|
121
|
+
catch (error) {
|
|
122
|
+
if (isEnoent(error)) {
|
|
123
|
+
throw new Error(`Snapshot "${targetId}" not found for "${resource}" on "${environment}". Run \`lps ${resource} rollback --list\` to see what's available.`, { cause: error });
|
|
124
|
+
}
|
|
125
|
+
throw error;
|
|
126
|
+
}
|
|
127
|
+
return { ...JSON.parse(raw), id: targetId };
|
|
128
|
+
}
|
|
129
|
+
export async function pruneSnapshots(rootDir, resource, environment, retain) {
|
|
130
|
+
const summaries = await listSnapshots(rootDir, resource, environment);
|
|
131
|
+
const stale = summaries.slice(retain);
|
|
132
|
+
await Promise.all(stale.map(async (summary) => rm(snapshotPath(rootDir, resource, environment, summary.id), { force: true })));
|
|
133
|
+
}
|
|
@@ -6,12 +6,13 @@ import { loadSnippets } from './load-snippets.js';
|
|
|
6
6
|
import { readdirTolerant } from './readdir-tolerant.js';
|
|
7
7
|
// Directories holding one JSON document per resource. Snippets (code file plus JSON sidecar)
|
|
8
8
|
// and api routes (`.php`, not JSON) are checked separately.
|
|
9
|
-
const JSON_RESOURCE_DIRS = ['acfDir', 'formDir', 'seoDir'];
|
|
9
|
+
const JSON_RESOURCE_DIRS = ['acfDir', 'formDir', 'seoDir', 'menuDir'];
|
|
10
10
|
const DEFAULT_DIR = {
|
|
11
11
|
acfDir: 'acf',
|
|
12
12
|
apiDir: 'api',
|
|
13
13
|
formDir: 'forms',
|
|
14
14
|
hooksDir: 'hooks',
|
|
15
|
+
menuDir: 'menus',
|
|
15
16
|
seoDir: 'seo',
|
|
16
17
|
snippetsDir: 'snippets',
|
|
17
18
|
};
|
|
@@ -18,5 +18,9 @@ export type AuthorizeResult = {
|
|
|
18
18
|
* 5. The API callback returns an HTML page that POSTs a form with the
|
|
19
19
|
* Application Password to the local callback server.
|
|
20
20
|
* 6. The local server extracts the credentials and resolves the promise.
|
|
21
|
+
*
|
|
22
|
+
* The `state` value is threaded through `callbackUrl` (the relay copies it verbatim into the
|
|
23
|
+
* form's POST target), so it comes back as a query param on the credential POST and
|
|
24
|
+
* `waitForLocalCallback` can match it before this handler runs.
|
|
21
25
|
*/
|
|
22
26
|
export declare function authorizeWithBrowser(siteUrl: string, log: (message: string) => void): Promise<AuthorizeResult>;
|
|
@@ -15,13 +15,18 @@ import { renderResultPage, waitForLocalCallback } from './local-callback-server.
|
|
|
15
15
|
* 5. The API callback returns an HTML page that POSTs a form with the
|
|
16
16
|
* Application Password to the local callback server.
|
|
17
17
|
* 6. The local server extracts the credentials and resolves the promise.
|
|
18
|
+
*
|
|
19
|
+
* The `state` value is threaded through `callbackUrl` (the relay copies it verbatim into the
|
|
20
|
+
* form's POST target), so it comes back as a query param on the credential POST and
|
|
21
|
+
* `waitForLocalCallback` can match it before this handler runs.
|
|
18
22
|
*/
|
|
19
23
|
export async function authorizeWithBrowser(siteUrl, log) {
|
|
20
24
|
return waitForLocalCallback({
|
|
21
|
-
|
|
25
|
+
allowedOrigins: ['https://api.loopress.dev'],
|
|
26
|
+
buildUrl(callbackBaseUrl, state) {
|
|
22
27
|
const relayUrl = 'https://api.loopress.dev/auth/wp-authorize';
|
|
23
28
|
const params = new URLSearchParams({
|
|
24
|
-
callbackUrl: callbackBaseUrl
|
|
29
|
+
callbackUrl: `${callbackBaseUrl}?state=${state}`,
|
|
25
30
|
wpUrl: siteUrl,
|
|
26
31
|
});
|
|
27
32
|
return `${relayUrl}?${params}`;
|
|
@@ -31,12 +36,9 @@ export async function authorizeWithBrowser(siteUrl, log) {
|
|
|
31
36
|
rejectWithPage(REJECTED_PAGE, new Error('Authorization rejected in WordPress.'));
|
|
32
37
|
return;
|
|
33
38
|
}
|
|
34
|
-
//
|
|
35
|
-
//
|
|
36
|
-
|
|
37
|
-
const password = body.password || url.searchParams.get('password') || '';
|
|
38
|
-
// eslint-disable-next-line @typescript-eslint/prefer-nullish-coalescing
|
|
39
|
-
const userLogin = body.user_login || url.searchParams.get('user_login') || '';
|
|
39
|
+
// Credentials come from the relay's form POST only. Reading them from the query string
|
|
40
|
+
// would let them land in shell history, proxy logs and the browser address bar (F24).
|
|
41
|
+
const { password, user_login: userLogin } = body;
|
|
40
42
|
if (!password || !userLogin) {
|
|
41
43
|
respondBadRequest('Missing password or user_login');
|
|
42
44
|
return;
|
|
@@ -39,6 +39,14 @@ export interface LoopressProjectConfiguration {
|
|
|
39
39
|
* Directory where explicitly tracked WordPress options (wp_options rows) are read from and written to, relative to rootDir. Only options added via `lps option add` live here, this is never the whole options table.
|
|
40
40
|
*/
|
|
41
41
|
optionsDir?: string;
|
|
42
|
+
/**
|
|
43
|
+
* Directory where nav menus (one JSON file per menu, plus menu-locations.json for the active theme's menu locations) are read from and written to, relative to rootDir.
|
|
44
|
+
*/
|
|
45
|
+
menuDir?: string;
|
|
46
|
+
/**
|
|
47
|
+
* Directory where the active block theme's Global Styles customizations (Site Editor > Styles: colors, typography, spacing) are read from and written to, relative to rootDir. Classic (non-block) themes are not supported.
|
|
48
|
+
*/
|
|
49
|
+
themeStylesDir?: string;
|
|
42
50
|
/**
|
|
43
51
|
* Project identifier from the global Loopress config. When set, takes precedence over the currently active project in the global config.
|
|
44
52
|
*/
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
export declare const ACF_OBJECT_TYPES: readonly ["field-groups", "post-types", "taxonomies", "options-pages"];
|
|
2
2
|
export type AcfObjectType = (typeof ACF_OBJECT_TYPES)[number];
|
|
3
3
|
export declare function acfEndpoint(type: AcfObjectType): string;
|
|
4
|
+
export declare function acfObjectEndpoint(type: AcfObjectType, key: string): string;
|
|
4
5
|
export declare function getAcfKey(data: Record<string, unknown>): null | string;
|
package/dist/utils/acf-format.js
CHANGED
|
@@ -2,6 +2,12 @@ export const ACF_OBJECT_TYPES = ['field-groups', 'post-types', 'taxonomies', 'op
|
|
|
2
2
|
export function acfEndpoint(type) {
|
|
3
3
|
return `loopress/v1/acf/${type}`;
|
|
4
4
|
}
|
|
5
|
+
// GET/DELETE route for a single object by key, used by `acf push` to read the object's current
|
|
6
|
+
// revision right before writing it (see acf/push.ts, #234). Create/update both go through
|
|
7
|
+
// acfEndpoint() (a POST), the controller resolves create-vs-update server-side via `key`.
|
|
8
|
+
export function acfObjectEndpoint(type, key) {
|
|
9
|
+
return `${acfEndpoint(type)}/${key}`;
|
|
10
|
+
}
|
|
5
11
|
// Deliberately loose: ACF's own export JSON is large, deeply nested, and versioned by ACF
|
|
6
12
|
// itself (new settings appear across ACF releases). We only need `key` for filenames/identity;
|
|
7
13
|
// everything else round-trips through pull/push untouched, so there's no generated type for it
|
|
@@ -1,3 +1,7 @@
|
|
|
1
1
|
export declare const FORM_ENDPOINT = "loopress/v1/forms";
|
|
2
|
+
export declare function formEndpoint(id: number): string;
|
|
3
|
+
export type RemoteForm = Record<string, unknown> & {
|
|
4
|
+
revision: string;
|
|
5
|
+
};
|
|
2
6
|
export declare function getFormId(data: Record<string, unknown>): null | number;
|
|
3
7
|
export declare function getFormTitle(data: Record<string, unknown>): string;
|
|
@@ -1,11 +1,7 @@
|
|
|
1
1
|
export const FORM_ENDPOINT = 'loopress/v1/forms';
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
|
|
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.
|
|
2
|
+
export function formEndpoint(id) {
|
|
3
|
+
return `${FORM_ENDPOINT}/${id}`;
|
|
4
|
+
}
|
|
9
5
|
export function getFormId(data) {
|
|
10
6
|
const id = Number(data.id);
|
|
11
7
|
return Number.isSafeInteger(id) && id > 0 ? id : null;
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
export declare const MENU_ENDPOINT = "loopress/v1/menus";
|
|
2
|
+
export declare const MENU_LOCATIONS_ENDPOINT = "loopress/v1/menu-locations";
|
|
3
|
+
export declare function menuEndpoint(slug: string): string;
|
|
4
|
+
export type MenuItem = {
|
|
5
|
+
children: MenuItem[];
|
|
6
|
+
classes: string[];
|
|
7
|
+
description: string;
|
|
8
|
+
object: null | string;
|
|
9
|
+
objectSlug: null | string;
|
|
10
|
+
target: string;
|
|
11
|
+
title: string;
|
|
12
|
+
type: 'custom' | 'post_type' | 'taxonomy';
|
|
13
|
+
url: null | string;
|
|
14
|
+
xfn: string;
|
|
15
|
+
};
|
|
16
|
+
export type Menu = {
|
|
17
|
+
items: MenuItem[];
|
|
18
|
+
name: string;
|
|
19
|
+
revision: string;
|
|
20
|
+
slug: string;
|
|
21
|
+
warnings: string[];
|
|
22
|
+
};
|
|
23
|
+
export type MenuLocations = Record<string, null | string>;
|
|
24
|
+
export declare function getMenuSlug(data: Record<string, unknown>): null | string;
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
export const MENU_ENDPOINT = 'loopress/v1/menus';
|
|
2
|
+
export const MENU_LOCATIONS_ENDPOINT = 'loopress/v1/menu-locations';
|
|
3
|
+
export function menuEndpoint(slug) {
|
|
4
|
+
return `${MENU_ENDPOINT}/${encodeURIComponent(slug)}`;
|
|
5
|
+
}
|
|
6
|
+
export function getMenuSlug(data) {
|
|
7
|
+
return typeof data.slug === 'string' && data.slug.trim() !== '' ? data.slug : null;
|
|
8
|
+
}
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
export declare const OPTIONS_ENDPOINT = "loopress/v1/options";
|
|
2
2
|
export declare function optionEndpoint(name: string): string;
|
|
3
3
|
export declare const RESERVED_OPTION_NAMES: readonly ["active_plugins", "stylesheet", "template"];
|
|
4
|
-
export declare const READONLY_BY_DEFAULT_OPTION_NAMES: readonly ["siteurl", "home", "db_version", "cron", "rewrite_rules", "WPLANG"];
|
|
4
|
+
export declare const READONLY_BY_DEFAULT_OPTION_NAMES: readonly ["siteurl", "home", "db_version", "initial_db_version", "cron", "rewrite_rules", "WPLANG", "default_role", "users_can_register", "uninstall_plugins", "mailserver_url", "mailserver_login", "mailserver_pass", "mailserver_port"];
|
|
5
5
|
export type LocalOption = {
|
|
6
6
|
autoload: string;
|
|
7
7
|
name: string;
|
|
@@ -11,6 +11,7 @@ export type LocalOption = {
|
|
|
11
11
|
export type RemoteOption = {
|
|
12
12
|
autoload: string;
|
|
13
13
|
name: string;
|
|
14
|
+
revision: string;
|
|
14
15
|
value: unknown;
|
|
15
16
|
};
|
|
16
17
|
export type ListedOption = {
|
|
@@ -6,11 +6,29 @@ export function optionEndpoint(name) {
|
|
|
6
6
|
// enforcement lives there). Checked here too so `option add` fails fast with a clear pointer to
|
|
7
7
|
// the right command, instead of a round trip just to hit the server's 409.
|
|
8
8
|
export const RESERVED_OPTION_NAMES = ['active_plugins', 'stylesheet', 'template'];
|
|
9
|
-
// Options WordPress core owns or regenerates itself
|
|
10
|
-
//
|
|
9
|
+
// Options WordPress core owns or regenerates itself, plus the ones OptionsService::
|
|
10
|
+
// DENY_WRITE_NAMES refuses server-side (behaviour-changing core options: default_role,
|
|
11
|
+
// users_can_register, mailserver_*, ...). Legitimate to track for visibility (`diff` still
|
|
12
|
+
// shows drift), but never something `push` should overwrite by default. siteurl/home in
|
|
11
13
|
// particular: pushing a value copied from another environment would repoint the target site's
|
|
12
|
-
// own URLs, potentially locking out wp-admin.
|
|
13
|
-
|
|
14
|
+
// own URLs, potentially locking out wp-admin. Flip `readonly` in the file to push one anyway;
|
|
15
|
+
// for the server-denied names that then needs the `loopress_option_writable` filter too.
|
|
16
|
+
export const READONLY_BY_DEFAULT_OPTION_NAMES = [
|
|
17
|
+
'siteurl',
|
|
18
|
+
'home',
|
|
19
|
+
'db_version',
|
|
20
|
+
'initial_db_version',
|
|
21
|
+
'cron',
|
|
22
|
+
'rewrite_rules',
|
|
23
|
+
'WPLANG',
|
|
24
|
+
'default_role',
|
|
25
|
+
'users_can_register',
|
|
26
|
+
'uninstall_plugins',
|
|
27
|
+
'mailserver_url',
|
|
28
|
+
'mailserver_login',
|
|
29
|
+
'mailserver_pass',
|
|
30
|
+
'mailserver_port',
|
|
31
|
+
];
|
|
14
32
|
export function isReservedOptionName(name) {
|
|
15
33
|
return RESERVED_OPTION_NAMES.includes(name);
|
|
16
34
|
}
|
|
@@ -9,9 +9,15 @@ export type SyncPayload = {
|
|
|
9
9
|
intent: SyncIntent;
|
|
10
10
|
lock: null | string;
|
|
11
11
|
};
|
|
12
|
+
export type LockDriftEntry = {
|
|
13
|
+
from: null | string;
|
|
14
|
+
name: string;
|
|
15
|
+
to: null | string;
|
|
16
|
+
};
|
|
12
17
|
export type SyncResponse = {
|
|
13
18
|
composerJson: string;
|
|
14
19
|
composerLock: string;
|
|
20
|
+
lockDrift?: LockDriftEntry[];
|
|
15
21
|
message: string;
|
|
16
22
|
output: string;
|
|
17
23
|
removed: string[];
|
|
@@ -5,9 +5,11 @@ export declare const RESOURCE_DIR_DEFAULTS: {
|
|
|
5
5
|
readonly apps: "apps";
|
|
6
6
|
readonly form: "forms";
|
|
7
7
|
readonly hooks: "hooks";
|
|
8
|
+
readonly menu: "menus";
|
|
8
9
|
readonly options: "options";
|
|
9
10
|
readonly seo: "seo";
|
|
10
11
|
readonly snippets: "snippets";
|
|
12
|
+
readonly themeStyles: "theme";
|
|
11
13
|
};
|
|
12
14
|
export type ResourceDirKind = keyof typeof RESOURCE_DIR_DEFAULTS;
|
|
13
15
|
export declare function resolveResourceDir(kind: ResourceDirKind, localConfig: LoopressLocalConfig, override?: string): string;
|
|
@@ -7,9 +7,11 @@ export const RESOURCE_DIR_DEFAULTS = {
|
|
|
7
7
|
apps: 'apps',
|
|
8
8
|
form: 'forms',
|
|
9
9
|
hooks: 'hooks',
|
|
10
|
+
menu: 'menus',
|
|
10
11
|
options: 'options',
|
|
11
12
|
seo: 'seo',
|
|
12
13
|
snippets: 'snippets',
|
|
14
|
+
themeStyles: 'theme',
|
|
13
15
|
};
|
|
14
16
|
const CONFIG_KEY = {
|
|
15
17
|
acf: 'acfDir',
|
|
@@ -17,9 +19,11 @@ const CONFIG_KEY = {
|
|
|
17
19
|
apps: 'appsDir',
|
|
18
20
|
form: 'formDir',
|
|
19
21
|
hooks: 'hooksDir',
|
|
22
|
+
menu: 'menuDir',
|
|
20
23
|
options: 'optionsDir',
|
|
21
24
|
seo: 'seoDir',
|
|
22
25
|
snippets: 'snippetsDir',
|
|
26
|
+
themeStyles: 'themeStylesDir',
|
|
23
27
|
};
|
|
24
28
|
// Resolves where a resource's files live: an explicit override wins, otherwise the
|
|
25
29
|
// loopress.json `<kind>Dir` setting, otherwise the built-in default, all under `rootDir`.
|
|
@@ -3,11 +3,19 @@ export declare const SEO_SETTINGS_ENDPOINT = "loopress/v1/seo/settings";
|
|
|
3
3
|
export declare const SEO_REDIRECTS_ENDPOINT = "loopress/v1/seo/redirects";
|
|
4
4
|
export declare function seoRedirectEndpoint(id: number): string;
|
|
5
5
|
export declare function seoPostMetaEndpoint(postType: string): string;
|
|
6
|
+
export declare function seoPostMetaItemEndpoint(postType: string, slug: string): string;
|
|
6
7
|
export type SeoPostMeta = {
|
|
7
8
|
meta: Record<string, unknown>;
|
|
8
9
|
slug: string;
|
|
9
10
|
title: string;
|
|
10
11
|
};
|
|
12
|
+
export type RemoteSeoPostMeta = SeoPostMeta & {
|
|
13
|
+
revision: string;
|
|
14
|
+
};
|
|
15
|
+
export type RemoteSeoSettings = {
|
|
16
|
+
revision: string;
|
|
17
|
+
settings: Record<string, unknown>;
|
|
18
|
+
};
|
|
11
19
|
export type SeoRedirect = {
|
|
12
20
|
createdAt: string | undefined;
|
|
13
21
|
headerCode: number;
|
package/dist/utils/seo-format.js
CHANGED
|
@@ -9,6 +9,9 @@ export function seoRedirectEndpoint(id) {
|
|
|
9
9
|
export function seoPostMetaEndpoint(postType) {
|
|
10
10
|
return `loopress/v1/seo/post-meta/${postType}`;
|
|
11
11
|
}
|
|
12
|
+
export function seoPostMetaItemEndpoint(postType, slug) {
|
|
13
|
+
return `${seoPostMetaEndpoint(postType)}/${encodeURIComponent(slug)}`;
|
|
14
|
+
}
|
|
12
15
|
// The `<id>-<slug>.json` on-disk convention shared by `seo pull` and `seo push`.
|
|
13
16
|
export function redirectFileBase(redirect) {
|
|
14
17
|
return `${redirect.id}-${toSlug(redirect.urlTo, 'redirect')}`;
|
|
@@ -81,6 +81,7 @@ export function normalizeSnippet(data) {
|
|
|
81
81
|
location: parseLocation(data.location) ?? defaultLocationForType(parseType(data.type) ?? 'php'),
|
|
82
82
|
name: coerceString(data.name),
|
|
83
83
|
priority: resolvePriority(data.priority),
|
|
84
|
+
revision: typeof data.revision === 'string' ? data.revision : undefined,
|
|
84
85
|
shortcodeAttributes: Array.isArray(data.shortcodeAttributes) ? data.shortcodeAttributes.map(String) : [],
|
|
85
86
|
tags: Array.isArray(data.tags) ? data.tags : [],
|
|
86
87
|
type: parseType(data.type) ?? 'php',
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
import { type WpClient } from '../lib/wp-client.js';
|
|
2
|
+
export declare const THEME_STYLES_FILE_SUFFIX = "-global-styles.json";
|
|
3
|
+
export declare function themeStylesFileName(stylesheet: string): string;
|
|
4
|
+
export declare function globalStylesEndpoint(id: number): string;
|
|
5
|
+
export type WpThemeSupportInfo = {
|
|
6
|
+
_links?: Record<string, Array<{
|
|
7
|
+
href: string;
|
|
8
|
+
}>>;
|
|
9
|
+
status: 'active' | 'inactive';
|
|
10
|
+
stylesheet: string;
|
|
11
|
+
theme_supports?: Record<string, unknown>;
|
|
12
|
+
};
|
|
13
|
+
export declare function isBlockTheme(theme: Pick<WpThemeSupportInfo, 'theme_supports'>): boolean;
|
|
14
|
+
export declare function getUserGlobalStylesId(theme: Pick<WpThemeSupportInfo, '_links'>): null | number;
|
|
15
|
+
export declare function getActiveThemeGlobalStyles(wp: WpClient): Promise<{
|
|
16
|
+
id: number;
|
|
17
|
+
stylesheet: string;
|
|
18
|
+
}>;
|
|
19
|
+
export type GlobalStylesRecord = {
|
|
20
|
+
settings?: Record<string, unknown>;
|
|
21
|
+
styles?: Record<string, unknown>;
|
|
22
|
+
};
|
|
23
|
+
export declare function canonicalGlobalStyles(record: {
|
|
24
|
+
settings?: unknown;
|
|
25
|
+
styles?: unknown;
|
|
26
|
+
}): Record<string, unknown>;
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
// Global Styles are the user's customizations from the Site Editor's "Styles" screen (colors,
|
|
2
|
+
// typography, spacing), stored by WordPress core in a `wp_global_styles` post, one per theme.
|
|
3
|
+
// This is separate from the theme's own theme.json file, which lives in the theme's repo and is
|
|
4
|
+
// out of scope here. Only block themes (Full Site Editing) have a Styles screen at all.
|
|
5
|
+
export const THEME_STYLES_FILE_SUFFIX = '-global-styles.json';
|
|
6
|
+
export function themeStylesFileName(stylesheet) {
|
|
7
|
+
return `${stylesheet}${THEME_STYLES_FILE_SUFFIX}`;
|
|
8
|
+
}
|
|
9
|
+
export function globalStylesEndpoint(id) {
|
|
10
|
+
return `wp/v2/global-styles/${id}`;
|
|
11
|
+
}
|
|
12
|
+
// WordPress core surfaces `wp_is_block_theme()` over the `wp/v2/themes` REST response as
|
|
13
|
+
// `theme_supports['block-templates']` (see `_add_default_theme_supports()` in
|
|
14
|
+
// wp-includes/theme.php): core adds that feature flag itself exactly when the theme has its own
|
|
15
|
+
// block templates. No Loopress-side controller is needed to detect this.
|
|
16
|
+
export function isBlockTheme(theme) {
|
|
17
|
+
return theme.theme_supports?.['block-templates'] === true;
|
|
18
|
+
}
|
|
19
|
+
// The `wp/v2/themes` list item links to its theme's editable `wp_global_styles` post under this
|
|
20
|
+
// core-defined relation rather than exposing the id as a plain field (the same relation
|
|
21
|
+
// @wordpress/e2e-test-utils-playwright's `getCurrentThemeGlobalStylesPostId()` reads). The href
|
|
22
|
+
// itself varies with the site's permalink structure (`?rest_route=/wp/v2/global-styles/123` vs
|
|
23
|
+
// `/wp-json/wp/v2/global-styles/123`), so the id is pulled out with a regex rather than assumed
|
|
24
|
+
// to be the URL's last path segment.
|
|
25
|
+
const USER_GLOBAL_STYLES_REL = 'wp:user-global-styles';
|
|
26
|
+
export function getUserGlobalStylesId(theme) {
|
|
27
|
+
const href = theme._links?.[USER_GLOBAL_STYLES_REL]?.[0]?.href;
|
|
28
|
+
if (!href)
|
|
29
|
+
return null;
|
|
30
|
+
const match = /\/wp\/v2\/global-styles\/(\d+)/.exec(href);
|
|
31
|
+
return match ? Number(match[1]) : null;
|
|
32
|
+
}
|
|
33
|
+
// Resolves the active theme's stylesheet slug and the id of its editable Global Styles post in
|
|
34
|
+
// one call, failing clearly (rather than with a raw REST error later) when the active theme
|
|
35
|
+
// isn't a block theme: classic themes have no Styles screen and no `wp_global_styles` post at all.
|
|
36
|
+
export async function getActiveThemeGlobalStyles(wp) {
|
|
37
|
+
const themes = await wp.get('wp/v2/themes');
|
|
38
|
+
const active = themes.find((theme) => theme.status === 'active');
|
|
39
|
+
if (!active)
|
|
40
|
+
throw new Error('Could not determine the active theme on WordPress.');
|
|
41
|
+
if (!isBlockTheme(active)) {
|
|
42
|
+
throw new Error(`Global Styles sync only supports block themes (Full Site Editing). The active theme "${active.stylesheet}" is a classic theme.`);
|
|
43
|
+
}
|
|
44
|
+
const id = getUserGlobalStylesId(active);
|
|
45
|
+
if (id === null) {
|
|
46
|
+
throw new Error(`Could not resolve the editable Global Styles post for "${active.stylesheet}". Requires an admin-capable account (edit_theme_options) on WordPress 5.9+.`);
|
|
47
|
+
}
|
|
48
|
+
return { id, stylesheet: active.stylesheet };
|
|
49
|
+
}
|
|
50
|
+
// The compared/stored value: `settings` and `styles` only. `id` and `_links` are WordPress
|
|
51
|
+
// bookkeeping, not part of the tracked configuration (a fresh install resolves to a different
|
|
52
|
+
// id for the same content), so they're dropped rather than becoming spurious drift.
|
|
53
|
+
export function canonicalGlobalStyles(record) {
|
|
54
|
+
return { settings: record.settings ?? {}, styles: record.styles ?? {} };
|
|
55
|
+
}
|