@loopress/cli 0.24.1 → 0.26.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 (106) hide show
  1. package/README.md +721 -90
  2. package/dist/commands/acf/push.d.ts +1 -0
  3. package/dist/commands/acf/push.js +25 -2
  4. package/dist/commands/acf/rollback.d.ts +2 -0
  5. package/dist/commands/acf/rollback.js +5 -0
  6. package/dist/commands/api/rollback.d.ts +2 -0
  7. package/dist/commands/api/rollback.js +5 -0
  8. package/dist/commands/diff.js +1 -1
  9. package/dist/commands/doctor.d.ts +17 -1
  10. package/dist/commands/doctor.js +55 -38
  11. package/dist/commands/form/pull.js +4 -1
  12. package/dist/commands/form/push.d.ts +1 -0
  13. package/dist/commands/form/push.js +29 -3
  14. package/dist/commands/form/rollback.d.ts +2 -0
  15. package/dist/commands/form/rollback.js +5 -0
  16. package/dist/commands/hook/rollback.d.ts +2 -0
  17. package/dist/commands/hook/rollback.js +5 -0
  18. package/dist/commands/init.js +2 -0
  19. package/dist/commands/menu/diff.d.ts +2 -0
  20. package/dist/commands/menu/diff.js +5 -0
  21. package/dist/commands/menu/list.d.ts +13 -0
  22. package/dist/commands/menu/list.js +32 -0
  23. package/dist/commands/menu/pull.d.ts +15 -0
  24. package/dist/commands/menu/pull.js +72 -0
  25. package/dist/commands/menu/push.d.ts +17 -0
  26. package/dist/commands/menu/push.js +120 -0
  27. package/dist/commands/menu/rollback.d.ts +2 -0
  28. package/dist/commands/menu/rollback.js +5 -0
  29. package/dist/commands/option/add.js +1 -1
  30. package/dist/commands/option/push.d.ts +1 -0
  31. package/dist/commands/option/push.js +26 -1
  32. package/dist/commands/option/rollback.d.ts +2 -0
  33. package/dist/commands/option/rollback.js +5 -0
  34. package/dist/commands/page/diff.d.ts +2 -0
  35. package/dist/commands/page/diff.js +5 -0
  36. package/dist/commands/page/list.d.ts +8 -0
  37. package/dist/commands/page/list.js +22 -0
  38. package/dist/commands/page/push.d.ts +20 -0
  39. package/dist/commands/page/push.js +60 -0
  40. package/dist/commands/plugin/push.d.ts +1 -0
  41. package/dist/commands/plugin/push.js +14 -0
  42. package/dist/commands/promote.d.ts +8 -1
  43. package/dist/commands/promote.js +2 -0
  44. package/dist/commands/pull.d.ts +11 -1
  45. package/dist/commands/pull.js +9 -2
  46. package/dist/commands/push.d.ts +11 -1
  47. package/dist/commands/push.js +10 -2
  48. package/dist/commands/seo/pull.js +6 -2
  49. package/dist/commands/seo/push.d.ts +1 -0
  50. package/dist/commands/seo/push.js +33 -3
  51. package/dist/commands/seo/rollback.d.ts +2 -0
  52. package/dist/commands/seo/rollback.js +5 -0
  53. package/dist/commands/snippet/push.d.ts +1 -0
  54. package/dist/commands/snippet/push.js +31 -1
  55. package/dist/commands/snippet/rollback.d.ts +2 -0
  56. package/dist/commands/snippet/rollback.js +5 -0
  57. package/dist/commands/theme-styles/diff.d.ts +2 -0
  58. package/dist/commands/theme-styles/diff.js +5 -0
  59. package/dist/commands/theme-styles/pull.d.ts +12 -0
  60. package/dist/commands/theme-styles/pull.js +31 -0
  61. package/dist/commands/theme-styles/push.d.ts +12 -0
  62. package/dist/commands/theme-styles/push.js +45 -0
  63. package/dist/commands/theme-styles/rollback.d.ts +2 -0
  64. package/dist/commands/theme-styles/rollback.js +5 -0
  65. package/dist/lib/app-manifest.js +4 -27
  66. package/dist/lib/base.d.ts +2 -0
  67. package/dist/lib/base.js +6 -0
  68. package/dist/lib/dev-watch.d.ts +1 -1
  69. package/dist/lib/dev-watch.js +2 -1
  70. package/dist/lib/find-orphaned-files.js +5 -47
  71. package/dist/lib/load-files.js +4 -42
  72. package/dist/lib/php-files-command.d.ts +1 -0
  73. package/dist/lib/php-files-command.js +84 -47
  74. package/dist/lib/push-command.d.ts +4 -0
  75. package/dist/lib/push-command.js +49 -0
  76. package/dist/lib/readdir-tolerant.d.ts +1 -0
  77. package/dist/lib/readdir-tolerant.js +12 -0
  78. package/dist/lib/resource-state.js +116 -3
  79. package/dist/lib/rollback-command.d.ts +21 -0
  80. package/dist/lib/rollback-command.js +180 -0
  81. package/dist/lib/rollback-materialize.d.ts +1 -0
  82. package/dist/lib/rollback-materialize.js +121 -0
  83. package/dist/lib/snapshot-store.d.ts +30 -0
  84. package/dist/lib/snapshot-store.js +133 -0
  85. package/dist/lib/validate-local.js +7 -1
  86. package/dist/types/project-config.generated.d.ts +12 -0
  87. package/dist/utils/acf-format.d.ts +1 -0
  88. package/dist/utils/acf-format.js +6 -0
  89. package/dist/utils/form-format.d.ts +4 -0
  90. package/dist/utils/form-format.js +3 -7
  91. package/dist/utils/menu-format.d.ts +24 -0
  92. package/dist/utils/menu-format.js +8 -0
  93. package/dist/utils/option-format.d.ts +1 -0
  94. package/dist/utils/page-format.d.ts +27 -0
  95. package/dist/utils/page-format.js +111 -0
  96. package/dist/utils/resource-dirs.d.ts +3 -0
  97. package/dist/utils/resource-dirs.js +6 -0
  98. package/dist/utils/seo-format.d.ts +8 -0
  99. package/dist/utils/seo-format.js +3 -0
  100. package/dist/utils/snippet-format.d.ts +1 -0
  101. package/dist/utils/snippet-format.js +1 -0
  102. package/dist/utils/theme-styles-format.d.ts +26 -0
  103. package/dist/utils/theme-styles-format.js +55 -0
  104. package/oclif.manifest.json +1633 -168
  105. package/package.json +4 -2
  106. package/schemas/project-config.schema.json +15 -0
