@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.
Files changed (149) hide show
  1. package/README.md +511 -154
  2. package/dist/commands/acf/list.d.ts +1 -1
  3. package/dist/commands/acf/list.js +3 -4
  4. package/dist/commands/acf/pull.d.ts +1 -1
  5. package/dist/commands/acf/pull.js +9 -6
  6. package/dist/commands/acf/push.d.ts +1 -1
  7. package/dist/commands/acf/push.js +5 -4
  8. package/dist/commands/api/publish.js +2 -1
  9. package/dist/commands/app/pull.js +2 -1
  10. package/dist/commands/app/push.js +5 -4
  11. package/dist/commands/app/{remove.d.ts → rm.d.ts} +1 -0
  12. package/dist/commands/app/{remove.js → rm.js} +2 -1
  13. package/dist/commands/block/diff.d.ts +2 -0
  14. package/dist/commands/block/diff.js +5 -0
  15. package/dist/commands/block/list.d.ts +8 -0
  16. package/dist/commands/block/list.js +23 -0
  17. package/dist/commands/block/pull.d.ts +21 -0
  18. package/dist/commands/block/pull.js +67 -0
  19. package/dist/commands/block/push.d.ts +20 -0
  20. package/dist/commands/block/push.js +48 -0
  21. package/dist/commands/block/rm.d.ts +23 -0
  22. package/dist/commands/block/rm.js +69 -0
  23. package/dist/commands/block/rollback.d.ts +2 -0
  24. package/dist/commands/block/rollback.js +5 -0
  25. package/dist/commands/composer/init.js +1 -0
  26. package/dist/commands/composer/push.js +5 -4
  27. package/dist/commands/content/copy.d.ts +3 -0
  28. package/dist/commands/content/copy.js +79 -2
  29. package/dist/commands/dev.js +4 -3
  30. package/dist/commands/diff.d.ts +1 -0
  31. package/dist/commands/diff.js +33 -3
  32. package/dist/commands/doctor.d.ts +4 -2
  33. package/dist/commands/doctor.js +37 -23
  34. package/dist/commands/form/pull.js +7 -5
  35. package/dist/commands/form/push.js +4 -3
  36. package/dist/commands/init.js +4 -3
  37. package/dist/commands/menu/push.js +8 -8
  38. package/dist/commands/option/list.js +5 -2
  39. package/dist/commands/option/push.js +2 -1
  40. package/dist/commands/option/{remove.d.ts → rm.d.ts} +1 -0
  41. package/dist/commands/option/{remove.js → rm.js} +2 -1
  42. package/dist/commands/page/push.js +2 -1
  43. package/dist/commands/plugin/audit.d.ts +4 -4
  44. package/dist/commands/plugin/audit.js +35 -15
  45. package/dist/commands/plugin/pull.js +3 -2
  46. package/dist/commands/plugin/push.js +2 -2
  47. package/dist/commands/plugin/status.js +3 -1
  48. package/dist/commands/project/config.js +7 -6
  49. package/dist/commands/project/pull.js +3 -1
  50. package/dist/commands/project/push.js +6 -4
  51. package/dist/commands/project/{remove.d.ts → rm.d.ts} +1 -0
  52. package/dist/commands/project/{remove.js → rm.js} +3 -2
  53. package/dist/commands/project/switch.js +6 -4
  54. package/dist/commands/promote.js +7 -2
  55. package/dist/commands/pull.d.ts +2 -0
  56. package/dist/commands/pull.js +42 -18
  57. package/dist/commands/push.d.ts +2 -1
  58. package/dist/commands/push.js +69 -25
  59. package/dist/commands/snippet/publish.js +9 -5
  60. package/dist/commands/snippet/push.d.ts +1 -1
  61. package/dist/commands/snippet/push.js +15 -12
  62. package/dist/commands/status.js +1 -0
  63. package/dist/commands/theme/activate.d.ts +21 -0
  64. package/dist/commands/theme/activate.js +44 -0
  65. package/dist/commands/theme/pull.js +2 -1
  66. package/dist/commands/theme/push.js +7 -3
  67. package/dist/commands/theme/style/push.js +7 -2
  68. package/dist/commands/theme/template/push.js +25 -6
  69. package/dist/config/auth.manager.d.ts +10 -7
  70. package/dist/config/auth.manager.js +5 -1
  71. package/dist/config/json-file.d.ts +1 -1
  72. package/dist/config/project-config.manager.d.ts +2 -2
  73. package/dist/config/project-config.manager.js +13 -21
  74. package/dist/lib/api-client.js +16 -21
  75. package/dist/lib/app-build.js +6 -2
  76. package/dist/lib/app-manifest.js +6 -6
  77. package/dist/lib/base.d.ts +2 -1
  78. package/dist/lib/base.js +10 -8
  79. package/dist/lib/bootstrap-full-install.js +8 -6
  80. package/dist/lib/capabilities.d.ts +44 -0
  81. package/dist/lib/capabilities.js +85 -0
  82. package/dist/lib/declared-type-command.js +4 -2
  83. package/dist/lib/dev-watch.js +5 -5
  84. package/dist/lib/diff-command.d.ts +2 -3
  85. package/dist/lib/diff-command.js +9 -7
  86. package/dist/lib/diff-state.js +2 -3
  87. package/dist/lib/load-files.js +2 -1
  88. package/dist/lib/load-snippets.js +37 -42
  89. package/dist/lib/local-callback-server.js +7 -1
  90. package/dist/lib/narrow.d.ts +5 -0
  91. package/dist/lib/narrow.js +26 -0
  92. package/dist/lib/php-files-command.js +8 -5
  93. package/dist/lib/plugin-detection.d.ts +0 -1
  94. package/dist/lib/plugin-detection.js +2 -4
  95. package/dist/lib/push-command.d.ts +1 -1
  96. package/dist/lib/push-command.js +19 -7
  97. package/dist/lib/put-or-create.d.ts +2 -2
  98. package/dist/lib/readdir-tolerant.js +3 -1
  99. package/dist/lib/resource-state.d.ts +1 -1
  100. package/dist/lib/resource-state.js +58 -19
  101. package/dist/lib/rollback-command.d.ts +1 -1
  102. package/dist/lib/rollback-command.js +3 -2
  103. package/dist/lib/rollback-materialize.js +28 -8
  104. package/dist/lib/rotate-app-password.js +1 -0
  105. package/dist/lib/sentry.js +2 -2
  106. package/dist/lib/snapshot-store.d.ts +10 -7
  107. package/dist/lib/snapshot-store.js +21 -5
  108. package/dist/lib/spinner.js +4 -0
  109. package/dist/lib/validate-local.js +35 -20
  110. package/dist/lib/wp-client.d.ts +6 -1
  111. package/dist/lib/wp-client.js +54 -69
  112. package/dist/lib/wp-site-diagnostic.js +3 -5
  113. package/dist/types/global-config.generated.d.ts +1 -1
  114. package/dist/types/plugin.d.ts +7 -5
  115. package/dist/types/plugin.js +6 -1
  116. package/dist/types/project-config.generated.d.ts +9 -1
  117. package/dist/types/snippet.d.ts +2 -2
  118. package/dist/types/snippet.js +1 -1
  119. package/dist/utils/acf-format.d.ts +1 -0
  120. package/dist/utils/acf-format.js +11 -3
  121. package/dist/utils/block-format.d.ts +27 -0
  122. package/dist/utils/block-format.js +93 -0
  123. package/dist/utils/composer.d.ts +7 -15
  124. package/dist/utils/composer.js +43 -16
  125. package/dist/utils/declared-type-format.js +26 -19
  126. package/dist/utils/form-format.js +2 -2
  127. package/dist/utils/loopress-config.js +9 -1
  128. package/dist/utils/menu-format.d.ts +3 -1
  129. package/dist/utils/menu-format.js +5 -0
  130. package/dist/utils/option-format.d.ts +2 -2
  131. package/dist/utils/option-format.js +15 -25
  132. package/dist/utils/page-format.js +7 -6
  133. package/dist/utils/php-top-level.js +15 -4
  134. package/dist/utils/plugin-sync.d.ts +7 -7
  135. package/dist/utils/plugin-sync.js +11 -11
  136. package/dist/utils/plugins.js +9 -6
  137. package/dist/utils/resource-dirs.d.ts +2 -0
  138. package/dist/utils/resource-dirs.js +9 -1
  139. package/dist/utils/snippet-format.d.ts +1 -1
  140. package/dist/utils/snippet-format.js +5 -3
  141. package/dist/utils/template-format.js +2 -2
  142. package/dist/utils/theme-css.js +14 -6
  143. package/dist/utils/theme-styles-format.d.ts +3 -0
  144. package/dist/utils/theme-styles-format.js +44 -6
  145. package/dist/utils/version.js +1 -1
  146. package/oclif.manifest.json +1588 -1043
  147. package/package.json +42 -17
  148. package/schemas/global-config.schema.json +1 -1
  149. package/schemas/project-config.schema.json +11 -1
