@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
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@loopress/cli",
3
3
  "description": "CLI tool for syncing WordPress code snippets, plugins, and Composer dependencies via the REST API",
4
- "version": "0.28.0",
4
+ "version": "0.30.0",
5
5
  "author": "jean-smaug",
6
6
  "bin": {
7
7
  "loopress": "bin/run.js",
@@ -11,41 +11,42 @@
11
11
  "url": "https://github.com/loopress/loopress/issues"
12
12
  },
13
13
  "dependencies": {
14
- "@inquirer/prompts": "^8.7.2",
15
- "@oclif/core": "^5.0.0",
16
- "@oclif/plugin-help": "^7.0.0",
17
- "@oclif/plugin-warn-if-update-available": "^4.0.0",
18
- "@sentry/node": "^10.74.0",
14
+ "@inquirer/prompts": "^8.7.3",
15
+ "@oclif/core": "^5.1.7",
16
+ "@oclif/plugin-help": "^7.0.4",
17
+ "@oclif/plugin-warn-if-update-available": "^4.0.3",
18
+ "@sentry/node": "^11.6.0",
19
19
  "chokidar": "^5.0.0",
20
20
  "compare-versions": "^6.1.1",
21
21
  "diff": "^9.0.0",
22
+ "esbuild": "0.28.2",
22
23
  "got": "^16.0.0",
23
- "listr2": "^11.1.0",
24
+ "listr2": "^11.1.1",
24
25
  "microdiff": "^1.6.0",
25
- "open": "^11.0.2",
26
- "playwright-core": "^1.63.0",
26
+ "open": "^11.0.4",
27
+ "playwright-core": "^1.64.0",
27
28
  "slugify": "^1.6.9",
28
29
  "tty-table": "^5.0.0",
29
30
  "write-file-atomic": "^8.0.0"
30
31
  },
31
32
  "devDependencies": {
32
- "@eslint/compat": "2.1.1",
33
33
  "@oclif/prettier-config": "^0.2.1",
34
34
  "@stryker-mutator/core": "^10.0.0",
35
35
  "@stryker-mutator/vitest-runner": "^10.0.0",
36
- "@types/node": "26.5.0",
36
+ "@types/node": "26.6.5",
37
37
  "@types/write-file-atomic": "^4.0.3",
38
38
  "@vitest/coverage-v8": "^4.1.11",
39
- "eslint": "^10.10.0",
40
- "eslint-config-oclif": "^7.1.7",
39
+ "@vitest/eslint-plugin": "^1.6.27",
40
+ "eslint": "^10.12.0",
41
+ "eslint-config-oclif": "^7.1.10",
41
42
  "eslint-config-prettier": "^10",
42
- "eslint-plugin-sonarjs": "^4.2.0",
43
+ "eslint-plugin-sonarjs": "^4.2.2",
43
44
  "json-schema-to-typescript": "^16.0.0",
44
- "oclif": "^5.0.1",
45
- "prettier": "3.9.6",
45
+ "oclif": "^6.0.6",
46
+ "prettier": "3.9.10",
46
47
  "shx": "^0.4.0",
47
48
  "ts-node": "10.9.2",
48
- "tsx": "4.23.13",
49
+ "tsx": "4.23.15",
49
50
  "typescript": "^6.0.3",
50
51
  "vitest": "^4.1.11"
51
52
  },
@@ -103,6 +104,9 @@
103
104
  "api": {
104
105
  "description": "Manage custom WordPress REST API routes"
105
106
  },
107
+ "cpt": {
108
+ "description": "Manage custom post types declared in cpt/<slug>.json files, and list every post type on the site"
109
+ },
106
110
  "composer": {
107
111
  "description": "Sync composer.json and composer.lock between your project and WordPress"
108
112
  },
@@ -112,18 +116,33 @@
112
116
  "project": {
113
117
  "description": "Manage WordPress project and environment configurations"
114
118
  },
