@loopress/cli 0.22.0 → 0.24.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 +794 -85
- package/dist/commands/acf/diff.d.ts +2 -0
- package/dist/commands/acf/diff.js +5 -0
- package/dist/commands/api/diff.d.ts +2 -0
- package/dist/commands/api/diff.js +5 -0
- package/dist/commands/api/list.d.ts +2 -12
- package/dist/commands/api/list.js +2 -20
- package/dist/commands/api/pull.d.ts +2 -20
- package/dist/commands/api/pull.js +2 -53
- package/dist/commands/api/push.d.ts +2 -22
- package/dist/commands/api/push.js +2 -116
- package/dist/commands/app/list.d.ts +17 -0
- package/dist/commands/app/list.js +35 -0
- package/dist/commands/{page → app}/pull.d.ts +7 -5
- package/dist/commands/app/pull.js +94 -0
- package/dist/commands/app/push.d.ts +28 -0
- package/dist/commands/app/push.js +130 -0
- package/dist/commands/app/remove.d.ts +20 -0
- package/dist/commands/app/remove.js +42 -0
- package/dist/commands/composer/pull.js +6 -16
- package/dist/commands/composer/push.d.ts +1 -0
- package/dist/commands/composer/push.js +43 -19
- package/dist/commands/dev.js +1 -1
- package/dist/commands/diff.d.ts +13 -0
- package/dist/commands/diff.js +42 -0
- package/dist/commands/form/diff.d.ts +2 -0
- package/dist/commands/form/diff.js +5 -0
- package/dist/commands/hook/diff.d.ts +2 -0
- package/dist/commands/hook/diff.js +5 -0
- package/dist/commands/hook/list.d.ts +2 -0
- package/dist/commands/hook/list.js +2 -0
- package/dist/commands/hook/pull.d.ts +2 -0
- package/dist/commands/hook/pull.js +2 -0
- package/dist/commands/hook/push.d.ts +2 -0
- package/dist/commands/hook/push.js +2 -0
- package/dist/commands/init.js +2 -1
- package/dist/commands/option/add.d.ts +15 -0
- package/dist/commands/option/add.js +47 -0
- package/dist/commands/option/diff.d.ts +2 -0
- package/dist/commands/option/diff.js +5 -0
- package/dist/commands/{page → option}/list.d.ts +5 -1
- package/dist/commands/option/list.js +151 -0
- package/dist/commands/option/pull.d.ts +16 -0
- package/dist/commands/option/pull.js +75 -0
- package/dist/commands/{page → option}/push.d.ts +3 -8
- package/dist/commands/option/push.js +66 -0
- package/dist/commands/option/remove.d.ts +22 -0
- package/dist/commands/option/remove.js +69 -0
- package/dist/commands/plugin/add.d.ts +1 -0
- package/dist/commands/plugin/add.js +18 -8
- package/dist/commands/plugin/audit.d.ts +30 -0
- package/dist/commands/plugin/audit.js +138 -0
- package/dist/commands/plugin/pull.d.ts +1 -2
- package/dist/commands/plugin/pull.js +11 -11
- package/dist/commands/plugin/push.d.ts +13 -8
- package/dist/commands/plugin/push.js +161 -67
- package/dist/commands/plugin/status.d.ts +22 -0
- package/dist/commands/plugin/status.js +65 -0
- package/dist/commands/pull.js +3 -2
- package/dist/commands/push.js +4 -3
- package/dist/commands/seo/diff.d.ts +2 -0
- package/dist/commands/seo/diff.js +5 -0
- package/dist/commands/snippet/diff.d.ts +2 -0
- package/dist/commands/snippet/diff.js +5 -0
- package/dist/commands/theme/add.d.ts +13 -0
- package/dist/commands/theme/add.js +35 -0
- package/dist/commands/theme/pull.d.ts +21 -0
- package/dist/commands/theme/pull.js +37 -0
- package/dist/commands/theme/push.d.ts +22 -0
- package/dist/commands/theme/push.js +123 -0
- package/dist/commands/theme/status.d.ts +20 -0
- package/dist/commands/theme/status.js +51 -0
- package/dist/lib/app-manifest.d.ts +36 -0
- package/dist/lib/app-manifest.js +162 -0
- package/dist/lib/base.d.ts +3 -1
- package/dist/lib/base.js +14 -19
- package/dist/lib/dev-watch.d.ts +1 -1
- package/dist/lib/dev-watch.js +1 -2
- package/dist/lib/diff-command.d.ts +47 -0
- package/dist/lib/diff-command.js +180 -0
- package/dist/lib/diff-state.d.ts +22 -0
- package/dist/lib/diff-state.js +88 -0
- package/dist/lib/find-orphaned-files.js +1 -1
- package/dist/lib/interactive.d.ts +1 -0
- package/dist/lib/interactive.js +10 -0
- package/dist/lib/php-files-command.d.ts +54 -0
- package/dist/lib/php-files-command.js +216 -0
- package/dist/lib/put-or-create.js +1 -1
- package/dist/lib/readdir-tolerant.d.ts +1 -0
- package/dist/lib/readdir-tolerant.js +3 -1
- package/dist/lib/resource-state.d.ts +14 -0
- package/dist/lib/resource-state.js +361 -0
- package/dist/lib/validate-local.js +15 -7
- package/dist/lib/wp-client.d.ts +5 -0
- package/dist/lib/wp-client.js +56 -0
- package/dist/types/plugin.d.ts +1 -0
- package/dist/types/project-config.generated.d.ts +19 -5
- package/dist/utils/option-format.d.ts +31 -0
- package/dist/utils/option-format.js +51 -0
- package/dist/utils/plugin-sync.d.ts +26 -0
- package/dist/utils/plugin-sync.js +31 -0
- package/dist/utils/plugins.d.ts +15 -2
- package/dist/utils/plugins.js +57 -12
- package/dist/utils/resource-dirs.d.ts +13 -0
- package/dist/utils/resource-dirs.js +34 -0
- package/dist/utils/themes.d.ts +30 -0
- package/dist/utils/themes.js +29 -0
- package/dist/utils/version.d.ts +2 -0
- package/dist/utils/version.js +21 -0
- package/oclif.manifest.json +1897 -244
- package/package.json +26 -19
- package/schemas/project-config.schema.json +32 -9
- package/dist/commands/page/list.js +0 -19
- package/dist/commands/page/pull.js +0 -57
- package/dist/commands/page/push.js +0 -132
- package/dist/utils/page-format.d.ts +0 -7
- package/dist/utils/page-format.js +0 -78
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
// Shared helpers for driving the server-side `loopress/v1/composer/sync` endpoint, used by
|
|
2
|
+
// `lps plugin push`, `lps theme push` and `lps composer push`. The endpoint runs Composer +
|
|
3
|
+
// WPackagist inside WordPress; the CLI only ever sends intent (which slugs at which versions),
|
|
4
|
+
// never a composer.json, so the plugin stays the single authority on that file's shape.
|
|
5
|
+
import { compareVersions } from './version.js';
|
|
6
|
+
// A cold `composer update` on a fresh site easily exceeds the global 30s WpClient timeout.
|
|
7
|
+
export const SYNC_TIMEOUT_MS = 600_000;
|
|
8
|
+
// 422 from the sync endpoint when the intent references a plugin/theme whose folder already
|
|
9
|
+
// exists on the site but isn't tracked in composer.lock (installed by hand through wp-admin).
|
|
10
|
+
// Re-running with --force lets the server remove the folder so Composer can take it over.
|
|
11
|
+
export function parseCollisions(error) {
|
|
12
|
+
const body = error.cause?.response;
|
|
13
|
+
if (body?.statusCode !== 422 || !body.body)
|
|
14
|
+
return null;
|
|
15
|
+
try {
|
|
16
|
+
const parsed = JSON.parse(body.body);
|
|
17
|
+
if (parsed.error === 'unmanaged_plugins_present' && Array.isArray(parsed.collisions)) {
|
|
18
|
+
return parsed.collisions;
|
|
19
|
+
}
|
|
20
|
+
}
|
|
21
|
+
catch {
|
|
22
|
+
return null;
|
|
23
|
+
}
|
|
24
|
+
return null;
|
|
25
|
+
}
|
|
26
|
+
// Only gates whether a version change needs --force. Unorderable inputs ("latest", a
|
|
27
|
+
// constraint) count as "not a downgrade" so they don't block the push.
|
|
28
|
+
export function isDowngrade(from, to) {
|
|
29
|
+
const order = compareVersions(to, from);
|
|
30
|
+
return order !== null && order < 0;
|
|
31
|
+
}
|
package/dist/utils/plugins.d.ts
CHANGED
|
@@ -1,13 +1,25 @@
|
|
|
1
1
|
import { type InstalledPlugin, type PluginManifest, type WpNativePlugin } from '../types/plugin.js';
|
|
2
2
|
export type PluginDiff = {
|
|
3
|
+
collisions: Array<{
|
|
4
|
+
installedVersion: string;
|
|
5
|
+
slug: string;
|
|
6
|
+
}>;
|
|
7
|
+
inSync: string[];
|
|
3
8
|
toActivate: Array<{
|
|
4
9
|
file: string;
|
|
5
10
|
slug: string;
|
|
6
11
|
}>;
|
|
7
12
|
toInstall: Array<{
|
|
8
13
|
slug: string;
|
|
14
|
+
version: string;
|
|
15
|
+
}>;
|
|
16
|
+
toPin: Array<{
|
|
17
|
+
from: string;
|
|
18
|
+
slug: string;
|
|
19
|
+
to: string;
|
|
9
20
|
}>;
|
|
10
|
-
|
|
21
|
+
toRemove: string[];
|
|
22
|
+
untrackedActive: string[];
|
|
11
23
|
};
|
|
12
24
|
export type MergeResult = {
|
|
13
25
|
added: string[];
|
|
@@ -20,4 +32,5 @@ export type MergeResult = {
|
|
|
20
32
|
};
|
|
21
33
|
export declare function mergePluginManifest(existing: PluginManifest, incoming: PluginManifest): MergeResult;
|
|
22
34
|
export declare function parseInstalledPlugins(raw: WpNativePlugin[]): InstalledPlugin[];
|
|
23
|
-
export declare function diffPlugins(manifest: PluginManifest, installed: InstalledPlugin[]): PluginDiff;
|
|
35
|
+
export declare function diffPlugins(manifest: PluginManifest, installed: InstalledPlugin[], managedSlugs?: Set<string>): PluginDiff;
|
|
36
|
+
export declare function lockedWpackagistSlugs(composerLock: null | string, kind: 'plugin' | 'theme'): Set<string>;
|
package/dist/utils/plugins.js
CHANGED
|
@@ -14,9 +14,18 @@ export function mergePluginManifest(existing, incoming) {
|
|
|
14
14
|
}
|
|
15
15
|
// WordPress core identifies each plugin by a `<folder>/<file>` id (or a bare `<file>` for a
|
|
16
16
|
// single-file plugin) with the `.php` extension stripped; the WordPress.org slug is just the
|
|
17
|
-
// folder name (or the bare id itself for a single-file plugin).
|
|
18
|
-
|
|
19
|
-
|
|
17
|
+
// folder name (or the bare id itself for a single-file plugin). Composer + composer/installers
|
|
18
|
+
// installs a WPackagist plugin into `wp-content/plugins/<slug>/`, so folder name and slug stay
|
|
19
|
+
// aligned for anything Loopress manages.
|
|
20
|
+
//
|
|
21
|
+
// A single-file plugin's bare id isn't always its real slug though: Hello Dolly ships as
|
|
22
|
+
// `hello.php` but its WordPress.org (and WPackagist) slug is `hello-dolly`. When that happens,
|
|
23
|
+
// trust the plugin's own `Plugin URI` header over the guessed id.
|
|
24
|
+
const WP_ORG_PLUGIN_URI = /^https?:\/\/wordpress\.org\/plugins\/([^/]+)\/?$/;
|
|
25
|
+
function slugFromPluginFile(file, pluginUri) {
|
|
26
|
+
if (file.includes('/'))
|
|
27
|
+
return file.split('/', 1)[0];
|
|
28
|
+
return WP_ORG_PLUGIN_URI.exec(pluginUri)?.[1] ?? file;
|
|
20
29
|
}
|
|
21
30
|
export function parseInstalledPlugins(raw) {
|
|
22
31
|
return raw
|
|
@@ -24,27 +33,63 @@ export function parseInstalledPlugins(raw) {
|
|
|
24
33
|
active: item.status !== 'inactive',
|
|
25
34
|
file: item.plugin,
|
|
26
35
|
name: item.name,
|
|
27
|
-
slug: slugFromPluginFile(item.plugin),
|
|
36
|
+
slug: slugFromPluginFile(item.plugin, item.plugin_uri),
|
|
28
37
|
version: item.version,
|
|
29
38
|
}))
|
|
30
39
|
.filter((plugin) => !LOOPRESS_PLUGIN_SLUGS.has(plugin.slug));
|
|
31
40
|
}
|
|
32
|
-
export function diffPlugins(manifest, installed
|
|
41
|
+
export function diffPlugins(manifest, installed,
|
|
42
|
+
// Slugs Loopress currently manages, from the local composer.lock's wpackagist-plugin/*
|
|
43
|
+
// entries. Empty for a loopress.json-only project that doesn't keep a lockfile in the repo:
|
|
44
|
+
// `toRemove` then stays empty (removals still happen server-side, they just aren't previewed).
|
|
45
|
+
managedSlugs = new Set()) {
|
|
33
46
|
const installedMap = new Map(installed.map((p) => [p.slug, p]));
|
|
34
47
|
const toInstall = [];
|
|
48
|
+
const toPin = [];
|
|
35
49
|
const toActivate = [];
|
|
36
|
-
const
|
|
37
|
-
|
|
50
|
+
const collisions = [];
|
|
51
|
+
const inSync = [];
|
|
52
|
+
for (const [slug, wanted] of Object.entries(manifest)) {
|
|
38
53
|
const live = installedMap.get(slug);
|
|
39
54
|
if (!live) {
|
|
40
|
-
toInstall.push({ slug });
|
|
55
|
+
toInstall.push({ slug, version: wanted });
|
|
56
|
+
continue;
|
|
41
57
|
}
|
|
42
|
-
else
|
|
43
|
-
|
|
58
|
+
// Installed, but Loopress doesn't own the folder yet: nothing else can be done with it
|
|
59
|
+
// until `--force` lets Composer take it over.
|
|
60
|
+
if (!managedSlugs.has(slug)) {
|
|
61
|
+
collisions.push({ installedVersion: live.version, slug });
|
|
62
|
+
continue;
|
|
44
63
|
}
|
|
45
|
-
|
|
64
|
+
if (wanted !== 'latest' && live.version !== wanted) {
|
|
65
|
+
toPin.push({ from: live.version, slug, to: wanted });
|
|
66
|
+
continue;
|
|
67
|
+
}
|
|
68
|
+
if (!live.active) {
|
|
46
69
|
toActivate.push({ file: live.file, slug });
|
|
70
|
+
continue;
|
|
47
71
|
}
|
|
72
|
+
inSync.push(slug);
|
|
73
|
+
}
|
|
74
|
+
const toRemove = [...managedSlugs].filter((slug) => !Object.hasOwn(manifest, slug));
|
|
75
|
+
const untrackedActive = installed
|
|
76
|
+
.filter((p) => p.active && !Object.hasOwn(manifest, p.slug) && !managedSlugs.has(p.slug))
|
|
77
|
+
.map((p) => p.slug);
|
|
78
|
+
return { collisions, inSync, toActivate, toInstall, toPin, toRemove, untrackedActive };
|
|
79
|
+
}
|
|
80
|
+
// wpackagist-plugin/<slug> or wpackagist-theme/<slug> entries from a composer.lock string.
|
|
81
|
+
export function lockedWpackagistSlugs(composerLock, kind) {
|
|
82
|
+
if (!composerLock)
|
|
83
|
+
return new Set();
|
|
84
|
+
const prefix = `wpackagist-${kind}/`;
|
|
85
|
+
try {
|
|
86
|
+
const parsed = JSON.parse(composerLock);
|
|
87
|
+
return new Set((parsed.packages ?? [])
|
|
88
|
+
.map((p) => p.name ?? '')
|
|
89
|
+
.filter((name) => name.startsWith(prefix))
|
|
90
|
+
.map((name) => name.slice(prefix.length)));
|
|
91
|
+
}
|
|
92
|
+
catch {
|
|
93
|
+
return new Set();
|
|
48
94
|
}
|
|
49
|
-
return { toActivate, toInstall, upToDate };
|
|
50
95
|
}
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
import { type LoopressLocalConfig } from './loopress-config.js';
|
|
2
|
+
export declare const RESOURCE_DIR_DEFAULTS: {
|
|
3
|
+
readonly acf: "acf";
|
|
4
|
+
readonly api: "api";
|
|
5
|
+
readonly apps: "apps";
|
|
6
|
+
readonly form: "forms";
|
|
7
|
+
readonly hooks: "hooks";
|
|
8
|
+
readonly options: "options";
|
|
9
|
+
readonly seo: "seo";
|
|
10
|
+
readonly snippets: "snippets";
|
|
11
|
+
};
|
|
12
|
+
export type ResourceDirKind = keyof typeof RESOURCE_DIR_DEFAULTS;
|
|
13
|
+
export declare function resolveResourceDir(kind: ResourceDirKind, localConfig: LoopressLocalConfig, override?: string): string;
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
import { join } from 'node:path';
|
|
2
|
+
// The default subdirectory for each file-backed resource, relative to rootDir. `lps init`
|
|
3
|
+
// writes these same defaults into loopress.json, keep the two in sync.
|
|
4
|
+
export const RESOURCE_DIR_DEFAULTS = {
|
|
5
|
+
acf: 'acf',
|
|
6
|
+
api: 'api',
|
|
7
|
+
apps: 'apps',
|
|
8
|
+
form: 'forms',
|
|
9
|
+
hooks: 'hooks',
|
|
10
|
+
options: 'options',
|
|
11
|
+
seo: 'seo',
|
|
12
|
+
snippets: 'snippets',
|
|
13
|
+
};
|
|
14
|
+
const CONFIG_KEY = {
|
|
15
|
+
acf: 'acfDir',
|
|
16
|
+
api: 'apiDir',
|
|
17
|
+
apps: 'appsDir',
|
|
18
|
+
form: 'formDir',
|
|
19
|
+
hooks: 'hooksDir',
|
|
20
|
+
options: 'optionsDir',
|
|
21
|
+
seo: 'seoDir',
|
|
22
|
+
snippets: 'snippetsDir',
|
|
23
|
+
};
|
|
24
|
+
// Resolves where a resource's files live: an explicit override wins, otherwise the
|
|
25
|
+
// loopress.json `<kind>Dir` setting, otherwise the built-in default, all under `rootDir`.
|
|
26
|
+
// Shared by every pull/push command (via LoopressCommand's resolve*Path helpers) and by
|
|
27
|
+
// `lps diff`, so the resolution rule has a single home.
|
|
28
|
+
export function resolveResourceDir(kind, localConfig, override) {
|
|
29
|
+
if (override)
|
|
30
|
+
return override;
|
|
31
|
+
const rootDir = localConfig.rootDir ?? '.';
|
|
32
|
+
const configured = localConfig[CONFIG_KEY[kind]];
|
|
33
|
+
return join(rootDir, configured ?? RESOURCE_DIR_DEFAULTS[kind]);
|
|
34
|
+
}
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
export type WpNativeTheme = {
|
|
2
|
+
status: 'active' | 'inactive';
|
|
3
|
+
stylesheet: string;
|
|
4
|
+
version: string;
|
|
5
|
+
};
|
|
6
|
+
export type InstalledTheme = {
|
|
7
|
+
active: boolean;
|
|
8
|
+
slug: string;
|
|
9
|
+
version: string;
|
|
10
|
+
};
|
|
11
|
+
export type ThemeManifest = Record<string, string>;
|
|
12
|
+
export type ThemeDiff = {
|
|
13
|
+
collisions: Array<{
|
|
14
|
+
installedVersion: string;
|
|
15
|
+
slug: string;
|
|
16
|
+
}>;
|
|
17
|
+
inSync: string[];
|
|
18
|
+
toInstall: Array<{
|
|
19
|
+
slug: string;
|
|
20
|
+
version: string;
|
|
21
|
+
}>;
|
|
22
|
+
toPin: Array<{
|
|
23
|
+
from: string;
|
|
24
|
+
slug: string;
|
|
25
|
+
to: string;
|
|
26
|
+
}>;
|
|
27
|
+
toRemove: string[];
|
|
28
|
+
};
|
|
29
|
+
export declare function parseInstalledThemes(raw: WpNativeTheme[]): InstalledTheme[];
|
|
30
|
+
export declare function diffThemes(manifest: ThemeManifest, installed: InstalledTheme[], managedSlugs?: Set<string>): ThemeDiff;
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
// Themes are version-managed only: Loopress never switches the active theme (that can break a
|
|
2
|
+
// live site hard), so unlike plugins there is no activate / prune dimension here.
|
|
3
|
+
export function parseInstalledThemes(raw) {
|
|
4
|
+
return raw.map((t) => ({ active: t.status === 'active', slug: t.stylesheet, version: t.version }));
|
|
5
|
+
}
|
|
6
|
+
export function diffThemes(manifest, installed, managedSlugs = new Set()) {
|
|
7
|
+
const map = new Map(installed.map((t) => [t.slug, t]));
|
|
8
|
+
const toInstall = [];
|
|
9
|
+
const toPin = [];
|
|
10
|
+
const collisions = [];
|
|
11
|
+
const inSync = [];
|
|
12
|
+
for (const [slug, wanted] of Object.entries(manifest)) {
|
|
13
|
+
const live = map.get(slug);
|
|
14
|
+
if (!live) {
|
|
15
|
+
toInstall.push({ slug, version: wanted });
|
|
16
|
+
}
|
|
17
|
+
else if (!managedSlugs.has(slug)) {
|
|
18
|
+
collisions.push({ installedVersion: live.version, slug });
|
|
19
|
+
}
|
|
20
|
+
else if (wanted !== 'latest' && live.version !== wanted) {
|
|
21
|
+
toPin.push({ from: live.version, slug, to: wanted });
|
|
22
|
+
}
|
|
23
|
+
else {
|
|
24
|
+
inSync.push(slug);
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
const toRemove = [...managedSlugs].filter((slug) => !Object.hasOwn(manifest, slug));
|
|
28
|
+
return { collisions, inSync, toInstall, toPin, toRemove };
|
|
29
|
+
}
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
import { compareVersions as cmp, validate } from 'compare-versions';
|
|
2
|
+
// An exact version like 9.4.2 or 3.0.0-beta1, not a Composer constraint (^, ~, *, ranges,
|
|
3
|
+
// ||, x-wildcards). loopress.json pins an exact version or the literal "latest"; a constraint
|
|
4
|
+
// breaks the CLI's drift detection (it never string-equals the resolved version), so it is
|
|
5
|
+
// rejected at `add` time. Not `compare-versions`' `validate()`: that accepts "9.4.x".
|
|
6
|
+
//
|
|
7
|
+
// A prerelease suffix is only allowed once there are at least 3 numeric segments
|
|
8
|
+
// (major.minor.patch): compare-versions' own `validate()`, which compareVersions() below
|
|
9
|
+
// defers to, rejects a suffix on a 1- or 2-segment version (e.g. "0.9-beta"). Accepting that
|
|
10
|
+
// shape here would let it slip past `isExactVersion` at `add` time, then have
|
|
11
|
+
// `compareVersions`/`isDowngrade` silently treat it as unorderable, bypassing the --force
|
|
12
|
+
// gate on a real downgrade.
|
|
13
|
+
const EXACT_VERSION = /^\d+(\.\d+){0,3}$|^\d+(\.\d+){2,3}-[0-9A-Za-z.]+$/;
|
|
14
|
+
export function isExactVersion(value) {
|
|
15
|
+
return EXACT_VERSION.test(value);
|
|
16
|
+
}
|
|
17
|
+
// -1 / 0 / 1, or null when either side isn't a comparable version (e.g. "latest"). Wraps
|
|
18
|
+
// compare-versions so callers never have to guard against the throw it does on bad input.
|
|
19
|
+
export function compareVersions(a, b) {
|
|
20
|
+
return validate(a) && validate(b) ? cmp(a, b) : null;
|
|
21
|
+
}
|