@loopress/cli 0.32.0 → 0.32.2

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.
@@ -40,7 +40,7 @@ export class DiffCommand extends LoopressCommand {
40
40
  return {
41
41
  left: async () => provider.remote(this.wp, sides.warn, dir),
42
42
  resource: provider.resource,
43
- right: sides.againstWp ? async () => provider.remote(sides.againstWp, sides.warn, dir) : async () => provider.local(dir, sides.warn),
43
+ right: sides.againstWp ? async () => provider.remote(sides.againstWp, sides.warn, dir) : async () => provider.local(dir, sides.warn, this.wp),
44
44
  title: provider.title,
45
45
  };
46
46
  }
@@ -65,7 +65,7 @@ export class PushCommand extends LoopressCommand {
65
65
  const remote = await this.previewRemote(provider, dir);
66
66
  await this.guardOutsideEdits(provider, dir, remote, writes);
67
67
  try {
68
- const local = await provider.local(dir, () => { });
68
+ const local = await provider.local(dir, () => { }, this.wp);
69
69
  this.previewStates = { local, remote };
70
70
  this.previewChanges = pushChanges(local, remote, { writes });
71
71
  }
@@ -236,12 +236,19 @@ export class PushCommand extends LoopressCommand {
236
236
  const snapshot = await readSnapshot(resolve(process.cwd(), this.rootDir), provider.resource, this.siteConfig.name);
237
237
  expected = recordToState(snapshot.afterState);
238
238
  pushedAt = snapshot.createdAt;
239
- local = await provider.local(dir, () => { });
240
239
  }
241
240
  catch {
242
- // No earlier push from this machine, or unreadable local files the push reports itself.
241
+ // No earlier push from this machine.
243
242
  return;
244
243
  }
244
+ // Unreadable files are skipped with a warning, so a failure here is the site's (ACF's
245
+ // normalize route): the check can't run, and a push must not go on unchecked.
246
+ try {
247
+ local = await provider.local(dir, () => { }, this.wp);
248
+ }
249
+ catch (error) {
250
+ this.error(`Could not check for changes made on ${this.siteConfig.url} outside Loopress: ${error.message}. Retry, or pass --overwrite.`);
251
+ }
245
252
  const overwritten = [...local.keys()].filter((id) => (writes?.(id) ?? true) &&
246
253
  remote.get(id) !== undefined &&
247
254
  !sameEntry(remote, expected, id) &&
@@ -3,7 +3,7 @@ import { type ResourceState } from './diff-state.js';
3
3
  import { type WpClient } from './wp-client.js';
4
4
  export type ResourceStateProvider = {
5
5
  dirKind: ResourceDirKind;
6
- local(dir: string, onWarn: (message: string) => void): Promise<ResourceState>;
6
+ local(dir: string, onWarn: (message: string) => void, wp?: WpClient): Promise<ResourceState>;
7
7
  remote(wp: WpClient, onWarn: (message: string) => void, dir: string): Promise<ResourceState>;
8
8
  resource: string;
9
9
  title: string;
@@ -1,6 +1,6 @@
1
1
  import { readFile } from 'node:fs/promises';
2
2
  import { basename, join, relative, sep } from 'node:path';
3
- import { ACF_OBJECT_TYPES, acfEndpoint, getAcfKey } from '../utils/acf-format.js';
3
+ import { ACF_OBJECT_TYPES, acfEndpoint, acfNormalizeEndpoint, getAcfKey } from '../utils/acf-format.js';
4
4
  import { CPT_SPEC, fileExtension, META_SPEC, parseDeclaredFile, PATTERN_SPEC, ROLE_SPEC, TAXONOMY_SPEC, } from '../utils/declared-type-format.js';
5
5
  import { FORM_ENDPOINT, getFormId } from '../utils/form-format.js';
6
6
  import { getMenuSlug, MENU_ENDPOINT, MENU_LOCATIONS_ENDPOINT } from '../utils/menu-format.js';
@@ -115,19 +115,33 @@ const formProvider = {
115
115
  };
116
116
  // ---- ACF --------------------------------------------------------------------------------
117
117
  // ACF's export JSON is large, deeply nested, and versioned by ACF itself; `key` is the stable
118
- // identity and the whole object (minus the `modified` timestamp) round-trips untouched, so it
119
- // is all compared. Ids are namespaced by object type (`field-groups/group_x`) since keys are
120
- // only unique within a type.
118
+ // identity and the whole object (minus the `modified` timestamp) is compared. Ids are
119
+ // namespaced by object type (`field-groups/group_x`) since keys are only unique within a type.
120
+ // The site stores every setting, defaults included, while a hand- or agent-written file holds
121
+ // only what its author wrote: given the site, the local side is what the site would store
122
+ // (ACF's own normalization, nothing saved), or every such file would read as drift forever.
123
+ async function normalizeAcf(wp, type, objects) {
124
+ try {
125
+ return await wp.post(acfNormalizeEndpoint(type), { objects });
126
+ }
127
+ catch (error) {
128
+ // A plugin older than the route: compared as written, as before it existed.
129
+ if (isNotFoundError(error))
130
+ return objects;
131
+ throw error;
132
+ }
133
+ }
121
134
  const acfProvider = {
122
135
  dirKind: 'acf',
123
- async local(dir, onWarn) {
136
+ async local(dir, onWarn, wp) {
124
137
  const state = new Map();
125
138
  for (const type of ACF_OBJECT_TYPES) {
126
- const objects = await loadFiles(join(dir, type), {
139
+ const files = await loadFiles(join(dir, type), {
127
140
  extension: '.json',
128
141
  onSkip: onWarn,
129
142
  parse: readJson,
130
143
  });
144
+ const objects = wp && files.length > 0 ? await normalizeAcf(wp, type, files) : files;
131
145
  for (const object of objects) {
132
146
  const key = getAcfKey(object);
133
147
  if (key !== null)
@@ -2,6 +2,7 @@ export declare const ACF_OBJECT_TYPES: readonly ["field-groups", "post-types", "
2
2
  export type AcfObjectType = (typeof ACF_OBJECT_TYPES)[number];
3
3
  export declare function acfEndpoint(type: AcfObjectType): string;
4
4
  export declare function acfObjectEndpoint(type: AcfObjectType, key: string): string;
5
+ export declare function acfNormalizeEndpoint(type: AcfObjectType): string;
5
6
  export declare function getAcfKey(data: Record<string, unknown>): null | string;
6
7
  export declare function acfFieldChanges(before: unknown, after: unknown): {
7
8
  added: string[];
@@ -9,6 +9,11 @@ export function acfEndpoint(type) {
9
9
  export function acfObjectEndpoint(type, key) {
10
10
  return `${acfEndpoint(type)}/${key}`;
11
11
  }
12
+ // POST route returning local objects the way the site would store them, defaults filled in,
13
+ // nothing saved (see resource-state.ts's acfProvider).
14
+ export function acfNormalizeEndpoint(type) {
15
+ return `loopress/v1/acf/normalize/${type}`;
16
+ }
12
17
  // Deliberately loose: ACF's own export JSON is large, deeply nested, and versioned by ACF
13
18
  // itself (new settings appear across ACF releases). We only need `key` for filenames/identity;
14
19
  // everything else round-trips through pull/push untouched, so there's no generated type for it
@@ -34,10 +34,8 @@ export function startsWithStrictTypes(content) {
34
34
  // Advances the brace depth and "a statement starts here" flag past one token; returns the token
35
35
  // when it starts a top-level statement that isn't a declaration.
36
36
  function step(state, token) {
37
- if (token.type === 'html') {
38
- state.atStart = true;
39
- return token.text === '' ? null : 'inline HTML after ?>';
40
- }
37
+ if (token.type === 'html')
38
+ return inlineHtml(state, token);
41
39
  const statementStart = state.depth === 0 && state.atStart;
42
40
  switch (token.text) {
43
41
  case ';': {
@@ -66,6 +64,13 @@ function step(state, token) {
66
64
  }
67
65
  }
68
66
  }
67
+ // A template inside a class or function body (`?> <p>...</p> <?php`) only runs when it is called.
68
+ function inlineHtml(state, token) {
69
+ if (state.depth > 0)
70
+ return null;
71
+ state.atStart = true;
72
+ return token.text === '' ? null : 'inline HTML after ?>';
73
+ }
69
74
  function openBrace(state, statementStart) {
70
75
  if (state.namespaceHeader) {
71
76
  state.namespaceHeader = false;
@@ -78,6 +78,34 @@ export async function getActiveThemeGlobalStyles(wp) {
78
78
  // The compared/stored value: `settings` and `styles` only. `id` and `_links` are WordPress
79
79
  // bookkeeping, not part of the tracked configuration (a fresh install resolves to a different
80
80
  // id for the same content), so they're dropped rather than becoming spurious drift.
81
+ // WordPress also rewrites two things on save, so both sides take its stored form, or a file
82
+ // pushed as is would read as drift right after its push:
83
+ // - a preset list (`palette: [...]`) is keyed by its origin, `palette: {custom: [...]}`, and
84
+ // - a `var:preset|color|base` reference becomes `var(--wp--preset--color--base)`.
81
85
  export function canonicalGlobalStyles(record) {
82
- return { settings: record.settings ?? {}, styles: record.styles ?? {} };
86
+ return { settings: expandVarReferences(unwrapPresets(record.settings ?? {})), styles: expandVarReferences(record.styles ?? {}) };
87
+ }
88
+ const PRESET_KEYS = new Set(['aspectRatios', 'duotone', 'fontFamilies', 'fontSizes', 'gradients', 'palette', 'presets', 'radiusSizes', 'spacingSizes']);
89
+ function unwrapPresets(value) {
90
+ if (typeof value !== 'object' || value === null)
91
+ return value;
92
+ if (Array.isArray(value))
93
+ return value.map((item) => unwrapPresets(item));
94
+ return Object.fromEntries(Object.entries(value).map(([key, item]) => {
95
+ const custom = PRESET_KEYS.has(key) && isOnlyCustom(item) ? item.custom : item;
96
+ return [key, unwrapPresets(custom)];
97
+ }));
98
+ }
99
+ function isOnlyCustom(value) {
100
+ return typeof value === 'object' && value !== null && Object.keys(value).length === 1 && Array.isArray(value.custom);
101
+ }
102
+ // WP_Theme_JSON::convert_custom_properties()'s rule.
103
+ function expandVarReferences(value) {
104
+ if (typeof value === 'string')
105
+ return value.startsWith('var:') ? `var(--wp--${value.slice(4).replaceAll('|', '--')})` : value;
106
+ if (typeof value !== 'object' || value === null)
107
+ return value;
108
+ if (Array.isArray(value))
109
+ return value.map((item) => expandVarReferences(item));
110
+ return Object.fromEntries(Object.entries(value).map(([key, item]) => [key, expandVarReferences(item)]));
83
111
  }