@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.
Files changed (62) hide show
  1. package/README.md +174 -161
  2. package/dist/commands/app/push.js +2 -1
  3. package/dist/commands/block/push.js +9 -4
  4. package/dist/commands/dev.d.ts +2 -2
  5. package/dist/commands/dev.js +10 -16
  6. package/dist/commands/diff.d.ts +6 -4
  7. package/dist/commands/diff.js +49 -29
  8. package/dist/commands/doctor.js +12 -2
  9. package/dist/commands/option/push.js +1 -0
  10. package/dist/commands/page/push.js +1 -0
  11. package/dist/commands/plugin/add.d.ts +1 -0
  12. package/dist/commands/plugin/add.js +50 -9
  13. package/dist/commands/plugin/push.js +39 -4
  14. package/dist/commands/project/config.js +0 -1
  15. package/dist/commands/promote.d.ts +1 -0
  16. package/dist/commands/promote.js +8 -4
  17. package/dist/commands/pull.d.ts +5 -0
  18. package/dist/commands/pull.js +26 -1
  19. package/dist/commands/push.d.ts +9 -0
  20. package/dist/commands/push.js +104 -39
  21. package/dist/commands/theme/style/diff.js +1 -1
  22. package/dist/commands/theme/style/push.js +1 -1
  23. package/dist/commands/theme/style/rollback.js +1 -1
  24. package/dist/commands/theme/template/push.d.ts +2 -0
  25. package/dist/commands/theme/template/push.js +42 -13
  26. package/dist/commands/validate.js +3 -0
  27. package/dist/lib/base.d.ts +6 -0
  28. package/dist/lib/base.js +33 -1
  29. package/dist/lib/declared-plugins.d.ts +9 -0
  30. package/dist/lib/declared-plugins.js +21 -0
  31. package/dist/lib/declared-type-command.js +3 -0
  32. package/dist/lib/dev-watch.d.ts +1 -1
  33. package/dist/lib/dev-watch.js +11 -13
  34. package/dist/lib/diff-command.d.ts +17 -0
  35. package/dist/lib/diff-command.js +125 -9
  36. package/dist/lib/environment-urls.d.ts +6 -0
  37. package/dist/lib/environment-urls.js +59 -0
  38. package/dist/lib/exclude.d.ts +16 -0
  39. package/dist/lib/exclude.js +102 -0
  40. package/dist/lib/load-snippets.js +2 -1
  41. package/dist/lib/php-files-command.js +5 -3
  42. package/dist/lib/push-command.d.ts +4 -0
  43. package/dist/lib/push-command.js +38 -2
  44. package/dist/lib/readdir-tolerant.js +6 -3
  45. package/dist/lib/resource-selection.d.ts +9 -0
  46. package/dist/lib/resource-selection.js +49 -0
  47. package/dist/lib/resource-state.d.ts +2 -0
  48. package/dist/lib/resource-state.js +25 -4
  49. package/dist/lib/rollback-command.js +1 -1
  50. package/dist/lib/rollback-materialize.js +1 -1
  51. package/dist/lib/validate-local.d.ts +1 -0
  52. package/dist/lib/validate-local.js +67 -8
  53. package/dist/lib/wp-client.d.ts +1 -0
  54. package/dist/lib/wp-client.js +6 -0
  55. package/dist/types/project-config.generated.d.ts +15 -4
  56. package/dist/utils/block-format.d.ts +2 -0
  57. package/dist/utils/block-format.js +18 -2
  58. package/dist/utils/page-format.js +2 -1
  59. package/dist/utils/resource-dirs.js +4 -4
  60. package/oclif.manifest.json +2275 -1394
  61. package/package.json +7 -7
  62. 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
- async local(dir) {
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
- return new Map(blocks.map((block) => [block.slug, block.files]));
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-styles',
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-styles).
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-styles': themeStyles,
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
@@ -6,5 +6,6 @@ export type ValidateResult = {
6
6
  checked: number;
7
7
  problems: Problem[];
8
8
  valid: boolean;
9
+ warnings: Problem[];
9
10
  };
10
11
  export declare function validateLocal(cwd: string): Promise<ValidateResult>;
@@ -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', 'themeStylesDir'];
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
- hooksDir: 'hooks',
28
+ hookDir: 'hooks',
28
29
  menuDir: 'menus',
29
30
  metaDir: 'meta',
30
- optionsDir: 'options',
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
- themeStylesDir: 'theme',
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('optionsDir'), problems, parseLocalOption);
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('hooksDir'), problems, 'Hook file is empty', HOOK_FILES_RESOURCE);
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
- return { checked, problems, valid: problems.length === 0 };
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) {
@@ -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;
@@ -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
- hooksDir?: string;
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
- appsDir?: string;
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
- optionsDir?: string;
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
- themeStylesDir?: string;
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
- blocks.push({ files, slug });
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: 'appsDir',
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: 'hooksDir',
37
+ hooks: 'hookDir',
38
38
  menu: 'menuDir',
39
39
  meta: 'metaDir',
40
- options: 'optionsDir',
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: 'themeStylesDir',
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`.