115
- "seo": {
116
- "description": "Manage SEO settings, post meta, and redirects (RankMath or Yoast SEO, whichever is active)"
117
- },
118
119
  "snippet": {
119
120
  "description": "Manage WordPress code snippets"
120
121
  },
121
122
  "theme": {
122
- "description": "Manage WordPress.org themes tracked in loopress.json (versions only, never switches the active theme)"
123
+ "description": "Manage everything theme related: versions, block templates and parts, Global Styles (never switches the active theme)"
124
+ },
125
+ "theme:template": {
126
+ "description": "Manage block templates and template parts, written as the files of the <parent>-loopress child theme"
127
+ },
128
+ "theme:version": {
129
+ "description": "Manage WordPress.org themes tracked in loopress.json (versions only)"
123
130
  },
124
131
  "theme:style": {
125
132
  "description": "Manage the active block theme's Global Styles customizations (Site Editor > Styles: colors, typography, spacing)"
126
133
  },
134
+ "pattern": {
135
+ "description": "Manage block patterns declared in patterns/<slug>.html files (registered as loopress/<slug>), and list the theme and plugin ones"
136
+ },
137
+ "role": {
138
+ "description": "Manage user roles declared in roles/<slug>.json files (never which users have them), and list every role on the site"
139
+ },
140
+ "meta": {
141
+ "description": "Manage post meta keys declared in meta/<post-type>.json files (never their values), and list every registered meta key"
142
+ },
143
+ "taxonomy": {
144
+ "description": "Manage taxonomies declared in taxonomies/<slug>.json files (never their terms), and list every taxonomy on the site"
145
+ },
127
146
  "telemetry": {
128
147
  "description": "Manage error reporting preferences"
129
148
  }
@@ -8,7 +8,7 @@
8
8
  "required": ["currentProject", "projects"],