@@ -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
- export function parseLocalOption(raw) {
53
- const parsed = JSON.parse(raw);
54
- if (typeof parsed !== 'object' || parsed === null || Array.isArray(parsed)) {
55
- throw new Error('not a JSON object');
56
- }
57
- const { autoload, name, refs, value } = parsed;
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
- if (value === undefined) {
68
- throw new Error('missing a "value" field');
69
- }
70
- if (refs !== undefined && !isOptionRefs(refs)) {
71
- throw new TypeError('"refs" must map a value path to a post type, e.g. {"purchase_page": "page"}');
72
- }
73
- return parsed;
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.includes(status)) {
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: 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.code === 'ENOENT')
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.message });
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 === 'attribute' || (token.type === 'word' && ALLOWED.has(token.text.toLowerCase()));
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[i])) {
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[i], type: 'punct' }, i + 1];
146
+ return [{ text: source.charAt(i), type: 'punct' }, i + 1];
136
147
  }
137
148
  // Index right after the string (quoted, backtick, heredoc or nowdoc) starting at `i`.
138
149
  function skipString(source, i) {
@@ -1,3 +1,4 @@
1
+ import { z } from 'zod';
1
2
  export declare const SYNC_TIMEOUT_MS = 600000;
2
3
  export type SyncIntent = {
3
4
  libraries?: Record<string, string>;
@@ -20,13 +21,12 @@ export type SyncResponse = {
20
21
  lockDrift?: LockDriftEntry[];
21
22
  message: string;
22
23
  output: string;
23
- removed: string[];
24
- };
25
- export type Collision = {
26
- installedVersion: string;
27
- path: string;
28
- slug: string;
29
- type: 'plugin' | 'theme';
24
+ removed?: string[];
30
25
  };
26
+ declare const COLLISIONS: z.ZodArray<z.ZodObject<{
27
+ slug: z.ZodString;
28
+ }, z.core.$loose>>;
29
+ export type Collision = z.infer<typeof COLLISIONS>[number];
31
30
  export declare function parseCollisions(error: unknown): Collision[] | null;
32
31
  export declare function isDowngrade(from: string, to: string): boolean;
32
+ export {};
@@ -2,26 +2,26 @@
2
2
  // `lps plugin push`, `lps theme version push` and `lps composer push`. The endpoint runs Composer +
3
3
  // WPackagist inside WordPress; the CLI only ever sends intent (which slugs at which versions),
4
4
  // never a composer.json, so the plugin stays the single authority on that file's shape.
5
+ import { z } from 'zod';
6
+ import { causeOf } from '../lib/narrow.js';
7
+ import { parseBody, responseOf } from '../lib/wp-client.js';
5
8
  import { compareVersions } from './version.js';
6
9
  // A cold `composer update` on a fresh site easily exceeds the global 30s WpClient timeout.
7
10
  export const SYNC_TIMEOUT_MS = 600_000;
11
+ // The server also sends installedVersion, path and type, only the slug is read here.
12
+ const COLLISIONS = z.array(z.looseObject({ slug: z.string() }));
8
13
  // 422 from the sync endpoint when the intent references a plugin/theme whose folder already
9
14
  // exists on the site but isn't tracked in composer.lock (installed by hand through wp-admin).
10
15
  // Re-running with --force lets the server remove the folder so Composer can take it over.
11
16
  export function parseCollisions(error) {
12
- const body = error.cause?.response;
13
- if (body?.statusCode !== 422 || !body.body)
17
+ const { body, statusCode } = responseOf(causeOf(error));
18
+ if (statusCode !== 422)
14
19
  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 {
20
+ const parsed = parseBody(body);
21
+ if (parsed.error !== 'unmanaged_plugins_present')
22
22
  return null;
23
- }
24
- return null;
23
+ const collisions = COLLISIONS.safeParse(parsed.collisions);
24
+ return collisions.success ? collisions.data : null;
25
25
  }
26
26
  // Only gates whether a version change needs --force. Unorderable inputs ("latest", a
27
27
  // constraint) count as "not a downgrade" so they don't block the push.
@@ -1,3 +1,4 @@
1
+ import { isRecord } from '../lib/narrow.js';
1
2
  import { pinActive, pinVersion, } from '../types/plugin.js';
2
3
  import { isExactVersion } from './version.js';
3
4
  // "1.7.2", or "1.7.2 (inactive)" for a pin that keeps the plugin inactive.
@@ -13,9 +14,10 @@ const LOOPRESS_PLUGIN_SLUGS = new Set(['loopress', 'loopress-full', 'loopress-li
13
14
  export function mergePluginManifest(existing, incoming) {
14
15
  const merged = { ...existing, ...incoming };
15
16
  const added = Object.keys(incoming).filter((s) => !Object.hasOwn(existing, s));
16
- const updated = Object.keys(incoming)
17
- .filter((s) => Object.hasOwn(existing, s) && describePin(existing[s]) !== describePin(incoming[s]))
18
- .map((s) => ({ from: describePin(existing[s]), slug: s, to: describePin(incoming[s]) }));
17
+ const updated = Object.entries(incoming).flatMap(([slug, pin]) => {
18
+ const before = Object.hasOwn(existing, slug) ? existing[slug] : undefined;
19
+ return before !== undefined && describePin(before) !== describePin(pin) ? [{ from: describePin(before), slug, to: describePin(pin) }] : [];
20
+ });
19
21
  return { added, merged, updated };
20
22
  }
21
23
  // WordPress core identifies each plugin by a `<folder>/<file>` id (or a bare `<file>` for a
@@ -30,7 +32,7 @@ export function mergePluginManifest(existing, incoming) {
30
32
  const WP_ORG_PLUGIN_URI = /^https?:\/\/wordpress\.org\/plugins\/([^/]+)\/?$/;
31
33
  function slugFromPluginFile(file, pluginUri) {
32
34
  if (file.includes('/'))
33
- return file.split('/', 1)[0];
35
+ return file.slice(0, file.indexOf('/'));
34
36
  return WP_ORG_PLUGIN_URI.exec(pluginUri)?.[1] ?? file;
35
37
  }
36
38
  export function parseInstalledPlugins(raw) {
@@ -93,8 +95,9 @@ export function lockedWpackagistSlugs(composerLock, kind) {
93
95
  const prefix = `wpackagist-${kind}/`;
94
96
  try {
95
97
  const parsed = JSON.parse(composerLock);
96
- return new Set((parsed.packages ?? [])
97
- .map((p) => p.name ?? '')
98
+ const packages = isRecord(parsed) && Array.isArray(parsed.packages) ? parsed.packages : [];
99
+ return new Set(packages
100
+ .map((p) => (isRecord(p) && typeof p.name === 'string' ? p.name : ''))
98
101
  .filter((name) => name.startsWith(prefix))
99
102
  .map((name) => name.slice(prefix.length)));
100
103
  }
@@ -3,6 +3,7 @@ export declare const RESOURCE_DIR_DEFAULTS: {
3
3
  readonly acf: "acf";
4
4
  readonly api: "api";
5
5
  readonly apps: "apps";
6
+ readonly block: "blocks";
6
7
  readonly blockCss: "theme/blocks";
7
8
  readonly cpt: "cpt";
8
9
  readonly css: "theme/css";
@@ -23,3 +24,4 @@ export declare const RESOURCE_DIR_DEFAULTS: {
23
24
  export type ResourceDirKind = keyof typeof RESOURCE_DIR_DEFAULTS;
24
25
  export declare function resolveResourceDir(kind: ResourceDirKind, localConfig: LoopressLocalConfig, override?: string): string;
25
26
  export declare function projectUses(kind: ResourceDirKind, localConfig: LoopressLocalConfig): boolean;
27
+ export declare function projectHasChildThemeFiles(localConfig: LoopressLocalConfig): boolean;
@@ -1,11 +1,13 @@
1
1
  import { existsSync } from 'node:fs';
2
2
  import { join, resolve } from 'node:path';
3
+ import {} from './loopress-config.js';
3
4
  // The default subdirectory for each file-backed resource, relative to rootDir. Used whenever
4
5
  // loopress.json has no `<kind>Dir` key, which is the case for a fresh `lps init`.
5
6
  export const RESOURCE_DIR_DEFAULTS = {
6
7
  acf: 'acf',
7
8
  api: 'api',
8
9
  apps: 'apps',
10
+ block: 'blocks',
9
11
  blockCss: 'theme/blocks',
10
12
  cpt: 'cpt',
11
13
  css: 'theme/css',
@@ -27,6 +29,7 @@ const CONFIG_KEY = {
27
29
  acf: 'acfDir',
28
30
  api: 'apiDir',
29
31
  apps: 'appsDir',
32
+ block: 'blockDir',
30
33
  blockCss: 'blockCssDir',
31
34
  cpt: 'cptDir',
32
35
  css: 'cssDir',
@@ -53,10 +56,15 @@ export function resolveResourceDir(kind, localConfig, override) {
53
56
  return override;
54
57
  const rootDir = localConfig.rootDir ?? '.';
55
58
  const configured = localConfig[CONFIG_KEY[kind]];
56
- return join(rootDir, configured ?? RESOURCE_DIR_DEFAULTS[kind]);
59
+ return join(rootDir, typeof configured === 'string' ? configured : RESOURCE_DIR_DEFAULTS[kind]);
57
60
  }
58
61
  // Whether the project uses a resource at all: its directory exists. What the aggregate `lps push`,
59
62
  // `lps pull` and `lps diff` go by for a resource backed by a plugin the site may not have.
60
63
  export function projectUses(kind, localConfig) {
61
64
  return existsSync(resolve(resolveResourceDir(kind, localConfig)));
62
65
  }
66
+ // The project has files for the Loopress child theme to hold: templates, parts or CSS. Without
67
+ // them the child would be empty, activateChildTheme or not: `lps theme activate` covers that.
68
+ export function projectHasChildThemeFiles(localConfig) {
69
+ return ['template', 'part', 'css', 'blockCss'].some((kind) => projectUses(kind, localConfig));
70
+ }
@@ -11,7 +11,7 @@ export type NormalizedSnippet = {
11
11
  location: SnippetLocation;
12
12
  name: string;
13
13
  priority: number;
14
- revision?: string;
14
+ revision?: string | undefined;
15
15
  shortcodeAttributes: string[];
16
16
  tags: string[];
17
17
  type: SnippetType;
@@ -1,3 +1,5 @@
1
+ import { isOneOf } from '../lib/narrow.js';
2
+ import {} from '../types/snippet.generated.js';
1
3
  export const SNIPPETS_ENDPOINT = 'loopress/v1/snippets';
2
4
  // Coerces server JSON that should be a string but, being unknown, could in principle be an
3
5
  // object; a plain String() on that would print "[object Object]" instead of falling back.
@@ -7,12 +9,12 @@ function coerceString(value, fallback = '') {
7
9
  export function parseType(raw) {
8
10
  const valid = ['css', 'html', 'js', 'php', 'text'];
9
11
  const value = coerceString(raw).toLowerCase();
10
- return valid.includes(value) ? value : null;
12
+ return isOneOf(valid, value) ? value : null;
11
13
  }
12
14
  const VALID_LOCATIONS = new Set(['admin', 'body', 'everywhere', 'footer', 'frontend', 'header', 'once']);
13
15
  export function parseLocation(raw) {
14
16
  const value = coerceString(raw).toLowerCase();
15
- return VALID_LOCATIONS.has(value) ? value : null;
17
+ return isOneOf(VALID_LOCATIONS, value) ? value : null;
16
18
  }
17
19
  export function parseInsertMethod(raw) {
18
20
  return raw === 'auto' || raw === 'shortcode' ? raw : null;
@@ -83,7 +85,7 @@ export function normalizeSnippet(data) {
83
85
  priority: resolvePriority(data.priority),
84
86
  revision: typeof data.revision === 'string' ? data.revision : undefined,
85
87
  shortcodeAttributes: Array.isArray(data.shortcodeAttributes) ? data.shortcodeAttributes.map(String) : [],
86
- tags: Array.isArray(data.tags) ? data.tags : [],
88
+ tags: Array.isArray(data.tags) ? data.tags.map(String) : [],
87
89
  type: parseType(data.type) ?? 'php',
88
90
  };
89
91
  }
@@ -32,8 +32,8 @@ export function parseTemplateFile(slug, raw) {
32
32
  // `area` defaults from the name, the way themes name their parts: header, header-large-title...
33
33
  export function parsePartFile(slug, raw) {
34
34
  const { body, meta } = parseFile(raw, ['title', 'area']);
35
- const nameArea = /^(header|footer)(-|$)/.exec(slug)?.[1] ?? 'uncategorized';
36
- return { area: meta.area || nameArea, html: body, slug, title: meta.title || titleFromSlug(slug) };
35
+ const nameArea = /^(header|footer)(?:-|$)/.exec(slug)?.[1] ?? 'uncategorized';
36
+ return { area: (meta.area ?? '') || nameArea, html: body, slug, title: (meta.title ?? '') || titleFromSlug(slug) };
37
37
  }
38
38
  // Same directory rules as pages/ (flat, `.html` only), with template slugs, every problem at once.
39
39
  export async function readLocalTemplates(templatesDir, partsDir) {
@@ -3,6 +3,9 @@ import { Buffer } from 'node:buffer';
3
3
  import { existsSync } from 'node:fs';
4
4
  import { readdir } from 'node:fs/promises';
5
5
  import { extname, join, relative, resolve, sep } from 'node:path';
6
+ import { z } from 'zod';
7
+ import { errorCode } from '../lib/narrow.js';
8
+ import {} from './page-format.js';
6
9
  // The global stylesheet's entry: it @imports the rest of theme/css/ in cascade order.
7
10
  export const STYLE_ENTRY = 'style.css';
8
11
  // WordPress's own block name rule (register_block_type): lowercase letters, digits and dashes,
@@ -18,7 +21,7 @@ async function listFiles(dir) {
18
21
  .toSorted((a, b) => a.localeCompare(b));
19
22
  }
20
23
  catch (error) {
21
- if (error.code === 'ENOENT')
24
+ if (errorCode(error) === 'ENOENT')
22
25
  return [];
23
26
  throw error;
24
27
  }
@@ -27,8 +30,9 @@ async function readBlockEntries(blocksDir, problems) {
27
30
  const entries = [];
28
31
  for (const file of await listFiles(blocksDir)) {
29
32
  const segments = relative(blocksDir, file).split(sep);
30
- const [namespace, block] = [segments[0], segments[1]?.replace(/\.css$/, '')];
31
- if (segments.length !== 2 || extname(file) !== '.css' || !BLOCK_SEGMENT.test(namespace) || !BLOCK_SEGMENT.test(block)) {
33
+ const [namespace, fileName] = segments;
34
+ const block = fileName?.replace(/\.css$/, '');
35
+ if (namespace === undefined || block === undefined || segments.length !== 2 || extname(file) !== '.css' || !BLOCK_SEGMENT.test(namespace) || !BLOCK_SEGMENT.test(block)) {
32
36
  problems.push({ file, message: `must be <namespace>/<block>.css, named after a block (e.g. ${join(blocksDir, 'core', 'button.css')})` });
33
37
  continue;
34
38
  }
@@ -36,11 +40,15 @@ async function readBlockEntries(blocksDir, problems) {
36
40
  }
37
41
  return entries;
38
42
  }
43
+ const BUILD_LOCATION = z.object({ file: z.string(), line: z.number() });
44
+ const BUILD_MESSAGE = z.object({ location: BUILD_LOCATION.nullable(), text: z.string() });
45
+ const BUILD_FAILURE = z.object({ errors: z.array(BUILD_MESSAGE) });
39
46
  function buildProblems(error) {
40
- const { errors } = error;
41
- if (!Array.isArray(errors))
47
+ // esbuild's BuildFailure, the only part of it read here. Anything else is not a CSS problem.
48
+ const failure = BUILD_FAILURE.safeParse(error);
49
+ if (!failure.success)
42
50
  throw error;
43
- return errors.map((message) => ({
51
+ return failure.data.errors.map((message) => ({
44
52
  file: message.location ? `${message.location.file}:${message.location.line}` : 'esbuild',
45
53
  message: message.text,
46
54
  }));
@@ -8,8 +8,11 @@ export type WpThemeSupportInfo = {
8
8
  }>>;
9
9
  status: 'active' | 'inactive';
10
10
  stylesheet: string;
11
+ template?: string;
11
12
  theme_supports?: Record<string, unknown>;
12
13
  };
14
+ export declare const CHILD_THEME_SUFFIX = "-loopress";
15
+ export declare function projectStylesheet(theme: Pick<WpThemeSupportInfo, 'stylesheet' | 'template'>): string;
13
16
  export declare function isBlockTheme(theme: Pick<WpThemeSupportInfo, 'theme_supports'>): boolean;
14
17
  export declare function getUserGlobalStylesId(theme: Pick<WpThemeSupportInfo, '_links'>): null | number;
15
18
  export declare function globalStylesFiles(dir: string): Promise<string[]>;
@@ -1,5 +1,6 @@
1
1
  import { resolve } from 'node:path';
2
2
  import { readdirTolerant } from '../lib/readdir-tolerant.js';
3
+ import {} from '../lib/wp-client.js';
3
4
  // Global Styles are the user's customizations from the Site Editor's "Styles" screen (colors,
4
5
  // typography, spacing), stored by WordPress core in a `wp_global_styles` post, one per theme.
5
6
  // This is separate from the theme's own theme.json file, which lives in the theme's repo and is
@@ -11,6 +12,15 @@ export function themeStylesFileName(stylesheet) {
11
12
  export function globalStylesEndpoint(id) {
12
13
  return `wp/v2/global-styles/${id}`;
13
14
  }
15
+ // Appended to the parent's stylesheet by the plugin's ChildThemeController.
16
+ export const CHILD_THEME_SUFFIX = '-loopress';
17
+ // The stylesheet a project's Global Styles file is named after: the parent's when the Loopress
18
+ // child is active, so the tracked file stays the same whether or not the child was activated
19
+ // (the plugin copies the parent's Global Styles into the child when activating it).
20
+ export function projectStylesheet(theme) {
21
+ const { stylesheet, template } = theme;
22
+ return template && stylesheet === `${template}${CHILD_THEME_SUFFIX}` ? template : stylesheet;
23
+ }
14
24
  // WordPress core surfaces `wp_is_block_theme()` over the `wp/v2/themes` REST response as
15
25
  // `theme_supports['block-templates']` (see `_add_default_theme_supports()` in
16
26
  // wp-includes/theme.php): core adds that feature flag itself exactly when the theme has its own
@@ -56,13 +66,13 @@ export async function globalStylesSkipNote(wp, dir, action) {
56
66
  const active = await getActiveTheme(wp);
57
67
  if (!isBlockTheme(active))
58
68
  return `the active theme "${active.stylesheet}" is a classic theme, skipped`;
59
- if (action === 'push' && !files.includes(themeStylesFileName(active.stylesheet))) {
60
- return `no file for the active theme "${active.stylesheet}", skipped`;
69
+ if (action === 'push' && !files.includes(themeStylesFileName(projectStylesheet(active)))) {
70
+ return `no file for the active theme "${projectStylesheet(active)}", skipped`;
61
71
  }
62
72
  return undefined;
63
73
  }
64
- // Resolves the active theme's stylesheet slug and the id of its editable Global Styles post in
65
- // one call, failing clearly (rather than with a raw REST error later) when the active theme
74
+ // Resolves the active theme's stylesheet slug, as the project names it (see projectStylesheet),
75
+ // and the id of its editable Global Styles post in one call, failing clearly (rather than with a raw REST error later) when the active theme
66
76
  // isn't a block theme: classic themes have no Styles screen and no `wp_global_styles` post at all.
67
77
  export async function getActiveThemeGlobalStyles(wp) {
68
78
  const active = await getActiveTheme(wp);
@@ -73,11 +83,39 @@ export async function getActiveThemeGlobalStyles(wp) {
73
83
  if (id === null) {
74
84
  throw new Error(`Could not resolve the editable Global Styles post for "${active.stylesheet}". Requires an admin-capable account (edit_theme_options) on WordPress 5.9+.`);
75
85
  }
76
- return { id, stylesheet: active.stylesheet };
86
+ return { id, stylesheet: projectStylesheet(active) };
77
87
  }
78
88
  // The compared/stored value: `settings` and `styles` only. `id` and `_links` are WordPress
79
89
  // bookkeeping, not part of the tracked configuration (a fresh install resolves to a different
80
90
  // id for the same content), so they're dropped rather than becoming spurious drift.
91
+ // WordPress also rewrites two things on save, so both sides take its stored form, or a file
92
+ // pushed as is would read as drift right after its push:
93
+ // - a preset list (`palette: [...]`) is keyed by its origin, `palette: {custom: [...]}`, and
94
+ // - a `var:preset|color|base` reference becomes `var(--wp--preset--color--base)`.
81
95
  export function canonicalGlobalStyles(record) {
82
- return { settings: record.settings ?? {}, styles: record.styles ?? {} };
96
+ return { settings: expandVarReferences(unwrapPresets(record.settings ?? {})), styles: expandVarReferences(record.styles ?? {}) };
97
+ }
98
+ const PRESET_KEYS = new Set(['aspectRatios', 'duotone', 'fontFamilies', 'fontSizes', 'gradients', 'palette', 'presets', 'radiusSizes', 'spacingSizes']);
99
+ function unwrapPresets(value) {
100
+ if (typeof value !== 'object' || value === null)
101
+ return value;
102
+ if (Array.isArray(value))
103
+ return value.map((item) => unwrapPresets(item));
104
+ return Object.fromEntries(Object.entries(value).map(([key, item]) => {
105
+ const custom = PRESET_KEYS.has(key) && isOnlyCustom(item) ? item.custom : item;
106
+ return [key, unwrapPresets(custom)];
107
+ }));
108
+ }
109
+ function isOnlyCustom(value) {
110
+ return typeof value === 'object' && value !== null && Object.keys(value).length === 1 && Array.isArray(value.custom);
111
+ }
112
+ // WP_Theme_JSON::convert_custom_properties()'s rule.
113
+ function expandVarReferences(value) {
114
+ if (typeof value === 'string')
115
+ return value.startsWith('var:') ? `var(--wp--${value.slice(4).replaceAll('|', '--')})` : value;
116
+ if (typeof value !== 'object' || value === null)
117
+ return value;
118
+ if (Array.isArray(value))
119
+ return value.map((item) => expandVarReferences(item));
120
+ return Object.fromEntries(Object.entries(value).map(([key, item]) => [key, expandVarReferences(item)]));
83
121
  }
@@ -10,7 +10,7 @@ import { compareVersions as cmp, validate } from 'compare-versions';
10
10
  // shape here would let it slip past `isExactVersion` at `add` time, then have
11
11
  // `compareVersions`/`isDowngrade` silently treat it as unorderable, bypassing the --force
12
12
  // gate on a real downgrade.
13
- const EXACT_VERSION = /^\d+(\.\d+){0,3}$|^\d+(\.\d+){2,3}-[0-9A-Za-z.]+$/;
13
+ const EXACT_VERSION = /^\d+(?:\.\d+){0,3}$|^\d+(?:\.\d+){2,3}-[\d.a-z]+$/i;
14
14
  export function isExactVersion(value) {
15
15
  return EXACT_VERSION.test(value);
16
16
  }