@@ -0,0 +1,111 @@
1
+ import { readdir, readFile } from 'node:fs/promises';
2
+ import { basename, extname, join } from 'node:path';
3
+ export const PAGES_ENDPOINT = 'loopress/v1/pages';
4
+ // Mirrored server-side in PagesController. No leading, trailing or doubled hyphen: WordPress's
5
+ // sanitize_title() would rewrite those, and the page would no longer match its file name.
6
+ export const PAGE_SLUG_PATTERN = /^[a-z0-9]+(?:-[a-z0-9]+)*$/;
7
+ export const PAGE_STATUSES = ['draft', 'publish'];
8
+ const HEADER_KEYS = ['title', 'status', 'full-width', 'hide-title', 'template'];
9
+ function parseBoolean(key, value) {
10
+ if (value === undefined)
11
+ return false;
12
+ if (value !== 'true' && value !== 'false')
13
+ throw new Error(`"${key}" must be "true" or "false", got "${value}"`);
14
+ return value === 'true';
15
+ }
16
+ export function formatPageProblems(problems) {
17
+ return problems.map((problem) => `${problem.file}: ${problem.message}`).join('\n ');
18
+ }
19
+ // `legal-notice` -> `Legal notice`.
20
+ export function titleFromSlug(slug) {
21
+ const words = slug.split('-').filter(Boolean).join(' ');
22
+ return words.charAt(0).toUpperCase() + words.slice(1);
23
+ }
24
+ // A page file is plain HTML, optionally opened by an HTML comment of `key: value` lines:
25
+ //
26
+ // <!--
27
+ // title: Legal notice
28
+ // status: publish
29
+ // template: page-no-title
30
+ // -->
31
+ //
32
+ // A comment rather than YAML front matter so the file stays valid HTML, previewable as is in a
33
+ // browser. The header is stripped from what gets pushed. Throws on an unknown key or status so a
34
+ // typo never silently falls back to a default. `template` is the active theme's own template
35
+ // slug (a block theme's templates/<slug>.html, or a classic theme's page-<slug>.php); unlike
36
+ // `status`, it's never validated here, an unknown slug just makes WordPress fall back to the
37
+ // default template, the same silent no-op as `full-width`/`hide-title` on a theme that doesn't
38
+ // support them.
39
+ export function parsePageFile(slug, raw) {
40
+ const header = /^\s*<!--([\s\S]*?)-->\r?\n?/.exec(raw);
41
+ const meta = {};
42
+ if (header) {
43
+ for (const line of header[1].split(/\r?\n/)) {
44
+ if (line.trim() === '')
45
+ continue;
46
+ const colon = line.indexOf(':');
47
+ if (colon === -1)
48
+ throw new Error(`header line "${line.trim()}" is not a "key: value" pair`);
49
+ const key = line.slice(0, colon).trim();
50
+ if (!HEADER_KEYS.includes(key))
51
+ throw new Error(`unknown header key "${key}" (allowed: ${HEADER_KEYS.join(', ')})`);
52
+ meta[key] = line.slice(colon + 1).trim();
53
+ }
54
+ }
55
+ const status = meta.status ?? 'draft';
56
+ if (!PAGE_STATUSES.includes(status)) {
57
+ throw new Error(`status "${status}" is not one of ${PAGE_STATUSES.join(', ')}`);
58
+ }
59
+ return {
60
+ fullWidth: parseBoolean('full-width', meta['full-width']),
61
+ hideTitle: parseBoolean('hide-title', meta['hide-title']),
62
+ html: header ? raw.slice(header[0].length) : raw,
63
+ slug,
64
+ status: status,
65
+ template: meta.template ?? '',
66
+ title: meta.title || titleFromSlug(slug),
67
+ };
68
+ }
69
+ // Reads every page of `dir` and reports every problem at once instead of skipping bad files
70
+ // like the other resources do: a page push is declarative, so anything unexpected in the
71
+ // directory (a `.php`, a subdirectory, a bad header) must stop it before any network call.
72
+ // Dotfiles (.DS_Store, editor droppings) are ignored. A missing directory is "no pages".
73
+ export async function readLocalPages(dir) {
74
+ let entries;
75
+ try {
76
+ entries = await readdir(dir, { withFileTypes: true });
77
+ }
78
+ catch (error) {
79
+ if (error.code === 'ENOENT')
80
+ return { pages: [], problems: [] };
81
+ throw error;
82
+ }
83
+ const pages = [];
84
+ const problems = [];
85
+ entries.sort((a, b) => a.name.localeCompare(b.name));
86
+ for (const entry of entries) {
87
+ if (entry.name.startsWith('.'))
88
+ continue;
89
+ const filePath = join(dir, entry.name);
90
+ if (!entry.isFile()) {
91
+ problems.push({ file: filePath, message: `subdirectories are not supported, keep every page at the top of ${dir}` });
92
+ continue;
93
+ }
94
+ if (extname(entry.name) !== '.html') {
95
+ problems.push({ file: filePath, message: `only .html files are allowed in ${dir}` });
96
+ continue;
97
+ }
98
+ const slug = basename(entry.name, '.html');
99
+ if (!PAGE_SLUG_PATTERN.test(slug)) {
100
+ problems.push({ file: filePath, message: 'the file name must be lowercase letters and digits separated by single hyphens (e.g. "legal-notice.html")' });
101
+ continue;
102
+ }
103
+ try {
104
+ pages.push(parsePageFile(slug, await readFile(filePath, 'utf8')));
105
+ }
106
+ catch (error) {
107
+ problems.push({ file: filePath, message: error.message });
108
+ }
109
+ }
110
+ return { pages, problems };
111
+ }
@@ -5,9 +5,12 @@ export declare const RESOURCE_DIR_DEFAULTS: {
5
5
  readonly apps: "apps";
6
6
  readonly form: "forms";
7
7
  readonly hooks: "hooks";
8
+ readonly menu: "menus";
8
9
  readonly options: "options";
10
+ readonly page: "pages";
9
11
  readonly seo: "seo";
10
12
  readonly snippets: "snippets";
13
+ readonly themeStyles: "theme";
11
14
  };
