@loopress/cli 0.33.0 → 0.34.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 +174 -161
- package/dist/commands/app/push.js +2 -1
- package/dist/commands/block/push.js +9 -4
- package/dist/commands/dev.d.ts +2 -2
- package/dist/commands/dev.js +10 -16
- package/dist/commands/diff.d.ts +6 -4
- package/dist/commands/diff.js +49 -29
- package/dist/commands/doctor.js +12 -2
- package/dist/commands/option/push.js +1 -0
- package/dist/commands/page/push.js +1 -0
- package/dist/commands/plugin/add.d.ts +1 -0
- package/dist/commands/plugin/add.js +50 -9
- package/dist/commands/plugin/push.js +39 -4
- package/dist/commands/project/config.js +0 -1
- package/dist/commands/promote.d.ts +1 -0
- package/dist/commands/promote.js +8 -4
- package/dist/commands/pull.d.ts +5 -0
- package/dist/commands/pull.js +26 -1
- package/dist/commands/push.d.ts +9 -0
- package/dist/commands/push.js +104 -39
- package/dist/commands/theme/style/diff.js +1 -1
- package/dist/commands/theme/style/push.js +1 -1
- package/dist/commands/theme/style/rollback.js +1 -1
- package/dist/commands/theme/template/push.d.ts +2 -0
- package/dist/commands/theme/template/push.js +42 -13
- package/dist/commands/validate.js +3 -0
- package/dist/lib/base.d.ts +6 -0
- package/dist/lib/base.js +33 -1
- package/dist/lib/declared-plugins.d.ts +9 -0
- package/dist/lib/declared-plugins.js +21 -0
- package/dist/lib/declared-type-command.js +3 -0
- package/dist/lib/dev-watch.d.ts +1 -1
- package/dist/lib/dev-watch.js +11 -13
- package/dist/lib/diff-command.d.ts +17 -0
- package/dist/lib/diff-command.js +125 -9
- package/dist/lib/environment-urls.d.ts +6 -0
- package/dist/lib/environment-urls.js +59 -0
- package/dist/lib/exclude.d.ts +16 -0
- package/dist/lib/exclude.js +102 -0
- package/dist/lib/load-snippets.js +2 -1
- package/dist/lib/php-files-command.js +5 -3
- package/dist/lib/push-command.d.ts +4 -0
- package/dist/lib/push-command.js +38 -2
- package/dist/lib/readdir-tolerant.js +6 -3
- package/dist/lib/resource-selection.d.ts +9 -0
- package/dist/lib/resource-selection.js +49 -0
- package/dist/lib/resource-state.d.ts +2 -0
- package/dist/lib/resource-state.js +25 -4
- package/dist/lib/rollback-command.js +1 -1
- package/dist/lib/rollback-materialize.js +1 -1
- package/dist/lib/validate-local.d.ts +1 -0
- package/dist/lib/validate-local.js +67 -8
- package/dist/lib/wp-client.d.ts +1 -0
- package/dist/lib/wp-client.js +6 -0
- package/dist/types/project-config.generated.d.ts +15 -4
- package/dist/utils/block-format.d.ts +2 -0
- package/dist/utils/block-format.js +18 -2
- package/dist/utils/page-format.js +2 -1
- package/dist/utils/resource-dirs.js +4 -4
- package/oclif.manifest.json +2275 -1394
- package/package.json +7 -7
- package/schemas/project-config.schema.json +21 -4
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
import { Flags } from '@oclif/core';
|
|
2
|
+
export declare const RESOURCE_IDS: readonly ["plugin", "composer", "cpt", "taxonomy", "meta", "role", "acf", "api", "hook", "block", "form", "pattern", "template", "part", "css", "theme-style", "page", "menu", "option", "snippet"];
|
|
3
|
+
export type ResourceId = (typeof RESOURCE_IDS)[number];
|
|
4
|
+
export declare function selectionFlags(noun: string, valid: readonly string[]): {
|
|
5
|
+
only: ReturnType<typeof Flags.string>;
|
|
6
|
+
skip: ReturnType<typeof Flags.string>;
|
|
7
|
+
};
|
|
8
|
+
export declare function parseSelectionFlag(raw: string[] | undefined, flagName: 'only' | 'skip', valid: readonly string[], fail: (message: string) => never): string[];
|
|
9
|
+
export declare function selectResources<T extends string>(all: readonly T[], only?: readonly string[], skip?: readonly string[]): T[];
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
import { Flags } from '@oclif/core';
|
|
2
|
+
// One singular id per resource, shared by every command taking `--only` / `--skip`
|
|
3
|
+
// (`lps dev`, `lps diff`, `lps push`). Each command accepts the subset it can act on.
|
|
4
|
+
export const RESOURCE_IDS = [
|
|
5
|
+
'plugin',
|
|
6
|
+
'composer',
|
|
7
|
+
'cpt',
|
|
8
|
+
'taxonomy',
|
|
9
|
+
'meta',
|
|
10
|
+
'role',
|
|
11
|
+
'acf',
|
|
12
|
+
'api',
|
|
13
|
+
'hook',
|
|
14
|
+
'block',
|
|
15
|
+
'form',
|
|
16
|
+
'pattern',
|
|
17
|
+
'template',
|
|
18
|
+
'part',
|
|
19
|
+
'css',
|
|
20
|
+
'theme-style',
|
|
21
|
+
'page',
|
|
22
|
+
'menu',
|
|
23
|
+
'option',
|
|
24
|
+
'snippet',
|
|
25
|
+
];
|
|
26
|
+
// Comma-separated or repeated: `--only a,b`, `--only a --only b`, or both.
|
|
27
|
+
export function selectionFlags(noun, valid) {
|
|
28
|
+
return {
|
|
29
|
+
only: Flags.string({ description: `Only ${noun} these resources (comma-separated or repeated): ${valid.join(', ')}`, multiple: true }),
|
|
30
|
+
skip: Flags.string({ description: `${noun.charAt(0).toUpperCase()}${noun.slice(1)} every resource except these (comma-separated or repeated)`, multiple: true }),
|
|
31
|
+
};
|
|
32
|
+
}
|
|
33
|
+
// Splits the raw flag values and rejects any id outside `valid`.
|
|
34
|
+
export function parseSelectionFlag(raw, flagName, valid, fail) {
|
|
35
|
+
return (raw ?? [])
|
|
36
|
+
.flatMap((value) => value.split(','))
|
|
37
|
+
.map((value) => value.trim())
|
|
38
|
+
.filter(Boolean)
|
|
39
|
+
.map((value) => {
|
|
40
|
+
if (!valid.includes(value))
|
|
41
|
+
fail(`Unknown resource "${value}" in --${flagName}. Valid resources: ${valid.join(', ')}`);
|
|
42
|
+
return value;
|
|
43
|
+
});
|
|
44
|
+
}
|
|
45
|
+
// `only` narrows `all`, `skip` then removes from whatever that is, so a combined
|
|
46
|
+
// `--only=a,b --skip=b` reads as: a, b, then drop b. The result always follows `all`'s order.
|
|
47
|
+
export function selectResources(all, only = [], skip = []) {
|
|
48
|
+
return all.filter((id) => (only.length === 0 || only.includes(id)) && !skip.includes(id));
|
|
49
|
+
}
|
|
@@ -3,6 +3,8 @@ import { type ResourceState } from './diff-state.js';
|
|
|
3
3
|
import { type WpClient } from './wp-client.js';
|
|
4
4
|
export type ResourceStateProvider = {
|
|
5
5
|
dirKind: ResourceDirKind;
|
|
6
|
+
fileExtension?: string;
|
|
7
|
+
fileName?(id: string): string | undefined;
|
|
6
8
|
local(dir: string, onWarn: (message: string) => void, wp?: WpClient): Promise<ResourceState>;
|
|
7
9
|
remote(wp: WpClient, onWarn: (message: string) => void, dir: string): Promise<ResourceState>;
|
|
8
10
|
resource: string;
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { readFile } from 'node:fs/promises';
|
|
2
2
|
import { basename, join, relative, sep } from 'node:path';
|
|
3
3
|
import { ACF_OBJECT_TYPES, acfEndpoint, acfNormalizeEndpoint, getAcfKey } from '../utils/acf-format.js';
|
|
4
|
-
import { BLOCKS_ENDPOINT, formatBlockProblems, readLocalBlocks } from '../utils/block-format.js';
|
|
4
|
+
import { blockFilesToPush, BLOCKS_ENDPOINT, excludedBlockFiles, formatBlockProblems, readLocalBlocks } from '../utils/block-format.js';
|
|
5
5
|
import { CPT_SPEC, fileExtension, META_SPEC, parseDeclaredFile, PATTERN_SPEC, ROLE_SPEC, TAXONOMY_SPEC, } from '../utils/declared-type-format.js';
|
|
6
6
|
import { FORM_ENDPOINT, getFormId } from '../utils/form-format.js';
|
|
7
7
|
import { getMenuSlug, MENU_ENDPOINT, MENU_LOCATIONS_ENDPOINT } from '../utils/menu-format.js';
|
|
@@ -91,6 +91,7 @@ const snippetProvider = {
|
|
|
91
91
|
// stance as form-format.ts.
|
|
92
92
|
const formProvider = {
|
|
93
93
|
dirKind: 'form',
|
|
94
|
+
fileExtension: '.json',
|
|
94
95
|
async local(dir, onWarn) {
|
|
95
96
|
const entries = await loadFiles(dir, {
|
|
96
97
|
extension: '.json',
|
|
@@ -137,6 +138,7 @@ async function normalizeAcf(wp, type, objects) {
|
|
|
137
138
|
}
|
|
138
139
|
const acfProvider = {
|
|
139
140
|
dirKind: 'acf',
|
|
141
|
+
fileExtension: '.json',
|
|
140
142
|
async local(dir, onWarn, wp) {
|
|
141
143
|
const state = new Map();
|
|
142
144
|
for (const type of ACF_OBJECT_TYPES) {
|
|
@@ -182,6 +184,7 @@ function filesToState(files) {
|
|
|
182
184
|
function phpFilesProvider(spec) {
|
|
183
185
|
return {
|
|
184
186
|
dirKind: spec.dirKind,
|
|
187
|
+
fileExtension: '.php',
|
|
185
188
|
async local(dir, onWarn) {
|
|
186
189
|
return filesToState(await loadFiles(dir, {
|
|
187
190
|
extension: '.php',
|
|
@@ -217,11 +220,17 @@ const hookProvider = phpFilesProvider({
|
|
|
217
220
|
// resource, the same stance as pages: pushing would stop on it anyway.
|
|
218
221
|
const blockProvider = {
|
|
219
222
|
dirKind: 'block',
|
|
220
|
-
|
|
223
|
+
// A block is a folder: only the bare `blocks/<slug>` path names a site-only block.
|
|
224
|
+
fileExtension: '',
|
|
225
|
+
// A file the environment excludes inside a block folder keeps the site's copy, as the push
|
|
226
|
+
// sends it back: without `wp`, that file is just left out.
|
|
227
|
+
async local(dir, _onWarn, wp) {
|
|
221
228
|
const { blocks, problems } = await readLocalBlocks(dir);
|
|
222
229
|
if (problems.length > 0)
|
|
223
230
|
throw new Error(formatBlockProblems(problems));
|
|
224
|
-
|
|
231
|
+
const withExcluded = wp !== undefined && blocks.some((block) => excludedBlockFiles(dir, block.slug).length > 0);
|
|
232
|
+
const site = withExcluded ? new Map((await wp.get(BLOCKS_ENDPOINT)).map((block) => [block.slug, block.files])) : undefined;
|
|
233
|
+
return new Map(blocks.map((block) => [block.slug, site ? blockFilesToPush(dir, block, site.get(block.slug)) : block.files]));
|
|
225
234
|
},
|
|
226
235
|
async remote(wp) {
|
|
227
236
|
const blocks = await wp.get(BLOCKS_ENDPOINT);
|
|
@@ -271,6 +280,7 @@ async function childThemeState(wp, kind) {
|
|
|
271
280
|
}
|
|
272
281
|
const templateProvider = {
|
|
273
282
|
dirKind: 'template',
|
|
283
|
+
fileExtension: '.html',
|
|
274
284
|
async local(dir) {
|
|
275
285
|
const { files, problems } = await readLocalHtmlFiles(dir, parseTemplateFile, TEMPLATE_SLUG_RULE);
|
|
276
286
|
if (problems.length > 0)
|
|
@@ -283,6 +293,7 @@ const templateProvider = {
|
|
|
283
293
|
};
|
|
284
294
|
const partProvider = {
|
|
285
295
|
dirKind: 'part',
|
|
296
|
+
fileExtension: '.html',
|
|
286
297
|
async local(dir) {
|
|
287
298
|
const { files, problems } = await readLocalHtmlFiles(dir, parsePartFile, TEMPLATE_SLUG_RULE);
|
|
288
299
|
if (problems.length > 0)
|
|
@@ -311,6 +322,12 @@ function canonicalMenu(menu) {
|
|
|
311
322
|
}
|
|
312
323
|
const menuProvider = {
|
|
313
324
|
dirKind: 'menu',
|
|
325
|
+
fileExtension: '.json',
|
|
326
|
+
fileName(id) {
|
|
327
|
+
if (id === MENU_LOCATIONS_FILE)
|
|
328
|
+
return `${id}.json`;
|
|
329
|
+
return id.startsWith('menu/') ? `${id.slice('menu/'.length)}.json` : undefined;
|
|
330
|
+
},
|
|
314
331
|
async local(dir, onWarn) {
|
|
315
332
|
const state = new Map();
|
|
316
333
|
const entries = await loadFiles(dir, {
|
|
@@ -348,6 +365,7 @@ const menuProvider = {
|
|
|
348
365
|
function declaredTypeProvider(spec, title) {
|
|
349
366
|
return {
|
|
350
367
|
dirKind: spec.dirKind,
|
|
368
|
+
fileExtension: fileExtension(spec),
|
|
351
369
|
async local(dir, onWarn) {
|
|
352
370
|
const entries = await loadFiles(dir, {
|
|
353
371
|
extension: fileExtension(spec),
|
|
@@ -377,6 +395,7 @@ const patternProvider = declaredTypeProvider(PATTERN_SPEC, 'Patterns');
|
|
|
377
395
|
// sides so it can never itself show up as drift.
|
|
378
396
|
const optionsProvider = {
|
|
379
397
|
dirKind: 'options',
|
|
398
|
+
fileExtension: '.json',
|
|
380
399
|
async local(dir, onWarn) {
|
|
381
400
|
const entries = await loadFiles(dir, {
|
|
382
401
|
extension: '.json',
|
|
@@ -422,6 +441,8 @@ const optionsProvider = {
|
|
|
422
441
|
// individually rather than colliding on a single key.
|
|
423
442
|
const themeStylesProvider = {
|
|
424
443
|
dirKind: 'themeStyles',
|
|
444
|
+
fileExtension: '.json',
|
|
445
|
+
fileName: (stylesheet) => `${stylesheet}${THEME_STYLES_FILE_SUFFIX}`,
|
|
425
446
|
async local(dir, onWarn) {
|
|
426
447
|
const entries = await loadFiles(dir, {
|
|
427
448
|
extension: '.json',
|
|
@@ -445,7 +466,7 @@ const themeStylesProvider = {
|
|
|
445
466
|
const item = await wp.get(globalStylesEndpoint(id));
|
|
446
467
|
return new Map([[stylesheet, canonicalGlobalStyles(item)]]);
|
|
447
468
|
},
|
|
448
|
-
resource: 'theme-
|
|
469
|
+
resource: 'theme-style',
|
|
449
470
|
title: 'Theme styles',
|
|
450
471
|
};
|
|
451
472
|
export const RESOURCE_STATE_PROVIDERS = [
|
|
@@ -34,7 +34,7 @@ function indentPatch(patch) {
|
|
|
34
34
|
// anything, this runs the equivalent of `lps <resource> diff` between the environment's current
|
|
35
35
|
// state and the state the original push expected to leave behind, and requires explicit
|
|
36
36
|
// confirmation (the same interactive/`--yes` shape as guardProductionPush) when they differ.
|
|
37
|
-
// `command` is the CLI topic when it isn't the resource key (e.g. `theme style` for theme-
|
|
37
|
+
// `command` is the CLI topic when it isn't the resource key (e.g. `theme style` for theme-style).
|
|
38
38
|
export function resourceRollbackCommand(resource, options) {
|
|
39
39
|
const command = options.command ?? resource;
|
|
40
40
|
class ResourceRollback extends LoopressCommand {
|
|
@@ -115,7 +115,7 @@ const MATERIALIZERS = {
|
|
|
115
115
|
role: declaredType(ROLE_SPEC),
|
|
116
116
|
snippet,
|
|
117
117
|
taxonomy: declaredType(TAXONOMY_SPEC),
|
|
118
|
-
'theme-
|
|
118
|
+
'theme-style': themeStyles,
|
|
119
119
|
};
|
|
120
120
|
// Writes a snapshot's state back to `dir` in the exact on-disk layout `<resource> pull` would
|
|
121
121
|
// have produced, so the existing `<resource> push` command can push it unmodified: a rollback
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { readFile } from 'node:fs/promises';
|
|
1
|
+
import { readdir, readFile } from 'node:fs/promises';
|
|
2
2
|
import { join, relative, sep } from 'node:path';
|
|
3
3
|
import { z } from 'zod';
|
|
4
4
|
import { configManager } from '../config/project-config.manager.js';
|
|
@@ -11,23 +11,24 @@ import { readLocalHtmlFiles, readLocalPages } from '../utils/page-format.js';
|
|
|
11
11
|
import { RESOURCE_DIR_DEFAULTS } from '../utils/resource-dirs.js';
|
|
12
12
|
import { parseType } from '../utils/snippet-format.js';
|
|
13
13
|
import { readLocalTemplates } from '../utils/template-format.js';
|
|
14
|
+
import { globToRegExp, matchesAny } from './exclude.js';
|
|
14
15
|
import { loadSnippets } from './load-snippets.js';
|
|
15
16
|
import { errorCode, errorMessage, isRecord } from './narrow.js';
|
|
16
17
|
import { API_FILES_RESOURCE, HOOK_FILES_RESOURCE, phpFileProblems } from './php-files-command.js';
|
|
17
18
|
import { readdirTolerant } from './readdir-tolerant.js';
|
|
18
19
|
// Directories holding one JSON document per resource. Snippets (code file plus JSON sidecar)
|
|
19
20
|
// and api routes (`.php`, not JSON) are checked separately, options too (a stricter shape).
|
|
20
|
-
const JSON_RESOURCE_DIRS = ['acfDir', 'formDir', 'menuDir', '
|
|
21
|
+
const JSON_RESOURCE_DIRS = ['acfDir', 'formDir', 'menuDir', 'themeStyleDir'];
|
|
21
22
|
const DEFAULT_DIR = {
|
|
22
23
|
acfDir: 'acf',
|
|
23
24
|
apiDir: 'api',
|
|
24
25
|
blockDir: RESOURCE_DIR_DEFAULTS.block,
|
|
25
26
|
cptDir: 'cpt',
|
|
26
27
|
formDir: 'forms',
|
|
27
|
-
|
|
28
|
+
hookDir: 'hooks',
|
|
28
29
|
menuDir: 'menus',
|
|
29
30
|
metaDir: 'meta',
|
|
30
|
-
|
|
31
|
+
optionDir: 'options',
|
|
31
32
|
pageDir: 'pages',
|
|
32
33
|
partDir: RESOURCE_DIR_DEFAULTS.part,
|
|
33
34
|
patternDir: RESOURCE_DIR_DEFAULTS.pattern,
|
|
@@ -35,7 +36,7 @@ const DEFAULT_DIR = {
|
|
|
35
36
|
snippetsDir: 'snippets',
|
|
36
37
|
taxonomyDir: 'taxonomies',
|
|
37
38
|
templateDir: RESOURCE_DIR_DEFAULTS.template,
|
|
38
|
-
|
|
39
|
+
themeStyleDir: 'theme',
|
|
39
40
|
};
|
|
40
41
|
// Inspects the local tracked files under `cwd` without contacting WordPress: JSON files parse
|
|
41
42
|
// and have the right shape, snippet sidecars use a known type, `loopress.json` points at a
|
|
@@ -58,7 +59,7 @@ export async function validateLocal(cwd) {
|
|
|
58
59
|
}
|
|
59
60
|
// Same reader `option push` uses: a file missing "name", "autoload" or "value" is skipped
|
|
60
61
|
// there with a warning, so it's a problem here.
|
|
61
|
-
checked += await checkJsonDir(resolve('
|
|
62
|
+
checked += await checkJsonDir(resolve('optionDir'), problems, parseLocalOption);
|
|
62
63
|
checked += await checkJsonDir(resolve('cptDir'), problems, declaredFileChecker(CPT_SPEC));
|
|
63
64
|
checked += await checkJsonDir(resolve('taxonomyDir'), problems, declaredFileChecker(TAXONOMY_SPEC));
|
|
64
65
|
checked += await checkJsonDir(resolve('metaDir'), problems, declaredFileChecker(META_SPEC));
|
|
@@ -66,7 +67,7 @@ export async function validateLocal(cwd) {
|
|
|
66
67
|
checked += await checkPatterns(resolve('patternDir'), problems);
|
|
67
68
|
checked += await checkSnippets(resolve('snippetsDir'), problems);
|
|
68
69
|
checked += await checkPhpDir(resolve('apiDir'), problems, 'API route file is empty', API_FILES_RESOURCE);
|
|
69
|
-
checked += await checkPhpDir(resolve('
|
|
70
|
+
checked += await checkPhpDir(resolve('hookDir'), problems, 'Hook file is empty', HOOK_FILES_RESOURCE);
|
|
70
71
|
const blocks = await readLocalBlocks(resolve('blockDir'));
|
|
71
72
|
checked += blocks.blocks.length;
|
|
72
73
|
problems.push(...blocks.problems);
|
|
@@ -76,7 +77,65 @@ export async function validateLocal(cwd) {
|
|
|
76
77
|
const templates = await readLocalTemplates(resolve('templateDir'), resolve('partDir'));
|
|
77
78
|
checked += templates.templates.length + templates.parts.length + templates.problems.length;
|
|
78
79
|
problems.push(...templates.problems);
|
|
79
|
-
|
|
80
|
+
const warnings = await checkExcludes(localConfig, join(cwd, rootDir), problems);
|
|
81
|
+
return { checked, problems, valid: problems.length === 0, warnings };
|
|
82
|
+
}
|
|
83
|
+
// `environments.<name>.exclude` (lib/exclude.ts): a list of globs per environment. A glob that
|
|
84
|
+
// matches no file is a warning, not a problem: likely a typo or a renamed file, so the feature it
|
|
85
|
+
// was meant to hold back would reach that environment.
|
|
86
|
+
async function checkExcludes(localConfig, root, problems) {
|
|
87
|
+
const { environments } = localConfig;
|
|
88
|
+
if (environments === undefined)
|
|
89
|
+
return [];
|
|
90
|
+
if (!isRecord(environments) || Array.isArray(environments)) {
|
|
91
|
+
problems.push({ file: 'loopress.json', message: '"environments" must be an object of environment name -> settings' });
|
|
92
|
+
return [];
|
|
93
|
+
}
|
|
94
|
+
const warnings = [];
|
|
95
|
+
let files;
|
|
96
|
+
for (const [name, settings] of Object.entries(environments)) {
|
|
97
|
+
const parsed = ENVIRONMENT_SETTINGS.safeParse(settings);
|
|
98
|
+
if (!parsed.success) {
|
|
99
|
+
problems.push({ file: 'loopress.json', message: `environments.${name} must be an object with an optional "exclude" list of glob strings` });
|
|
100
|
+
continue;
|
|
101
|
+
}
|
|
102
|
+
for (const pattern of parsed.data.exclude ?? []) {
|
|
103
|
+
files ??= await projectFiles(root);
|
|
104
|
+
const matcher = globToRegExp(pattern);
|
|
105
|
+
if (files.every((segments) => !matchesAny([matcher], segments))) {
|
|
106
|
+
warnings.push({ file: 'loopress.json', message: `environments.${name}.exclude: "${pattern}" matches no file` });
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
return warnings;
|
|
111
|
+
}
|
|
112
|
+
const ENVIRONMENT_SETTINGS = z.strictObject({ exclude: z.array(z.string()).optional() });
|
|
113
|
+
// Every file under `root` as '/'-split segments, without node_modules and .git.
|
|
114
|
+
async function projectFiles(root) {
|
|
115
|
+
const files = [];
|
|
116
|
+
const walk = async (dir) => {
|
|
117
|
+
let entries;
|
|
118
|
+
try {
|
|
119
|
+
entries = await readdir(dir, { withFileTypes: true });
|
|
120
|
+
}
|
|
121
|
+
catch (error) {
|
|
122
|
+
if (errorCode(error) === 'ENOENT')
|
|
123
|
+
return;
|
|
124
|
+
throw error;
|
|
125
|
+
}
|
|
126
|
+
for (const entry of entries) {
|
|
127
|
+
const path = join(dir, entry.name);
|
|
128
|
+
if (entry.isDirectory()) {
|
|
129
|
+
if (entry.name !== 'node_modules' && entry.name !== '.git')
|
|
130
|
+
await walk(path);
|
|
131
|
+
}
|
|
132
|
+
else {
|
|
133
|
+
files.push(relative(root, path).split(sep));
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
};
|
|
137
|
+
await walk(root);
|
|
138
|
+
return files;
|
|
80
139
|
}
|
|
81
140
|
// Same directory rules as pages (flat, .html only), then the same checks as `pattern push`.
|
|
82
141
|
async function checkPatterns(dir, problems) {
|
package/dist/lib/wp-client.d.ts
CHANGED
|
@@ -24,6 +24,7 @@ type RequestOptions = {
|
|
|
24
24
|
};
|
|
25
25
|
export declare function isNotFoundError(error: unknown): boolean;
|
|
26
26
|
export declare function isForbiddenError(error: unknown): boolean;
|
|
27
|
+
export declare function isApplicationPasswordsDisabled(error: unknown): boolean;
|
|
27
28
|
export declare function isApplicative404(error: unknown, marker: string): boolean;
|
|
28
29
|
export declare function isConflictCode(error: unknown, code: string): boolean;
|
|
29
30
|
export declare function isTimeoutError(error: unknown): boolean;
|
package/dist/lib/wp-client.js
CHANGED
|
@@ -100,6 +100,12 @@ export function isNotFoundError(error) {
|
|
|
100
100
|
export function isForbiddenError(error) {
|
|
101
101
|
return responseOf(causeOf(error)).statusCode === 403;
|
|
102
102
|
}
|
|
103
|
+
// WordPress core answers the authenticated request with this `code` when Application
|
|
104
|
+
// Passwords are turned off for the user (a security plugin, `wp_is_application_passwords_available`
|
|
105
|
+
// filter or the user's role), as opposed to `incorrect_password`/`invalid_username` for bad credentials.
|
|
106
|
+
export function isApplicationPasswordsDisabled(error) {
|
|
107
|
+
return parseBody(responseOf(causeOf(error)).body).code === 'application_passwords_disabled_for_user';
|
|
108
|
+
}
|
|
103
109
|
// A 404 a Loopress controller raises on purpose, identified by its JSON body's `error` field
|
|
104
110
|
// (e.g. `{"error": "composer.lock not found"}` from ComposerController). Narrower than
|
|
105
111
|
// isNotFoundError: a bare 404 also covers the route being absent because the plugin isn't
|
|
@@ -30,11 +30,11 @@ export interface LoopressProjectConfiguration {
|
|
|
30
30
|
/**
|
|
31
31
|
* Directory where hook files (WordPress actions, filters, and scheduled tasks) are read from and written to, relative to rootDir.
|
|
32
32
|
*/
|
|
33
|
-
|
|
33
|
+
hookDir?: string;
|
|
34
34
|
/**
|
|
35
35
|
* Directory where single-page app bundles are read from and written to, relative to rootDir. Each subdirectory is one app: a loopress.app.json plus a built dist/ folder.
|
|
36
36
|
*/
|
|
37
|
-
|
|
37
|
+
appDir?: string;
|
|
38
38
|
/**
|
|
39
39
|
* Directory where static HTML pages (one <slug>.html per WordPress page, managed by Loopress) are read from, relative to rootDir.
|
|
40
40
|
*/
|
|
@@ -58,7 +58,7 @@ export interface LoopressProjectConfiguration {
|
|
|
58
58
|
/**
|
|
59
59
|
* 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.
|
|
60
60
|
*/
|
|
61
|
-
|
|
61
|
+
optionDir?: string;
|
|
62
62
|
/**
|
|
63
63
|
* Directory where custom post types (one JSON file of register_post_type() arguments per post type, named <slug>.json) are read from and written to, relative to rootDir.
|
|
64
64
|
*/
|
|
@@ -86,7 +86,7 @@ export interface LoopressProjectConfiguration {
|
|
|
86
86
|
/**
|
|
87
87
|
* 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.
|
|
88
88
|
*/
|
|
89
|
-
|
|
89
|
+
themeStyleDir?: string;
|
|
90
90
|
/**
|
|
91
91
|
* Project identifier from the global Loopress config. When set, takes precedence over the currently active project in the global config.
|
|
92
92
|
*/
|
|
@@ -106,6 +106,17 @@ export interface LoopressProjectConfiguration {
|
|
|
106
106
|
active?: boolean;
|
|
107
107
|
};
|
|
108
108
|
};
|
|
109
|
+
/**
|
|
110
|
+
* Per-environment settings, keyed by environment name (as in `lps project config`).
|
|
111
|
+
*/
|
|
112
|
+
environments?: {
|
|
113
|
+
[k: string]: {
|
|
114
|
+
/**
|
|
115
|
+
* Globs relative to rootDir (`*`, `**`, `?`) of files this environment never receives. `lps push`, `lps diff` and `lps promote` leave them alone on it: never pushed, never pruned or mirrored away, reported as held back instead of drift. A glob matching a directory covers everything inside. `lps pull` and `lps dev` ignore it.
|
|
116
|
+
*/
|
|
117
|
+
exclude?: string[];
|
|
118
|
+
};
|
|
119
|
+
};
|
|
109
120
|
/**
|
|
110
121
|
* Keep the Loopress child theme (<parent>-loopress) active: `lps theme template push`, and so `lps push`, activates it when it isn't. Off by default, a theme switch changes the settings WordPress stores per theme.
|
|
111
122
|
*/
|
|
@@ -15,6 +15,8 @@ export declare function readLocalBlocks(dir: string): Promise<{
|
|
|
15
15
|
blocks: LocalBlock[];
|
|
16
16
|
problems: BlockProblem[];
|
|
17
17
|
}>;
|
|
18
|
+
export declare function excludedBlockFiles(dir: string, slug: string): string[];
|
|
19
|
+
export declare function blockFilesToPush(dir: string, block: LocalBlock, site: BlockFiles | undefined): BlockFiles;
|
|
18
20
|
export declare function formatBlockProblems(problems: BlockProblem[]): string;
|
|
19
21
|
export declare function assertSafeBlock(block: LocalBlock): void;
|
|
20
22
|
export declare function writeBlock(dir: string, block: LocalBlock): Promise<void>;
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { mkdir, readdir, readFile, writeFile } from 'node:fs/promises';
|
|
2
2
|
import { join } from 'node:path';
|
|
3
|
+
import { isExcluded, withoutExcluded } from '../lib/exclude.js';
|
|
3
4
|
import { errorCode, isRecord } from '../lib/narrow.js';
|
|
4
5
|
// A dynamic block: blocks/<slug>/ in WordPress's own block format, registered on the site as
|
|
5
6
|
// loopress/<slug> (the plugin's BlocksDirectory and BlockFilesController).
|
|
@@ -53,7 +54,7 @@ export function blockProblems(slug, files) {
|
|
|
53
54
|
export async function readLocalBlocks(dir) {
|
|
54
55
|
let entries;
|
|
55
56
|
try {
|
|
56
|
-
entries = await readdir(dir, { withFileTypes: true });
|
|
57
|
+
entries = withoutExcluded(dir, await readdir(dir, { withFileTypes: true }), (entry) => entry.name);
|
|
57
58
|
}
|
|
58
59
|
catch (error) {
|
|
59
60
|
if (errorCode(error) === 'ENOENT')
|
|
@@ -69,12 +70,27 @@ export async function readLocalBlocks(dir) {
|
|
|
69
70
|
if (file.isFile() && !file.name.startsWith('.'))
|
|
70
71
|
files[file.name] = await readFile(join(dir, slug, file.name), 'utf8');
|
|
71
72
|
}
|
|
73
|
+
// Checked whole, then the environment's excluded files are dropped: a push keeps the site's
|
|
74
|
+
// copy of those (see blockFilesToPush), a diff holds the block back.
|
|
72
75
|
for (const message of blockProblems(slug, files))
|
|
73
76
|
problems.push({ file: join(dir, slug), message });
|
|
74
|
-
|
|
77
|
+
const kept = new Set(withoutExcluded(join(dir, slug), Object.keys(files), (name) => name));
|
|
78
|
+
blocks.push({ files: Object.fromEntries(Object.entries(files).filter(([name]) => kept.has(name))), slug });
|
|
75
79
|
}
|
|
76
80
|
return { blocks, problems };
|
|
77
81
|
}
|
|
82
|
+
// Block files the environment excludes: never sent, so the site's copy of each one goes back in
|
|
83
|
+
// the PUT (a block is replaced as a whole).
|
|
84
|
+
export function excludedBlockFiles(dir, slug) {
|
|
85
|
+
return BLOCK_FILES.filter((name) => isExcluded(join(dir, slug, name)));
|
|
86
|
+
}
|
|
87
|
+
// What a push sends for `block`: its local files plus the site's copy of its excluded ones.
|
|
88
|
+
export function blockFilesToPush(dir, block, site) {
|
|
89
|
+
const excluded = new Set(excludedBlockFiles(dir, block.slug));
|
|
90
|
+
const kept = Object.entries(block.files).filter(([name]) => !excluded.has(name));
|
|
91
|
+
const fromSite = Object.entries(site ?? {}).filter(([name]) => excluded.has(name));
|
|
92
|
+
return Object.fromEntries([...kept, ...fromSite]);
|
|
93
|
+
}
|
|
78
94
|
export function formatBlockProblems(problems) {
|
|
79
95
|
return problems.map((problem) => `${problem.file}: ${problem.message}`).join('\n');
|
|
80
96
|
}
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { readdir, readFile } from 'node:fs/promises';
|
|
2
2
|
import { basename, extname, join } from 'node:path';
|
|
3
|
+
import { withoutExcluded } from '../lib/exclude.js';
|
|
3
4
|
import { errorCode, errorMessage, isOneOf } from '../lib/narrow.js';
|
|
4
5
|
export const PAGES_ENDPOINT = 'loopress/v1/pages';
|
|
5
6
|
// Mirrored server-side in PagesController. No leading, trailing or doubled hyphen: WordPress's
|
|
@@ -90,7 +91,7 @@ export async function readLocalPages(dir) {
|
|
|
90
91
|
export async function readLocalHtmlFiles(dir, parse, slugRule = PAGE_SLUG_RULE) {
|
|
91
92
|
let entries;
|
|
92
93
|
try {
|
|
93
|
-
entries = await readdir(dir, { withFileTypes: true });
|
|
94
|
+
entries = withoutExcluded(dir, await readdir(dir, { withFileTypes: true }), (entry) => entry.name);
|
|
94
95
|
}
|
|
95
96
|
catch (error) {
|
|
96
97
|
if (errorCode(error) === 'ENOENT')
|
|
@@ -28,16 +28,16 @@ export const RESOURCE_DIR_DEFAULTS = {
|
|
|
28
28
|
const CONFIG_KEY = {
|
|
29
29
|
acf: 'acfDir',
|
|
30
30
|
api: 'apiDir',
|
|
31
|
-
apps: '
|
|
31
|
+
apps: 'appDir',
|
|
32
32
|
block: 'blockDir',
|
|
33
33
|
blockCss: 'blockCssDir',
|
|
34
34
|
cpt: 'cptDir',
|
|
35
35
|
css: 'cssDir',
|
|
36
36
|
form: 'formDir',
|
|
37
|
-
hooks: '
|
|
37
|
+
hooks: 'hookDir',
|
|
38
38
|
menu: 'menuDir',
|
|
39
39
|
meta: 'metaDir',
|
|
40
|
-
options: '
|
|
40
|
+
options: 'optionDir',
|
|
41
41
|
page: 'pageDir',
|
|
42
42
|
part: 'partDir',
|
|
43
43
|
pattern: 'patternDir',
|
|
@@ -45,7 +45,7 @@ const CONFIG_KEY = {
|
|
|
45
45
|
snippets: 'snippetsDir',
|
|
46
46
|
taxonomy: 'taxonomyDir',
|
|
47
47
|
template: 'templateDir',
|
|
48
|
-
themeStyles: '
|
|
48
|
+
themeStyles: 'themeStyleDir',
|
|
49
49
|
};
|
|
50
50
|
// Resolves where a resource's files live: an explicit override wins, otherwise the
|
|
51
51
|
// loopress.json `<kind>Dir` setting, otherwise the built-in default, all under `rootDir`.
|