@loopress/cli 0.31.0 → 0.32.1

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 (72) hide show
  1. package/README.md +440 -221
  2. package/dist/commands/acf/diff.d.ts +2 -2
  3. package/dist/commands/acf/diff.js +3 -1
  4. package/dist/commands/acf/list.d.ts +1 -0
  5. package/dist/commands/acf/list.js +2 -1
  6. package/dist/commands/acf/pull.d.ts +1 -0
  7. package/dist/commands/acf/pull.js +2 -1
  8. package/dist/commands/acf/push.d.ts +7 -2
  9. package/dist/commands/acf/push.js +12 -4
  10. package/dist/commands/acf/rollback.d.ts +2 -2
  11. package/dist/commands/acf/rollback.js +3 -1
  12. package/dist/commands/composer/pull.js +1 -1
  13. package/dist/commands/diff.d.ts +1 -0
  14. package/dist/commands/diff.js +13 -2
  15. package/dist/commands/doctor.js +1 -1
  16. package/dist/commands/form/list.js +1 -1
  17. package/dist/commands/form/pull.js +1 -1
  18. package/dist/commands/form/push.d.ts +2 -1
  19. package/dist/commands/form/push.js +2 -2
  20. package/dist/commands/init.js +0 -23
  21. package/dist/commands/menu/push.d.ts +2 -1
  22. package/dist/commands/menu/push.js +1 -1
  23. package/dist/commands/option/push.d.ts +2 -1
  24. package/dist/commands/option/push.js +1 -1
  25. package/dist/commands/page/push.d.ts +4 -1
  26. package/dist/commands/page/push.js +40 -8
  27. package/dist/commands/pattern/push.js +1 -1
  28. package/dist/commands/pattern/rm.js +1 -1
  29. package/dist/commands/plugin/audit.js +1 -1
  30. package/dist/commands/plugin/push.js +1 -1
  31. package/dist/commands/plugin/status.js +1 -1
  32. package/dist/commands/pull.d.ts +1 -1
  33. package/dist/commands/pull.js +22 -10
  34. package/dist/commands/push.d.ts +2 -0
  35. package/dist/commands/push.js +29 -9
  36. package/dist/commands/snippet/list.js +1 -1
  37. package/dist/commands/snippet/pull.js +1 -1
  38. package/dist/commands/snippet/push.d.ts +2 -1
  39. package/dist/commands/snippet/push.js +2 -2
  40. package/dist/commands/theme/pull.js +1 -1
  41. package/dist/commands/theme/push.d.ts +1 -0
  42. package/dist/commands/theme/push.js +3 -3
  43. package/dist/commands/theme/style/push.d.ts +2 -1
  44. package/dist/commands/theme/style/push.js +4 -5
  45. package/dist/commands/theme/template/list.js +1 -1
  46. package/dist/commands/theme/template/push.d.ts +11 -1
  47. package/dist/commands/theme/template/push.js +34 -5
  48. package/dist/commands/theme/version/push.js +1 -1
  49. package/dist/lib/declared-type-command.d.ts +2 -1
  50. package/dist/lib/declared-type-command.js +1 -1
  51. package/dist/lib/diff-command.d.ts +1 -0
  52. package/dist/lib/diff-command.js +10 -2
  53. package/dist/lib/php-files-command.d.ts +3 -1
  54. package/dist/lib/php-files-command.js +34 -19
  55. package/dist/lib/plugin-detection.d.ts +1 -0
  56. package/dist/lib/plugin-detection.js +4 -0
  57. package/dist/lib/push-command.d.ts +17 -0
  58. package/dist/lib/push-command.js +42 -3
  59. package/dist/lib/validate-local.js +12 -5
  60. package/dist/lib/wp-client.d.ts +1 -0
  61. package/dist/lib/wp-client.js +13 -0
  62. package/dist/utils/acf-format.d.ts +5 -0
  63. package/dist/utils/acf-format.js +29 -0
  64. package/dist/utils/php-top-level.d.ts +2 -0
  65. package/dist/utils/php-top-level.js +153 -0
  66. package/dist/utils/resource-dirs.d.ts +1 -0
  67. package/dist/utils/resource-dirs.js +7 -1
  68. package/dist/utils/template-format.d.ts +1 -0
  69. package/dist/utils/theme-styles-format.d.ts +2 -0
  70. package/dist/utils/theme-styles-format.js +32 -4
  71. package/oclif.manifest.json +548 -537
  72. package/package.json +2 -2
