@loopress/cli 0.28.0 → 0.30.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 (190) hide show
  1. package/README.md +1460 -337
  2. package/bin/dev.js +0 -0
  3. package/dist/commands/acf/pull.d.ts +9 -1
  4. package/dist/commands/acf/pull.js +2 -0
  5. package/dist/commands/acf/push.d.ts +9 -1
  6. package/dist/commands/acf/push.js +3 -1
  7. package/dist/commands/api/publish.js +1 -1
  8. package/dist/commands/app/push.js +1 -1
  9. package/dist/commands/composer/push.js +3 -3
  10. package/dist/commands/cpt/diff.js +5 -0
  11. package/dist/commands/cpt/list.d.ts +2 -0
  12. package/dist/commands/cpt/list.js +4 -0
  13. package/dist/commands/cpt/pull.d.ts +2 -0
  14. package/dist/commands/cpt/pull.js +4 -0
  15. package/dist/commands/cpt/push.d.ts +2 -0
  16. package/dist/commands/cpt/push.js +4 -0
  17. package/dist/commands/cpt/rm.d.ts +2 -0
  18. package/dist/commands/cpt/rm.js +3 -0
  19. package/dist/commands/cpt/rollback.js +5 -0
  20. package/dist/commands/dev.js +14 -4
  21. package/dist/commands/diff.js +1 -1
  22. package/dist/commands/doctor.d.ts +1 -0
  23. package/dist/commands/doctor.js +30 -2
  24. package/dist/commands/form/pull.d.ts +9 -1
  25. package/dist/commands/form/pull.js +3 -1
  26. package/dist/commands/form/push.d.ts +7 -1
  27. package/dist/commands/form/push.js +3 -1
  28. package/dist/commands/init.js +6 -38
  29. package/dist/commands/menu/pull.d.ts +7 -1
  30. package/dist/commands/menu/pull.js +2 -0
  31. package/dist/commands/menu/push.d.ts +7 -1
  32. package/dist/commands/menu/push.js +3 -1
  33. package/dist/commands/meta/diff.d.ts +2 -0
  34. package/dist/commands/meta/diff.js +5 -0
  35. package/dist/commands/meta/list.d.ts +19 -0
  36. package/dist/commands/meta/list.js +21 -0
  37. package/dist/commands/meta/pull.d.ts +2 -0
  38. package/dist/commands/meta/pull.js +4 -0
  39. package/dist/commands/meta/push.d.ts +2 -0
  40. package/dist/commands/meta/push.js +4 -0
  41. package/dist/commands/meta/rm.d.ts +2 -0
  42. package/dist/commands/meta/rm.js +3 -0
  43. package/dist/commands/meta/rollback.d.ts +2 -0
  44. package/dist/commands/meta/rollback.js +5 -0
  45. package/dist/commands/option/list.js +2 -8
  46. package/dist/commands/option/pull.js +4 -4
  47. package/dist/commands/option/push.js +2 -0
  48. package/dist/commands/page/push.js +3 -0
  49. package/dist/commands/pattern/diff.d.ts +2 -0
  50. package/dist/commands/pattern/diff.js +5 -0
  51. package/dist/commands/pattern/list.d.ts +2 -0
  52. package/dist/commands/pattern/list.js +3 -0
  53. package/dist/commands/pattern/pull.d.ts +2 -0
  54. package/dist/commands/pattern/pull.js +4 -0
  55. package/dist/commands/pattern/push.d.ts +2 -0
  56. package/dist/commands/pattern/push.js +4 -0
  57. package/dist/commands/pattern/rm.d.ts +2 -0
  58. package/dist/commands/pattern/rm.js +3 -0
  59. package/dist/commands/pattern/rollback.d.ts +2 -0
  60. package/dist/commands/pattern/rollback.js +5 -0
  61. package/dist/commands/plugin/audit.js +2 -1
  62. package/dist/commands/plugin/pull.d.ts +4 -0
  63. package/dist/commands/plugin/pull.js +56 -10
  64. package/dist/commands/plugin/push.js +5 -4
  65. package/dist/commands/project/config.d.ts +1 -0
  66. package/dist/commands/project/config.js +20 -1
  67. package/dist/commands/project/list.d.ts +1 -1
  68. package/dist/commands/project/list.js +6 -6
  69. package/dist/commands/project/pull.js +11 -8
  70. package/dist/commands/project/push.js +23 -18
  71. package/dist/commands/project/remove.js +1 -1
  72. package/dist/commands/project/switch.d.ts +0 -1
  73. package/dist/commands/project/switch.js +16 -36
  74. package/dist/commands/pull.js +7 -2
  75. package/dist/commands/push.js +14 -6
  76. package/dist/commands/role/diff.d.ts +2 -0
  77. package/dist/commands/role/diff.js +5 -0
  78. package/dist/commands/role/list.d.ts +2 -0
  79. package/dist/commands/role/list.js +4 -0
  80. package/dist/commands/role/pull.d.ts +2 -0
  81. package/dist/commands/role/pull.js +4 -0
  82. package/dist/commands/role/push.d.ts +2 -0
  83. package/dist/commands/role/push.js +4 -0
  84. package/dist/commands/role/rm.d.ts +2 -0
  85. package/dist/commands/role/rm.js +3 -0
  86. package/dist/commands/role/rollback.d.ts +2 -0
  87. package/dist/commands/role/rollback.js +5 -0
  88. package/dist/commands/snippet/publish.js +1 -1
  89. package/dist/commands/snippet/pull.js +1 -1
  90. package/dist/commands/snippet/push.js +1 -1
  91. package/dist/commands/status.d.ts +3 -3
  92. package/dist/commands/status.js +21 -53
  93. package/dist/commands/taxonomy/diff.d.ts +2 -0
  94. package/dist/commands/taxonomy/diff.js +5 -0
  95. package/dist/commands/taxonomy/list.d.ts +2 -0
  96. package/dist/commands/taxonomy/list.js +5 -0
  97. package/dist/commands/taxonomy/pull.d.ts +2 -0
  98. package/dist/commands/taxonomy/pull.js +4 -0
  99. package/dist/commands/taxonomy/push.d.ts +2 -0
  100. package/dist/commands/taxonomy/push.js +4 -0
  101. package/dist/commands/taxonomy/rm.d.ts +2 -0
  102. package/dist/commands/taxonomy/rm.js +3 -0
  103. package/dist/commands/taxonomy/rollback.d.ts +2 -0
  104. package/dist/commands/taxonomy/rollback.js +5 -0
  105. package/dist/commands/theme/pull.d.ts +6 -10
  106. package/dist/commands/theme/pull.js +30 -32
  107. package/dist/commands/theme/push.d.ts +8 -9
  108. package/dist/commands/theme/push.js +54 -111
  109. package/dist/commands/theme/style/pull.d.ts +7 -1
  110. package/dist/commands/theme/style/pull.js +3 -1
  111. package/dist/commands/theme/style/push.d.ts +8 -1
  112. package/dist/commands/theme/style/push.js +4 -1
  113. package/dist/commands/{template → theme/template}/diff.d.ts +1 -1
  114. package/dist/commands/{template → theme/template}/diff.js +3 -3
  115. package/dist/commands/{template → theme/template}/list.d.ts +2 -2
  116. package/dist/commands/{template → theme/template}/list.js +5 -5
  117. package/dist/commands/{template → theme/template}/push.d.ts +3 -1
  118. package/dist/commands/theme/template/push.js +65 -0
  119. package/dist/commands/theme/version/pull.d.ts +23 -0
  120. package/dist/commands/theme/version/pull.js +43 -0
  121. package/dist/commands/theme/version/push.d.ts +22 -0
  122. package/dist/commands/theme/version/push.js +123 -0
  123. package/dist/config/project-config.manager.d.ts +3 -3
  124. package/dist/config/project-config.manager.js +22 -31
  125. package/dist/hooks/finally.d.ts +2 -1
  126. package/dist/hooks/finally.js +38 -7
  127. package/dist/lib/api-client.js +2 -1
  128. package/dist/lib/app-build.js +3 -1
  129. package/dist/lib/app-manifest.js +2 -2
  130. package/dist/lib/base.d.ts +2 -2
  131. package/dist/lib/base.js +33 -38
  132. package/dist/lib/declared-type-command.d.ts +35 -0
  133. package/dist/lib/declared-type-command.js +192 -0
  134. package/dist/lib/dev-watch.d.ts +1 -1
  135. package/dist/lib/dev-watch.js +7 -3
  136. package/dist/lib/diff-state.js +1 -0
  137. package/dist/lib/github-release.js +4 -1
  138. package/dist/lib/local-callback-server.d.ts +12 -6
  139. package/dist/lib/local-callback-server.js +24 -23
  140. package/dist/lib/php-files-command.js +3 -1
  141. package/dist/lib/push-command.d.ts +6 -0
  142. package/dist/lib/push-command.js +61 -18
  143. package/dist/lib/put-or-create.js +3 -4
  144. package/dist/lib/resource-state.js +40 -86
  145. package/dist/lib/rollback-materialize.js +13 -24
  146. package/dist/lib/snapshot-store.js +2 -2
  147. package/dist/lib/spinner.d.ts +2 -0
  148. package/dist/lib/spinner.js +72 -0
  149. package/dist/lib/temp-admin.d.ts +10 -1
  150. package/dist/lib/temp-admin.js +27 -2
  151. package/dist/lib/validate-local.js +31 -9
  152. package/dist/lib/wp-authorize-flow.d.ts +10 -17
  153. package/dist/lib/wp-authorize-flow.js +24 -28
  154. package/dist/lib/wp-client.d.ts +1 -0
  155. package/dist/lib/wp-client.js +7 -1
  156. package/dist/lib/wp-site-diagnostic.js +39 -1
  157. package/dist/types/global-config.generated.d.ts +1 -5
  158. package/dist/types/project-config.generated.d.ts +28 -4
  159. package/dist/utils/composer.d.ts +3 -1
  160. package/dist/utils/composer.js +16 -7
  161. package/dist/utils/declared-type-format.d.ts +52 -0
  162. package/dist/utils/declared-type-format.js +233 -0
  163. package/dist/utils/option-format.d.ts +3 -1
  164. package/dist/utils/option-format.js +14 -3
  165. package/dist/utils/page-format.d.ts +5 -1
  166. package/dist/utils/page-format.js +7 -3
  167. package/dist/utils/plugin-sync.js +1 -1
  168. package/dist/utils/resource-dirs.d.ts +9 -3
  169. package/dist/utils/resource-dirs.js +18 -6
  170. package/dist/utils/template-format.d.ts +3 -1
  171. package/dist/utils/template-format.js +10 -3
  172. package/dist/utils/theme-css.d.ts +19 -0
  173. package/dist/utils/theme-css.js +100 -0
  174. package/oclif.manifest.json +3542 -1087
  175. package/package.json +40 -21
  176. package/schemas/global-config.schema.json +2 -6
  177. package/schemas/project-config.schema.json +37 -7
  178. package/dist/commands/seo/diff.js +0 -5
  179. package/dist/commands/seo/list.d.ts +0 -18
  180. package/dist/commands/seo/list.js +0 -58
  181. package/dist/commands/seo/pull.d.ts +0 -17
  182. package/dist/commands/seo/pull.js +0 -87
  183. package/dist/commands/seo/push.d.ts +0 -23
  184. package/dist/commands/seo/push.js +0 -176
  185. package/dist/commands/seo/rollback.js +0 -5
  186. package/dist/commands/template/push.js +0 -42
  187. package/dist/utils/seo-format.d.ts +0 -29
  188. package/dist/utils/seo-format.js +0 -18
  189. /package/dist/commands/{seo → cpt}/diff.d.ts +0 -0
  190. /package/dist/commands/{seo → cpt}/rollback.d.ts +0 -0
