@loopress/cli 0.24.1 → 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 +629 -88
- 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/rollback.d.ts +2 -0
- package/dist/commands/api/rollback.js +5 -0
- 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 +1 -0
- package/dist/commands/form/push.js +29 -3
- package/dist/commands/form/rollback.d.ts +2 -0
- package/dist/commands/form/rollback.js +5 -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/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 +1 -0
- package/dist/commands/seo/push.js +33 -3
- 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/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/php-files-command.d.ts +1 -0
- package/dist/lib/php-files-command.js +84 -47
- 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/snapshot-store.d.ts +30 -0
- package/dist/lib/snapshot-store.js +133 -0
- package/dist/lib/validate-local.js +2 -1
- 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 +1 -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 +1736 -465
- package/package.json +4 -2
- package/schemas/project-config.schema.json +10 -0
|
@@ -2,9 +2,11 @@ import { readFile } from 'node:fs/promises';
|
|
|
2
2
|
import { basename, join, relative, sep } from 'node:path';
|
|
3
3
|
import { ACF_OBJECT_TYPES, acfEndpoint, getAcfKey } from '../utils/acf-format.js';
|
|
4
4
|
import { FORM_ENDPOINT, getFormId } from '../utils/form-format.js';
|
|
5
|
+
import { getMenuSlug, MENU_ENDPOINT, MENU_LOCATIONS_ENDPOINT } from '../utils/menu-format.js';
|
|
5
6
|
import { optionEndpoint, parseLocalOption } from '../utils/option-format.js';
|
|
6
7
|
import { DEFAULT_POST_TYPES, SEO_REDIRECTS_ENDPOINT, SEO_SETTINGS_ENDPOINT, seoPostMetaEndpoint, } from '../utils/seo-format.js';
|
|
7
8
|
import { normalizeSnippet, SNIPPETS_ENDPOINT, stripPhpOpeningTag } from '../utils/snippet-format.js';
|
|
9
|
+
import { canonicalGlobalStyles, getActiveThemeGlobalStyles, globalStylesEndpoint, THEME_STYLES_FILE_SUFFIX, } from '../utils/theme-styles-format.js';
|
|
8
10
|
import { loadFiles } from './load-files.js';
|
|
9
11
|
import { loadSnippets } from './load-snippets.js';
|
|
10
12
|
import { isApplicative404, isNotFoundError } from './wp-client.js';
|
|
@@ -239,12 +241,16 @@ const seoProvider = {
|
|
|
239
241
|
},
|
|
240
242
|
async remote(wp, onWarn) {
|
|
241
243
|
const state = new Map();
|
|
242
|
-
|
|
244
|
+
// `revision` (#234) is dropped from both, same as optionsProvider.remote() does for
|
|
245
|
+
// RemoteOption.revision below: it's bookkeeping for the conditional-write precondition, not
|
|
246
|
+
// configuration, and comparing it here would flag drift whenever the mere act of reading
|
|
247
|
+
// changed nothing meaningful.
|
|
248
|
+
const { settings } = await wp.get(SEO_SETTINGS_ENDPOINT);
|
|
243
249
|
state.set('settings', settings);
|
|
244
250
|
for (const postType of DEFAULT_POST_TYPES) {
|
|
245
251
|
const posts = await wp.get(seoPostMetaEndpoint(postType));
|
|
246
|
-
for (const
|
|
247
|
-
state.set(`post-meta/${postType}/${
|
|
252
|
+
for (const { meta, slug, title } of posts)
|
|
253
|
+
state.set(`post-meta/${postType}/${slug}`, { meta, slug, title });
|
|
248
254
|
}
|
|
249
255
|
// Redirects are a RankMath-only feature; on Yoast the endpoint 404s, same graceful skip
|
|
250
256
|
// as `seo pull`.
|
|
@@ -264,6 +270,55 @@ const seoProvider = {
|
|
|
264
270
|
resource: 'seo',
|
|
265
271
|
title: 'SEO',
|
|
266
272
|
};
|
|
273
|
+
// ---- Menus --------------------------------------------------------------------------------
|
|
274
|
+
// Every item's post_type/taxonomy target is already resolved to `object`/`objectSlug` by the
|
|
275
|
+
// REST layer (MenuService), never a raw `_menu_item_object_id`, so the exported tree is already
|
|
276
|
+
// portable and compared as-is. `warnings` is diagnostic (a dangling item, an off-environment
|
|
277
|
+
// custom URL, ...), not tracked configuration, so it's dropped before comparing, the same
|
|
278
|
+
// "volatile key" treatment ACF/forms/redirects give their own server-computed fields. `revision`
|
|
279
|
+
// (#234) is bookkeeping for menu push's conditional-write precondition, not tracked configuration
|
|
280
|
+
// either: it's a pure hash of name + items, so it never disagrees when they don't, but a local
|
|
281
|
+
// file pulled before this field existed simply wouldn't have it, which must never show up as
|
|
282
|
+
// drift on its own.
|
|
283
|
+
const MENU_VOLATILE_KEYS = ['revision', 'warnings'];
|
|
284
|
+
// The reserved local filename for menu locations, see commands/menu/pull.ts.
|
|
285
|
+
const MENU_LOCATIONS_FILE = 'menu-locations';
|
|
286
|
+
function canonicalMenu(menu) {
|
|
287
|
+
return omit(menu, MENU_VOLATILE_KEYS);
|
|
288
|
+
}
|
|
289
|
+
const menuProvider = {
|
|
290
|
+
dirKind: 'menu',
|
|
291
|
+
async local(dir, onWarn) {
|
|
292
|
+
const state = new Map();
|
|
293
|
+
const entries = await loadFiles(dir, {
|
|
294
|
+
extension: '.json',
|
|
295
|
+
onSkip: onWarn,
|
|
296
|
+
parse: (raw, filePath) => ({ file: basename(filePath, '.json'), value: readJson(raw) }),
|
|
297
|
+
});
|
|
298
|
+
for (const { file, value } of entries) {
|
|
299
|
+
if (file === MENU_LOCATIONS_FILE) {
|
|
300
|
+
state.set(MENU_LOCATIONS_FILE, value);
|
|
301
|
+
continue;
|
|
302
|
+
}
|
|
303
|
+
const slug = getMenuSlug(value) ?? `local:${file}`;
|
|
304
|
+
state.set(`menu/${slug}`, canonicalMenu(value));
|
|
305
|
+
}
|
|
306
|
+
return state;
|
|
307
|
+
},
|
|
308
|
+
async remote(wp) {
|
|
309
|
+
const state = new Map();
|
|
310
|
+
const menus = await wp.get(MENU_ENDPOINT);
|
|
311
|
+
for (const menu of menus) {
|
|
312
|
+
const slug = getMenuSlug(menu);
|
|
313
|
+
if (slug !== null)
|
|
314
|
+
state.set(`menu/${slug}`, canonicalMenu(menu));
|
|
315
|
+
}
|
|
316
|
+
state.set(MENU_LOCATIONS_FILE, await wp.get(MENU_LOCATIONS_ENDPOINT));
|
|
317
|
+
return state;
|
|
318
|
+
},
|
|
319
|
+
resource: 'menu',
|
|
320
|
+
title: 'Menus',
|
|
321
|
+
};
|
|
267
322
|
// ---- Options ------------------------------------------------------------------------------
|
|
268
323
|
// Unlike every provider above, remote() has no "everything" endpoint to read: GET /options only
|
|
269
324
|
// lists names, never values (by design, see OptionsController), so both sides compare exactly
|
|
@@ -310,6 +365,38 @@ const optionsProvider = {
|
|
|
310
365
|
resource: 'option',
|
|
311
366
|
title: 'Options',
|
|
312
367
|
};
|
|
368
|
+
// ---- Theme styles (Global Styles) ----------------------------------------------------------
|
|
369
|
+
// Local files are named `<stylesheet>-global-styles.json`, keyed by stylesheet so a directory
|
|
370
|
+
// that has accumulated files from more than one previously-active theme still compares each one
|
|
371
|
+
// individually rather than colliding on a single key.
|
|
372
|
+
const themeStylesProvider = {
|
|
373
|
+
dirKind: 'themeStyles',
|
|
374
|
+
async local(dir, onWarn) {
|
|
375
|
+
const entries = await loadFiles(dir, {
|
|
376
|
+
extension: '.json',
|
|
377
|
+
onSkip: onWarn,
|
|
378
|
+
parse(raw, filePath) {
|
|
379
|
+
const name = basename(filePath);
|
|
380
|
+
const stylesheet = name.endsWith(THEME_STYLES_FILE_SUFFIX) ? name.slice(0, -THEME_STYLES_FILE_SUFFIX.length) : basename(filePath, '.json');
|
|
381
|
+
return { stylesheet, value: readJson(raw) };
|
|
382
|
+
},
|
|
383
|
+
});
|
|
384
|
+
const state = new Map();
|
|
385
|
+
for (const { stylesheet, value } of entries)
|
|
386
|
+
state.set(stylesheet, canonicalGlobalStyles(value));
|
|
387
|
+
return state;
|
|
388
|
+
},
|
|
389
|
+
// Only the active theme's Global Styles are compared (see theme-styles-format.ts): a classic
|
|
390
|
+
// active theme has no Styles screen at all, surfaced here as a normal provider error, the same
|
|
391
|
+
// "inconclusive for this resource" treatment DiffCommand gives any other fetch failure.
|
|
392
|
+
async remote(wp) {
|
|
393
|
+
const { id, stylesheet } = await getActiveThemeGlobalStyles(wp);
|
|
394
|
+
const item = await wp.get(globalStylesEndpoint(id));
|
|
395
|
+
return new Map([[stylesheet, canonicalGlobalStyles(item)]]);
|
|
396
|
+
},
|
|
397
|
+
resource: 'theme-styles',
|
|
398
|
+
title: 'Theme styles',
|
|
399
|
+
};
|
|
313
400
|
export const RESOURCE_STATE_PROVIDERS = [
|
|
314
401
|
snippetProvider,
|
|
315
402
|
formProvider,
|
|
@@ -317,7 +404,9 @@ export const RESOURCE_STATE_PROVIDERS = [
|
|
|
317
404
|
apiProvider,
|
|
318
405
|
hookProvider,
|
|
319
406
|
seoProvider,
|
|
407
|
+
menuProvider,
|
|
320
408
|
optionsProvider,
|
|
409
|
+
themeStylesProvider,
|
|
321
410
|
];
|
|
322
411
|
export function getResourceStateProvider(resource) {
|
|
323
412
|
const provider = RESOURCE_STATE_PROVIDERS.find((candidate) => candidate.resource === resource);
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
import { LoopressCommand } from './base.js';
|
|
2
|
+
import { type SnapshotSummary } from './snapshot-store.js';
|
|
3
|
+
export type RollbackResult = {
|
|
4
|
+
drift?: {
|
|
5
|
+
added: string[];
|
|
6
|
+
changed: string[];
|
|
7
|
+
removed: string[];
|
|
8
|
+
};
|
|
9
|
+
id?: string;
|
|
10
|
+
restored?: {
|
|
11
|
+
added: string[];
|
|
12
|
+
changed: string[];
|
|
13
|
+
removed: string[];
|
|
14
|
+
};
|
|
15
|
+
snapshots?: SnapshotSummary[];
|
|
16
|
+
status: 'dry-run' | 'listed' | 'no-op' | 'success';
|
|
17
|
+
};
|
|
18
|
+
export declare function resourceRollbackCommand(resource: string, options: {
|
|
19
|
+
description: string;
|
|
20
|
+
pathNoun: string;
|
|
21
|
+
}): typeof LoopressCommand;
|
|
@@ -0,0 +1,180 @@
|
|
|
1
|
+
import { confirm } from '@inquirer/prompts';
|
|
2
|
+
import { Args, Flags, ux } from '@oclif/core';
|
|
3
|
+
import { mkdtemp, rm } from 'node:fs/promises';
|
|
4
|
+
import { tmpdir } from 'node:os';
|
|
5
|
+
import { join, resolve } from 'node:path';
|
|
6
|
+
import { pluralize } from '../utils/pluralize.js';
|
|
7
|
+
import { resolveResourceDir } from '../utils/resource-dirs.js';
|
|
8
|
+
import { LoopressCommand } from './base.js';
|
|
9
|
+
import { compareStates, isEmptyDiff } from './diff-state.js';
|
|
10
|
+
import { guardProductionPush } from './guard-production-push.js';
|
|
11
|
+
import { isInteractive } from './interactive.js';
|
|
12
|
+
import { getResourceStateProvider } from './resource-state.js';
|
|
13
|
+
import { materializeSnapshot } from './rollback-materialize.js';
|
|
14
|
+
import { listSnapshots, readSnapshot, recordToState } from './snapshot-store.js';
|
|
15
|
+
const c = ux.colorize;
|
|
16
|
+
function summarize(diff) {
|
|
17
|
+
return { added: diff.added, changed: diff.changed.map((change) => change.id), removed: diff.removed };
|
|
18
|
+
}
|
|
19
|
+
function indentPatch(patch) {
|
|
20
|
+
return patch
|
|
21
|
+
.split('\n')
|
|
22
|
+
.map((line) => (line === '' ? line : ` ${line}`))
|
|
23
|
+
.join('\n')
|
|
24
|
+
.trimEnd();
|
|
25
|
+
}
|
|
26
|
+
// Builds the `lps <resource> rollback` command for one resource-state-backed resource (the 9
|
|
27
|
+
// listed in resource-state.ts's RESOURCE_STATE_PROVIDERS). `lps <resource> push` writes a
|
|
28
|
+
// snapshot of the environment's state right before every real push (see snapshot-store.ts);
|
|
29
|
+
// this command restores one: by default the most recent, or `--to <id>` for an older one.
|
|
30
|
+
//
|
|
31
|
+
// A rollback is only safe when nothing else touched the environment between the original push
|
|
32
|
+
// and now, otherwise it silently overwrites a legitimate later change. So before restoring
|
|
33
|
+
// anything, this runs the equivalent of `lps <resource> diff` between the environment's current
|
|
34
|
+
// state and the state the original push expected to leave behind, and requires explicit
|
|
35
|
+
// confirmation (the same interactive/`--yes` shape as guardProductionPush) when they differ.
|
|
36
|
+
export function resourceRollbackCommand(resource, options) {
|
|
37
|
+
class ResourceRollback extends LoopressCommand {
|
|
38
|
+
static args = {
|
|
39
|
+
path: Args.string({ description: `Path to ${options.pathNoun} (overrides project config)` }),
|
|
40
|
+
};
|
|
41
|
+
static description = options.description;
|
|
42
|
+
static enableJsonFlag = true;
|
|
43
|
+
static examples = [
|
|
44
|
+
`$ lps ${resource} rollback`,
|
|
45
|
+
`$ lps ${resource} rollback --list`,
|
|
46
|
+
`$ lps ${resource} rollback --to 1732000000000`,
|
|
47
|
+
`$ lps ${resource} rollback --dry-run`,
|
|
48
|
+
];
|
|
49
|
+
static flags = {
|
|
50
|
+
...LoopressCommand.dryRunFlag,
|
|
51
|
+
...LoopressCommand.yesFlag,
|
|
52
|
+
list: Flags.boolean({ description: 'List available snapshots instead of rolling back' }),
|
|
53
|
+
to: Flags.string({ description: 'Roll back to this snapshot id instead of the most recent one' }),
|
|
54
|
+
};
|
|
55
|
+
async run() {
|
|
56
|
+
const { args, flags } = await this.parse(ResourceRollback);
|
|
57
|
+
const provider = getResourceStateProvider(resource);
|
|
58
|
+
const rootDir = resolve(process.cwd(), this.rootDir);
|
|
59
|
+
if (flags.list) {
|
|
60
|
+
const snapshots = await listSnapshots(rootDir, resource, this.siteConfig.name);
|
|
61
|
+
this.renderList(snapshots);
|
|
62
|
+
return { snapshots, status: 'listed' };
|
|
63
|
+
}
|
|
64
|
+
const dir = resolveResourceDir(provider.dirKind, this.localConfig, args.path);
|
|
65
|
+
let snapshot;
|
|
66
|
+
try {
|
|
67
|
+
snapshot = await readSnapshot(rootDir, resource, this.siteConfig.name, flags.to);
|
|
68
|
+
}
|
|
69
|
+
catch (error) {
|
|
70
|
+
this.error(error.message);
|
|
71
|
+
}
|
|
72
|
+
await guardProductionPush({
|
|
73
|
+
dryRun: this.dryRun,
|
|
74
|
+
error: (message) => this.error(message),
|
|
75
|
+
siteConfig: this.siteConfig,
|
|
76
|
+
yes: this.yes,
|
|
77
|
+
});
|
|
78
|
+
this.log(`Rolling back ${provider.title} on ${this.siteConfig.url} to the snapshot from ${snapshot.createdAt} (${snapshot.id})`);
|
|
79
|
+
const warn = (message) => {
|
|
80
|
+
this.warn(message);
|
|
81
|
+
};
|
|
82
|
+
const currentState = await provider.remote(this.wp, warn, dir);
|
|
83
|
+
// Has anything else changed the environment since the original push? Compare its current
|
|
84
|
+
// state against what that push expected to leave behind (`afterState`), not against the
|
|
85
|
+
// snapshot we're about to restore (`beforeState`), those are expected to differ, that's
|
|
86
|
+
// the whole point of rolling back.
|
|
87
|
+
const drift = compareStates(recordToState(snapshot.afterState), currentState, {
|
|
88
|
+
left: `expected (right after the ${snapshot.createdAt} push)`,
|
|
89
|
+
right: 'current',
|
|
90
|
+
});
|
|
91
|
+
const driftSummary = isEmptyDiff(drift) ? undefined : summarize(drift);
|
|
92
|
+
if (driftSummary) {
|
|
93
|
+
this.renderDiff(`${provider.title} has changed on ${this.siteConfig.url} since that push:`, drift);
|
|
94
|
+
await this.confirmDespiteDrift();
|
|
95
|
+
}
|
|
96
|
+
const restoreState = recordToState(snapshot.beforeState);
|
|
97
|
+
const restoreDiff = compareStates(currentState, restoreState, { left: 'current', right: `snapshot ${snapshot.id}` });
|
|
98
|
+
if (isEmptyDiff(restoreDiff)) {
|
|
99
|
+
this.log('Nothing to restore, the environment already matches this snapshot.');
|
|
100
|
+
return { drift: driftSummary, id: snapshot.id, restored: summarize(restoreDiff), status: 'no-op' };
|
|
101
|
+
}
|
|
102
|
+
this.renderDiff(`This would restore ${provider.title} to:`, restoreDiff);
|
|
103
|
+
// `<resource> push` only creates and updates, it never deletes (see each resource's own
|
|
104
|
+
// push command, e.g. option push's "upsert only, never deletes an untracked option"):
|
|
105
|
+
// an item present now but absent from the snapshot stays present after "restoring" it.
|
|
106
|
+
if (restoreDiff.removed.length > 0) {
|
|
107
|
+
this.warn(`${pluralize(restoreDiff.removed.length, 'item')} present now but not in this snapshot will NOT be removed ` +
|
|
108
|
+
`(${restoreDiff.removed.join(', ')}): restoring only creates and updates, the same as \`lps ${resource} push\`. Remove ${restoreDiff.removed.length === 1 ? 'it' : 'them'} by hand if that's part of undoing the original push.`);
|
|
109
|
+
}
|
|
110
|
+
if (this.dryRun) {
|
|
111
|
+
return { drift: driftSummary, id: snapshot.id, restored: summarize(restoreDiff), status: 'dry-run' };
|
|
112
|
+
}
|
|
113
|
+
// Only the items that actually need restoring, not the whole snapshot: an item already
|
|
114
|
+
// matching `beforeState` needs no push, and re-pushing it anyway would put every other
|
|
115
|
+
// item this resource ever had at push time (however unrelated to what actually drifted)
|
|
116
|
+
// through the delegated push's own validation, where a single unrelated failure would
|
|
117
|
+
// fail the whole rollback.
|
|
118
|
+
const idsToRestore = new Set([...restoreDiff.added, ...restoreDiff.changed.map((change) => change.id)]);
|
|
119
|
+
const stateToRestore = Object.fromEntries(Object.entries(snapshot.beforeState).filter(([id]) => idsToRestore.has(id)));
|
|
120
|
+
const tmpDir = await mkdtemp(join(tmpdir(), `loopress-rollback-${resource}-`));
|
|
121
|
+
try {
|
|
122
|
+
await materializeSnapshot(resource, stateToRestore, tmpDir);
|
|
123
|
+
// tmpDir is positional (the same [PATH] arg every resource push command takes, see e.g.
|
|
124
|
+
// commands/snippet/push.ts's `static args`), not a --path flag. --env/--yes: the
|
|
125
|
+
// production guard and the drift confirmation above already covered what the delegated
|
|
126
|
+
// push's own guard would ask again, same reasoning as the top-level `lps push`
|
|
127
|
+
// delegating to each resource's push command in commands/push.ts.
|
|
128
|
+
await this.config.runCommand(`${resource}:push`, [tmpDir, '--env', this.siteConfig.name, '--yes']);
|
|
129
|
+
}
|
|
130
|
+
finally {
|
|
131
|
+
// A cleanup failure here must never hide a real error from the push above: warn about
|
|
132
|
+
// it and let the original error (if any) keep propagating instead of being replaced.
|
|
133
|
+
await rm(tmpDir, { force: true, recursive: true }).catch((error) => {
|
|
134
|
+
this.warn(`Could not remove the temporary directory "${tmpDir}": ${error.message}`);
|
|
135
|
+
});
|
|
136
|
+
}
|
|
137
|
+
this.log(`Rolled back ${provider.title} to the snapshot from ${snapshot.createdAt}.`);
|
|
138
|
+
return { drift: driftSummary, id: snapshot.id, restored: summarize(restoreDiff), status: 'success' };
|
|
139
|
+
}
|
|
140
|
+
async confirmDespiteDrift() {
|
|
141
|
+
if (this.dryRun || this.yes)
|
|
142
|
+
return;
|
|
143
|
+
if (!isInteractive()) {
|
|
144
|
+
this.error('The environment has changed since this snapshot was taken. Re-run with --yes to roll back anyway (overwriting those later changes).');
|
|
145
|
+
}
|
|
146
|
+
const proceed = await confirm({
|
|
147
|
+
default: false,
|
|
148
|
+
message: 'The environment has changed since this snapshot was taken. Roll back anyway, overwriting those later changes?',
|
|
149
|
+
});
|
|
150
|
+
if (!proceed)
|
|
151
|
+
this.error('Aborted.');
|
|
152
|
+
}
|
|
153
|
+
renderDiff(heading, diff) {
|
|
154
|
+
if (this.jsonEnabled())
|
|
155
|
+
return;
|
|
156
|
+
this.log(c('bold', heading));
|
|
157
|
+
for (const id of diff.added)
|
|
158
|
+
this.log(c('green', ` + ${id}`));
|
|
159
|
+
for (const id of diff.removed)
|
|
160
|
+
this.log(c('red', ` - ${id}`));
|
|
161
|
+
for (const change of diff.changed) {
|
|
162
|
+
this.log(c('yellow', ` ~ ${change.id}`));
|
|
163
|
+
this.log(indentPatch(change.patch));
|
|
164
|
+
}
|
|
165
|
+
}
|
|
166
|
+
renderList(snapshots) {
|
|
167
|
+
if (this.jsonEnabled())
|
|
168
|
+
return;
|
|
169
|
+
if (snapshots.length === 0) {
|
|
170
|
+
this.log(`No snapshots found for "${resource}" on "${this.siteConfig.name}". A snapshot is written automatically by \`lps ${resource} push\`.`);
|
|
171
|
+
return;
|
|
172
|
+
}
|
|
173
|
+
this.log(`Snapshots for "${resource}" on "${this.siteConfig.name}" (most recent first):`);
|
|
174
|
+
for (const snapshot of snapshots) {
|
|
175
|
+
this.log(` ${snapshot.id} ${snapshot.createdAt}`);
|
|
176
|
+
}
|
|
177
|
+
}
|
|
178
|
+
}
|
|
179
|
+
return ResourceRollback;
|
|
180
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export declare function materializeSnapshot(resource: string, state: Record<string, unknown>, dir: string): Promise<void>;
|
|
@@ -0,0 +1,121 @@
|
|
|
1
|
+
import { mkdir, writeFile } from 'node:fs/promises';
|
|
2
|
+
import { dirname, join } from 'node:path';
|
|
3
|
+
import { getAcfKey } from '../utils/acf-format.js';
|
|
4
|
+
import { getFormId, getFormTitle } from '../utils/form-format.js';
|
|
5
|
+
import { getMenuSlug } from '../utils/menu-format.js';
|
|
6
|
+
import { defaultReadonlyFor, optionFileName } from '../utils/option-format.js';
|
|
7
|
+
import { redirectFileBase } from '../utils/seo-format.js';
|
|
8
|
+
import { buildMetaFile, buildSnippetFile } from '../utils/snippet-format.js';
|
|
9
|
+
import { themeStylesFileName } from '../utils/theme-styles-format.js';
|
|
10
|
+
import { toSlug } from '../utils/to-slug.js';
|
|
11
|
+
const SNIPPET_EXTENSIONS = { css: 'css', html: 'html', js: 'js', php: 'php', text: 'txt' };
|
|
12
|
+
async function writeJson(path, value) {
|
|
13
|
+
await mkdir(dirname(path), { recursive: true });
|
|
14
|
+
await writeFile(path, JSON.stringify(value, null, 2) + '\n');
|
|
15
|
+
}
|
|
16
|
+
// id: the snapshot's map key, a numeric WordPress id (snapshots only ever come from
|
|
17
|
+
// `provider.remote()`, which never produces the "local:<file>" placeholder ids a not-yet-pushed
|
|
18
|
+
// local file gets, see resource-state.ts). value: the canonical snippet fields resource-state.ts
|
|
19
|
+
// compares (no `id`, no `description`, `code` with its `<?php` tag already stripped), exactly
|
|
20
|
+
// what `normalizeSnippet` + `stripPhpOpeningTag` produce, so it plugs straight into the same
|
|
21
|
+
// `buildSnippetFile`/`buildMetaFile` pair `snippet pull` uses to write files.
|
|
22
|
+
async function snippet(id, value, dir) {
|
|
23
|
+
const fields = value;
|
|
24
|
+
const normalized = { ...fields, description: '', id: Number(id) };
|
|
25
|
+
const ext = SNIPPET_EXTENSIONS[normalized.type];
|
|
26
|
+
const base = `${normalized.id}-${toSlug(normalized.name)}`;
|
|
27
|
+
await mkdir(dir, { recursive: true });
|
|
28
|
+
await writeFile(join(dir, `${base}.${ext}`), buildSnippetFile(normalized));
|
|
29
|
+
await writeFile(join(dir, `${base}.json`), buildMetaFile(normalized));
|
|
30
|
+
}
|
|
31
|
+
// value is the whole form JSON (minus `modified`/`modified_gmt`), the same shape `form pull`
|
|
32
|
+
// writes verbatim; `form push` re-sends it as-is.
|
|
33
|
+
async function form(id, value, dir) {
|
|
34
|
+
const data = value;
|
|
35
|
+
const formId = getFormId(data) ?? Number(id);
|
|
36
|
+
const slug = toSlug(getFormTitle(data), 'untitled');
|
|
37
|
+
await writeJson(join(dir, `${formId}-${slug}.json`), data);
|
|
38
|
+
}
|
|
39
|
+
// id is `<type>/<key>` (see resource-state.ts's acfProvider); value is the whole ACF export
|
|
40
|
+
// object (minus `modified`), already carrying its own `key`.
|
|
41
|
+
async function acf(id, value, dir) {
|
|
42
|
+
const separator = id.indexOf('/');
|
|
43
|
+
const type = id.slice(0, separator);
|
|
44
|
+
const data = value;
|
|
45
|
+
const key = getAcfKey(data) ?? id.slice(separator + 1);
|
|
46
|
+
await writeJson(join(dir, type, `${key}.json`), data);
|
|
47
|
+
}
|
|
48
|
+
// api/hook: id is the filename (possibly nested, `/`-joined); value is the raw PHP file text.
|
|
49
|
+
async function phpFile(id, value, dir) {
|
|
50
|
+
const filePath = join(dir, `${id}.php`);
|
|
51
|
+
await mkdir(dirname(filePath), { recursive: true });
|
|
52
|
+
await writeFile(filePath, String(value));
|
|
53
|
+
}
|
|
54
|
+
// ids are 'settings', `post-meta/<postType>/<slug>`, or `redirects/<id>` (see resource-state.ts's
|
|
55
|
+
// seoProvider). Each value round-trips to the exact file `seo pull` would have written.
|
|
56
|
+
async function seo(id, value, dir) {
|
|
57
|
+
if (id === 'settings') {
|
|
58
|
+
await writeJson(join(dir, 'settings.json'), value);
|
|
59
|
+
return;
|
|
60
|
+
}
|
|
61
|
+
if (id.startsWith('post-meta/')) {
|
|
62
|
+
const rest = id.slice('post-meta/'.length);
|
|
63
|
+
const separator = rest.indexOf('/');
|
|
64
|
+
const postType = rest.slice(0, separator);
|
|
65
|
+
const slug = rest.slice(separator + 1);
|
|
66
|
+
await writeJson(join(dir, 'post-meta', postType, `${slug}.json`), value);
|
|
67
|
+
return;
|
|
68
|
+
}
|
|
69
|
+
if (id.startsWith('redirects/')) {
|
|
70
|
+
const redirect = value;
|
|
71
|
+
await writeJson(join(dir, 'redirects', `${redirectFileBase(redirect)}.json`), redirect);
|
|
72
|
+
return;
|
|
73
|
+
}
|
|
74
|
+
throw new Error(`Unrecognized SEO snapshot id "${id}"`);
|
|
75
|
+
}
|
|
76
|
+
// ids are 'menu-locations' or `menu/<slug>` (see resource-state.ts's menuProvider).
|
|
77
|
+
async function menu(id, value, dir) {
|
|
78
|
+
if (id === 'menu-locations') {
|
|
79
|
+
await writeJson(join(dir, 'menu-locations.json'), value);
|
|
80
|
+
return;
|
|
81
|
+
}
|
|
82
|
+
const data = value;
|
|
83
|
+
const slug = getMenuSlug(data) ?? id.slice('menu/'.length);
|
|
84
|
+
await writeJson(join(dir, `${slug}.json`), data);
|
|
85
|
+
}
|
|
86
|
+
// `value` is `{autoload, value}` (resource-state.ts's optionsProvider deliberately drops
|
|
87
|
+
// `readonly`: it's a local policy flag with no WordPress counterpart). Restoring it as writable
|
|
88
|
+
// by default would silently lift that protection for a rollback that happens to include a
|
|
89
|
+
// dangerous option (siteurl, home, ...); defaulting to the same policy `option add` itself uses
|
|
90
|
+
// keeps a rolled-back option exactly as protected as a freshly tracked one, never less.
|
|
91
|
+
async function option(id, value, dir) {
|
|
92
|
+
const { autoload, value: optionValue } = value;
|
|
93
|
+
const local = { autoload, name: id, readonly: defaultReadonlyFor(id), value: optionValue };
|
|
94
|
+
await writeJson(join(dir, optionFileName(id)), local);
|
|
95
|
+
}
|
|
96
|
+
// id is the stylesheet slug; value is `{settings, styles}` (canonicalGlobalStyles' shape).
|
|
97
|
+
async function themeStyles(id, value, dir) {
|
|
98
|
+
await writeJson(join(dir, themeStylesFileName(id)), value);
|
|
99
|
+
}
|
|
100
|
+
const MATERIALIZERS = {
|
|
101
|
+
acf,
|
|
102
|
+
api: phpFile,
|
|
103
|
+
form,
|
|
104
|
+
hook: phpFile,
|
|
105
|
+
menu,
|
|
106
|
+
option,
|
|
107
|
+
seo,
|
|
108
|
+
snippet,
|
|
109
|
+
'theme-styles': themeStyles,
|
|
110
|
+
};
|
|
111
|
+
// Writes a snapshot's state back to `dir` in the exact on-disk layout `<resource> pull` would
|
|
112
|
+
// have produced, so the existing `<resource> push` command can push it unmodified: a rollback
|
|
113
|
+
// is push() applied to an archived state instead of the current local files.
|
|
114
|
+
export async function materializeSnapshot(resource, state, dir) {
|
|
115
|
+
const materialize = MATERIALIZERS[resource];
|
|
116
|
+
if (!materialize)
|
|
117
|
+
throw new Error(`Rollback is not supported for "${resource}".`);
|
|
118
|
+
await mkdir(dir, { recursive: true });
|
|
119
|
+
for (const [id, value] of Object.entries(state))
|
|
120
|
+
await materialize(id, value, dir);
|
|
121
|
+
}
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
import { type ResourceState } from './diff-state.js';
|
|
2
|
+
export declare const SNAPSHOT_RETENTION = 10;
|
|
3
|
+
export type Snapshot = {
|
|
4
|
+
afterState: Record<string, unknown>;
|
|
5
|
+
beforeState: Record<string, unknown>;
|
|
6
|
+
createdAt: string;
|
|
7
|
+
environment: string;
|
|
8
|
+
resource: string;
|
|
9
|
+
};
|
|
10
|
+
export type SnapshotSummary = {
|
|
11
|
+
createdAt: string;
|
|
12
|
+
environment: string;
|
|
13
|
+
id: string;
|
|
14
|
+
resource: string;
|
|
15
|
+
};
|
|
16
|
+
export declare function stateToRecord(state: ResourceState): Record<string, unknown>;
|
|
17
|
+
export declare function recordToState(record: Record<string, unknown>): ResourceState;
|
|
18
|
+
export declare function writeSnapshot(options: {
|
|
19
|
+
afterState: ResourceState;
|
|
20
|
+
beforeState: ResourceState;
|
|
21
|
+
environment: string;
|
|
22
|
+
resource: string;
|
|
23
|
+
retain?: number;
|
|
24
|
+
rootDir: string;
|
|
25
|
+
}): Promise<string>;
|
|
26
|
+
export declare function listSnapshots(rootDir: string, resource: string, environment: string): Promise<SnapshotSummary[]>;
|
|
27
|
+
export declare function readSnapshot(rootDir: string, resource: string, environment: string, id?: string): Promise<Snapshot & {
|
|
28
|
+
id: string;
|
|
29
|
+
}>;
|
|
30
|
+
export declare function pruneSnapshots(rootDir: string, resource: string, environment: string, retain: number): Promise<void>;
|
|
@@ -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
|
};
|
|
@@ -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
|
*/
|