@@ -1,5 +1,5 @@
1
1
  import { readFile } from 'node:fs/promises';
2
- import { join } from 'node:path';
2
+ import { join, relative, sep } from 'node:path';
3
3
  import { configManager } from '../config/project-config.manager.js';
4
4
  import { CPT_SPEC, declaredFileChecker, META_SPEC, PATTERN_SPEC, ROLE_SPEC, TAXONOMY_SPEC } from '../utils/declared-type-format.js';
5
5
  import { parseLocalOption } from '../utils/option-format.js';
@@ -8,6 +8,7 @@ import { RESOURCE_DIR_DEFAULTS } from '../utils/resource-dirs.js';
8
8
  import { parseType } from '../utils/snippet-format.js';
9
9
  import { readLocalTemplates } from '../utils/template-format.js';
10
10
  import { loadSnippets } from './load-snippets.js';
11
+ import { API_FILES_RESOURCE, HOOK_FILES_RESOURCE, phpFileProblems } from './php-files-command.js';
11
12
  import { readdirTolerant } from './readdir-tolerant.js';
12
13
  // Directories holding one JSON document per resource. Snippets (code file plus JSON sidecar)
13
14
  // and api routes (`.php`, not JSON) are checked separately, options too (a stricter shape).
@@ -58,8 +59,8 @@ export async function validateLocal(cwd) {
58
59
  checked += await checkJsonDir(resolve('roleDir'), problems, declaredFileChecker(ROLE_SPEC));
59
60
  checked += await checkPatterns(resolve('patternDir'), problems);
60
61
  checked += await checkSnippets(resolve('snippetsDir'), problems);
61
- checked += await checkPhpDir(resolve('apiDir'), problems, 'API route file is empty');
62
- checked += await checkPhpDir(resolve('hooksDir'), problems, 'Hook file is empty');
62
+ checked += await checkPhpDir(resolve('apiDir'), problems, 'API route file is empty', API_FILES_RESOURCE);
63
+ checked += await checkPhpDir(resolve('hooksDir'), problems, 'Hook file is empty', HOOK_FILES_RESOURCE);
63
64
  const pages = await readLocalPages(resolve('pageDir'));
64
65
  checked += pages.pages.length + pages.problems.length;
65
66
  problems.push(...pages.problems);
@@ -83,7 +84,7 @@ async function checkPatterns(dir, problems) {
83
84
  // Recursive: both api/ and hooks/ files can live in a nested path-segment directory
84
85
  // (invoice-pdf/[order_id].php, content/filters.php), a non-recursive scan would silently
85
86
  // never check those.
86
- async function checkPhpDir(dir, problems, emptyMessage) {
87
+ async function checkPhpDir(dir, problems, emptyMessage, spec) {
87
88
  const entries = await readdirTolerant(dir, { recursive: true, withFileTypes: true });
88
89
  let checked = 0;
89
90
  for (const entry of entries) {
@@ -96,8 +97,14 @@ async function checkPhpDir(dir, problems, emptyMessage) {
96
97
  // one level deep or more.
97
98
  const filePath = join(entry.parentPath, entry.name);
98
99
  const content = await readFile(filePath, 'utf8');
99
- if (content.trim() === '')
100
+ if (content.trim() === '') {
100
101
  problems.push({ file: filePath, message: emptyMessage });
102
+ continue;
103
+ }
104
+ // The same checks push runs, so a file that passes here doesn't fail there.
105
+ const filename = relative(dir, filePath).replaceAll(sep, '/').replace(/\.php$/, '');
106
+ for (const message of phpFileProblems(spec, filename, content))
107
+ problems.push({ file: filePath, message });
101
108
  }
102
109
  return checked;
103
110
  }
@@ -25,6 +25,7 @@ type RequestOptions = {
25
25
  export declare function isNotFoundError(error: unknown): boolean;
26
26
  export declare function isForbiddenError(error: unknown): boolean;
27
27
  export declare function isApplicative404(error: unknown, marker: string): boolean;
28
+ export declare function isConflictCode(error: unknown, code: string): boolean;
28
29
  export declare function isTimeoutError(error: unknown): boolean;
29
30
  export declare function isInvalidPageNumber(error: unknown): boolean;
30
31
  export declare function formatWpError(error: unknown, url: string, timeoutMs?: number): string;
@@ -116,6 +116,19 @@ export function isApplicative404(error, marker) {
116
116
  return false;
117
117
  }
118
118
  }
119
+ // A 409 a Loopress controller tags with a `code`, for a refusal the CLI can act on (e.g.
120
+ // `loopress_page_not_managed`, which `lps page push` offers to take over).
121
+ export function isConflictCode(error, code) {
122
+ const response = error?.cause?.response;
123
+ if (response?.statusCode !== 409 || !response.body)
124
+ return false;
125
+ try {
126
+ return JSON.parse(response.body).code === code;
127
+ }
128
+ catch {
129
+ return false;
130
+ }
131
+ }
119
132
  export function isTimeoutError(error) {
120
133
  const cause = error?.cause;
121
134
  return cause?.name === 'TimeoutError';
@@ -3,3 +3,8 @@ export type AcfObjectType = (typeof ACF_OBJECT_TYPES)[number];
3
3
  export declare function acfEndpoint(type: AcfObjectType): string;
4
4
  export declare function acfObjectEndpoint(type: AcfObjectType, key: string): string;
5
5
  export declare function getAcfKey(data: Record<string, unknown>): null | string;
6
+ export declare function acfFieldChanges(before: unknown, after: unknown): {
7
+ added: string[];
8
+ changed: string[];
9
+ removed: string[];
10
+ };
@@ -1,3 +1,4 @@
1
+ import { isDeepStrictEqual } from 'node:util';
1
2
  export const ACF_OBJECT_TYPES = ['field-groups', 'post-types', 'taxonomies', 'options-pages'];
2
3
  export function acfEndpoint(type) {
3
4
  return `loopress/v1/acf/${type}`;
@@ -16,3 +17,31 @@ export function acfObjectEndpoint(type, key) {
16
17
  export function getAcfKey(data) {
17
18
  return typeof data.key === 'string' && data.key.trim() !== '' ? data.key : null;
18
19
  }
20
+ function topLevelFields(group) {
21
+ const fields = group?.fields;
22
+ const byKey = new Map();
23
+ for (const field of Array.isArray(fields) ? fields : []) {
24
+ if (typeof field?.key === 'string')
25
+ byKey.set(field.key, field);
26
+ }
27
+ return byKey;
28
+ }
29
+ // A changed field group's top-level fields, matched by `key` and named by `name` (a repeater's
30
+ // sub-field change shows as its parent changing). Only for the dry-run preview: the push itself
31
+ // always sends the whole group.
32
+ export function acfFieldChanges(before, after) {
33
+ const old = topLevelFields(before);
34
+ const next = topLevelFields(after);
35
+ const label = (field) => (typeof field.name === 'string' && field.name !== '' ? field.name : String(field.key));
36
+ const changes = { added: [], changed: [], removed: [] };
37
+ for (const [key, field] of next) {
38
+ if (!old.has(key))
39
+ changes.added.push(label(field));
40
+ else if (!isDeepStrictEqual(old.get(key), field))
41
+ changes.changed.push(label(field));
42
+ }
43
+ for (const [key, field] of old)
44
+ if (!next.has(key))
45
+ changes.removed.push(label(field));
46
+ return changes;
47
+ }
@@ -0,0 +1,2 @@
1
+ export declare function firstTopLevelSideEffect(content: string): null | string;
2
+ export declare function startsWithStrictTypes(content: string): boolean;
@@ -0,0 +1,153 @@
1
+ // Port of the plugin's ClassScanner::firstTopLevelSideEffect(): the first statement at the top
2
+ // level (brace depth 0) that would run when the file is loaded, or null when the file only
3
+ // declares things. The server rejects such a file at push ("code outside the class"); checking
4
+ // it here lets `lps validate` catch it first, a hand-written ABSPATH guard included (the plugin
5
+ // adds its own). PHP has no tokenizer in Node, so this is a minimal lexer: it only needs to skip
6
+ // comments, strings and heredocs correctly, and to see keywords, braces and ';'.
7
+ const ALLOWED = new Set(['abstract', 'class', 'const', 'declare', 'enum', 'final', 'function', 'interface', 'namespace', 'readonly', 'trait', 'use']);
8
+ const OPEN_TAG = /<\?php\b/i;
9
+ // Returns a short description of the offending token, for the error message.
10
+ export function firstTopLevelSideEffect(content) {
11
+ if (content.search(OPEN_TAG) !== 0)
12
+ return 'inline HTML before <?php';
13
+ const state = { atStart: true, braces: [], depth: 0, namespaceHeader: false };
14
+ for (const token of lex(content.slice(5))) {
15
+ const found = step(state, token);
16
+ if (found !== null)
17
+ return found;
18
+ }
19
+ return null;
20
+ }
21
+ // True when the file's first statement is `declare(strict_types=1);` (PHP refuses it anywhere
22
+ // else). A count of the text alone would also accept one that only sits in a comment.
23
+ export function startsWithStrictTypes(content) {
24
+ if (content.search(OPEN_TAG) !== 0)
25
+ return false;
26
+ const texts = [];
27
+ for (const token of lex(content.slice(5))) {
28
+ texts.push(token.text.toLowerCase());
29
+ if (texts.length === 7)
30
+ break;
31
+ }
32
+ return texts.join('') === 'declare(strict_types=1);';
33
+ }
34
+ // Advances the brace depth and "a statement starts here" flag past one token; returns the token
35
+ // when it starts a top-level statement that isn't a declaration.
36
+ function step(state, token) {
37
+ if (token.type === 'html')
38
+ return inlineHtml(state, token);
39
+ const statementStart = state.depth === 0 && state.atStart;
40
+ switch (token.text) {
41
+ case ';': {
42
+ if (state.depth === 0) {
43
+ state.atStart = true;
44
+ state.namespaceHeader = false;
45
+ }
46
+ return null;
47
+ }
48
+ case '{': {
49
+ return openBrace(state, statementStart);
50
+ }
51
+ case '}': {
52
+ if (state.braces.pop() !== true)
53
+ state.depth = Math.max(0, state.depth - 1);
54
+ if (state.depth === 0)
55
+ state.atStart = true;
56
+ return null;
57
+ }
58
+ default: {
59
+ if (!statementStart)
60
+ return null;
61
+ state.atStart = false;
62
+ state.namespaceHeader = token.text.toLowerCase() === 'namespace';
63
+ return isDeclaration(token) ? null : token.text;
64
+ }
65
+ }
66
+ }
67
+ // A template inside a class or function body (`?> <p>...</p> <?php`) only runs when it is called.
68
+ function inlineHtml(state, token) {
69
+ if (state.depth > 0)
70
+ return null;
71
+ state.atStart = true;
72
+ return token.text === '' ? null : 'inline HTML after ?>';
73
+ }
74
+ function openBrace(state, statementStart) {
75
+ if (state.namespaceHeader) {
76
+ state.namespaceHeader = false;
77
+ state.braces.push(true);
78
+ state.atStart = true;
79
+ return null;
80
+ }
81
+ // A '{' starting a statement is a bare block, its body runs at load time.
82
+ if (statementStart)
83
+ return '{';
84
+ state.braces.push(false);
85
+ state.depth += 1;
86
+ return null;
87
+ }
88
+ function isDeclaration(token) {
89
+ return token.type === 'attribute' || (token.type === 'word' && ALLOWED.has(token.text.toLowerCase()));
90
+ }
91
+ function* lex(source) {
92
+ let i = skipTrivia(source, 0);
93
+ while (i < source.length) {
94
+ const [token, end] = readToken(source, i);
95
+ yield token;
96
+ i = skipTrivia(source, end);
97
+ }
98
+ }
99
+ // Skips whitespace and comments (`#[` is an attribute, not a comment).
100
+ function skipTrivia(source, start) {
101
+ let i = start;
102
+ for (;;) {
103
+ if (/\s/.test(source[i] ?? '')) {
104
+ i += 1;
105
+ }
106
+ else if (source.startsWith('//', i) || (source[i] === '#' && source[i + 1] !== '[')) {
107
+ // A line comment also ends at a closing tag, PHP outputs what follows it.
108
+ const end = source.slice(i).search(/\n|\?>/);
109
+ i = end === -1 ? source.length : i + end;
110
+ }
111
+ else if (source.startsWith('/*', i)) {
112
+ const end = source.indexOf('*/', i + 2);
113
+ i = end === -1 ? source.length : end + 2;
114
+ }
115
+ else {
116
+ return i;
117
+ }
118
+ }
119
+ }
120
+ function readToken(source, i) {
121
+ if (source.startsWith('?>', i)) {
122
+ // Closing tag: everything up to the next <?php is output, but for one newline PHP eats.
123
+ const next = source.slice(i + 2).search(OPEN_TAG);
124
+ const html = next === -1 ? source.slice(i + 2) : source.slice(i + 2, i + 2 + next);
125
+ return [{ text: html.replace(/^\r?\n/, ''), type: 'html' }, next === -1 ? source.length : i + 2 + next + 5];
126
+ }
127
+ if (source.startsWith('#[', i))
128
+ return [{ text: '#[', type: 'attribute' }, i + 2];
129
+ if (source.startsWith('<<<', i) || `'"\``.includes(source[i])) {
130
+ return [{ text: source[i] === '`' ? '`' : 'a string', type: 'string' }, skipString(source, i)];
131
+ }
132
+ const word = /^[\w$\\]+/.exec(source.slice(i));
133
+ if (word)
134
+ return [{ text: word[0], type: 'word' }, i + word[0].length];
135
+ return [{ text: source[i], type: 'punct' }, i + 1];
136
+ }
137
+ // Index right after the string (quoted, backtick, heredoc or nowdoc) starting at `i`.
138
+ function skipString(source, i) {
139
+ if (source[i] === '<') {
140
+ const head = /^<<<[ \t]*(["']?)(\w+)\1\r?\n/.exec(source.slice(i));
141
+ if (!head)
142
+ return i + 3;
143
+ // PHP 7.3+: the closing identifier may be indented and followed by anything but a word char.
144
+ const bodyStart = i + head[0].length - 1;
145
+ const close = new RegExp(String.raw `\n[ \t]*${head[2]}\b`).exec(source.slice(bodyStart));
146
+ return close ? bodyStart + close.index + close[0].length : source.length;
147
+ }
148
+ const quote = source[i];
149
+ let j = i + 1;
150
+ while (j < source.length && source[j] !== quote)
151
+ j += source[j] === '\\' ? 2 : 1;
152
+ return j + 1;
153
+ }
@@ -22,3 +22,4 @@ export declare const RESOURCE_DIR_DEFAULTS: {
22
22
  };
23
23
  export type ResourceDirKind = keyof typeof RESOURCE_DIR_DEFAULTS;
24
24
  export declare function resolveResourceDir(kind: ResourceDirKind, localConfig: LoopressLocalConfig, override?: string): string;
25
+ export declare function projectUses(kind: ResourceDirKind, localConfig: LoopressLocalConfig): boolean;
@@ -1,4 +1,5 @@
1
- import { join } from 'node:path';
1
+ import { existsSync } from 'node:fs';
2
+ import { join, resolve } from 'node:path';
2
3
  // The default subdirectory for each file-backed resource, relative to rootDir. Used whenever
3
4
  // loopress.json has no `<kind>Dir` key, which is the case for a fresh `lps init`.
4
5
  export const RESOURCE_DIR_DEFAULTS = {
@@ -54,3 +55,8 @@ export function resolveResourceDir(kind, localConfig, override) {
54
55
  const configured = localConfig[CONFIG_KEY[kind]];
55
56
  return join(rootDir, configured ?? RESOURCE_DIR_DEFAULTS[kind]);
56
57
  }
58
+ // Whether the project uses a resource at all: its directory exists. What the aggregate `lps push`,
59
+ // `lps pull` and `lps diff` go by for a resource backed by a plugin the site may not have.
60
+ export function projectUses(kind, localConfig) {
61
+ return existsSync(resolve(resolveResourceDir(kind, localConfig)));
62
+ }
@@ -15,6 +15,7 @@ export type Part = {
15
15
  };
16
16
  export type ChildTheme = {
17
17
  active: boolean;
18
+ css?: Record<string, string>;
18
19
  customized: string[];
19
20
  exists: boolean;
20
21
  parent: string;
@@ -12,6 +12,8 @@ export type WpThemeSupportInfo = {
12
12
  };
13
13
  export declare function isBlockTheme(theme: Pick<WpThemeSupportInfo, 'theme_supports'>): boolean;
14
14
  export declare function getUserGlobalStylesId(theme: Pick<WpThemeSupportInfo, '_links'>): null | number;
15
+ export declare function globalStylesFiles(dir: string): Promise<string[]>;
16
+ export declare function globalStylesSkipNote(wp: WpClient, dir: string, action: 'pull' | 'push'): Promise<string | undefined>;
15
17
  export declare function getActiveThemeGlobalStyles(wp: WpClient): Promise<{
16
18
  id: number;
17
19
  stylesheet: string;
@@ -1,3 +1,5 @@
1
+ import { resolve } from 'node:path';
2
+ import { readdirTolerant } from '../lib/readdir-tolerant.js';
1
3
  // Global Styles are the user's customizations from the Site Editor's "Styles" screen (colors,
2
4
  // typography, spacing), stored by WordPress core in a `wp_global_styles` post, one per theme.
3
5
  // This is separate from the theme's own theme.json file, which lives in the theme's repo and is
@@ -30,14 +32,40 @@ export function getUserGlobalStylesId(theme) {
30
32
  const match = /\/wp\/v2\/global-styles\/(\d+)/.exec(href);
31
33
  return match ? Number(match[1]) : null;
32
34
  }
33
- // Resolves the active theme's stylesheet slug and the id of its editable Global Styles post in
34
- // one call, failing clearly (rather than with a raw REST error later) when the active theme
35
- // isn't a block theme: classic themes have no Styles screen and no `wp_global_styles` post at all.
36
- export async function getActiveThemeGlobalStyles(wp) {
35
+ async function getActiveTheme(wp) {
37
36
  const themes = await wp.get('wp/v2/themes');
38
37
  const active = themes.find((theme) => theme.status === 'active');
39
38
  if (!active)
40
39
  throw new Error('Could not determine the active theme on WordPress.');
40
+ return active;
41
+ }
42
+ // The project's Global Styles files, one per theme it was pulled from. None: the project doesn't
43
+ // track them.
44
+ export async function globalStylesFiles(dir) {
45
+ return (await readdirTolerant(resolve(dir))).filter((file) => file.endsWith(THEME_STYLES_FILE_SUFFIX));
46
+ }
47
+ // Why the aggregate `lps push` / `lps pull` leave Global Styles out of this run, or undefined to
48
+ // include them. Only a project that tracks them (a `*-global-styles.json` file in `dir`) on a
49
+ // site whose active theme is a block theme qualifies: unlike `lps theme style push`, a classic
50
+ // theme is no error there, the rest of the run must go on. A push also needs the active theme's
51
+ // own file, there is nothing to send otherwise.
52
+ export async function globalStylesSkipNote(wp, dir, action) {
53
+ const files = await globalStylesFiles(dir);
54
+ if (files.length === 0)
55
+ return 'not configured, skipped';
56
+ const active = await getActiveTheme(wp);
57
+ if (!isBlockTheme(active))
58
+ return `the active theme "${active.stylesheet}" is a classic theme, skipped`;
59
+ if (action === 'push' && !files.includes(themeStylesFileName(active.stylesheet))) {
60
+ return `no file for the active theme "${active.stylesheet}", skipped`;
61
+ }
62
+ return undefined;
63
+ }
64
+ // Resolves the active theme's stylesheet slug and the id of its editable Global Styles post in
65
+ // one call, failing clearly (rather than with a raw REST error later) when the active theme
66
+ // isn't a block theme: classic themes have no Styles screen and no `wp_global_styles` post at all.
67
+ export async function getActiveThemeGlobalStyles(wp) {
68
+ const active = await getActiveTheme(wp);
41
69
  if (!isBlockTheme(active)) {
42
70
  throw new Error(`Global Styles sync only supports block themes (Full Site Editing). The active theme "${active.stylesheet}" is a classic theme.`);
43
71
  }