@loopress/cli 0.32.1 → 0.33.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 +511 -154
- 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 +5 -4
- 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 +48 -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.js +4 -3
- package/dist/commands/diff.d.ts +1 -0
- package/dist/commands/diff.js +33 -3
- package/dist/commands/doctor.d.ts +4 -2
- package/dist/commands/doctor.js +37 -23
- 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 +2 -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 +2 -1
- 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 +2 -2
- package/dist/commands/plugin/status.js +3 -1
- package/dist/commands/project/config.js +7 -6
- 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.js +7 -2
- package/dist/commands/pull.d.ts +2 -0
- package/dist/commands/pull.js +42 -18
- package/dist/commands/push.d.ts +2 -1
- package/dist/commands/push.js +69 -25
- 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/push.js +7 -2
- package/dist/commands/theme/template/push.js +25 -6
- 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 +2 -1
- package/dist/lib/base.js +10 -8
- 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-type-command.js +4 -2
- package/dist/lib/dev-watch.js +5 -5
- package/dist/lib/diff-command.d.ts +2 -3
- package/dist/lib/diff-command.js +9 -7
- package/dist/lib/diff-state.js +2 -3
- package/dist/lib/load-files.js +2 -1
- package/dist/lib/load-snippets.js +37 -42
- 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 +8 -5
- package/dist/lib/plugin-detection.d.ts +0 -1
- package/dist/lib/plugin-detection.js +2 -4
- package/dist/lib/push-command.d.ts +1 -1
- package/dist/lib/push-command.js +19 -7
- package/dist/lib/put-or-create.d.ts +2 -2
- package/dist/lib/readdir-tolerant.js +3 -1
- package/dist/lib/resource-state.d.ts +1 -1
- package/dist/lib/resource-state.js +58 -19
- package/dist/lib/rollback-command.d.ts +1 -1
- package/dist/lib/rollback-command.js +3 -2
- package/dist/lib/rollback-materialize.js +28 -8
- 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.js +35 -20
- package/dist/lib/wp-client.d.ts +6 -1
- package/dist/lib/wp-client.js +54 -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 +9 -1
- package/dist/types/snippet.d.ts +2 -2
- package/dist/types/snippet.js +1 -1
- package/dist/utils/acf-format.d.ts +1 -0
- package/dist/utils/acf-format.js +11 -3
- package/dist/utils/block-format.d.ts +27 -0
- package/dist/utils/block-format.js +93 -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 +7 -6
- 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 +9 -1
- 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 +44 -6
- package/dist/utils/version.js +1 -1
- package/oclif.manifest.json +1588 -1043
- package/package.json +42 -17
- package/schemas/global-config.schema.json +1 -1
- package/schemas/project-config.schema.json +11 -1
|
@@ -54,7 +54,7 @@ export interface EnvironmentConfig {
|
|
|
54
54
|
*/
|
|
55
55
|
name: string;
|
|
56
56
|
/**
|
|
57
|
-
*
|
|
57
|
+
* WordPress Application Password credentials for this environment, as "user:password". Not the Loopress account token.
|
|
58
58
|
*/
|
|
59
59
|
token?: string;
|
|
60
60
|
/**
|
package/dist/types/plugin.d.ts
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { z } from 'zod';
|
|
1
2
|
export type InstalledPlugin = {
|
|
2
3
|
active: boolean;
|
|
3
4
|
file: string;
|
|
@@ -12,11 +13,12 @@ export type WpNativePlugin = {
|
|
|
12
13
|
status: 'active' | 'inactive' | 'network-active';
|
|
13
14
|
version: string;
|
|
14
15
|
};
|
|
15
|
-
export
|
|
16
|
-
active
|
|
17
|
-
version:
|
|
18
|
-
}
|
|
16
|
+
export declare const PLUGIN_PIN: z.ZodUnion<readonly [z.ZodString, z.ZodObject<{
|
|
17
|
+
active: z.ZodExactOptional<z.ZodBoolean>;
|
|
18
|
+
version: z.ZodString;
|
|
19
|
+
}, z.core.$strip>]>;
|
|
20
|
+
export type PluginPin = z.infer<typeof PLUGIN_PIN>;
|
|
19
21
|
export type PluginManifest = Record<string, PluginPin>;
|
|
20
22
|
export declare const pinVersion: (pin: PluginPin) => string;
|
|
21
|
-
export declare const pinActive: (pin: PluginPin) => boolean;
|
|
23
|
+
export declare const pinActive: (pin: PluginPin | undefined) => boolean;
|
|
22
24
|
export declare const pinVersions: (manifest: PluginManifest) => Record<string, string>;
|
package/dist/types/plugin.js
CHANGED
|
@@ -1,4 +1,9 @@
|
|
|
1
|
+
import { z } from 'zod';
|
|
2
|
+
// A bare version string means "installed and active"; the object form records a plugin that
|
|
3
|
+
// must stay installed but inactive.
|
|
4
|
+
export const PLUGIN_PIN = z.union([z.string(), z.object({ active: z.boolean().exactOptional(), version: z.string() })]);
|
|
1
5
|
export const pinVersion = (pin) => (typeof pin === 'string' ? pin : pin.version);
|
|
2
|
-
|
|
6
|
+
// No pin at all (a slug loopress.json doesn't list) keeps the default: active.
|
|
7
|
+
export const pinActive = (pin) => typeof pin === 'string' || pin?.active !== false;
|
|
3
8
|
// The version-only view the Composer sync endpoint expects.
|
|
4
9
|
export const pinVersions = (manifest) => Object.fromEntries(Object.entries(manifest).map(([slug, pin]) => [slug, pinVersion(pin)]));
|
|
@@ -23,6 +23,10 @@ export interface LoopressProjectConfiguration {
|
|
|
23
23
|
* Directory where custom API route files are read from and written to, relative to rootDir.
|
|
24
24
|
*/
|
|
25
25
|
apiDir?: string;
|
|
26
|
+
/**
|
|
27
|
+
* Directory of dynamic blocks, one <slug>/ folder holding block.json and render.php, registered as loopress/<slug>, relative to rootDir.
|
|
28
|
+
*/
|
|
29
|
+
blockDir?: string;
|
|
26
30
|
/**
|
|
27
31
|
* Directory where hook files (WordPress actions, filters, and scheduled tasks) are read from and written to, relative to rootDir.
|
|
28
32
|
*/
|
|
@@ -103,7 +107,11 @@ export interface LoopressProjectConfiguration {
|
|
|
103
107
|
};
|
|
104
108
|
};
|
|
105
109
|
/**
|
|
106
|
-
*
|
|
110
|
+
* Keep the Loopress child theme (<parent>-loopress) active: `lps theme template push`, and so `lps push`, activates it when it isn't. Off by default, a theme switch changes the settings WordPress stores per theme.
|
|
111
|
+
*/
|
|
112
|
+
activateChildTheme?: boolean;
|
|
113
|
+
/**
|
|
114
|
+
* WordPress.org themes managed by Loopress, installed via Composer + WPackagist on the site. Keys are theme slugs; values are an exact version or "latest". Loopress manages installed theme versions only, it never switches the active theme (see activateChildTheme and `lps theme activate`). Ignored when a composer.json exists in rootDir.
|
|
107
115
|
*/
|
|
108
116
|
themes?: {
|
|
109
117
|
[k: string]: string;
|
package/dist/types/snippet.d.ts
CHANGED
|
@@ -2,8 +2,8 @@ import { type SnippetInsertMethod, type SnippetLocation, type SnippetType } from
|
|
|
2
2
|
export type Snippet = {
|
|
3
3
|
active: boolean;
|
|
4
4
|
code: string;
|
|
5
|
-
description?: string;
|
|
6
|
-
id?: number;
|
|
5
|
+
description?: string | undefined;
|
|
6
|
+
id?: number | undefined;
|
|
7
7
|
insertMethod: SnippetInsertMethod;
|
|
8
8
|
location: SnippetLocation;
|
|
9
9
|
name: string;
|
package/dist/types/snippet.js
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
|
|
1
|
+
import {} from '../utils/snippet-format.js';
|
|
@@ -2,6 +2,7 @@ export declare const ACF_OBJECT_TYPES: readonly ["field-groups", "post-types", "
|
|
|
2
2
|
export type AcfObjectType = (typeof ACF_OBJECT_TYPES)[number];
|
|
3
3
|
export declare function acfEndpoint(type: AcfObjectType): string;
|
|
4
4
|
export declare function acfObjectEndpoint(type: AcfObjectType, key: string): string;
|
|
5
|
+
export declare function acfNormalizeEndpoint(type: AcfObjectType): string;
|
|
5
6
|
export declare function getAcfKey(data: Record<string, unknown>): null | string;
|
|
6
7
|
export declare function acfFieldChanges(before: unknown, after: unknown): {
|
|
7
8
|
added: string[];
|
package/dist/utils/acf-format.js
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { isDeepStrictEqual } from 'node:util';
|
|
2
|
+
import { isRecord } from '../lib/narrow.js';
|
|
2
3
|
export const ACF_OBJECT_TYPES = ['field-groups', 'post-types', 'taxonomies', 'options-pages'];
|
|
3
4
|
export function acfEndpoint(type) {
|
|
4
5
|
return `loopress/v1/acf/${type}`;
|
|
@@ -9,6 +10,11 @@ export function acfEndpoint(type) {
|
|
|
9
10
|
export function acfObjectEndpoint(type, key) {
|
|
10
11
|
return `${acfEndpoint(type)}/${key}`;
|
|
11
12
|
}
|
|
13
|
+
// POST route returning local objects the way the site would store them, defaults filled in,
|
|
14
|
+
// nothing saved (see resource-state.ts's acfProvider).
|
|
15
|
+
export function acfNormalizeEndpoint(type) {
|
|
16
|
+
return `loopress/v1/acf/normalize/${type}`;
|
|
17
|
+
}
|
|
12
18
|
// Deliberately loose: ACF's own export JSON is large, deeply nested, and versioned by ACF
|
|
13
19
|
// itself (new settings appear across ACF releases). We only need `key` for filenames/identity;
|
|
14
20
|
// everything else round-trips through pull/push untouched, so there's no generated type for it
|
|
@@ -18,21 +24,23 @@ export function getAcfKey(data) {
|
|
|
18
24
|
return typeof data.key === 'string' && data.key.trim() !== '' ? data.key : null;
|
|
19
25
|
}
|
|
20
26
|
function topLevelFields(group) {
|
|
21
|
-
const fields = group
|
|
27
|
+
const fields = isRecord(group) ? group.fields : undefined;
|
|
22
28
|
const byKey = new Map();
|
|
23
29
|
for (const field of Array.isArray(fields) ? fields : []) {
|
|
24
|
-
if (typeof field
|
|
30
|
+
if (isRecord(field) && typeof field.key === 'string')
|
|
25
31
|
byKey.set(field.key, field);
|
|
26
32
|
}
|
|
27
33
|
return byKey;
|
|
28
34
|
}
|
|
35
|
+
function label(field) {
|
|
36
|
+
return typeof field.name === 'string' && field.name !== '' ? field.name : String(field.key);
|
|
37
|
+
}
|
|
29
38
|
// A changed field group's top-level fields, matched by `key` and named by `name` (a repeater's
|
|
30
39
|
// sub-field change shows as its parent changing). Only for the dry-run preview: the push itself
|
|
31
40
|
// always sends the whole group.
|
|
32
41
|
export function acfFieldChanges(before, after) {
|
|
33
42
|
const old = topLevelFields(before);
|
|
34
43
|
const next = topLevelFields(after);
|
|
35
|
-
const label = (field) => (typeof field.name === 'string' && field.name !== '' ? field.name : String(field.key));
|
|
36
44
|
const changes = { added: [], changed: [], removed: [] };
|
|
37
45
|
for (const [key, field] of next) {
|
|
38
46
|
if (!old.has(key))
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
export declare const BLOCKS_ENDPOINT = "loopress/v1/block-files";
|
|
2
|
+
export declare const BLOCK_FILES: readonly ["block.json", "render.php", "style.css", "view.js"];
|
|
3
|
+
export declare const BLOCK_SLUG: RegExp;
|
|
4
|
+
export type BlockFiles = Record<string, string>;
|
|
5
|
+
export type LocalBlock = {
|
|
6
|
+
files: BlockFiles;
|
|
7
|
+
slug: string;
|
|
8
|
+
};
|
|
9
|
+
export type BlockProblem = {
|
|
10
|
+
file: string;
|
|
11
|
+
message: string;
|
|
12
|
+
};
|
|
13
|
+
export declare function blockProblems(slug: string, files: BlockFiles): string[];
|
|
14
|
+
export declare function readLocalBlocks(dir: string): Promise<{
|
|
15
|
+
blocks: LocalBlock[];
|
|
16
|
+
problems: BlockProblem[];
|
|
17
|
+
}>;
|
|
18
|
+
export declare function formatBlockProblems(problems: BlockProblem[]): string;
|
|
19
|
+
export declare function assertSafeBlock(block: LocalBlock): void;
|
|
20
|
+
export declare function writeBlock(dir: string, block: LocalBlock): Promise<void>;
|
|
21
|
+
export type RemoteBlock = LocalBlock & {
|
|
22
|
+
error?: string;
|
|
23
|
+
runtimeError?: {
|
|
24
|
+
at: string;
|
|
25
|
+
message: string;
|
|
26
|
+
};
|
|
27
|
+
};
|
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
import { mkdir, readdir, readFile, writeFile } from 'node:fs/promises';
|
|
2
|
+
import { join } from 'node:path';
|
|
3
|
+
import { errorCode, isRecord } from '../lib/narrow.js';
|
|
4
|
+
// A dynamic block: blocks/<slug>/ in WordPress's own block format, registered on the site as
|
|
5
|
+
// loopress/<slug> (the plugin's BlocksDirectory and BlockFilesController).
|
|
6
|
+
export const BLOCKS_ENDPOINT = 'loopress/v1/block-files';
|
|
7
|
+
// The only files a block folder may hold, mirrors BlocksDirectory::FILES.
|
|
8
|
+
export const BLOCK_FILES = ['block.json', 'render.php', 'style.css', 'view.js'];
|
|
9
|
+
// WordPress's block name segment rule, capped like the plugin's slug pattern.
|
|
10
|
+
export const BLOCK_SLUG = /^[a-z][a-z0-9-]{0,63}$/;
|
|
11
|
+
// Every `file:./x` path a block.json field points at (style, viewScriptModule, ...), top level
|
|
12
|
+
// only, as a string or an array of strings, like WordPress reads them.
|
|
13
|
+
function referencedFiles(metadata) {
|
|
14
|
+
return Object.values(metadata)
|
|
15
|
+
.flatMap((value) => (Array.isArray(value) ? value : [value]))
|
|
16
|
+
.filter((value) => typeof value === 'string' && value.startsWith('file:'))
|
|
17
|
+
.map((value) => value.slice('file:'.length).replace(/^\.\//, ''));
|
|
18
|
+
}
|
|
19
|
+
// Local checks `lps block push` and `lps validate` share. The site checks the same again,
|
|
20
|
+
// plus render.php's syntax.
|
|
21
|
+
export function blockProblems(slug, files) {
|
|
22
|
+
if (!BLOCK_SLUG.test(slug))
|
|
23
|
+
return ['the folder name must be lowercase letters, digits and hyphens, starting with a letter'];
|
|
24
|
+
const problems = [];
|
|
25
|
+
for (const name of Object.keys(files)) {
|
|
26
|
+
if (!BLOCK_FILES.includes(name))
|
|
27
|
+
problems.push(`${name} is not a block file, a block folder holds only ${BLOCK_FILES.join(', ')}`);
|
|
28
|
+
}
|
|
29
|
+
if (files['render.php'] === undefined)
|
|
30
|
+
problems.push('render.php is missing');
|
|
31
|
+
const raw = files['block.json'];
|
|
32
|
+
if (raw === undefined)
|
|
33
|
+
return [...problems, 'block.json is missing'];
|
|
34
|
+
let metadata;
|
|
35
|
+
try {
|
|
36
|
+
metadata = JSON.parse(raw);
|
|
37
|
+
}
|
|
38
|
+
catch {
|
|
39
|
+
return [...problems, 'block.json is not valid JSON'];
|
|
40
|
+
}
|
|
41
|
+
if (!isRecord(metadata) || Array.isArray(metadata))
|
|
42
|
+
return [...problems, 'block.json is not a JSON object'];
|
|
43
|
+
if (metadata.name !== `loopress/${slug}`)
|
|
44
|
+
problems.push(`block.json "name" must be "loopress/${slug}"`);
|
|
45
|
+
for (const file of referencedFiles(metadata)) {
|
|
46
|
+
if (files[file] === undefined)
|
|
47
|
+
problems.push(`block.json references file:./${file}, which is not in the folder`);
|
|
48
|
+
}
|
|
49
|
+
return problems;
|
|
50
|
+
}
|
|
51
|
+
// Every blocks/<slug>/ folder with its files. Unknown files are read too, so blockProblems()
|
|
52
|
+
// reports them instead of push silently dropping them.
|
|
53
|
+
export async function readLocalBlocks(dir) {
|
|
54
|
+
let entries;
|
|
55
|
+
try {
|
|
56
|
+
entries = await readdir(dir, { withFileTypes: true });
|
|
57
|
+
}
|
|
58
|
+
catch (error) {
|
|
59
|
+
if (errorCode(error) === 'ENOENT')
|
|
60
|
+
return { blocks: [], problems: [] };
|
|
61
|
+
throw error;
|
|
62
|
+
}
|
|
63
|
+
const blocks = [];
|
|
64
|
+
const problems = [];
|
|
65
|
+
for (const entry of entries.filter((candidate) => candidate.isDirectory()).toSorted((a, b) => a.name.localeCompare(b.name))) {
|
|
66
|
+
const slug = entry.name;
|
|
67
|
+
const files = {};
|
|
68
|
+
for (const file of await readdir(join(dir, slug), { withFileTypes: true })) {
|
|
69
|
+
if (file.isFile() && !file.name.startsWith('.'))
|
|
70
|
+
files[file.name] = await readFile(join(dir, slug, file.name), 'utf8');
|
|
71
|
+
}
|
|
72
|
+
for (const message of blockProblems(slug, files))
|
|
73
|
+
problems.push({ file: join(dir, slug), message });
|
|
74
|
+
blocks.push({ files, slug });
|
|
75
|
+
}
|
|
76
|
+
return { blocks, problems };
|
|
77
|
+
}
|
|
78
|
+
export function formatBlockProblems(problems) {
|
|
79
|
+
return problems.map((problem) => `${problem.file}: ${problem.message}`).join('\n');
|
|
80
|
+
}
|
|
81
|
+
// A block from the site or a snapshot names a folder and files on disk: anything but a valid
|
|
82
|
+
// slug and the known file names could write or delete outside blocks/.
|
|
83
|
+
export function assertSafeBlock(block) {
|
|
84
|
+
const unsafe = !BLOCK_SLUG.test(block.slug) || Object.keys(block.files).some((name) => !BLOCK_FILES.includes(name));
|
|
85
|
+
if (unsafe)
|
|
86
|
+
throw new Error(`Refusing block "${block.slug}": invalid slug or file name (${Object.keys(block.files).join(', ')})`);
|
|
87
|
+
}
|
|
88
|
+
export async function writeBlock(dir, block) {
|
|
89
|
+
assertSafeBlock(block);
|
|
90
|
+
await mkdir(join(dir, block.slug), { recursive: true });
|
|
91
|
+
for (const [name, content] of Object.entries(block.files))
|
|
92
|
+
await writeFile(join(dir, block.slug, name), content);
|
|
93
|
+
}
|
package/dist/utils/composer.d.ts
CHANGED
|
@@ -1,21 +1,13 @@
|
|
|
1
|
+
import { z } from 'zod';
|
|
1
2
|
import { type SyncIntent } from './plugin-sync.js';
|
|
2
3
|
import { type MergeResult } from './plugins.js';
|
|
3
4
|
export declare const NOT_ON_WORDPRESS_ORG = "not on WordPress.org";
|
|
4
|
-
export
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
};
|
|
11
|
-
name?: string;
|
|
12
|
-
repositories?: Array<{
|
|
13
|
-
type: string;
|
|
14
|
-
url: string;
|
|
15
|
-
}>;
|
|
16
|
-
require?: Record<string, string>;
|
|
17
|
-
'require-dev'?: Record<string, string>;
|
|
18
|
-
};
|
|
5
|
+
export declare const COMPOSER_JSON: z.ZodObject<{
|
|
6
|
+
require: z.ZodOptional<z.ZodRecord<z.ZodString, z.ZodString>>;
|
|
7
|
+
'require-dev': z.ZodOptional<z.ZodRecord<z.ZodString, z.ZodString>>;
|
|
8
|
+
}, z.core.$loose>;
|
|
9
|
+
export type ComposerJson = z.infer<typeof COMPOSER_JSON>;
|
|
10
|
+
export declare function parseComposerJson(raw: string): ComposerJson;
|
|
19
11
|
export declare function readComposerJson(): Promise<ComposerJson | null>;
|
|
20
12
|
export declare function readComposerLock(): Promise<null | string>;
|
|
21
13
|
export declare function getComposerManagedSlugs(composerJson: ComposerJson): string[];
|
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 = {
|