@loopress/cli 0.32.2 → 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 +10 -5
- package/dist/lib/put-or-create.d.ts +2 -2
- package/dist/lib/readdir-tolerant.js +3 -1
- package/dist/lib/resource-state.js +38 -13
- 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.js +6 -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 +15 -5
- package/dist/utils/version.js +1 -1
- package/oclif.manifest.json +1938 -1393
- package/package.json +42 -17
- package/schemas/global-config.schema.json +1 -1
- package/schemas/project-config.schema.json +11 -1
|
@@ -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 = {
|
|
@@ -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,6 @@
|
|
|
1
1
|
import { readdir, readFile } from 'node:fs/promises';
|
|
2
2
|
import { basename, extname, join } from 'node:path';
|
|
3
|
+
import { errorCode, errorMessage, isOneOf } from '../lib/narrow.js';
|
|
3
4
|
export const PAGES_ENDPOINT = 'loopress/v1/pages';
|
|
4
5
|
// Mirrored server-side in PagesController. No leading, trailing or doubled hyphen: WordPress's
|
|
5
6
|
// sanitize_title() would rewrite those, and the page would no longer match its file name.
|
|
@@ -45,7 +46,7 @@ export function titleFromSlug(slug) {
|
|
|
45
46
|
export function parsePageFile(slug, raw) {
|
|
46
47
|
const { body, meta } = parseHtmlHeader(raw, HEADER_KEYS);
|
|
47
48
|
const status = meta.status ?? 'draft';
|
|
48
|
-
if (!PAGE_STATUSES
|
|
49
|
+
if (!isOneOf(PAGE_STATUSES, status)) {
|
|
49
50
|
throw new Error(`status "${status}" is not one of ${PAGE_STATUSES.join(', ')}`);
|
|
50
51
|
}
|
|
51
52
|
return {
|
|
@@ -53,9 +54,9 @@ export function parsePageFile(slug, raw) {
|
|
|
53
54
|
hideTitle: parseBoolean('hide-title', meta['hide-title']),
|
|
54
55
|
html: body,
|
|
55
56
|
slug,
|
|
56
|
-
status
|
|
57
|
+
status,
|
|
57
58
|
template: meta.template ?? '',
|
|
58
|
-
title: meta.title || titleFromSlug(slug),
|
|
59
|
+
title: (meta.title ?? '') || titleFromSlug(slug),
|
|
59
60
|
};
|
|
60
61
|
}
|
|
61
62
|
// Splits the optional `key: value` HTML comment header off `raw`. Shared with templates/.
|
|
@@ -64,7 +65,7 @@ export function parseHtmlHeader(raw, keys) {
|
|
|
64
65
|
const meta = {};
|
|
65
66
|
if (!header)
|
|
66
67
|
return { body: raw, meta };
|
|
67
|
-
for (const line of header[1].split(/\r?\n/)) {
|
|
68
|
+
for (const line of (header[1] ?? '').split(/\r?\n/)) {
|
|
68
69
|
if (line.trim() === '')
|
|
69
70
|
continue;
|
|
70
71
|
const colon = line.indexOf(':');
|
|
@@ -92,7 +93,7 @@ export async function readLocalHtmlFiles(dir, parse, slugRule = PAGE_SLUG_RULE)
|
|
|
92
93
|
entries = await readdir(dir, { withFileTypes: true });
|
|
93
94
|
}
|
|
94
95
|
catch (error) {
|
|
95
|
-
if (error
|
|
96
|
+
if (errorCode(error) === 'ENOENT')
|
|
96
97
|
return { files: [], problems: [] };
|
|
97
98
|
throw error;
|
|
98
99
|
}
|
|
@@ -120,7 +121,7 @@ export async function readLocalHtmlFiles(dir, parse, slugRule = PAGE_SLUG_RULE)
|
|
|
120
121
|
files.push(parse(slug, await readFile(filePath, 'utf8')));
|
|
121
122
|
}
|
|
122
123
|
catch (error) {
|
|
123
|
-
problems.push({ file: filePath, message: error
|
|
124
|
+
problems.push({ file: filePath, message: errorMessage(error) });
|
|
124
125
|
}
|
|
125
126
|
}
|
|
126
127
|
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) {
|