@loopress/cli 0.32.2 → 0.34.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +549 -179
- package/dist/commands/acf/list.d.ts +1 -1
- package/dist/commands/acf/list.js +3 -4
- package/dist/commands/acf/pull.d.ts +1 -1
- package/dist/commands/acf/pull.js +9 -6
- package/dist/commands/acf/push.d.ts +1 -1
- package/dist/commands/acf/push.js +5 -4
- package/dist/commands/api/publish.js +2 -1
- package/dist/commands/app/pull.js +2 -1
- package/dist/commands/app/push.js +7 -5
- package/dist/commands/app/{remove.d.ts → rm.d.ts} +1 -0
- package/dist/commands/app/{remove.js → rm.js} +2 -1
- package/dist/commands/block/diff.d.ts +2 -0
- package/dist/commands/block/diff.js +5 -0
- package/dist/commands/block/list.d.ts +8 -0
- package/dist/commands/block/list.js +23 -0
- package/dist/commands/block/pull.d.ts +21 -0
- package/dist/commands/block/pull.js +67 -0
- package/dist/commands/block/push.d.ts +20 -0
- package/dist/commands/block/push.js +53 -0
- package/dist/commands/block/rm.d.ts +23 -0
- package/dist/commands/block/rm.js +69 -0
- package/dist/commands/block/rollback.d.ts +2 -0
- package/dist/commands/block/rollback.js +5 -0
- package/dist/commands/composer/init.js +1 -0
- package/dist/commands/composer/push.js +5 -4
- package/dist/commands/content/copy.d.ts +3 -0
- package/dist/commands/content/copy.js +79 -2
- package/dist/commands/dev.d.ts +2 -2
- package/dist/commands/dev.js +13 -18
- package/dist/commands/diff.d.ts +6 -3
- package/dist/commands/diff.js +63 -13
- package/dist/commands/doctor.d.ts +4 -2
- package/dist/commands/doctor.js +49 -25
- package/dist/commands/form/pull.js +7 -5
- package/dist/commands/form/push.js +4 -3
- package/dist/commands/init.js +4 -3
- package/dist/commands/menu/push.js +8 -8
- package/dist/commands/option/list.js +5 -2
- package/dist/commands/option/push.js +3 -1
- package/dist/commands/option/{remove.d.ts → rm.d.ts} +1 -0
- package/dist/commands/option/{remove.js → rm.js} +2 -1
- package/dist/commands/page/push.js +3 -1
- package/dist/commands/plugin/add.d.ts +1 -0
- package/dist/commands/plugin/add.js +50 -9
- package/dist/commands/plugin/audit.d.ts +4 -4
- package/dist/commands/plugin/audit.js +35 -15
- package/dist/commands/plugin/pull.js +3 -2
- package/dist/commands/plugin/push.js +41 -6
- package/dist/commands/plugin/status.js +3 -1
- package/dist/commands/project/config.js +7 -7
- package/dist/commands/project/pull.js +3 -1
- package/dist/commands/project/push.js +6 -4
- package/dist/commands/project/{remove.d.ts → rm.d.ts} +1 -0
- package/dist/commands/project/{remove.js → rm.js} +3 -2
- package/dist/commands/project/switch.js +6 -4
- package/dist/commands/promote.d.ts +1 -0
- package/dist/commands/promote.js +15 -6
- package/dist/commands/pull.d.ts +7 -0
- package/dist/commands/pull.js +67 -18
- package/dist/commands/push.d.ts +11 -1
- package/dist/commands/push.js +157 -48
- package/dist/commands/snippet/publish.js +9 -5
- package/dist/commands/snippet/push.d.ts +1 -1
- package/dist/commands/snippet/push.js +15 -12
- package/dist/commands/status.js +1 -0
- package/dist/commands/theme/activate.d.ts +21 -0
- package/dist/commands/theme/activate.js +44 -0
- package/dist/commands/theme/pull.js +2 -1
- package/dist/commands/theme/push.js +7 -3
- package/dist/commands/theme/style/diff.js +1 -1
- package/dist/commands/theme/style/push.js +8 -3
- package/dist/commands/theme/style/rollback.js +1 -1
- package/dist/commands/theme/template/push.d.ts +2 -0
- package/dist/commands/theme/template/push.js +66 -18
- package/dist/commands/validate.js +3 -0
- package/dist/config/auth.manager.d.ts +10 -7
- package/dist/config/auth.manager.js +5 -1
- package/dist/config/json-file.d.ts +1 -1
- package/dist/config/project-config.manager.d.ts +2 -2
- package/dist/config/project-config.manager.js +13 -21
- package/dist/lib/api-client.js +16 -21
- package/dist/lib/app-build.js +6 -2
- package/dist/lib/app-manifest.js +6 -6
- package/dist/lib/base.d.ts +8 -1
- package/dist/lib/base.js +43 -9
- package/dist/lib/bootstrap-full-install.js +8 -6
- package/dist/lib/capabilities.d.ts +44 -0
- package/dist/lib/capabilities.js +85 -0
- package/dist/lib/declared-plugins.d.ts +9 -0
- package/dist/lib/declared-plugins.js +21 -0
- package/dist/lib/declared-type-command.js +7 -2
- package/dist/lib/dev-watch.d.ts +1 -1
- package/dist/lib/dev-watch.js +13 -15
- package/dist/lib/diff-command.d.ts +19 -3
- package/dist/lib/diff-command.js +132 -14
- package/dist/lib/diff-state.js +2 -3
- package/dist/lib/environment-urls.d.ts +6 -0
- package/dist/lib/environment-urls.js +59 -0
- package/dist/lib/exclude.d.ts +16 -0
- package/dist/lib/exclude.js +102 -0
- package/dist/lib/load-files.js +2 -1
- package/dist/lib/load-snippets.js +39 -43
- package/dist/lib/local-callback-server.js +7 -1
- package/dist/lib/narrow.d.ts +5 -0
- package/dist/lib/narrow.js +26 -0
- package/dist/lib/php-files-command.js +13 -8
- package/dist/lib/plugin-detection.d.ts +0 -1
- package/dist/lib/plugin-detection.js +2 -4
- package/dist/lib/push-command.d.ts +5 -1
- package/dist/lib/push-command.js +48 -7
- package/dist/lib/put-or-create.d.ts +2 -2
- package/dist/lib/readdir-tolerant.js +9 -4
- package/dist/lib/resource-selection.d.ts +9 -0
- package/dist/lib/resource-selection.js +49 -0
- package/dist/lib/resource-state.d.ts +2 -0
- package/dist/lib/resource-state.js +60 -14
- package/dist/lib/rollback-command.d.ts +1 -1
- package/dist/lib/rollback-command.js +4 -3
- package/dist/lib/rollback-materialize.js +29 -9
- package/dist/lib/rotate-app-password.js +1 -0
- package/dist/lib/sentry.js +2 -2
- package/dist/lib/snapshot-store.d.ts +10 -7
- package/dist/lib/snapshot-store.js +21 -5
- package/dist/lib/spinner.js +4 -0
- package/dist/lib/validate-local.d.ts +1 -0
- package/dist/lib/validate-local.js +102 -28
- package/dist/lib/wp-client.d.ts +7 -1
- package/dist/lib/wp-client.js +60 -69
- package/dist/lib/wp-site-diagnostic.js +3 -5
- package/dist/types/global-config.generated.d.ts +1 -1
- package/dist/types/plugin.d.ts +7 -5
- package/dist/types/plugin.js +6 -1
- package/dist/types/project-config.generated.d.ts +24 -5
- package/dist/types/snippet.d.ts +2 -2
- package/dist/types/snippet.js +1 -1
- package/dist/utils/acf-format.js +6 -3
- package/dist/utils/block-format.d.ts +29 -0
- package/dist/utils/block-format.js +109 -0
- package/dist/utils/composer.d.ts +7 -15
- package/dist/utils/composer.js +43 -16
- package/dist/utils/declared-type-format.js +26 -19
- package/dist/utils/form-format.js +2 -2
- package/dist/utils/loopress-config.js +9 -1
- package/dist/utils/menu-format.d.ts +3 -1
- package/dist/utils/menu-format.js +5 -0
- package/dist/utils/option-format.d.ts +2 -2
- package/dist/utils/option-format.js +15 -25
- package/dist/utils/page-format.js +9 -7
- package/dist/utils/php-top-level.js +15 -4
- package/dist/utils/plugin-sync.d.ts +7 -7
- package/dist/utils/plugin-sync.js +11 -11
- package/dist/utils/plugins.js +9 -6
- package/dist/utils/resource-dirs.d.ts +2 -0
- package/dist/utils/resource-dirs.js +13 -5
- package/dist/utils/snippet-format.d.ts +1 -1
- package/dist/utils/snippet-format.js +5 -3
- package/dist/utils/template-format.js +2 -2
- package/dist/utils/theme-css.js +14 -6
- package/dist/utils/theme-styles-format.d.ts +3 -0
- package/dist/utils/theme-styles-format.js +15 -5
- package/dist/utils/version.js +1 -1
- package/oclif.manifest.json +2661 -1235
- package/package.json +45 -20
- package/schemas/global-config.schema.json +1 -1
- package/schemas/project-config.schema.json +32 -5
package/dist/utils/composer.js
CHANGED
|
@@ -2,17 +2,41 @@ 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 { z } from 'zod';
|
|
6
|
+
import { errorMessage } from '../lib/narrow.js';
|
|
5
7
|
import { withSpinner } from '../lib/spinner.js';
|
|
8
|
+
import {} from './plugin-sync.js';
|
|
9
|
+
import {} from './plugins.js';
|
|
6
10
|
import { isExactVersion } from './version.js';
|
|
7
11
|
// Why pullIntoComposerJson skips a plugin it can't install: premium or custom, not on WPackagist.
|
|
8
12
|
export const NOT_ON_WORDPRESS_ORG = 'not on WordPress.org';
|
|
13
|
+
// Only the sections the CLI reads are checked: the rest is Composer's own business, and goes
|
|
14
|
+
// back to disk untouched.
|
|
15
|
+
export const COMPOSER_JSON = z.looseObject({
|
|
16
|
+
require: z.record(z.string(), z.string('"require" must map each package to a version constraint'), '"require" must be an object').optional(),
|
|
17
|
+
'require-dev': z
|
|
18
|
+
.record(z.string(), z.string('"require-dev" must map each package to a version constraint'), '"require-dev" must be an object')
|
|
19
|
+
.optional(),
|
|
20
|
+
}, 'expected a JSON object');
|
|
21
|
+
// Returns the parsed object itself, not zod's copy: zod's output moves the checked keys first, and
|
|
22
|
+
// pullIntoComposerJson writes this object back to the user's composer.json.
|
|
23
|
+
export function parseComposerJson(raw) {
|
|
24
|
+
const parsed = JSON.parse(raw);
|
|
25
|
+
if (isComposerJson(parsed))
|
|
26
|
+
return parsed;
|
|
27
|
+
const issues = COMPOSER_JSON.safeParse(parsed).error?.issues ?? [];
|
|
28
|
+
throw new Error(`Invalid composer.json: ${issues.map(({ message }) => message).join(', ')}`);
|
|
29
|
+
}
|
|
30
|
+
function isComposerJson(value) {
|
|
31
|
+
return COMPOSER_JSON.safeParse(value).success;
|
|
32
|
+
}
|
|
9
33
|
export async function readComposerJson() {
|
|
10
34
|
const path = join(process.cwd(), 'composer.json');
|
|
11
35
|
if (!existsSync(path))
|
|
12
36
|
return null;
|
|
13
37
|
try {
|
|
14
38
|
const content = await readFile(path, 'utf8');
|
|
15
|
-
return
|
|
39
|
+
return parseComposerJson(content);
|
|
16
40
|
}
|
|
17
41
|
catch {
|
|
18
42
|
return null;
|
|
@@ -48,7 +72,7 @@ async function isOnWordPressOrg(kind, slug) {
|
|
|
48
72
|
response = await withSpinner(`Checking WordPress.org for ${slug}`, async () => fetch(url));
|
|
49
73
|
}
|
|
50
74
|
catch (error) {
|
|
51
|
-
throw fail(`network error: ${error
|
|
75
|
+
throw fail(`network error: ${errorMessage(error)}`);
|
|
52
76
|
}
|
|
53
77
|
if (response.status === 404)
|
|
54
78
|
return false;
|
|
@@ -77,15 +101,16 @@ function planComposerPull(composerJson, prefix, incoming) {
|
|
|
77
101
|
const require = composerJson.require ?? {};
|
|
78
102
|
const allPackages = { ...require, ...composerJson['require-dev'] };
|
|
79
103
|
const providedBy = new Map(Object.keys(allPackages)
|
|
80
|
-
.filter((name) => !name.startsWith(
|
|
104
|
+
.filter((name) => !name.startsWith('wpackagist-plugin/') && !name.startsWith('wpackagist-theme/') && name.includes('/'))
|
|
81
105
|
.map((name) => [name.split('/', 2)[1], name]));
|
|
82
106
|
const result = { added: [], merged: {}, skipped: [], updated: [] };
|
|
83
107
|
const candidates = [];
|
|
84
108
|
for (const [slug, version] of Object.entries(incoming)) {
|
|
85
109
|
const name = prefix + slug;
|
|
86
110
|
const provider = providedBy.get(slug);
|
|
87
|
-
|
|
88
|
-
|
|
111
|
+
const declared = Object.hasOwn(allPackages, name) ? allPackages[name] : undefined;
|
|
112
|
+
if (declared !== undefined) {
|
|
113
|
+
const outcome = classifyDeclared(declared, version, Object.hasOwn(require, name));
|
|
89
114
|
if ('skip' in outcome) {
|
|
90
115
|
result.skipped.push({ reason: outcome.skip, slug });
|
|
91
116
|
continue;
|
|
@@ -98,7 +123,7 @@ function planComposerPull(composerJson, prefix, incoming) {
|
|
|
98
123
|
result.skipped.push({ reason: `provided by ${provider}`, slug });
|
|
99
124
|
}
|
|
100
125
|
else {
|
|
101
|
-
candidates.push(slug);
|
|
126
|
+
candidates.push([slug, version]);
|
|
102
127
|
}
|
|
103
128
|
}
|
|
104
129
|
return { candidates, result };
|
|
@@ -112,14 +137,14 @@ function planComposerPull(composerJson, prefix, incoming) {
|
|
|
112
137
|
// - A slug provided by another package (acme/<slug>) or absent from WordPress.org is skipped.
|
|
113
138
|
export async function pullIntoComposerJson(path, kind, incoming, { dryRun, log }) {
|
|
114
139
|
const raw = await readFile(path, 'utf8');
|
|
115
|
-
const composerJson =
|
|
140
|
+
const composerJson = parseComposerJson(raw);
|
|
116
141
|
const prefix = `wpackagist-${kind}/`;
|
|
117
142
|
const { candidates, result } = planComposerPull(composerJson, prefix, incoming);
|
|
118
|
-
const
|
|
119
|
-
for (const
|
|
120
|
-
if (
|
|
143
|
+
const checked = await Promise.all(candidates.map(async ([slug, version]) => ({ onWordPressOrg: await isOnWordPressOrg(kind, slug), slug, version })));
|
|
144
|
+
for (const { onWordPressOrg, slug, version } of checked) {
|
|
145
|
+
if (onWordPressOrg) {
|
|
121
146
|
result.added.push(slug);
|
|
122
|
-
result.merged[slug] =
|
|
147
|
+
result.merged[slug] = version;
|
|
123
148
|
}
|
|
124
149
|
else {
|
|
125
150
|
result.skipped.push({ reason: NOT_ON_WORDPRESS_ORG, slug });
|
|
@@ -146,21 +171,23 @@ export async function pullIntoComposerJson(path, kind, incoming, { dryRun, log }
|
|
|
146
171
|
// Split a composer.json `require` map into the three intent namespaces the sync endpoint
|
|
147
172
|
// understands. `composer/installers` is owned by the server scaffold, never sent.
|
|
148
173
|
export function toIntent(require) {
|
|
149
|
-
const
|
|
174
|
+
const plugins = {};
|
|
175
|
+
const themes = {};
|
|
176
|
+
const libraries = {};
|
|
150
177
|
for (const [name, constraint] of Object.entries(require)) {
|
|
151
178
|
if (name === 'composer/installers')
|
|
152
179
|
continue;
|
|
153
180
|
if (name.startsWith('wpackagist-plugin/')) {
|
|
154
|
-
|
|
181
|
+
plugins[name.slice('wpackagist-plugin/'.length)] = constraint;
|
|
155
182
|
}
|
|
156
183
|
else if (name.startsWith('wpackagist-theme/')) {
|
|
157
|
-
|
|
184
|
+
themes[name.slice('wpackagist-theme/'.length)] = constraint;
|
|
158
185
|
}
|
|
159
186
|
else {
|
|
160
|
-
|
|
187
|
+
libraries[name] = constraint;
|
|
161
188
|
}
|
|
162
189
|
}
|
|
163
|
-
return
|
|
190
|
+
return { libraries, plugins, themes };
|
|
164
191
|
}
|
|
165
192
|
// What a push installs: `require`, plus `require-dev` on the local environment only (Query
|
|
166
193
|
// Monitor, or a premium plugin's free edition to develop against). A remote site never gets
|
|
@@ -1,5 +1,7 @@
|
|
|
1
1
|
import { basename } from 'node:path';
|
|
2
|
+
import { z } from 'zod';
|
|
2
3
|
import { parseHtmlHeader, titleFromSlug } from './page-format.js';
|
|
4
|
+
import {} from './resource-dirs.js';
|
|
3
5
|
export const CPT_SPEC = {
|
|
4
6
|
arrayArgs: { capabilities: [], labels: [], rewrite: [true, false], supports: [false], taxonomies: [], template: [] },
|
|
5
7
|
cliName: 'cpt',
|
|
@@ -31,15 +33,16 @@ export const TAXONOMY_SPEC = {
|
|
|
31
33
|
// register_taxonomy() creates the default term when it doesn't exist yet.
|
|
32
34
|
termArgs: ['default_term'],
|
|
33
35
|
};
|
|
34
|
-
const
|
|
35
|
-
|
|
36
|
+
const JSON_OBJECT = z.record(z.string(), z.unknown());
|
|
37
|
+
const isObject = (value) => JSON_OBJECT.safeParse(value).success;
|
|
38
|
+
// register_post_meta()'s `type` values, each with the schema its `default` must pass.
|
|
36
39
|
const META_TYPES = {
|
|
37
|
-
array:
|
|
38
|
-
boolean: (
|
|
39
|
-
integer:
|
|
40
|
-
number: (
|
|
41
|
-
object:
|
|
42
|
-
string: (
|
|
40
|
+
array: z.array(z.unknown()),
|
|
41
|
+
boolean: z.boolean(),
|
|
42
|
+
integer: z.int(),
|
|
43
|
+
number: z.number(),
|
|
44
|
+
object: JSON_OBJECT,
|
|
45
|
+
string: z.string(),
|
|
43
46
|
};
|
|
44
47
|
function checkMetaRest(key, type, rest) {
|
|
45
48
|
if (typeof rest !== 'boolean' && !isObject(rest))
|
|
@@ -54,7 +57,7 @@ function checkMetaRest(key, type, rest) {
|
|
|
54
57
|
}
|
|
55
58
|
}
|
|
56
59
|
function checkMetaKey(key, meta) {
|
|
57
|
-
if (!/^[
|
|
60
|
+
if (!/^[\w-]{1,255}$/.test(key))
|
|
58
61
|
throw new Error(`"${key}" is not a valid meta key: 1 to 255 letters, digits, "_" or "-"`);
|
|
59
62
|
if (!isObject(meta))
|
|
60
63
|
throw new Error(`"${key}" must be a JSON object of register_post_meta() arguments`);
|
|
@@ -62,13 +65,14 @@ function checkMetaKey(key, meta) {
|
|
|
62
65
|
if (code)
|
|
63
66
|
throw new Error(`"${key}": "${code}" runs PHP code, use a hook instead of a file for it`);
|
|
64
67
|
const { type } = meta;
|
|
65
|
-
|
|
68
|
+
const schema = typeof type === 'string' && Object.hasOwn(META_TYPES, type) ? META_TYPES[type] : undefined;
|
|
69
|
+
if (typeof type !== 'string' || !schema) {
|
|
66
70
|
throw new Error(`"${key}": "type" must be one of ${Object.keys(META_TYPES).join(', ')}`);
|
|
67
71
|
}
|
|
68
72
|
if (Object.hasOwn(meta, 'single') && typeof meta.single !== 'boolean')
|
|
69
73
|
throw new Error(`"${key}": "single" must be true or false`);
|
|
70
|
-
checkMetaRest(key, type, meta.show_in_rest
|
|
71
|
-
if (Object.hasOwn(meta, 'default') && !
|
|
74
|
+
checkMetaRest(key, type, Object.hasOwn(meta, 'show_in_rest') ? meta.show_in_rest : false);
|
|
75
|
+
if (Object.hasOwn(meta, 'default') && !schema.safeParse(meta.default).success)
|
|
72
76
|
throw new Error(`"${key}": "default" must be a ${type}`);
|
|
73
77
|
}
|
|
74
78
|
// Same checks as the plugin's PostMetaService::assertArgs(): the file is `key => register_post_meta()
|
|
@@ -99,7 +103,7 @@ function checkRoleArgs(args) {
|
|
|
99
103
|
if (typeof args.name !== 'string' || args.name.trim() === '')
|
|
100
104
|
throw new Error('"name" must be a non-empty string');
|
|
101
105
|
const capabilities = args.capabilities ?? {};
|
|
102
|
-
if (!
|
|
106
|
+
if (!z.record(z.string(), z.boolean()).safeParse(capabilities).success) {
|
|
103
107
|
throw new Error('"capabilities" must map each capability name to true or false');
|
|
104
108
|
}
|
|
105
109
|
}
|
|
@@ -132,6 +136,8 @@ const PATTERN_HEADER = {
|
|
|
132
136
|
title: { key: 'title', type: 'text' },
|
|
133
137
|
'viewport-width': { key: 'viewportWidth', type: 'integer' },
|
|
134
138
|
};
|
|
139
|
+
// Typed lookups by header name: a plain object index needs `keyof typeof` on a string.
|
|
140
|
+
const PATTERN_HEADER_BY_NAME = new Map(Object.entries(PATTERN_HEADER));
|
|
135
141
|
function readPatternValue(header, type, value) {
|
|
136
142
|
if (type === 'list')
|
|
137
143
|
return value.split(',').map((item) => item.trim()).filter(Boolean);
|
|
@@ -152,8 +158,10 @@ export function parsePatternFile(slug, raw) {
|
|
|
152
158
|
const { body, meta } = parseHtmlHeader(raw, Object.keys(PATTERN_HEADER));
|
|
153
159
|
const args = { title: titleFromSlug(slug) };
|
|
154
160
|
for (const [header, value] of Object.entries(meta)) {
|
|
155
|
-
|
|
156
|
-
|
|
161
|
+
// parseHtmlHeader already refuses any other header name.
|
|
162
|
+
const spec = PATTERN_HEADER_BY_NAME.get(header);
|
|
163
|
+
if (spec)
|
|
164
|
+
args[spec.key] = readPatternValue(header, spec.type, value);
|
|
157
165
|
}
|
|
158
166
|
args.content = body;
|
|
159
167
|
return args;
|
|
@@ -199,11 +207,10 @@ export function itemEndpoint(spec, slug) {
|
|
|
199
207
|
return `${spec.endpoint}/${encodeURIComponent(slug)}`;
|
|
200
208
|
}
|
|
201
209
|
export function parseDeclaredArgs(raw) {
|
|
202
|
-
const
|
|
203
|
-
if (
|
|
210
|
+
const result = JSON_OBJECT.safeParse(JSON.parse(raw));
|
|
211
|
+
if (!result.success)
|
|
204
212
|
throw new Error('not a JSON object of arguments');
|
|
205
|
-
|
|
206
|
-
return parsed;
|
|
213
|
+
return result.data;
|
|
207
214
|
}
|
|
208
215
|
// `lps validate`'s check of one file: the file name is a valid slug, array arguments are arrays,
|
|
209
216
|
// and no argument is code.
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { isRecord } from '../lib/narrow.js';
|
|
1
2
|
export const FORM_ENDPOINT = 'loopress/v1/forms';
|
|
2
3
|
export function formEndpoint(id) {
|
|
3
4
|
return `${FORM_ENDPOINT}/${id}`;
|
|
@@ -7,7 +8,6 @@ export function getFormId(data) {
|
|
|
7
8
|
return Number.isSafeInteger(id) && id > 0 ? id : null;
|
|
8
9
|
}
|
|
9
10
|
export function getFormTitle(data) {
|
|
10
|
-
const
|
|
11
|
-
const title = settings?.form_title;
|
|
11
|
+
const title = isRecord(data.settings) ? data.settings.form_title : undefined;
|
|
12
12
|
return typeof title === 'string' && title.trim() !== '' ? title : '(untitled)';
|
|
13
13
|
}
|
|
@@ -1,6 +1,8 @@
|
|
|
1
1
|
import { existsSync } from 'node:fs';
|
|
2
2
|
import { readFile, writeFile } from 'node:fs/promises';
|
|
3
3
|
import { join } from 'node:path';
|
|
4
|
+
import { isRecord } from '../lib/narrow.js';
|
|
5
|
+
import {} from '../types/project-config.generated.js';
|
|
4
6
|
// Only a missing file is treated as "no config" (returns {}). A file that exists but
|
|
5
7
|
// fails to read or parse throws, so callers don't silently fall back to the global
|
|
6
8
|
// current environment when loopress.json is actually broken.
|
|
@@ -9,12 +11,18 @@ export async function readLocalConfig() {
|
|
|
9
11
|
if (!existsSync(configPath))
|
|
10
12
|
return {};
|
|
11
13
|
const content = await readFile(configPath, 'utf8');
|
|
14
|
+
let parsed;
|
|
12
15
|
try {
|
|
13
|
-
|
|
16
|
+
parsed = JSON.parse(content);
|
|
14
17
|
}
|
|
15
18
|
catch {
|
|
16
19
|
throw new Error('loopress.json is not valid JSON. Fix or delete it, then run `lps init` again.');
|
|
17
20
|
}
|
|
21
|
+
if (!isRecord(parsed) || Array.isArray(parsed))
|
|
22
|
+
throw new Error('loopress.json must be a JSON object. Fix or delete it, then run `lps init` again.');
|
|
23
|
+
// Its keys are checked by `lps validate` (lib/validate-local.ts), not here: schemas/project-config.schema.json
|
|
24
|
+
// refuses unknown keys, which would stop every command over one stray key.
|
|
25
|
+
return parsed;
|
|
18
26
|
}
|
|
19
27
|
export async function writeLocalConfig(config) {
|
|
20
28
|
const configPath = join(process.cwd(), 'loopress.json');
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { z } from 'zod';
|
|
1
2
|
export declare const MENU_ENDPOINT = "loopress/v1/menus";
|
|
2
3
|
export declare const MENU_LOCATIONS_ENDPOINT = "loopress/v1/menu-locations";
|
|
3
4
|
export declare function menuEndpoint(slug: string): string;
|
|
@@ -20,5 +21,6 @@ export type Menu = {
|
|
|
20
21
|
slug: string;
|
|
21
22
|
warnings: string[];
|
|
22
23
|
};
|
|
23
|
-
export
|
|
24
|
+
export declare const MENU_LOCATIONS: z.ZodRecord<z.ZodString, z.ZodNullable<z.ZodString>>;
|
|
25
|
+
export type MenuLocations = z.infer<typeof MENU_LOCATIONS>;
|
|
24
26
|
export declare function getMenuSlug(data: Record<string, unknown>): null | string;
|
|
@@ -1,8 +1,13 @@
|
|
|
1
|
+
import { z } from 'zod';
|
|
1
2
|
export const MENU_ENDPOINT = 'loopress/v1/menus';
|
|
2
3
|
export const MENU_LOCATIONS_ENDPOINT = 'loopress/v1/menu-locations';
|
|
3
4
|
export function menuEndpoint(slug) {
|
|
4
5
|
return `${MENU_ENDPOINT}/${encodeURIComponent(slug)}`;
|
|
5
6
|
}
|
|
7
|
+
// location => menu slug, or null when unassigned. Scoped to whichever theme is active on
|
|
8
|
+
// whichever environment answered the request (a theme_mod, see MenuService::getLocations()).
|
|
9
|
+
// Location slug -> assigned menu slug, null for an unassigned location.
|
|
10
|
+
export const MENU_LOCATIONS = z.record(z.string(), z.string().nullable());
|
|
6
11
|
export function getMenuSlug(data) {
|
|
7
12
|
return typeof data.slug === 'string' && data.slug.trim() !== '' ? data.slug : null;
|
|
8
13
|
}
|
|
@@ -6,8 +6,8 @@ export type OptionRefs = Record<string, string>;
|
|
|
6
6
|
export type LocalOption = {
|
|
7
7
|
autoload: string;
|
|
8
8
|
name: string;
|
|
9
|
-
readonly?: boolean;
|
|
10
|
-
refs?: OptionRefs;
|
|
9
|
+
readonly?: boolean | undefined;
|
|
10
|
+
refs?: OptionRefs | undefined;
|
|
11
11
|
value: unknown;
|
|
12
12
|
};
|
|
13
13
|
export type RemoteOption = {
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { z } from 'zod';
|
|
1
2
|
export const OPTIONS_ENDPOINT = 'loopress/v1/options';
|
|
2
3
|
// `refs` asks WordPress to return the declared post references as paths (see OptionRefs).
|
|
3
4
|
export function optionEndpoint(name, refs) {
|
|
@@ -49,32 +50,21 @@ export function partitionByReadonly(tracked) {
|
|
|
49
50
|
(option.readonly ? skipped : writable).push(option);
|
|
50
51
|
return { skipped, writable };
|
|
51
52
|
}
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
if (typeof name !== 'string' || name === '') {
|
|
59
|
-
throw new Error('missing a "name" string');
|
|
60
|
-
}
|
|
61
|
-
if (typeof autoload !== 'string') {
|
|
62
|
-
throw new TypeError('missing an "autoload" string');
|
|
63
|
-
}
|
|
53
|
+
const REFS_ERROR = '"refs" must map a value path to a post type, e.g. {"purchase_page": "page"}';
|
|
54
|
+
// Each check's message is the error thrown for it, the first failing one wins. Loose: unknown
|
|
55
|
+
// keys (`readonly`, future ones) pass through untouched.
|
|
56
|
+
const LOCAL_OPTION = z.looseObject({
|
|
57
|
+
name: z.string('missing a "name" string').min(1, 'missing a "name" string'),
|
|
58
|
+
autoload: z.string('missing an "autoload" string'),
|
|
64
59
|
// `value` itself is deliberately untyped (a WP option can hold any JSON-safe value, including
|
|
65
60
|
// null); only the key's presence is checked, so a hand-edited file that dropped it entirely is
|
|
66
61
|
// rejected here rather than silently pushing an `undefined` value to WordPress.
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
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 !== ''));
|
|
62
|
+
value: z.custom((value) => value !== undefined, 'missing a "value" field'),
|
|
63
|
+
refs: z.record(z.string().min(1, REFS_ERROR), z.string(REFS_ERROR).min(1, REFS_ERROR), REFS_ERROR).optional(),
|
|
64
|
+
}, 'not a JSON object');
|
|
65
|
+
export function parseLocalOption(raw) {
|
|
66
|
+
const result = LOCAL_OPTION.safeParse(JSON.parse(raw));
|
|
67
|
+
if (!result.success)
|
|
68
|
+
throw new TypeError(result.error.issues[0]?.message);
|
|
69
|
+
return result.data;
|
|
80
70
|
}
|
|
@@ -1,5 +1,7 @@
|
|
|
1
1
|
import { readdir, readFile } from 'node:fs/promises';
|
|
2
2
|
import { basename, extname, join } from 'node:path';
|
|
3
|
+
import { withoutExcluded } from '../lib/exclude.js';
|
|
4
|
+
import { errorCode, errorMessage, isOneOf } from '../lib/narrow.js';
|
|
3
5
|
export const PAGES_ENDPOINT = 'loopress/v1/pages';
|
|
4
6
|
// Mirrored server-side in PagesController. No leading, trailing or doubled hyphen: WordPress's
|
|
5
7
|
// sanitize_title() would rewrite those, and the page would no longer match its file name.
|
|
@@ -45,7 +47,7 @@ export function titleFromSlug(slug) {
|
|
|
45
47
|
export function parsePageFile(slug, raw) {
|
|
46
48
|
const { body, meta } = parseHtmlHeader(raw, HEADER_KEYS);
|
|
47
49
|
const status = meta.status ?? 'draft';
|
|
48
|
-
if (!PAGE_STATUSES
|
|
50
|
+
if (!isOneOf(PAGE_STATUSES, status)) {
|
|
49
51
|
throw new Error(`status "${status}" is not one of ${PAGE_STATUSES.join(', ')}`);
|
|
50
52
|
}
|
|
51
53
|
return {
|
|
@@ -53,9 +55,9 @@ export function parsePageFile(slug, raw) {
|
|
|
53
55
|
hideTitle: parseBoolean('hide-title', meta['hide-title']),
|
|
54
56
|
html: body,
|
|
55
57
|
slug,
|
|
56
|
-
status
|
|
58
|
+
status,
|
|
57
59
|
template: meta.template ?? '',
|
|
58
|
-
title: meta.title || titleFromSlug(slug),
|
|
60
|
+
title: (meta.title ?? '') || titleFromSlug(slug),
|
|
59
61
|
};
|
|
60
62
|
}
|
|
61
63
|
// Splits the optional `key: value` HTML comment header off `raw`. Shared with templates/.
|
|
@@ -64,7 +66,7 @@ export function parseHtmlHeader(raw, keys) {
|
|
|
64
66
|
const meta = {};
|
|
65
67
|
if (!header)
|
|
66
68
|
return { body: raw, meta };
|
|
67
|
-
for (const line of header[1].split(/\r?\n/)) {
|
|
69
|
+
for (const line of (header[1] ?? '').split(/\r?\n/)) {
|
|
68
70
|
if (line.trim() === '')
|
|
69
71
|
continue;
|
|
70
72
|
const colon = line.indexOf(':');
|
|
@@ -89,10 +91,10 @@ export async function readLocalPages(dir) {
|
|
|
89
91
|
export async function readLocalHtmlFiles(dir, parse, slugRule = PAGE_SLUG_RULE) {
|
|
90
92
|
let entries;
|
|
91
93
|
try {
|
|
92
|
-
entries = await readdir(dir, { withFileTypes: true });
|
|
94
|
+
entries = withoutExcluded(dir, await readdir(dir, { withFileTypes: true }), (entry) => entry.name);
|
|
93
95
|
}
|
|
94
96
|
catch (error) {
|
|
95
|
-
if (error
|
|
97
|
+
if (errorCode(error) === 'ENOENT')
|
|
96
98
|
return { files: [], problems: [] };
|
|
97
99
|
throw error;
|
|
98
100
|
}
|
|
@@ -120,7 +122,7 @@ export async function readLocalHtmlFiles(dir, parse, slugRule = PAGE_SLUG_RULE)
|
|
|
120
122
|
files.push(parse(slug, await readFile(filePath, 'utf8')));
|
|
121
123
|
}
|
|
122
124
|
catch (error) {
|
|
123
|
-
problems.push({ file: filePath, message: error
|
|
125
|
+
problems.push({ file: filePath, message: errorMessage(error) });
|
|
124
126
|
}
|
|
125
127
|
}
|
|
126
128
|
return { files, problems };
|
|
@@ -10,7 +10,7 @@ const OPEN_TAG = /<\?php\b/i;
|
|
|
10
10
|
export function firstTopLevelSideEffect(content) {
|
|
11
11
|
if (content.search(OPEN_TAG) !== 0)
|
|
12
12
|
return 'inline HTML before <?php';
|
|
13
|
-
const state = { atStart: true, braces: [], depth: 0, namespaceHeader: false };
|
|
13
|
+
const state = { atStart: true, attribute: 0, braces: [], depth: 0, namespaceHeader: false };
|
|
14
14
|
for (const token of lex(content.slice(5))) {
|
|
15
15
|
const found = step(state, token);
|
|
16
16
|
if (found !== null)
|
|
@@ -36,6 +36,8 @@ export function startsWithStrictTypes(content) {
|
|
|
36
36
|
function step(state, token) {
|
|
37
37
|
if (token.type === 'html')
|
|
38
38
|
return inlineHtml(state, token);
|
|
39
|
+
if (state.attribute > 0 || token.type === 'attribute')
|
|
40
|
+
return skipAttribute(state, token);
|
|
39
41
|
const statementStart = state.depth === 0 && state.atStart;
|
|
40
42
|
switch (token.text) {
|
|
41
43
|
case ';': {
|
|
@@ -64,6 +66,15 @@ function step(state, token) {
|
|
|
64
66
|
}
|
|
65
67
|
}
|
|
66
68
|
}
|
|
69
|
+
// An attribute (`#[Attr(...)]`) only decorates what follows it, so the statement still starts
|
|
70
|
+
// after its closing `]`: `#[Attr] foo();` must report `foo`.
|
|
71
|
+
function skipAttribute(state, token) {
|
|
72
|
+
if (token.type === 'attribute' || token.text === '[')
|
|
73
|
+
state.attribute += 1;
|
|
74
|
+
else if (token.text === ']')
|
|
75
|
+
state.attribute -= 1;
|
|
76
|
+
return null;
|
|
77
|
+
}
|
|
67
78
|
// A template inside a class or function body (`?> <p>...</p> <?php`) only runs when it is called.
|
|
68
79
|
function inlineHtml(state, token) {
|
|
69
80
|
if (state.depth > 0)
|
|
@@ -86,7 +97,7 @@ function openBrace(state, statementStart) {
|
|
|
86
97
|
return null;
|
|
87
98
|
}
|
|
88
99
|
function isDeclaration(token) {
|
|
89
|
-
return token.type === '
|
|
100
|
+
return token.type === 'word' && ALLOWED.has(token.text.toLowerCase());
|
|
90
101
|
}
|
|
91
102
|
function* lex(source) {
|
|
92
103
|
let i = skipTrivia(source, 0);
|
|
@@ -126,13 +137,13 @@ function readToken(source, i) {
|
|
|
126
137
|
}
|
|
127
138
|
if (source.startsWith('#[', i))
|
|
128
139
|
return [{ text: '#[', type: 'attribute' }, i + 2];
|
|
129
|
-
if (source.startsWith('<<<', i) || `'"\``.includes(source
|
|
140
|
+
if (source.startsWith('<<<', i) || `'"\``.includes(source.charAt(i))) {
|
|
130
141
|
return [{ text: source[i] === '`' ? '`' : 'a string', type: 'string' }, skipString(source, i)];
|
|
131
142
|
}
|
|
132
143
|
const word = /^[\w$\\]+/.exec(source.slice(i));
|
|
133
144
|
if (word)
|
|
134
145
|
return [{ text: word[0], type: 'word' }, i + word[0].length];
|
|
135
|
-
return [{ text: source
|
|
146
|
+
return [{ text: source.charAt(i), type: 'punct' }, i + 1];
|
|
136
147
|
}
|
|
137
148
|
// Index right after the string (quoted, backtick, heredoc or nowdoc) starting at `i`.
|
|
138
149
|
function skipString(source, i) {
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { z } from 'zod';
|
|
1
2
|
export declare const SYNC_TIMEOUT_MS = 600000;
|
|
2
3
|
export type SyncIntent = {
|
|
3
4
|
libraries?: Record<string, string>;
|
|
@@ -20,13 +21,12 @@ export type SyncResponse = {
|
|
|
20
21
|
lockDrift?: LockDriftEntry[];
|
|
21
22
|
message: string;
|
|
22
23
|
output: string;
|
|
23
|
-
removed
|
|
24
|
-
};
|
|
25
|
-
export type Collision = {
|
|
26
|
-
installedVersion: string;
|
|
27
|
-
path: string;
|
|
28
|
-
slug: string;
|
|
29
|
-
type: 'plugin' | 'theme';
|
|
24
|
+
removed?: string[];
|
|
30
25
|
};
|
|
26
|
+
declare const COLLISIONS: z.ZodArray<z.ZodObject<{
|
|
27
|
+
slug: z.ZodString;
|
|
28
|
+
}, z.core.$loose>>;
|
|
29
|
+
export type Collision = z.infer<typeof COLLISIONS>[number];
|
|
31
30
|
export declare function parseCollisions(error: unknown): Collision[] | null;
|
|
32
31
|
export declare function isDowngrade(from: string, to: string): boolean;
|
|
32
|
+
export {};
|
|
@@ -2,26 +2,26 @@
|
|
|
2
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
|
+
import { z } from 'zod';
|
|
6
|
+
import { causeOf } from '../lib/narrow.js';
|
|
7
|
+
import { parseBody, responseOf } from '../lib/wp-client.js';
|
|
5
8
|
import { compareVersions } from './version.js';
|
|
6
9
|
// A cold `composer update` on a fresh site easily exceeds the global 30s WpClient timeout.
|
|
7
10
|
export const SYNC_TIMEOUT_MS = 600_000;
|
|
11
|
+
// The server also sends installedVersion, path and type, only the slug is read here.
|
|
12
|
+
const COLLISIONS = z.array(z.looseObject({ slug: z.string() }));
|
|
8
13
|
// 422 from the sync endpoint when the intent references a plugin/theme whose folder already
|
|
9
14
|
// exists on the site but isn't tracked in composer.lock (installed by hand through wp-admin).
|
|
10
15
|
// Re-running with --force lets the server remove the folder so Composer can take it over.
|
|
11
16
|
export function parseCollisions(error) {
|
|
12
|
-
const body = error
|
|
13
|
-
if (
|
|
17
|
+
const { body, statusCode } = responseOf(causeOf(error));
|
|
18
|
+
if (statusCode !== 422)
|
|
14
19
|
return null;
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
if (parsed.error === 'unmanaged_plugins_present' && Array.isArray(parsed.collisions)) {
|
|
18
|
-
return parsed.collisions;
|
|
19
|
-
}
|
|
20
|
-
}
|
|
21
|
-
catch {
|
|
20
|
+
const parsed = parseBody(body);
|
|
21
|
+
if (parsed.error !== 'unmanaged_plugins_present')
|
|
22
22
|
return null;
|
|
23
|
-
|
|
24
|
-
return null;
|
|
23
|
+
const collisions = COLLISIONS.safeParse(parsed.collisions);
|
|
24
|
+
return collisions.success ? collisions.data : null;
|
|
25
25
|
}
|
|
26
26
|
// Only gates whether a version change needs --force. Unorderable inputs ("latest", a
|
|
27
27
|
// constraint) count as "not a downgrade" so they don't block the push.
|
package/dist/utils/plugins.js
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { isRecord } from '../lib/narrow.js';
|
|
1
2
|
import { pinActive, pinVersion, } from '../types/plugin.js';
|
|
2
3
|
import { isExactVersion } from './version.js';
|
|
3
4
|
// "1.7.2", or "1.7.2 (inactive)" for a pin that keeps the plugin inactive.
|
|
@@ -13,9 +14,10 @@ const LOOPRESS_PLUGIN_SLUGS = new Set(['loopress', 'loopress-full', 'loopress-li
|
|
|
13
14
|
export function mergePluginManifest(existing, incoming) {
|
|
14
15
|
const merged = { ...existing, ...incoming };
|
|
15
16
|
const added = Object.keys(incoming).filter((s) => !Object.hasOwn(existing, s));
|
|
16
|
-
const updated = Object.
|
|
17
|
-
|
|
18
|
-
|
|
17
|
+
const updated = Object.entries(incoming).flatMap(([slug, pin]) => {
|
|
18
|
+
const before = Object.hasOwn(existing, slug) ? existing[slug] : undefined;
|
|
19
|
+
return before !== undefined && describePin(before) !== describePin(pin) ? [{ from: describePin(before), slug, to: describePin(pin) }] : [];
|
|
20
|
+
});
|
|
19
21
|
return { added, merged, updated };
|
|
20
22
|
}
|
|
21
23
|
// WordPress core identifies each plugin by a `<folder>/<file>` id (or a bare `<file>` for a
|
|
@@ -30,7 +32,7 @@ export function mergePluginManifest(existing, incoming) {
|
|
|
30
32
|
const WP_ORG_PLUGIN_URI = /^https?:\/\/wordpress\.org\/plugins\/([^/]+)\/?$/;
|
|
31
33
|
function slugFromPluginFile(file, pluginUri) {
|
|
32
34
|
if (file.includes('/'))
|
|
33
|
-
return file.
|
|
35
|
+
return file.slice(0, file.indexOf('/'));
|
|
34
36
|
return WP_ORG_PLUGIN_URI.exec(pluginUri)?.[1] ?? file;
|
|
35
37
|
}
|
|
36
38
|
export function parseInstalledPlugins(raw) {
|
|
@@ -93,8 +95,9 @@ export function lockedWpackagistSlugs(composerLock, kind) {
|
|
|
93
95
|
const prefix = `wpackagist-${kind}/`;
|
|
94
96
|
try {
|
|
95
97
|
const parsed = JSON.parse(composerLock);
|
|
96
|
-
|
|
97
|
-
|
|
98
|
+
const packages = isRecord(parsed) && Array.isArray(parsed.packages) ? parsed.packages : [];
|
|
99
|
+
return new Set(packages
|
|
100
|
+
.map((p) => (isRecord(p) && typeof p.name === 'string' ? p.name : ''))
|
|
98
101
|
.filter((name) => name.startsWith(prefix))
|
|
99
102
|
.map((name) => name.slice(prefix.length)));
|
|
100
103
|
}
|
|
@@ -3,6 +3,7 @@ export declare const RESOURCE_DIR_DEFAULTS: {
|
|
|
3
3
|
readonly acf: "acf";
|
|
4
4
|
readonly api: "api";
|
|
5
5
|
readonly apps: "apps";
|
|
6
|
+
readonly block: "blocks";
|
|
6
7
|
readonly blockCss: "theme/blocks";
|
|
7
8
|
readonly cpt: "cpt";
|
|
8
9
|
readonly css: "theme/css";
|
|
@@ -23,3 +24,4 @@ export declare const RESOURCE_DIR_DEFAULTS: {
|
|
|
23
24
|
export type ResourceDirKind = keyof typeof RESOURCE_DIR_DEFAULTS;
|
|
24
25
|
export declare function resolveResourceDir(kind: ResourceDirKind, localConfig: LoopressLocalConfig, override?: string): string;
|
|
25
26
|
export declare function projectUses(kind: ResourceDirKind, localConfig: LoopressLocalConfig): boolean;
|
|
27
|
+
export declare function projectHasChildThemeFiles(localConfig: LoopressLocalConfig): boolean;
|