9
9
  "properties": {
10
10
  "currentProject": {
11
- "description": "Pointer to the currently active project and environment, or null if none is selected.",
11
+ "description": "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.",
12
12
  "oneOf": [{"type": "null"}, {"$ref": "#/definitions/CurrentProjectPointer"}]
13
13
  },
14
14
  "projects": {
@@ -26,12 +26,8 @@
26
26
  "title": "CurrentProjectPointer",
27
27
  "type": "object",
28
28
  "additionalProperties": false,
29
- "required": ["env", "id"],
29
+ "required": ["id"],
30
30
  "properties": {
31
- "env": {
32
- "type": "string",
33
- "description": "Name of the currently active environment."
34
- },
35
31
  "id": {
36
32
  "type": "string",
37
33
  "description": "Id of the currently active project."
@@ -24,11 +24,6 @@
24
24
  "description": "Directory where ACF field groups, post types, taxonomies and options pages are read from and written to, relative to rootDir.",
25
25
  "default": "acf"
26
26
  },
27
- "seoDir": {
28
- "type": "string",
29
- "description": "Directory where SEO settings, post meta, and redirects (RankMath and Yoast SEO, whichever is active) are read from and written to, relative to rootDir.",
30
- "default": "seo"
31
- },
32
27
  "formDir": {
33
28
  "type": "string",
34
29
  "description": "Directory where forms (WPForms today) are read from and written to, relative to rootDir.",
@@ -57,18 +52,53 @@
57
52
  "templateDir": {
58
53
  "type": "string",
59
54
  "description": "Directory where block templates (one <slug>.html per template, written to the Loopress child theme of the active block theme) are read from, relative to rootDir. A static page uses one with its `template` header.",
60
- "default": "templates"
55
+ "default": "theme/templates"
61
56
  },
62
57
  "partDir": {
63
58
  "type": "string",
64
59
  "description": "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.",
65
- "default": "parts"
60
+ "default": "theme/parts"
61
+ },
62
+ "cssDir": {
63
+ "type": "string",
64
+ "description": "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.",
65
+ "default": "theme/css"
66
+ },
67
+ "blockCssDir": {
68
+ "type": "string",
69
+ "description": "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.",
70
+ "default": "theme/blocks"
66
71
  },
67
72
  "optionsDir": {
68
73
  "type": "string",
69
74
  "description": "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.",
70
75
  "default": "options"
71
76
  },
77
+ "cptDir": {
78
+ "type": "string",
79
+ "description": "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.",
80
+ "default": "cpt"
81
+ },
82
+ "taxonomyDir": {
83
+ "type": "string",
84
+ "description": "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.",
85
+ "default": "taxonomies"
86
+ },
87
+ "roleDir": {
88
+ "type": "string",
89
+ "description": "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.",
90
+ "default": "roles"
91
+ },
92
+ "patternDir": {
93
+ "type": "string",
94
+ "description": "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.",
95
+ "default": "patterns"
96
+ },
97
+ "metaDir": {
98
+ "type": "string",
99
+ "description": "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.",
100
+ "default": "meta"
101
+ },
72
102
  "menuDir": {
73
103
  "type": "string",
74
104
  "description": "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.",
@@ -1,5 +0,0 @@
1
- import { resourceDiffCommand } from '../../lib/diff-command.js';
2
- export default resourceDiffCommand('seo', {
3
- description: 'Show what differs in SEO settings, post meta, and redirects between your local files and a WordPress environment, or between two environments',
4
- pathNoun: 'SEO directory',
5
- });
@@ -1,18 +0,0 @@
1
- import { LoopressCommand } from '../../lib/base.js';
2
- import { type SeoPostMeta, type SeoRedirect } from '../../utils/seo-format.js';
3
- type ListResult = {
4
- postMeta: Record<string, SeoPostMeta[]>;
5
- redirects: SeoRedirect[] | undefined;
6
- redirectsUnsupported: string | undefined;
7
- };
8
- export default class List extends LoopressCommand {
9
- static description: string;
10
- static enableJsonFlag: boolean;
11
- static examples: string[];
12
- static flags: {
13
- 'post-type': import("@oclif/core/interfaces").OptionFlag<string[] | undefined, import("@oclif/core/interfaces").CustomOptions>;
14
- };
15
- run(): Promise<ListResult>;
16
- private fetchRedirects;
17
- }
18
- export {};
@@ -1,58 +0,0 @@
1
- import { Flags } from '@oclif/core';
2
- import { LoopressCommand } from '../../lib/base.js';
3
- import { DEFAULT_POST_TYPES, SEO_REDIRECTS_ENDPOINT, seoPostMetaEndpoint } from '../../utils/seo-format.js';
4
- export default class List extends LoopressCommand {
5
- static description = 'List posts with SEO meta, and redirects if supported by the active SEO plugin, on WordPress';
6
- static enableJsonFlag = true;
7
- static examples = ['$ lps seo list', '$ lps seo list --post-type post'];
8
- static flags = {
9
- 'post-type': Flags.string({ description: 'Limit to specific post types', multiple: true }),
10
- };
11
- async run() {
12
- const { flags } = await this.parse(List);
13
- const postTypes = flags['post-type'] && flags['post-type'].length > 0 ? flags['post-type'] : [...DEFAULT_POST_TYPES];
14
- const byType = {};
15
- for (const postType of postTypes) {
16
- byType[postType] = await this.wp.get(seoPostMetaEndpoint(postType));
17
- }
18
- const { redirects, unsupportedReason } = await this.fetchRedirects();
19
- for (const postType of postTypes) {
20
- const posts = byType[postType];
21
- this.log(`${postType} (${posts.length}):`);
22
- if (posts.length === 0) {
23
- this.log(' (none)');
24
- this.log('');
25
- continue;
26
- }
27
- for (const post of posts) {
28
- this.log(` ${post.slug}. ${post.title}`);
29
- }
30
- this.log('');
31
- }
32
- if (unsupportedReason) {
33
- this.log(`redirects: ${unsupportedReason}`);
34
- return { postMeta: byType, redirects: undefined, redirectsUnsupported: unsupportedReason };
35
- }
36
- this.log(`redirects (${redirects.length}):`);
37
- if (redirects.length > 0) {
38
- for (const redirect of redirects) {
39
- this.log(` ${redirect.id}. [${redirect.status}] ${redirect.headerCode} -> ${redirect.urlTo}`);
40
- }
41
- }
42
- else {
43
- this.log(' (none)');
44
- }
45
- return { postMeta: byType, redirects: redirects, redirectsUnsupported: undefined };
46
- }
47
- // Redirects are only supported by some SeoProvider backends (RankMath, not Yoast); reported
48
- // as a line in the listing rather than failing the whole command, since post meta above may
49
- // well have succeeded.
50
- async fetchRedirects() {
51
- try {
52
- return { redirects: await this.wp.get(SEO_REDIRECTS_ENDPOINT), unsupportedReason: undefined };
53
- }
54
- catch (error) {
55
- return { redirects: null, unsupportedReason: error.message };
56
- }
57
- }
58
- }
@@ -1,17 +0,0 @@
1
- import { LoopressCommand } from '../../lib/base.js';
2
- export default class Pull extends LoopressCommand {
3
- static args: {
4
- path: import("@oclif/core/interfaces").Arg<string | undefined, Record<string, unknown>>;
5
- };
6
- static description: string;
7
- static examples: string[];
8
- static flags: {
9
- 'post-type': import("@oclif/core/interfaces").OptionFlag<string[] | undefined, import("@oclif/core/interfaces").CustomOptions>;
10
- yes: import("@oclif/core/interfaces").BooleanFlag<boolean>;
11
- 'dry-run': import("@oclif/core/interfaces").BooleanFlag<boolean>;
12
- };
13
- run(): Promise<void>;
14
- private pullPostMeta;
15
- private pullRedirects;
16
- private pullSettings;
17
- }
@@ -1,87 +0,0 @@
1
- import { Args, Flags } from '@oclif/core';
2
- import { mkdir, writeFile } from 'node:fs/promises';
3
- import { join } from 'node:path';
4
- import { LoopressCommand } from '../../lib/base.js';
5
- import { basenameKey, findOrphanedFiles, numericPrefixKey } from '../../lib/find-orphaned-files.js';
6
- import { DEFAULT_POST_TYPES, redirectFileBase, SEO_REDIRECTS_ENDPOINT, SEO_SETTINGS_ENDPOINT, seoPostMetaEndpoint, } from '../../utils/seo-format.js';
7
- export default class Pull extends LoopressCommand {
8
- static args = {
9
- path: Args.string({ description: 'Path to SEO directory (overrides project config)' }),
10
- };
11
- static description = 'Pull SEO settings, post meta, and (if supported) redirects from WordPress';
12
- static examples = ['$ lps seo pull', '$ lps seo pull --post-type post --post-type page'];
13
- static flags = {
14
- ...LoopressCommand.dryRunFlag,
15
- ...LoopressCommand.yesFlag,
16
- 'post-type': Flags.string({ description: 'Limit post meta to specific post types', multiple: true }),
17
- };
18
- async run() {
19
- const { args, flags } = await this.parse(Pull);
20
- const { url } = this.siteConfig;
21
- const path = this.resolveSeoPath(args.path);
22
- const postTypes = flags['post-type'] && flags['post-type'].length > 0 ? flags['post-type'] : [...DEFAULT_POST_TYPES];
23
- this.log(`Pulling SEO configuration from ${url}`);
24
- this.log(`SEO path: ${path}`);
25
- await this.pullSettings(path);
26
- for (const postType of postTypes) {
27
- await this.pullPostMeta(postType, path);
28
- }
29
- await this.pullRedirects(path);
30
- }
31
- async pullPostMeta(postType, basePath) {
32
- const dir = join(basePath, 'post-meta', postType);
33
- const remote = await this.wp.get(seoPostMetaEndpoint(postType));
34
- const orphans = await findOrphanedFiles(dir, new Set(remote.map((post) => post.slug)), {
35
- extensions: ['.json'],
36
- key: basenameKey,
37
- });
38
- await this.pullDirectory(dir, remote, orphans, {
39
- dryRunMessage: `Would pull ${remote.length} ${postType} post-meta file(s) to ${dir}`,
40
- orphanReason: `in ${dir} no longer present on WordPress`,
41
- pulledMessage: `Pulled ${remote.length} ${postType} post-meta file(s) to ${dir}`,
42
- title: (post) => post.slug,
43
- // `revision` (#234) is never persisted locally, same as RemoteOption.revision: it's only
44
- // ever read fresh, right before a push, never trusted from a stale local file.
45
- write: async ({ meta, slug, title }, writeDir) => writeFile(join(writeDir, `${slug}.json`), JSON.stringify({ meta, slug, title }, null, 2) + '\n'),
46
- });
47
- }
48
- // Redirects are only supported by some SeoProvider backends (RankMath, not Yoast). Unlike
49
- // push (which must fail loudly if the user has local redirect files that can't be synced),
50
- // pull degrades gracefully here: the active plugin never supporting redirects isn't an error
51
- // in the same sense a deleted-on-WordPress file is, there's simply nothing to pull.
52
- async pullRedirects(basePath) {
53
- const dir = join(basePath, 'redirects');
54
- let remote;
55
- try {
56
- remote = await this.wp.get(SEO_REDIRECTS_ENDPOINT);
57
- }
58
- catch (error) {
59
- this.warn(`Skipping redirects: ${error.message}`);
60
- return;
61
- }
62
- const orphans = await findOrphanedFiles(dir, new Set(remote.map((redirect) => String(redirect.id))), {
63
- extensions: ['.json'],
64
- key: numericPrefixKey,
65
- });
66
- await this.pullDirectory(dir, remote, orphans, {
67
- dryRunMessage: `Would pull ${remote.length} redirect(s) to ${dir}`,
68
- orphanReason: `in ${dir} no longer present on WordPress`,
69
- pulledMessage: `Pulled ${remote.length} redirect(s) to ${dir}`,
70
- title: (redirect) => `redirect #${redirect.id}`,
71
- write: async (redirect, writeDir) => writeFile(join(writeDir, `${redirectFileBase(redirect)}.json`), JSON.stringify(redirect, null, 2) + '\n'),
72
- });
73
- }
74
- async pullSettings(basePath) {
75
- const file = join(basePath, 'settings.json');
76
- // `revision` (#234) is never persisted locally, same as RemoteOption.revision: settings.json
77
- // holds only the settings themselves, exactly as it did before the wrapped response shape.
78
- const { settings } = await this.wp.get(SEO_SETTINGS_ENDPOINT);
79
- if (this.dryRun) {
80
- this.log(`[dry-run] Would pull settings to ${file}`);
81
- return;
82
- }
83
- await mkdir(basePath, { recursive: true });
84
- await writeFile(file, JSON.stringify(settings, null, 2) + '\n');
85
- this.log(`Pulled settings to ${file}`);
86
- }
87
- }
@@ -1,23 +0,0 @@
1
- import { PushCommand } from '../../lib/push-command.js';
2
- export default class Push extends PushCommand {
3
- static args: {
4
- path: import("@oclif/core/interfaces").Arg<string | undefined, Record<string, unknown>>;
5
- };
6
- static description: string;
7
- static examples: string[];
8
- static flags: {
9
- 'allow-external-redirects': import("@oclif/core/interfaces").BooleanFlag<boolean>;
10
- yes: import("@oclif/core/interfaces").BooleanFlag<boolean>;
11
- 'dry-run': import("@oclif/core/interfaces").BooleanFlag<boolean>;
12
- };
13
- private allowExternalRedirects;
14
- run(): Promise<void>;
15
- private currentPostMetaRevision;
16
- private jsonFilesIn;
17
- private pushPostMeta;
18
- private pushPostMetaFile;
19
- private pushRedirectFile;
20
- private pushRedirects;
21
- private pushSettings;
22
- private renameToCanonical;
23
- }
@@ -1,176 +0,0 @@
1
- import { Args, Flags } from '@oclif/core';
2
- import { readFile, rm, writeFile } from 'node:fs/promises';
3
- import { dirname, extname, join } from 'node:path';
4
- import { PushCommand } from '../../lib/push-command.js';
5
- import { putOrCreate } from '../../lib/put-or-create.js';
6
- import { readdirTolerant } from '../../lib/readdir-tolerant.js';
7
- import { getResourceStateProvider } from '../../lib/resource-state.js';
8
- import { isNotFoundError } from '../../lib/wp-client.js';
9
- import { pluralize } from '../../utils/pluralize.js';
10
- import { redirectFileBase, SEO_REDIRECTS_ENDPOINT, SEO_SETTINGS_ENDPOINT, seoPostMetaEndpoint, seoPostMetaItemEndpoint, seoRedirectEndpoint, } from '../../utils/seo-format.js';
11
- export default class Push extends PushCommand {
12
- static args = {
13
- path: Args.string({ description: 'Path to SEO directory (overrides project config)' }),
14
- };
15
- static description = 'Push SEO settings, post meta, and redirects to WordPress. Local redirect files created remotely are renamed on disk to the `<id>-<slug>` convention. Fails clearly per file if the active SEO plugin does not support redirects.';
16
- static examples = ['$ lps seo push', '$ lps seo push --allow-external-redirects'];
17
- static flags = {
18
- ...PushCommand.dryRunFlag,
19
- ...PushCommand.yesFlag,
20
- 'allow-external-redirects': Flags.boolean({
21
- default: false,
22
- description: 'Allow pushing a redirect whose target points off this site. Off by default: the server rejects an off-site `urlTo` so a stray push can\'t 301 traffic away.',
23
- }),
24
- };
25
- allowExternalRedirects = false;
26
- async run() {
27
- const { args, flags } = await this.parse(Push);
28
- const { url } = this.siteConfig;
29
- const path = this.resolveSeoPath(args.path);
30
- this.allowExternalRedirects = flags['allow-external-redirects'];
31
- this.log(`Pushing SEO configuration to ${url}`);
32
- this.log(`SEO path: ${path}`);
33
- const provider = getResourceStateProvider('seo');
34
- const beforeState = await this.captureBeforePushState(provider, path);
35
- await this.pushSettings(path);
36
- await this.pushPostMeta(path);
37
- await this.pushRedirects(path);
38
- await this.writeAfterPushSnapshot(provider, path, beforeState);
39
- if (this.failedCount > 0) {
40
- this.error(`${pluralize(this.failedCount, 'SEO item')} failed to push.`);
41
- }
42
- if (this.dryRun)
43
- return;
44
- await this.recordSuccess();
45
- this.log('All SEO configuration pushed.');
46
- }
47
- async currentPostMetaRevision(postType, slug) {
48
- try {
49
- const current = await this.wp.get(seoPostMetaItemEndpoint(postType, slug));
50
- return current.revision;
51
- }
52
- catch (error) {
53
- if (isNotFoundError(error))
54
- return undefined;
55
- throw error;
56
- }
57
- }
58
- async jsonFilesIn(dir) {
59
- return (await readdirTolerant(dir)).filter((file) => extname(file) === '.json');
60
- }
61
- async pushPostMeta(basePath) {
62
- const root = join(basePath, 'post-meta');
63
- const postTypeDirs = (await readdirTolerant(root, { withFileTypes: true }))
64
- .filter((entry) => entry.isDirectory())
65
- .map((entry) => entry.name);
66
- for (const postType of postTypeDirs) {
67
- const dir = join(root, postType);
68
- const files = await this.jsonFilesIn(dir);
69
- if (files.length === 0)
70
- continue;
71
- this.log(`Found ${pluralize(files.length, postType + ' post-meta file')} to push`);
72
- await this.runPushTasks(files, (file) => file, async (file, task) => this.pushPostMetaFile(postType, join(dir, file), task));
73
- }
74
- }
75
- async pushPostMetaFile(postType, filePath, task) {
76
- if (this.dryRun) {
77
- if (task)
78
- task.output = `[dry-run] Would push: ${filePath}`;
79
- return;
80
- }
81
- try {
82
- const post = JSON.parse(await readFile(filePath, 'utf8'));
83
- // Read the post's current SEO-meta revision right before writing it, and send it back as
84
- // `expectedRevision`: WordPress refuses the write (412) if something else changed that
85
- // post's meta in between, instead of this push silently overwriting it (#234). No revision
86
- // to condition on for a post whose meta doesn't exist remotely yet (an upsert that will
87
- // itself fail with a clearer "post not found" error): falls back to an unconditional write.
88
- const expectedRevision = await this.currentPostMetaRevision(postType, post.slug);
89
- const body = { meta: post.meta, slug: post.slug };
90
- if (expectedRevision !== undefined)
91
- body.expectedRevision = expectedRevision;
92
- await this.wp.post(seoPostMetaEndpoint(postType), body);
93
- if (task)
94
- task.output = `Pushed: ${post.slug}`;
95
- }
96
- catch (error) {
97
- this.reportTaskFailure(`Failed to push ${filePath}: ${error.message}`, error, task);
98
- }
99
- }
100
- async pushRedirectFile(filePath, task) {
101
- if (this.dryRun) {
102
- if (task)
103
- task.output = `[dry-run] Would push: ${filePath}`;
104
- return;
105
- }
106
- try {
107
- const redirect = JSON.parse(await readFile(filePath, 'utf8'));
108
- const payload = {
109
- headerCode: redirect.headerCode,
110
- sources: redirect.sources,
111
- status: redirect.status,
112
- urlTo: redirect.urlTo,
113
- ...(this.allowExternalRedirects && { allowExternal: true }),
114
- };
115
- // The id recorded locally may not exist on this site (e.g. a fresh install): putOrCreate
116
- // falls back to POST instead of failing, adopting whatever id the site assigns.
117
- const { body, created } = await putOrCreate(this.wp, {
118
- id: redirect.id ?? null,
119
- payload,
120
- postEndpoint: SEO_REDIRECTS_ENDPOINT,
121
- putEndpoint: (id) => seoRedirectEndpoint(id),
122
- });
123
- if (created)
124
- await this.renameToCanonical(filePath, body);
125
- if (task)
126
- task.output = `Pushed: redirect #${created ? body.id : redirect.id}`;
127
- }
128
- catch (error) {
129
- this.reportTaskFailure(`Failed to push ${filePath}: ${error.message}`, error, task);
130
- }
131
- }
132
- async pushRedirects(basePath) {
133
- const dir = join(basePath, 'redirects');
134
- const files = await this.jsonFilesIn(dir);
135
- if (files.length === 0)
136
- return;
137
- this.log(`Found ${pluralize(files.length, 'redirect')} to push`);
138
- await this.runPushTasks(files, (file) => file, async (file, task) => this.pushRedirectFile(join(dir, file), task));
139
- }
140
- async pushSettings(basePath) {
141
- const file = join(basePath, 'settings.json');
142
- let raw;
143
- try {
144
- raw = await readFile(file, 'utf8');
145
- }
146
- catch (error) {
147
- if (error.code === 'ENOENT')
148
- return;
149
- throw error;
150
- }
151
- if (this.dryRun) {
152
- this.log(`[dry-run] Would push: ${file}`);
153
- return;
154
- }
155
- try {
156
- const settings = JSON.parse(raw);
157
- // Read the settings' current revision right before writing them, and send it back as
158
- // `expectedRevision`: WordPress refuses the write (412) if something else changed the
159
- // settings in between, instead of this push silently overwriting it (#234).
160
- const { revision } = await this.wp.get(SEO_SETTINGS_ENDPOINT);
161
- await this.wp.put(SEO_SETTINGS_ENDPOINT, { expectedRevision: revision, settings });
162
- this.log(`Pushed: ${file}`);
163
- }
164
- catch (error) {
165
- this.failedCount++;
166
- this.warn(`Failed to push ${file}: ${error.message}`);
167
- }
168
- }
169
- async renameToCanonical(filePath, redirect) {
170
- const dir = dirname(filePath);
171
- const canonicalPath = join(dir, `${redirectFileBase(redirect)}.json`);
172
- await writeFile(canonicalPath, JSON.stringify(redirect, null, 2) + '\n');
173
- if (canonicalPath !== filePath)
174
- await rm(filePath, { force: true });
175
- }
176
- }
@@ -1,5 +0,0 @@
1
- import { resourceRollbackCommand } from '../../lib/rollback-command.js';
2
- export default resourceRollbackCommand('seo', {
3
- description: 'Restore SEO settings, post meta, and redirects to a snapshot taken automatically before an earlier `lps seo push`',
4
- pathNoun: 'SEO directory',
5
- });
@@ -1,42 +0,0 @@
1
- import { PushCommand } from '../../lib/push-command.js';
2
- import { formatPageProblems } from '../../utils/page-format.js';
3
- import { pluralize } from '../../utils/pluralize.js';
4
- import { resolveResourceDir } from '../../utils/resource-dirs.js';
5
- import { CHILD_THEME_ENDPOINT, readLocalTemplates } from '../../utils/template-format.js';
6
- export default class Push extends PushCommand {
7
- static description = 'Push block templates (templates/<slug>.html) and template parts (parts/<slug>.html) as the files of a child theme of the active block theme, <parent>-loopress. The child mirrors the project: files removed locally are removed from it. It is never activated for you.';
8
- static enableJsonFlag = true;
9
- static examples = ['$ lps template push', '$ lps template push --dry-run'];
10
- static flags = {
11
- ...PushCommand.dryRunFlag,
12
- ...PushCommand.yesFlag,
13
- };
14
- async run() {
15
- await this.parse(Push);
16
- const templatesPath = resolveResourceDir('template', this.localConfig);
17
- const partsPath = resolveResourceDir('part', this.localConfig);
18
- this.log(`Pushing templates and parts to ${this.siteConfig.url}`);
19
- this.log(`Templates path: ${templatesPath}, parts path: ${partsPath}`);
20
- const { parts, problems, templates } = await readLocalTemplates(templatesPath, partsPath);
21
- if (problems.length > 0) {
22
- this.error(`Nothing was pushed, fix these files first:\n ${formatPageProblems(problems)}`);
23
- }
24
- const names = { parts: parts.map((part) => part.slug), templates: templates.map((template) => template.slug) };
25
- this.log(`Found ${pluralize(templates.length, 'template')} and ${pluralize(parts.length, 'part')}`);
26
- if (this.dryRun) {
27
- this.log('[dry-run] Would rewrite the child theme with them.');
28
- return { ...names, status: 'dry-run' };
29
- }
30
- const child = await this.wp.put(CHILD_THEME_ENDPOINT, { parts, templates });
31
- await this.recordSuccess();
32
- this.log(`Child theme ${child.stylesheet} written.`);
33
- if (!child.active) {
34
- this.warn(`${child.stylesheet} is not the active theme, so nothing changed on the site yet. Activate it in Appearance > Themes.`);
35
- }
36
- if (child.customized.length > 0) {
37
- this.warn(`Edited in the Site Editor, their database copy still wins over the pushed file: ${child.customized.join(', ')}. ` +
38
- 'Clear their customizations in Appearance > Editor to use the pushed version.');
39
- }
40
- return { ...names, active: child.active, customized: child.customized, status: 'success', stylesheet: child.stylesheet };
41
- }
42
- }