12
15
  export type ResourceDirKind = keyof typeof RESOURCE_DIR_DEFAULTS;
13
16
  export declare function resolveResourceDir(kind: ResourceDirKind, localConfig: LoopressLocalConfig, override?: string): string;
@@ -7,9 +7,12 @@ export const RESOURCE_DIR_DEFAULTS = {
7
7
  apps: 'apps',
8
8
  form: 'forms',
9
9
  hooks: 'hooks',
10
+ menu: 'menus',
10
11
  options: 'options',
12
+ page: 'pages',
11
13
  seo: 'seo',
12
14
  snippets: 'snippets',
15
+ themeStyles: 'theme',
13
16
  };
14
17
  const CONFIG_KEY = {
15
18
  acf: 'acfDir',
@@ -17,9 +20,12 @@ const CONFIG_KEY = {
17
20
  apps: 'appsDir',
18
21
  form: 'formDir',
19
22
  hooks: 'hooksDir',
23
+ menu: 'menuDir',
20
24
  options: 'optionsDir',
25
+ page: 'pageDir',
21
26
  seo: 'seoDir',
22
27
  snippets: 'snippetsDir',
28
+ themeStyles: 'themeStylesDir',
23
29
  };
24
30
  // Resolves where a resource's files live: an explicit override wins, otherwise the
25
31
  // loopress.json `<kind>Dir` setting, otherwise the built-in default, all under `rootDir`.
@@ -3,11 +3,19 @@ export declare const SEO_SETTINGS_ENDPOINT = "loopress/v1/seo/settings";
3
3
  export declare const SEO_REDIRECTS_ENDPOINT = "loopress/v1/seo/redirects";
4
4
  export declare function seoRedirectEndpoint(id: number): string;
5
5
  export declare function seoPostMetaEndpoint(postType: string): string;
6
+ export declare function seoPostMetaItemEndpoint(postType: string, slug: string): string;
6
7
  export type SeoPostMeta = {
7
8
  meta: Record<string, unknown>;
8
9
  slug: string;
9
10
  title: string;
10
11
  };
12
+ export type RemoteSeoPostMeta = SeoPostMeta & {
13
+ revision: string;
14
+ };
15
+ export type RemoteSeoSettings = {
16
+ revision: string;
17
+ settings: Record<string, unknown>;
18
+ };
11
19
  export type SeoRedirect = {
12
20
  createdAt: string | undefined;
13
21
  headerCode: number;
@@ -9,6 +9,9 @@ export function seoRedirectEndpoint(id) {
9
9
  export function seoPostMetaEndpoint(postType) {
10
10
  return `loopress/v1/seo/post-meta/${postType}`;
11
11
  }
12
+ export function seoPostMetaItemEndpoint(postType, slug) {
13
+ return `${seoPostMetaEndpoint(postType)}/${encodeURIComponent(slug)}`;
14
+ }
12
15
  // The `<id>-<slug>.json` on-disk convention shared by `seo pull` and `seo push`.
13
16
  export function redirectFileBase(redirect) {
14
17
  return `${redirect.id}-${toSlug(redirect.urlTo, 'redirect')}`;
@@ -11,6 +11,7 @@ export type NormalizedSnippet = {
11
11
  location: SnippetLocation;
12
12
  name: string;
13
13
  priority: number;
14
+ revision?: string;
14
15
  shortcodeAttributes: string[];
15
16
  tags: string[];
16
17
  type: SnippetType;
@@ -81,6 +81,7 @@ export function normalizeSnippet(data) {
81
81
  location: parseLocation(data.location) ?? defaultLocationForType(parseType(data.type) ?? 'php'),
82
82
  name: coerceString(data.name),
83
83
  priority: resolvePriority(data.priority),
84
+ revision: typeof data.revision === 'string' ? data.revision : undefined,
84
85
  shortcodeAttributes: Array.isArray(data.shortcodeAttributes) ? data.shortcodeAttributes.map(String) : [],
85
86
  tags: Array.isArray(data.tags) ? data.tags : [],
86
87
  type: parseType(data.type) ?? 'php',
@@ -0,0 +1,26 @@
1
+ import { type WpClient } from '../lib/wp-client.js';
2
+ export declare const THEME_STYLES_FILE_SUFFIX = "-global-styles.json";
3
+ export declare function themeStylesFileName(stylesheet: string): string;
4
+ export declare function globalStylesEndpoint(id: number): string;
5
+ export type WpThemeSupportInfo = {
6
+ _links?: Record<string, Array<{
7
+ href: string;
8
+ }>>;
9
+ status: 'active' | 'inactive';
10
+ stylesheet: string;
11
+ theme_supports?: Record<string, unknown>;
12
+ };
13
+ export declare function isBlockTheme(theme: Pick<WpThemeSupportInfo, 'theme_supports'>): boolean;
14
+ export declare function getUserGlobalStylesId(theme: Pick<WpThemeSupportInfo, '_links'>): null | number;
15
+ export declare function getActiveThemeGlobalStyles(wp: WpClient): Promise<{
16
+ id: number;
17
+ stylesheet: string;
18
+ }>;
19
+ export type GlobalStylesRecord = {
20
+ settings?: Record<string, unknown>;
21
+ styles?: Record<string, unknown>;
22
+ };
23
+ export declare function canonicalGlobalStyles(record: {
24
+ settings?: unknown;
25
+ styles?: unknown;
26
+ }): Record<string, unknown>;
@@ -0,0 +1,55 @@
1
+ // Global Styles are the user's customizations from the Site Editor's "Styles" screen (colors,
2
+ // typography, spacing), stored by WordPress core in a `wp_global_styles` post, one per theme.
3
+ // This is separate from the theme's own theme.json file, which lives in the theme's repo and is
4
+ // out of scope here. Only block themes (Full Site Editing) have a Styles screen at all.
5
+ export const THEME_STYLES_FILE_SUFFIX = '-global-styles.json';
6
+ export function themeStylesFileName(stylesheet) {
7
+ return `${stylesheet}${THEME_STYLES_FILE_SUFFIX}`;
8
+ }
9
+ export function globalStylesEndpoint(id) {
10
+ return `wp/v2/global-styles/${id}`;
11
+ }
12
+ // WordPress core surfaces `wp_is_block_theme()` over the `wp/v2/themes` REST response as
13
+ // `theme_supports['block-templates']` (see `_add_default_theme_supports()` in
14
+ // wp-includes/theme.php): core adds that feature flag itself exactly when the theme has its own
15
+ // block templates. No Loopress-side controller is needed to detect this.
16
+ export function isBlockTheme(theme) {
17
+ return theme.theme_supports?.['block-templates'] === true;
18
+ }
19
+ // The `wp/v2/themes` list item links to its theme's editable `wp_global_styles` post under this
20
+ // core-defined relation rather than exposing the id as a plain field (the same relation
21
+ // @wordpress/e2e-test-utils-playwright's `getCurrentThemeGlobalStylesPostId()` reads). The href
22
+ // itself varies with the site's permalink structure (`?rest_route=/wp/v2/global-styles/123` vs
23
+ // `/wp-json/wp/v2/global-styles/123`), so the id is pulled out with a regex rather than assumed
24
+ // to be the URL's last path segment.
25
+ const USER_GLOBAL_STYLES_REL = 'wp:user-global-styles';
26
+ export function getUserGlobalStylesId(theme) {
27
+ const href = theme._links?.[USER_GLOBAL_STYLES_REL]?.[0]?.href;
28
+ if (!href)
29
+ return null;
30
+ const match = /\/wp\/v2\/global-styles\/(\d+)/.exec(href);
31
+ return match ? Number(match[1]) : null;
32
+ }
33
+ // Resolves the active theme's stylesheet slug and the id of its editable Global Styles post in
34
+ // one call, failing clearly (rather than with a raw REST error later) when the active theme
35
+ // isn't a block theme: classic themes have no Styles screen and no `wp_global_styles` post at all.
36
+ export async function getActiveThemeGlobalStyles(wp) {
37
+ const themes = await wp.get('wp/v2/themes');
38
+ const active = themes.find((theme) => theme.status === 'active');
39
+ if (!active)
40
+ throw new Error('Could not determine the active theme on WordPress.');
41
+ if (!isBlockTheme(active)) {
42
+ throw new Error(`Global Styles sync only supports block themes (Full Site Editing). The active theme "${active.stylesheet}" is a classic theme.`);
43
+ }
44
+ const id = getUserGlobalStylesId(active);
45
+ if (id === null) {
46
+ 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+.`);
47
+ }
48
+ return { id, stylesheet: active.stylesheet };
49
+ }
50
+ // The compared/stored value: `settings` and `styles` only. `id` and `_links` are WordPress
51
+ // bookkeeping, not part of the tracked configuration (a fresh install resolves to a different
52
+ // id for the same content), so they're dropped rather than becoming spurious drift.
53
+ export function canonicalGlobalStyles(record) {
54
+ return { settings: record.settings ?? {}, styles: record.styles ?? {} };
55
+ }