@@ -1,5 +1,6 @@
1
1
  import got from 'got';
2
2
  import { Buffer } from 'node:buffer';
3
+ import { withSpinner } from './spinner.js';
3
4
  export const REQUEST_TIMEOUT_MS = 30_000;
4
5
  // The one environment allowed to talk plain HTTP: Loopress's local dev environment, identified
5
6
  // by its config name (the same "local" environment `lps dev` targets), never by the URL's host.
@@ -81,7 +82,7 @@ export class WpClient {
81
82
  async request(method, path, json, options) {
82
83
  const timeoutMs = options?.timeoutMs ?? REQUEST_TIMEOUT_MS;
83
84
  try {
84
- const response = await this.client(path, { json, method, timeout: { request: timeoutMs } });
85
+ const response = await withSpinner(`Waiting for ${new URL(this.siteUrl).host}`, async () => this.client(path, { json, method, timeout: { request: timeoutMs } }));
85
86
  return (response.body ? JSON.parse(response.body) : undefined);
86
87
  }
87
88
  catch (error) {
@@ -93,6 +94,11 @@ export function isNotFoundError(error) {
93
94
  const cause = error?.cause;
94
95
  return cause?.response?.statusCode === 404;
95
96
  }
97
+ // Authenticated but missing the capability the endpoint needs (e.g. `list_users`).
98
+ export function isForbiddenError(error) {
99
+ const cause = error?.cause;
100
+ return cause?.response?.statusCode === 403;
101
+ }
96
102
  // A 404 a Loopress controller raises on purpose, identified by its JSON body's `error` field
97
103
  // (e.g. `{"error": "composer.lock not found"}` from ComposerController). Narrower than
98
104
  // isNotFoundError: a bare 404 also covers the route being absent because the plugin isn't
@@ -1,5 +1,13 @@
1
1
  import got from 'got';
2
+ import { withSpinner } from './spinner.js';
2
3
  export const REQUEST_TIMEOUT_MS = 10_000;
4
+ /**
5
+ * WordPress 7.0 is the first version that accepts a loopback `http://127.0.0.1` success_url on
6
+ * authorize-application.php whatever the site's environment type (wp_is_authorize_application_redirect_url_valid).
7
+ * Earlier versions only accept it when WP_ENVIRONMENT_TYPE is `local`, which isn't exposed publicly,
8
+ * so a 6.x site is sent to manual entry even though a local one would have worked.
9
+ */
10
+ const MIN_BROWSER_AUTH_MAJOR = 7;
3
11
  /**
4
12
  * Pre-flight checks run before starting the browser authorization flow, so failures
5
13
  * (unreachable site, blocked REST API, Application Passwords disabled) surface as an
@@ -15,7 +23,7 @@ export const REQUEST_TIMEOUT_MS = 10_000;
15
23
  export async function diagnoseWpSite(siteUrl) {
16
24
  let index;
17
25
  try {
18
- index = await got.get(`${siteUrl}/wp-json/`, { timeout: { request: REQUEST_TIMEOUT_MS } }).json();
26
+ index = await withSpinner(`Checking ${siteUrl}`, async () => got.get(`${siteUrl}/wp-json/`, { timeout: { request: REQUEST_TIMEOUT_MS } }).json());
19
27
  }
20
28
  catch (error) {
21
29
  return {
@@ -29,8 +37,38 @@ export async function diagnoseWpSite(siteUrl) {
29
37
  reason: `Application Passwords are not available on ${siteUrl}. The site may be older than WordPress 5.6, require HTTPS, or have the feature disabled by a plugin or filter.`,
30
38
  };
31
39
  }
40
+ const version = await detectWpVersion(siteUrl);
41
+ if (version && Number(version.split('.', 1)[0]) < MIN_BROWSER_AUTH_MAJOR) {
42
+ return {
43
+ ok: false,
44
+ reason: `${siteUrl} runs WordPress ${version}. Before WordPress ${MIN_BROWSER_AUTH_MAJOR}.0, WordPress refuses to send the Application Password back to the CLI on this machine (http://127.0.0.1), so authorizing in the browser is not possible. Create an Application Password in wp-admin (Users > Profile) and enter it below.`,
45
+ };
46
+ }
32
47
  return { ok: true };
33
48
  }
49
+ /**
50
+ * Best effort, from the public generator tag: the feed's `<generator>` first, then the home page
51
+ * `<meta name="generator">`. Security plugins often strip both, in which case this returns
52
+ * undefined and the browser flow is attempted anyway.
53
+ */
54
+ async function detectWpVersion(siteUrl) {
55
+ const sources = [
56
+ { pattern: /<generator>[^<]*[?&]v=(\d+(?:\.\d+)*)/, url: `${siteUrl}/feed/` },
57
+ { pattern: /<meta name="generator" content="WordPress (\d+(?:\.\d+)*)/, url: `${siteUrl}/` },
58
+ ];
59
+ for (const { pattern, url } of sources) {
60
+ try {
61
+ const body = await withSpinner(`Checking ${siteUrl}`, async () => got.get(url, { timeout: { request: REQUEST_TIMEOUT_MS } }).text());
62
+ const version = pattern.exec(body)?.[1];
63
+ if (version)
64
+ return version;
65
+ }
66
+ catch {
67
+ // Feed disabled or page unreachable: try the next source.
68
+ }
69
+ }
70
+ return undefined;
71
+ }
34
72
  function describe(error) {
35
73
  return error instanceof Error ? error.message : String(error);
36
74
  }
@@ -3,7 +3,7 @@
3
3
  */
4
4
  export interface LoopressGlobalConfiguration {
5
5
  /**
6
- * Pointer to the currently active project and environment, or null if none is selected.
6
+ * Pointer to the currently active project, or null if none is selected. The environment is not stored: commands use --env, else the project's "local" environment, else its only one.
7
7
  */
8
8
  currentProject: null | CurrentProjectPointer;
9
9
  /**
@@ -15,10 +15,6 @@ export interface LoopressGlobalConfiguration {
15
15
  telemetry?: TelemetryConfig;
16
16
  }
17
17
  export interface CurrentProjectPointer {
18
- /**
19
- * Name of the currently active environment.
20
- */
21
- env: string;
22
18
  /**
23
19
  * Id of the currently active project.
24
20
  */
@@ -15,10 +15,6 @@ export interface LoopressProjectConfiguration {
15
15
  * Directory where ACF field groups, post types, taxonomies and options pages are read from and written to, relative to rootDir.
16
16
  */
17
17
  acfDir?: string;
18
- /**
19
- * Directory where SEO settings, post meta, and redirects (RankMath and Yoast SEO, whichever is active) are read from and written to, relative to rootDir.
20
- */
21
- seoDir?: string;
22
18
  /**
23
19
  * Directory where forms (WPForms today) are read from and written to, relative to rootDir.
24
20
  */
@@ -47,10 +43,38 @@ export interface LoopressProjectConfiguration {
47
43
  * Directory where block template parts (one <slug>.html per part, written to the Loopress child theme of the active block theme) are read from, relative to rootDir.
48
44
  */
49
45
  partDir?: string;
46
+ /**
47
+ * Directory of the theme's global CSS, relative to rootDir. Its style.css is the entry: it @imports the other files, bundled into the style.css of the Loopress child theme and loaded on every page.
48
+ */
49
+ cssDir?: string;
50
+ /**
51
+ * Directory of per-block CSS (one <namespace>/<block>.css per block, e.g. core/button.css), relative to rootDir. Each file is bundled into the Loopress child theme and only loaded on pages that render that block.
52
+ */
53
+ blockCssDir?: string;
50
54
  /**
51
55
  * 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.
52
56
  */
53
57
  optionsDir?: string;
58
+ /**
59
+ * 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.
60
+ */
61
+ cptDir?: string;
62
+ /**
63
+ * Directory where taxonomies (one JSON file of register_taxonomy() arguments plus "object_type" per taxonomy, named <slug>.json) are read from and written to, relative to rootDir.
64
+ */
65
+ taxonomyDir?: string;
66
+ /**
67
+ * Directory where user roles (one JSON file of "name" and "capabilities" per role, named <slug>.json) are read from and written to, relative to rootDir.
68
+ */
69
+ roleDir?: string;
70
+ /**
71
+ * Directory where block patterns (one <slug>.html of block markup per pattern, registered as loopress/<slug>) are read from and written to, relative to rootDir.
72
+ */
73
+ patternDir?: string;
74
+ /**
75
+ * Directory where post meta keys (one JSON file per post type, named <post-type>.json, mapping each meta key to its register_post_meta() arguments) are read from and written to, relative to rootDir.
76
+ */
77
+ metaDir?: string;
54
78
  /**
55
79
  * 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.
56
80
  */
@@ -1,5 +1,6 @@
1
1
  import { type SyncIntent } from './plugin-sync.js';
2
2
  import { type MergeResult } from './plugins.js';
3
+ export declare const NOT_ON_WORDPRESS_ORG = "not on WordPress.org";
3
4
  export type ComposerJson = {
4
5
  config?: {
5
6
  'allow-plugins'?: Record<string, boolean>;
@@ -29,4 +30,5 @@ export declare function pullIntoComposerJson(path: string, kind: 'plugin' | 'the
29
30
  log: (message: string) => void;
30
31
  }): Promise<ComposerPullResult>;
31
32
  export declare function toIntent(require: Record<string, string>): SyncIntent;
32
- export declare function wpackagistRequire(composerJson: ComposerJson, kind: 'plugin' | 'theme'): Record<string, string>;
33
+ export declare function requireToInstall(composerJson: ComposerJson, includeDev: boolean): Record<string, string>;
34
+ export declare function wpackagistRequire(composerJson: ComposerJson, kind: 'plugin' | 'theme', includeDev?: boolean): Record<string, string>;
@@ -2,7 +2,10 @@ import { existsSync } from 'node:fs';
2
2
  import { readFile } from 'node:fs/promises';
3
3
  import { join } from 'node:path';
4
4
  import writeFileAtomic from 'write-file-atomic';
5
+ import { withSpinner } from '../lib/spinner.js';
5
6
  import { isExactVersion } from './version.js';
7
+ // Why pullIntoComposerJson skips a plugin it can't install: premium or custom, not on WPackagist.
8
+ export const NOT_ON_WORDPRESS_ORG = 'not on WordPress.org';
6
9
  export async function readComposerJson() {
7
10
  const path = join(process.cwd(), 'composer.json');
8
11
  if (!existsSync(path))
@@ -42,7 +45,7 @@ async function isOnWordPressOrg(kind, slug) {
42
45
  'Loopress only adds packages WPackagist can serve. Retry once api.wordpress.org is reachable.');
43
46
  let response;
44
47
  try {
45
- response = await fetch(url);
48
+ response = await withSpinner(`Checking WordPress.org for ${slug}`, async () => fetch(url));
46
49
  }
47
50
  catch (error) {
48
51
  throw fail(`network error: ${error.message}`);
@@ -100,7 +103,7 @@ function planComposerPull(composerJson, prefix, incoming) {
100
103
  }
101
104
  return { candidates, result };
102
105
  }
103
- // `lps plugin pull` / `lps theme pull` on a project that has a composer.json: pin installed
106
+ // `lps plugin pull` / `lps theme version pull` on a project that has a composer.json: pin installed
104
107
  // packages to their live versions as `wpackagist-<kind>/<slug>` under `require`, leaving every
105
108
  // other key untouched. The server adds the WPackagist repository and composer/installers on push.
106
109
  // - A declared version constraint (^9.4, *, ...) is the user's intent: never overwritten, only an
@@ -119,7 +122,7 @@ export async function pullIntoComposerJson(path, kind, incoming, { dryRun, log }
119
122
  result.merged[slug] = incoming[slug];
120
123
  }
121
124
  else {
122
- result.skipped.push({ reason: 'not on WordPress.org', slug });
125
+ result.skipped.push({ reason: NOT_ON_WORDPRESS_ORG, slug });
123
126
  }
124
127
  }
125
128
  if (!dryRun) {
@@ -159,11 +162,17 @@ export function toIntent(require) {
159
162
  }
160
163
  return intent;
161
164
  }
162
- // The `wpackagist-<kind>/<slug>` entries of a composer.json `require`, keyed by slug, with the
163
- // version or constraint as written.
164
- export function wpackagistRequire(composerJson, kind) {
165
+ // What a push installs: `require`, plus `require-dev` on the local environment only (Query
166
+ // Monitor, or a premium plugin's free edition to develop against). A remote site never gets
167
+ // `require-dev`, the same split as `composer install --no-dev` on a production server.
168
+ export function requireToInstall(composerJson, includeDev) {
169
+ return includeDev ? { ...composerJson.require, ...composerJson['require-dev'] } : { ...composerJson.require };
170
+ }
171
+ // The `wpackagist-<kind>/<slug>` entries of what a push installs (see requireToInstall), keyed
172
+ // by slug, with the version or constraint as written.
173
+ export function wpackagistRequire(composerJson, kind, includeDev = false) {
165
174
  const prefix = `wpackagist-${kind}/`;
166
- return Object.fromEntries(Object.entries(composerJson.require ?? {})
175
+ return Object.fromEntries(Object.entries(requireToInstall(composerJson, includeDev))
167
176
  .filter(([name]) => name.startsWith(prefix))
168
177
  .map(([name, version]) => [name.slice(prefix.length), version]));
169
178
  }
@@ -0,0 +1,52 @@
1
+ import { type ResourceDirKind } from './resource-dirs.js';
2
+ export type DeclaredTypeSpec = {
3
+ arrayArgs: Record<string, unknown[]>;
4
+ checkArgs?: (args: DeclaredArgs) => void;
5
+ cliName: 'cpt' | 'meta' | 'pattern' | 'role' | 'taxonomy';
6
+ codeArgs: string[];
7
+ dir: string;
8
+ dirKind: ResourceDirKind;
9
+ endpoint: string;
10
+ file?: {
11
+ extension: string;
12
+ format(args: DeclaredArgs): string;
13
+ parse(slug: string, raw: string): DeclaredArgs;
14
+ };
15
+ noun: string;
16
+ plural: string;
17
+ registeredEndpoint: string;
18
+ registerFunction: string;
19
+ slugMaxLength: number;
20
+ termArgs: string[];
21
+ };
22
+ export declare const CPT_SPEC: DeclaredTypeSpec;
23
+ export declare const TAXONOMY_SPEC: DeclaredTypeSpec;
24
+ export declare const META_SPEC: DeclaredTypeSpec;
25
+ export declare const ROLE_SPEC: DeclaredTypeSpec;
26
+ export declare function parsePatternFile(slug: string, raw: string): DeclaredArgs;
27
+ export declare function formatPatternFile(args: DeclaredArgs): string;
28
+ export declare const PATTERN_SPEC: DeclaredTypeSpec;
29
+ export declare function fileExtension(spec: DeclaredTypeSpec): string;
30
+ export declare function parseDeclaredFile(spec: DeclaredTypeSpec, filePath: string, raw: string): {
31
+ args: DeclaredArgs;
32
+ slug: string;
33
+ };
34
+ export declare function formatDeclaredFile(spec: DeclaredTypeSpec, args: DeclaredArgs): string;
35
+ export declare function itemEndpoint(spec: DeclaredTypeSpec, slug: string): string;
36
+ export type DeclaredArgs = Record<string, unknown>;
37
+ export type RemoteDeclaredType = {
38
+ args: DeclaredArgs;
39
+ revision: string;
40
+ slug: string;
41
+ };
42
+ export type RegisteredItem = {
43
+ conflict: boolean;
44
+ count: number;
45
+ label: string;
46
+ managed: boolean;
47
+ objectTypes?: string[];
48
+ slug: string;
49
+ source: 'acf' | 'cptui' | 'loopress' | 'other' | 'wordpress';
50
+ };
51
+ export declare function parseDeclaredArgs(raw: string): DeclaredArgs;
52
+ export declare function declaredFileChecker(spec: DeclaredTypeSpec): (raw: string, filePath: string) => void;
@@ -0,0 +1,233 @@
1
+ import { basename } from 'node:path';
2
+ import { parseHtmlHeader, titleFromSlug } from './page-format.js';
3
+ export const CPT_SPEC = {
4
+ arrayArgs: { capabilities: [], labels: [], rewrite: [true, false], supports: [false], taxonomies: [], template: [] },
5
+ cliName: 'cpt',
6
+ codeArgs: ['register_meta_box_cb', 'rest_controller_class', 'autosave_rest_controller_class', 'revisions_rest_controller_class'],
7
+ dir: 'cpt',
8
+ dirKind: 'cpt',
9
+ endpoint: 'loopress/v1/post-types',
10
+ noun: 'post type',
11
+ plural: 'post types',
12
+ registeredEndpoint: 'loopress/v1/registered-post-types',
13
+ registerFunction: 'register_post_type()',
14
+ slugMaxLength: 20,
15
+ termArgs: [],
16
+ };
17
+ // `object_type` is the one key that isn't a register_taxonomy() argument: its second parameter,
18
+ // the post types the taxonomy attaches to. Kept in the same file, split off by the plugin.
19
+ export const TAXONOMY_SPEC = {
20
+ arrayArgs: { capabilities: [], labels: [], object_type: [], rewrite: [true, false] },
21
+ cliName: 'taxonomy',
22
+ codeArgs: ['meta_box_cb', 'meta_box_sanitize_cb', 'update_count_callback', 'rest_controller_class'],
23
+ dir: 'taxonomies',
24
+ dirKind: 'taxonomy',
25
+ endpoint: 'loopress/v1/taxonomies',
26
+ noun: 'taxonomy',
27
+ plural: 'taxonomies',
28
+ registeredEndpoint: 'loopress/v1/registered-taxonomies',
29
+ registerFunction: 'register_taxonomy()',
30
+ slugMaxLength: 32,
31
+ // register_taxonomy() creates the default term when it doesn't exist yet.
32
+ termArgs: ['default_term'],
33
+ };
34
+ const isObject = (value) => typeof value === 'object' && value !== null && !Array.isArray(value);
35
+ // register_post_meta()'s `type` values, each with the check its `default` must pass.
36
+ const META_TYPES = {
37
+ array: Array.isArray,
38
+ boolean: (value) => typeof value === 'boolean',
39
+ integer: Number.isSafeInteger,
40
+ number: (value) => typeof value === 'number',
41
+ object: isObject,
42
+ string: (value) => typeof value === 'string',
43
+ };
44
+ function checkMetaRest(key, type, rest) {
45
+ if (typeof rest !== 'boolean' && !isObject(rest))
46
+ throw new Error(`"${key}": "show_in_rest" must be true, false or an object`);
47
+ // WordPress calls it with each value on every REST read (WP_REST_Meta_Fields).
48
+ if (isObject(rest) && Object.hasOwn(rest, 'prepare_callback')) {
49
+ throw new Error(`"${key}": "show_in_rest.prepare_callback" runs PHP code, use a hook instead of a file for it`);
50
+ }
51
+ // WordPress silently leaves an array or object key out of REST without a schema.
52
+ if (rest !== false && (type === 'array' || type === 'object') && !(isObject(rest) && isObject(rest.schema))) {
53
+ throw new Error(`"${key}": a ${type} exposed in REST needs "show_in_rest": { "schema": {...} }`);
54
+ }
55
+ }
56
+ function checkMetaKey(key, meta) {
57
+ if (!/^[a-zA-Z0-9_-]{1,255}$/.test(key))
58
+ throw new Error(`"${key}" is not a valid meta key: 1 to 255 letters, digits, "_" or "-"`);
59
+ if (!isObject(meta))
60
+ throw new Error(`"${key}" must be a JSON object of register_post_meta() arguments`);
61
+ const code = ['sanitize_callback', 'auth_callback'].find((name) => Object.hasOwn(meta, name));
62
+ if (code)
63
+ throw new Error(`"${key}": "${code}" runs PHP code, use a hook instead of a file for it`);
64
+ const { type } = meta;
65
+ if (typeof type !== 'string' || !Object.hasOwn(META_TYPES, type)) {
66
+ throw new Error(`"${key}": "type" must be one of ${Object.keys(META_TYPES).join(', ')}`);
67
+ }
68
+ if (Object.hasOwn(meta, 'single') && typeof meta.single !== 'boolean')
69
+ throw new Error(`"${key}": "single" must be true or false`);
70
+ checkMetaRest(key, type, meta.show_in_rest ?? false);
71
+ if (Object.hasOwn(meta, 'default') && !META_TYPES[type](meta.default))
72
+ throw new Error(`"${key}": "default" must be a ${type}`);
73
+ }
74
+ // Same checks as the plugin's PostMetaService::assertArgs(): the file is `key => register_post_meta()
75
+ // arguments`, so the code and type checks are one level deeper than for post types.
76
+ function checkMetaArgs(args) {
77
+ for (const [key, meta] of Object.entries(args))
78
+ checkMetaKey(key, meta);
79
+ }
80
+ // The file name is the post type (`post` and `page` included), the keys are meta keys. Only the
81
+ // keys are declared, never their values.
82
+ export const META_SPEC = {
83
+ arrayArgs: {},
84
+ checkArgs: checkMetaArgs,
85
+ cliName: 'meta',
86
+ codeArgs: [],
87
+ dir: 'meta',
88
+ dirKind: 'meta',
89
+ endpoint: 'loopress/v1/post-meta',
90
+ noun: 'post meta',
91
+ plural: 'post meta',
92
+ registeredEndpoint: 'loopress/v1/registered-post-meta',
93
+ registerFunction: 'register_post_meta()',
94
+ slugMaxLength: 20,
95
+ termArgs: [],
96
+ };
97
+ // Same checks as the plugin's RoleService::assertArgs().
98
+ function checkRoleArgs(args) {
99
+ if (typeof args.name !== 'string' || args.name.trim() === '')
100
+ throw new Error('"name" must be a non-empty string');
101
+ const capabilities = args.capabilities ?? {};
102
+ if (!isObject(capabilities) || Object.values(capabilities).some((granted) => typeof granted !== 'boolean')) {
103
+ throw new Error('"capabilities" must map each capability name to true or false');
104
+ }
105
+ }
106
+ // `{"name": ..., "capabilities": {cap: bool}}`, added in memory on every request, never written
107
+ // to the roles option. Built-in roles are refused by the plugin.
108
+ export const ROLE_SPEC = {
109
+ arrayArgs: { capabilities: [] },
110
+ checkArgs: checkRoleArgs,
111
+ cliName: 'role',
112
+ codeArgs: [],
113
+ dir: 'roles',
114
+ dirKind: 'role',
115
+ endpoint: 'loopress/v1/roles',
116
+ noun: 'role',
117
+ plural: 'roles',
118
+ registeredEndpoint: 'loopress/v1/registered-roles',
119
+ registerFunction: 'add_role()',
120
+ slugMaxLength: 50,
121
+ termArgs: [],
122
+ };
123
+ // Header key => register_block_pattern() property, and how its text is read.
124
+ const PATTERN_HEADER = {
125
+ 'block-types': { key: 'blockTypes', type: 'list' },
126
+ categories: { key: 'categories', type: 'list' },
127
+ description: { key: 'description', type: 'text' },
128
+ inserter: { key: 'inserter', type: 'boolean' },
129
+ keywords: { key: 'keywords', type: 'list' },
130
+ 'post-types': { key: 'postTypes', type: 'list' },
131
+ 'template-types': { key: 'templateTypes', type: 'list' },
132
+ title: { key: 'title', type: 'text' },
133
+ 'viewport-width': { key: 'viewportWidth', type: 'integer' },
134
+ };
135
+ function readPatternValue(header, type, value) {
136
+ if (type === 'list')
137
+ return value.split(',').map((item) => item.trim()).filter(Boolean);
138
+ if (type === 'boolean') {
139
+ if (value !== 'true' && value !== 'false')
140
+ throw new Error(`"${header}" must be "true" or "false", got "${value}"`);
141
+ return value === 'true';
142
+ }
143
+ if (type === 'integer') {
144
+ if (!/^\d+$/.test(value))
145
+ throw new Error(`"${header}" must be a whole number, got "${value}"`);
146
+ return Number(value);
147
+ }
148
+ return value;
149
+ }
150
+ // A pattern file is its block markup, opened by the same `key: value` comment header as a page.
151
+ export function parsePatternFile(slug, raw) {
152
+ const { body, meta } = parseHtmlHeader(raw, Object.keys(PATTERN_HEADER));
153
+ const args = { title: titleFromSlug(slug) };
154
+ for (const [header, value] of Object.entries(meta)) {
155
+ const { key, type } = PATTERN_HEADER[header];
156
+ args[key] = readPatternValue(header, type, value);
157
+ }
158
+ args.content = body;
159
+ return args;
160
+ }
161
+ export function formatPatternFile(args) {
162
+ const lines = Object.entries(PATTERN_HEADER)
163
+ .filter(([, { key }]) => args[key] !== undefined)
164
+ .map(([header, { key }]) => `${header}: ${Array.isArray(args[key]) ? args[key].join(', ') : String(args[key])}`);
165
+ return `<!--\n${lines.join('\n')}\n-->\n${typeof args.content === 'string' ? args.content : ''}`;
166
+ }
167
+ function checkPatternArgs(args) {
168
+ if (typeof args.title !== 'string' || args.title.trim() === '')
169
+ throw new Error('"title" must be a non-empty string');
170
+ }
171
+ // Registered as `loopress/<slug>`, referenced with `<!-- wp:pattern {"slug":"loopress/<slug>"} /-->`.
172
+ export const PATTERN_SPEC = {
173
+ arrayArgs: { blockTypes: [], categories: [], keywords: [], postTypes: [], templateTypes: [] },
174
+ checkArgs: checkPatternArgs,
175
+ cliName: 'pattern',
176
+ codeArgs: ['filePath'],
177
+ dir: 'patterns',
178
+ dirKind: 'pattern',
179
+ endpoint: 'loopress/v1/patterns',
180
+ file: { extension: '.html', format: formatPatternFile, parse: parsePatternFile },
181
+ noun: 'pattern',
182
+ plural: 'patterns',
183
+ registeredEndpoint: 'loopress/v1/registered-patterns',
184
+ registerFunction: 'register_block_pattern()',
185
+ slugMaxLength: 100,
186
+ termArgs: [],
187
+ };
188
+ export function fileExtension(spec) {
189
+ return spec.file?.extension ?? '.json';
190
+ }
191
+ export function parseDeclaredFile(spec, filePath, raw) {
192
+ const slug = basename(filePath, fileExtension(spec));
193
+ return { args: spec.file ? spec.file.parse(slug, raw) : parseDeclaredArgs(raw), slug };
194
+ }
195
+ export function formatDeclaredFile(spec, args) {
196
+ return spec.file ? spec.file.format(args) : JSON.stringify(args, null, 2) + '\n';
197
+ }
198
+ export function itemEndpoint(spec, slug) {
199
+ return `${spec.endpoint}/${encodeURIComponent(slug)}`;
200
+ }
201
+ export function parseDeclaredArgs(raw) {
202
+ const parsed = JSON.parse(raw);
203
+ if (typeof parsed !== 'object' || parsed === null || Array.isArray(parsed)) {
204
+ throw new Error('not a JSON object of arguments');
205
+ }
206
+ return parsed;
207
+ }
208
+ // `lps validate`'s check of one file: the file name is a valid slug, array arguments are arrays,
209
+ // and no argument is code.
210
+ export function declaredFileChecker(spec) {
211
+ const slugPattern = new RegExp(`^[a-z0-9_-]{1,${spec.slugMaxLength}}$`);
212
+ return (raw, filePath) => {
213
+ const slug = basename(filePath, fileExtension(spec));
214
+ if (!slugPattern.test(slug)) {
215
+ throw new Error(`"${slug}" is not a valid ${spec.noun} slug: 1 to ${spec.slugMaxLength} lowercase letters, digits, "_" or "-"`);
216
+ }
217
+ const { args } = parseDeclaredFile(spec, filePath, raw);
218
+ for (const [key, alsoAllowed] of Object.entries(spec.arrayArgs)) {
219
+ const value = args[key];
220
+ if (Object.hasOwn(args, key) && (typeof value !== 'object' || value === null) && !alsoAllowed.includes(value)) {
221
+ const or = alsoAllowed.length > 0 ? ' (or ' + alsoAllowed.join(', ') + ')' : '';
222
+ throw new Error(`"${key}" must be a JSON object or array${or}`);
223
+ }
224
+ }
225
+ const term = spec.termArgs.find((key) => Object.hasOwn(args, key));
226
+ if (term)
227
+ throw new Error(`"${term}" creates a term, and Loopress doesn't sync terms: remove it and create the term in wp-admin`);
228
+ const code = spec.codeArgs.find((key) => Object.hasOwn(args, key));
229
+ if (code)
230
+ throw new Error(`"${code}" runs PHP code, use a hook instead of a file for it`);
231
+ spec.checkArgs?.(args);
232
+ };
233
+ }
@@ -1,11 +1,13 @@
1
1
  export declare const OPTIONS_ENDPOINT = "loopress/v1/options";
2
- export declare function optionEndpoint(name: string): string;
2
+ export declare function optionEndpoint(name: string, refs?: OptionRefs): string;
3
3
  export declare const RESERVED_OPTION_NAMES: readonly ["active_plugins", "stylesheet", "template"];
4
4
  export declare const READONLY_BY_DEFAULT_OPTION_NAMES: readonly ["siteurl", "home", "db_version", "initial_db_version", "cron", "rewrite_rules", "WPLANG", "default_role", "users_can_register", "uninstall_plugins", "mailserver_url", "mailserver_login", "mailserver_pass", "mailserver_port"];
5
+ export type OptionRefs = Record<string, string>;
5
6
  export type LocalOption = {
6
7
  autoload: string;
7
8
  name: string;
8
9
  readonly?: boolean;
10
+ refs?: OptionRefs;
9
11
  value: unknown;
10
12
  };
11
13
  export type RemoteOption = {
@@ -1,6 +1,8 @@
1
1
  export const OPTIONS_ENDPOINT = 'loopress/v1/options';
2
- export function optionEndpoint(name) {
3
- return `${OPTIONS_ENDPOINT}/${encodeURIComponent(name)}`;
2
+ // `refs` asks WordPress to return the declared post references as paths (see OptionRefs).
3
+ export function optionEndpoint(name, refs) {
4
+ const endpoint = `${OPTIONS_ENDPOINT}/${encodeURIComponent(name)}`;
5
+ return refs && Object.keys(refs).length > 0 ? `${endpoint}?refs=${encodeURIComponent(JSON.stringify(refs))}` : endpoint;
4
6
  }
5
7
  // Mirrors OptionsService::RESERVED_NAMES on the WordPress side (the actual, unbypassable
6
8
  // enforcement lives there). Checked here too so `option add` fails fast with a clear pointer to
@@ -52,7 +54,7 @@ export function parseLocalOption(raw) {
52
54
  if (typeof parsed !== 'object' || parsed === null || Array.isArray(parsed)) {
53
55
  throw new Error('not a JSON object');
54
56
  }
55
- const { autoload, name, value } = parsed;
57
+ const { autoload, name, refs, value } = parsed;
56
58
  if (typeof name !== 'string' || name === '') {
57
59
  throw new Error('missing a "name" string');
58
60
  }
@@ -65,5 +67,14 @@ export function parseLocalOption(raw) {
65
67
  if (value === undefined) {
66
68
  throw new Error('missing a "value" field');
67
69
  }
70
+ if (refs !== undefined && !isOptionRefs(refs)) {
71
+ throw new TypeError('"refs" must map a value path to a post type, e.g. {"purchase_page": "page"}');
72
+ }
68
73
  return parsed;
69
74
  }
75
+ function isOptionRefs(refs) {
76
+ return (typeof refs === 'object' &&
77
+ refs !== null &&
78
+ !Array.isArray(refs) &&
79
+ Object.entries(refs).every(([path, postType]) => path !== '' && typeof postType === 'string' && postType !== ''));
80
+ }
@@ -1,5 +1,9 @@
1
1
  export declare const PAGES_ENDPOINT = "loopress/v1/pages";
2
2
  export declare const PAGE_SLUG_PATTERN: RegExp;
3
+ export type SlugRule = {
4
+ message: string;
5
+ pattern: RegExp;
6
+ };
3
7
  export declare const PAGE_STATUSES: readonly ["draft", "publish"];
4
8
  export type PageStatus = (typeof PAGE_STATUSES)[number];
5
9
  export type Page = {
@@ -32,7 +36,7 @@ export declare function readLocalPages(dir: string): Promise<{
32
36
  pages: Page[];
33
37
  problems: PageProblem[];
34
38
  }>;
35
- export declare function readLocalHtmlFiles<T>(dir: string, parse: (slug: string, raw: string) => T): Promise<{
39
+ export declare function readLocalHtmlFiles<T>(dir: string, parse: (slug: string, raw: string) => T, slugRule?: SlugRule): Promise<{
36
40
  files: T[];
37
41
  problems: PageProblem[];
38
42
  }>;
@@ -4,6 +4,10 @@ export const PAGES_ENDPOINT = 'loopress/v1/pages';
4
4
  // Mirrored server-side in PagesController. No leading, trailing or doubled hyphen: WordPress's
5
5
  // sanitize_title() would rewrite those, and the page would no longer match its file name.
6
6
  export const PAGE_SLUG_PATTERN = /^[a-z0-9]+(?:-[a-z0-9]+)*$/;
7
+ const PAGE_SLUG_RULE = {
8
+ message: 'the file name must be lowercase letters and digits separated by single hyphens (e.g. "legal-notice.html")',
9
+ pattern: PAGE_SLUG_PATTERN,
10
+ };
7
11
  export const PAGE_STATUSES = ['draft', 'publish'];
8
12
  const HEADER_KEYS = ['title', 'status', 'full-width', 'hide-title', 'template'];
9
13
  function parseBoolean(key, value) {
@@ -82,7 +86,7 @@ export async function readLocalPages(dir) {
82
86
  return { pages: files, problems };
83
87
  }
84
88
  // The directory rules of readLocalPages(), for any flat folder of `<slug>.html` files.
85
- export async function readLocalHtmlFiles(dir, parse) {
89
+ export async function readLocalHtmlFiles(dir, parse, slugRule = PAGE_SLUG_RULE) {
86
90
  let entries;
87
91
  try {
88
92
  entries = await readdir(dir, { withFileTypes: true });
@@ -108,8 +112,8 @@ export async function readLocalHtmlFiles(dir, parse) {
108
112
  continue;
109
113
  }
110
114
  const slug = basename(entry.name, '.html');
111
- if (!PAGE_SLUG_PATTERN.test(slug)) {
112
- problems.push({ file: filePath, message: 'the file name must be lowercase letters and digits separated by single hyphens (e.g. "legal-notice.html")' });
115
+ if (!slugRule.pattern.test(slug)) {
116
+ problems.push({ file: filePath, message: slugRule.message });
113
117
  continue;
114
118
  }
115
119
  try {
@@ -1,5 +1,5 @@
1
1
  // Shared helpers for driving the server-side `loopress/v1/composer/sync` endpoint, used by
2
- // `lps plugin push`, `lps theme push` and `lps composer push`. The endpoint runs Composer +
2
+ // `lps plugin push`, `lps theme version push` and `lps composer push`. The endpoint runs Composer +
3
3
  // WPackagist inside WordPress; the CLI only ever sends intent (which slugs at which versions),
4
4
  // never a composer.json, so the plugin stays the single authority on that file's shape.
5
5
  import { compareVersions } from './version.js';
@@ -3,15 +3,21 @@ export declare const RESOURCE_DIR_DEFAULTS: {
3
3
  readonly acf: "acf";
4
4
  readonly api: "api";
5
5
  readonly apps: "apps";
6
+ readonly blockCss: "theme/blocks";
7
+ readonly cpt: "cpt";
8
+ readonly css: "theme/css";
6
9
  readonly form: "forms";
7
10
  readonly hooks: "hooks";
8
11
  readonly menu: "menus";
12
+ readonly meta: "meta";
9
13
  readonly options: "options";
10
14
  readonly page: "pages";
11
- readonly part: "parts";
12
- readonly seo: "seo";
15
+ readonly part: "theme/parts";
16
+ readonly pattern: "patterns";
17
+ readonly role: "roles";
13
18
  readonly snippets: "snippets";
14
- readonly template: "templates";
19
+ readonly taxonomy: "taxonomies";
20
+ readonly template: "theme/templates";
15
21
  readonly themeStyles: "theme";
16
22
  };
17
23
  export type ResourceDirKind = keyof typeof RESOURCE_DIR_DEFAULTS;