@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
@@ -4,8 +4,10 @@ import { dirname, extname, join } from 'node:path';
4
4
  import { PushCommand } from '../../lib/push-command.js';
5
5
  import { putOrCreate } from '../../lib/put-or-create.js';
6
6
  import { readdirTolerant } from '../../lib/readdir-tolerant.js';
7
+ import { getResourceStateProvider } from '../../lib/resource-state.js';
8
+ import { isNotFoundError } from '../../lib/wp-client.js';
7
9
  import { pluralize } from '../../utils/pluralize.js';
8
- import { redirectFileBase, SEO_REDIRECTS_ENDPOINT, SEO_SETTINGS_ENDPOINT, seoPostMetaEndpoint, seoRedirectEndpoint, } from '../../utils/seo-format.js';
10
+ import { redirectFileBase, SEO_REDIRECTS_ENDPOINT, SEO_SETTINGS_ENDPOINT, seoPostMetaEndpoint, seoPostMetaItemEndpoint, seoRedirectEndpoint, } from '../../utils/seo-format.js';
9
11
  export default class Push extends PushCommand {
10
12
  static args = {
11
13
  path: Args.string({ description: 'Path to SEO directory (overrides project config)' }),
@@ -28,9 +30,12 @@ export default class Push extends PushCommand {
28
30
  this.allowExternalRedirects = flags['allow-external-redirects'];
29
31
  this.log(`Pushing SEO configuration to ${url}`);
30
32
  this.log(`SEO path: ${path}`);
33
+ const provider = getResourceStateProvider('seo');
34
+ const beforeState = await this.captureBeforePushState(provider, path);
31
35
  await this.pushSettings(path);
32
36
  await this.pushPostMeta(path);
33
37
  await this.pushRedirects(path);
38
+ await this.writeAfterPushSnapshot(provider, path, beforeState);
34
39
  if (this.failedCount > 0) {
35
40
  this.error(`${pluralize(this.failedCount, 'SEO item')} failed to push.`);
36
41
  }
@@ -39,6 +44,17 @@ export default class Push extends PushCommand {
39
44
  await this.recordSuccess();
40
45
  this.log('All SEO configuration pushed.');
41
46
  }
47
+ async currentPostMetaRevision(postType, slug) {
48
+ try {
49
+ const current = await this.wp.get(seoPostMetaItemEndpoint(postType, slug));
50
+ return current.revision;
51
+ }
52
+ catch (error) {
53
+ if (isNotFoundError(error))
54
+ return undefined;
55
+ throw error;
56
+ }
57
+ }
42
58
  async jsonFilesIn(dir) {
43
59
  return (await readdirTolerant(dir)).filter((file) => extname(file) === '.json');
44
60
  }
@@ -64,7 +80,16 @@ export default class Push extends PushCommand {
64
80
  }
65
81
  try {
66
82
  const post = JSON.parse(await readFile(filePath, 'utf8'));
67
- await this.wp.post(seoPostMetaEndpoint(postType), { meta: post.meta, slug: post.slug });
83
+ // Read the post's current SEO-meta revision right before writing it, and send it back as
84
+ // `expectedRevision`: WordPress refuses the write (412) if something else changed that
85
+ // post's meta in between, instead of this push silently overwriting it (#234). No revision
86
+ // to condition on for a post whose meta doesn't exist remotely yet (an upsert that will
87
+ // itself fail with a clearer "post not found" error): falls back to an unconditional write.
88
+ const expectedRevision = await this.currentPostMetaRevision(postType, post.slug);
89
+ const body = { meta: post.meta, slug: post.slug };
90
+ if (expectedRevision !== undefined)
91
+ body.expectedRevision = expectedRevision;
92
+ await this.wp.post(seoPostMetaEndpoint(postType), body);
68
93
  if (task)
69
94
  task.output = `Pushed: ${post.slug}`;
70
95
  }
@@ -128,7 +153,12 @@ export default class Push extends PushCommand {
128
153
  return;
129
154
  }
130
155
  try {
131
- await this.wp.put(SEO_SETTINGS_ENDPOINT, JSON.parse(raw));
156
+ const settings = JSON.parse(raw);
157
+ // Read the settings' current revision right before writing them, and send it back as
158
+ // `expectedRevision`: WordPress refuses the write (412) if something else changed the
159
+ // settings in between, instead of this push silently overwriting it (#234).
160
+ const { revision } = await this.wp.get(SEO_SETTINGS_ENDPOINT);
161
+ await this.wp.put(SEO_SETTINGS_ENDPOINT, { expectedRevision: revision, settings });
132
162
  this.log(`Pushed: ${file}`);
133
163
  }
134
164
  catch (error) {
@@ -0,0 +1,2 @@
1
+ declare const _default: typeof import("../../lib/base.js").LoopressCommand;
2
+ export default _default;
@@ -0,0 +1,5 @@
1
+ import { resourceRollbackCommand } from '../../lib/rollback-command.js';
2
+ export default resourceRollbackCommand('seo', {
3
+ description: 'Restore SEO settings, post meta, and redirects to a snapshot taken automatically before an earlier `lps seo push`',
4
+ pathNoun: 'SEO directory',
5
+ });
@@ -19,6 +19,7 @@ export default class Push extends PushCommand {
19
19
  'dry-run': import("@oclif/core/interfaces").BooleanFlag<boolean>;
20
20
  };
21
21
  run(): Promise<PushResult>;
22
+ private currentRevision;
22
23
  private ensureCanonicalFilename;
23
24
  private loadSnippets;
24
25
  private pushSnippet;
@@ -4,6 +4,8 @@ import { basename, dirname, extname, join } from 'node:path';
4
4
  import { loadSnippets as loadSnippetsFromDisk } from '../../lib/load-snippets.js';
5
5
  import { PushCommand } from '../../lib/push-command.js';
6
6
  import { putOrCreate } from '../../lib/put-or-create.js';
7
+ import { getResourceStateProvider } from '../../lib/resource-state.js';
8
+ import { isNotFoundError } from '../../lib/wp-client.js';
7
9
  import { pluralize } from '../../utils/pluralize.js';
8
10
  import { normalizeSnippet, SNIPPETS_ENDPOINT, stripPhpOpeningTag } from '../../utils/snippet-format.js';
9
11
  import { toSlug } from '../../utils/to-slug.js';
@@ -24,6 +26,8 @@ export default class Push extends PushCommand {
24
26
  const path = this.resolveSnippetsPath(args.path);
25
27
  this.log(`Pushing snippets to ${url}`);
26
28
  this.log(`Snippets path: ${path}`);
29
+ const provider = getResourceStateProvider('snippet');
30
+ const beforeState = await this.captureBeforePushState(provider, path);
27
31
  const snippets = await this.loadSnippets(path);
28
32
  this.log(`Found ${pluralize(snippets.length, 'snippet')} to push`);
29
33
  const pushed = [];
@@ -31,6 +35,7 @@ export default class Push extends PushCommand {
31
35
  const id = await this.pushSnippet(snippet, task);
32
36
  pushed.push({ id, name: snippet.name });
33
37
  });
38
+ await this.writeAfterPushSnapshot(provider, path, beforeState);
34
39
  if (this.failedCount > 0) {
35
40
  this.error(`${pluralize(this.failedCount, 'snippet')} failed to push.`);
36
41
  }
@@ -40,6 +45,17 @@ export default class Push extends PushCommand {
40
45
  this.log('All snippets pushed.');
41
46
  return { pushed, status: 'success' };
42
47
  }
48
+ async currentRevision(id) {
49
+ try {
50
+ const current = await this.wp.get(`${SNIPPETS_ENDPOINT}/${id}`);
51
+ return normalizeSnippet(current).revision;
52
+ }
53
+ catch (error) {
54
+ if (isNotFoundError(error))
55
+ return undefined;
56
+ throw error;
57
+ }
58
+ }
43
59
  // Renames the local file pair to the `<id>-<slug>` convention used by `snippet pull` whenever
44
60
  // it doesn't already match (e.g. a hand-created `demo.php` with no id, or a stale slug after a rename).
45
61
  // This is a side effect of `push`: local files on disk are renamed, not just the remote snippet.
@@ -90,12 +106,26 @@ export default class Push extends PushCommand {
90
106
  }
91
107
  try {
92
108
  const payload = this.toPayload(snippet);
109
+ // Read the snippet's current revision right before writing it, and send it back as
110
+ // `expectedRevision`: WordPress refuses the write (412) if something else changed the
111
+ // snippet in between, instead of this push silently overwriting it (#234). No revision to
112
+ // condition on when there's no known remote id yet (a first push): putPayload then equals
113
+ // payload, and putOrCreate falls back to POST below exactly as before this existed. Kept
114
+ // out of the POST body specifically (see postPayload): a spurious expectedRevision on a
115
+ // create would be meaningless there.
116
+ let putPayload = payload;
117
+ if (snippet.id !== undefined) {
118
+ const expectedRevision = await this.currentRevision(snippet.id);
119
+ if (expectedRevision !== undefined)
120
+ putPayload = { ...payload, expectedRevision };
121
+ }
93
122
  // The id recorded locally may not exist on this site (e.g. a fresh install): putOrCreate
94
123
  // falls back to POST instead of failing, adopting whatever id the site assigns.
95
124
  const { body, created } = await putOrCreate(this.wp, {
96
125
  id: snippet.id ?? null,
97
- payload,
126
+ payload: putPayload,
98
127
  postEndpoint: SNIPPETS_ENDPOINT,
128
+ postPayload: payload,
99
129
  putEndpoint: (id) => `${SNIPPETS_ENDPOINT}/${id}`,
100
130
  });
101
131
  let { id } = snippet;
@@ -0,0 +1,2 @@
1
+ declare const _default: typeof import("../../lib/base.js").LoopressCommand;
2
+ export default _default;
@@ -0,0 +1,5 @@
1
+ import { resourceRollbackCommand } from '../../lib/rollback-command.js';
2
+ export default resourceRollbackCommand('snippet', {
3
+ description: 'Restore snippets to a snapshot taken automatically before an earlier `lps snippet push`',
4
+ pathNoun: 'snippets directory',
5
+ });
@@ -0,0 +1,2 @@
1
+ declare const _default: typeof import("../../lib/diff-command.js").DiffCommand;
2
+ export default _default;
@@ -0,0 +1,5 @@
1
+ import { resourceDiffCommand } from '../../lib/diff-command.js';
2
+ export default resourceDiffCommand('theme-styles', {
3
+ description: "Show what differs in the active block theme's Global Styles (Site Editor > Styles) between your local file and a WordPress environment, or between two environments",
4
+ pathNoun: 'theme styles directory',
5
+ });
@@ -0,0 +1,12 @@
1
+ import { LoopressCommand } from '../../lib/base.js';
2
+ export default class Pull extends LoopressCommand {
3
+ static args: {
4
+ path: import("@oclif/core/interfaces").Arg<string | undefined, Record<string, unknown>>;
5
+ };
6
+ static description: string;
7
+ static examples: string[];
8
+ static flags: {
9
+ 'dry-run': import("@oclif/core/interfaces").BooleanFlag<boolean>;
10
+ };
11
+ run(): Promise<void>;
12
+ }
@@ -0,0 +1,31 @@
1
+ import { Args } from '@oclif/core';
2
+ import { mkdir, writeFile } from 'node:fs/promises';
3
+ import { join } from 'node:path';
4
+ import { LoopressCommand } from '../../lib/base.js';
5
+ import { canonicalGlobalStyles, getActiveThemeGlobalStyles, globalStylesEndpoint, themeStylesFileName, } from '../../utils/theme-styles-format.js';
6
+ export default class Pull extends LoopressCommand {
7
+ static args = {
8
+ path: Args.string({ description: 'Path to theme styles directory (overrides project config)' }),
9
+ };
10
+ static description = "Pull the active block theme's Global Styles customizations (Site Editor > Styles) from WordPress into a local file";
11
+ static examples = ['$ lps theme-styles pull'];
12
+ static flags = {
13
+ ...LoopressCommand.dryRunFlag,
14
+ };
15
+ async run() {
16
+ const { args } = await this.parse(Pull);
17
+ const { url } = this.siteConfig;
18
+ const dir = this.resolveThemeStylesPath(args.path);
19
+ this.log(`Pulling Global Styles from ${url}`);
20
+ const { id, stylesheet } = await getActiveThemeGlobalStyles(this.wp);
21
+ const item = await this.wp.get(globalStylesEndpoint(id));
22
+ const file = join(dir, themeStylesFileName(stylesheet));
23
+ if (this.dryRun) {
24
+ this.log(`[dry-run] Would pull Global Styles for "${stylesheet}" to ${file}`);
25
+ return;
26
+ }
27
+ await mkdir(dir, { recursive: true });
28
+ await writeFile(file, JSON.stringify(canonicalGlobalStyles(item), null, 2) + '\n');
29
+ this.log(`Pulled Global Styles for "${stylesheet}" to ${file}`);
30
+ }
31
+ }
@@ -0,0 +1,12 @@
1
+ import { PushCommand } from '../../lib/push-command.js';
2
+ export default class Push extends PushCommand {
3
+ static args: {
4
+ path: import("@oclif/core/interfaces").Arg<string | undefined, Record<string, unknown>>;
5
+ };
6
+ static description: string;
7
+ static examples: string[];
8
+ static flags: {
9
+ 'dry-run': import("@oclif/core/interfaces").BooleanFlag<boolean>;
10
+ };
11
+ run(): Promise<void>;
12
+ }
@@ -0,0 +1,45 @@
1
+ import { Args } from '@oclif/core';
2
+ import { readFile } from 'node:fs/promises';
3
+ import { join } from 'node:path';
4
+ import { PushCommand } from '../../lib/push-command.js';
5
+ import { getResourceStateProvider } from '../../lib/resource-state.js';
6
+ import { getActiveThemeGlobalStyles, globalStylesEndpoint, themeStylesFileName } from '../../utils/theme-styles-format.js';
7
+ export default class Push extends PushCommand {
8
+ static args = {
9
+ path: Args.string({ description: 'Path to theme styles directory (overrides project config)' }),
10
+ };
11
+ static description = "Push the local Global Styles file to the active block theme's Site Editor > Styles on WordPress";
12
+ static examples = ['$ lps theme-styles push'];
13
+ static flags = {
14
+ ...PushCommand.dryRunFlag,
15
+ };
16
+ async run() {
17
+ const { args } = await this.parse(Push);
18
+ const { url } = this.siteConfig;
19
+ const dir = this.resolveThemeStylesPath(args.path);
20
+ this.log(`Pushing Global Styles to ${url}`);
21
+ const { id, stylesheet } = await getActiveThemeGlobalStyles(this.wp);
22
+ const file = join(dir, themeStylesFileName(stylesheet));
23
+ let raw;
24
+ try {
25
+ raw = await readFile(file, 'utf8');
26
+ }
27
+ catch (error) {
28
+ if (error.code === 'ENOENT') {
29
+ this.error(`No local Global Styles file found for the active theme ("${file}"). Run \`lps theme-styles pull\` first.`);
30
+ }
31
+ throw error;
32
+ }
33
+ if (this.dryRun) {
34
+ this.log(`[dry-run] Would push: ${file}`);
35
+ return;
36
+ }
37
+ const provider = getResourceStateProvider('theme-styles');
38
+ const beforeState = await this.captureBeforePushState(provider, dir);
39
+ const local = JSON.parse(raw);
40
+ await this.wp.post(globalStylesEndpoint(id), { settings: local.settings ?? {}, styles: local.styles ?? {} });
41
+ this.log(`Pushed: ${file}`);
42
+ await this.writeAfterPushSnapshot(provider, dir, beforeState);
43
+ await this.recordSuccess();
44
+ }
45
+ }
@@ -0,0 +1,2 @@
1
+ declare const _default: typeof import("../../lib/base.js").LoopressCommand;
2
+ export default _default;
@@ -0,0 +1,5 @@
1
+ import { resourceRollbackCommand } from '../../lib/rollback-command.js';
2
+ export default resourceRollbackCommand('theme-styles', {
3
+ description: 'Restore the active theme’s Global Styles to a snapshot taken automatically before an earlier `lps theme-styles push`',
4
+ pathNoun: 'theme styles directory',
5
+ });
@@ -1,6 +1,7 @@
1
1
  import { createHash } from 'node:crypto';
2
- import { readdir, readFile } from 'node:fs/promises';
3
- import { extname, join, relative } from 'node:path';
2
+ import { readFile } from 'node:fs/promises';
3
+ import { extname, join } from 'node:path';
4
+ import { walkFiles } from './readdir-tolerant.js';
4
5
  export const APP_CONFIG_FILENAME = 'loopress.app.json';
5
6
  const DEFAULT_ASSETS_DIR = 'dist';
6
7
  // Static assets a bundler emits, mirrors the server's AppsDirectory::ALLOWED_EXTENSIONS.
@@ -14,35 +15,11 @@ const ALLOWED_EXTENSIONS = new Set([
14
15
  export function isAllowedAsset(relPath) {
15
16
  return ALLOWED_EXTENSIONS.has(extname(relPath).toLowerCase());
16
17
  }
17
- async function walk(dir, root) {
18
- let entries;
19
- try {
20
- entries = await readdir(dir, { withFileTypes: true });
21
- }
22
- catch (error) {
23
- if (error.code === 'ENOENT')
24
- return [];
25
- throw error;
26
- }
27
- const out = [];
28
- for (const entry of entries) {
29
- const abs = join(dir, entry.name);
30
- if (entry.isDirectory()) {
31
- out.push(...(await walk(abs, root)));
32
- }
33
- else if (entry.isFile()) {
34
- // Identity is always '/'-joined, never the OS separator: it travels over HTTP and is
35
- // matched against the server's own '/'-joined paths.
36
- out.push(relative(root, abs).replaceAll(/[\\/]/g, '/'));
37
- }
38
- }
39
- return out;
40
- }
41
18
  // Builds the sorted file list of a dist directory. Throws on a file whose extension the
42
19
  // server would reject, so the push fails locally with a clear message instead of a 400
43
20
  // halfway through the upload.
44
21
  export async function buildFileList(distDir) {
45
- const relPaths = (await walk(distDir, distDir)).sort((a, b) => a.localeCompare(b));
22
+ const relPaths = (await walkFiles(distDir)).sort((a, b) => a.localeCompare(b));
46
23
  const files = [];
47
24
  for (const path of relPaths) {
48
25
  if (!isAllowedAsset(path)) {
@@ -36,9 +36,11 @@ export declare abstract class LoopressCommand extends Command {
36
36
  protected resolveAppsPath(override?: string): string;
37
37
  protected resolveFormPath(override?: string): string;
38
38
  protected resolveHooksPath(override?: string): string;
39
+ protected resolveMenuPath(override?: string): string;
39
40
  protected resolveOptionsPath(override?: string): string;
40
41
  protected resolveSeoPath(override?: string): string;
41
42
  protected resolveSnippetsPath(override?: string): string;
43
+ protected resolveThemeStylesPath(override?: string): string;
42
44
  private pickEnvironment;
43
45
  private resolveEnvironment;
44
46
  private resolveEnvironmentForConfiguredProject;
package/dist/lib/base.js CHANGED
@@ -147,6 +147,9 @@ export class LoopressCommand extends Command {
147
147
  resolveHooksPath(override) {
148
148
  return resolveResourceDir('hooks', this.localConfig, override);
149
149
  }
150
+ resolveMenuPath(override) {
151
+ return resolveResourceDir('menu', this.localConfig, override);
152
+ }
150
153
  resolveOptionsPath(override) {
151
154
  return resolveResourceDir('options', this.localConfig, override);
152
155
  }
@@ -156,6 +159,9 @@ export class LoopressCommand extends Command {
156
159
  resolveSnippetsPath(override) {
157
160
  return resolveResourceDir('snippets', this.localConfig, override);
158
161
  }
162
+ resolveThemeStylesPath(override) {
163
+ return resolveResourceDir('themeStyles', this.localConfig, override);
164
+ }
159
165
  pickEnvironment(project, envName) {
160
166
  const env = project.environments[envName];
161
167
  if (!env) {
@@ -1,5 +1,5 @@
1
1
  import { type LoopressLocalConfig } from '../utils/loopress-config.js';
2
- export type ResourceType = 'api' | 'plugins' | 'snippets';
2
+ export type ResourceType = 'api' | 'pages' | 'plugins' | 'snippets';
3
3
  export declare const RESOURCE_TYPES: ResourceType[];
4
4
  export type WatchTarget = {
5
5
  commandId: string;
@@ -1,6 +1,6 @@
1
1
  import { existsSync } from 'node:fs';
2
2
  import { join, sep } from 'node:path';
3
- export const RESOURCE_TYPES = ['snippets', 'api', 'plugins'];
3
+ export const RESOURCE_TYPES = ['snippets', 'pages', 'api', 'plugins'];
4
4
  // `only` narrows the default set, `skip` then removes from whatever that set is, same
5
5
  // precedence a combined `--only=a,b --skip=b` reads as: watch a, b, then drop b.
6
6
  export function resolveResourceTypes(only, skip = []) {
@@ -15,6 +15,7 @@ export function buildWatchTargets(types, localConfig, cwd) {
15
15
  const rootDir = join(cwd, localConfig.rootDir ?? '.');
16
16
  const candidates = {
17
17
  api: { commandId: 'api:push', path: join(rootDir, localConfig.apiDir ?? 'api'), type: 'api' },
18
+ pages: { commandId: 'page:push', path: join(rootDir, localConfig.pageDir ?? 'pages'), type: 'pages' },
18
19
  plugins: { commandId: 'plugin:push', path: join(cwd, 'loopress.json'), type: 'plugins' },
19
20
  snippets: { commandId: 'snippet:push', path: join(rootDir, localConfig.snippetsDir ?? 'snippets'), type: 'snippets' },
20
21
  };
@@ -1,51 +1,9 @@
1
- import { readdir } from 'node:fs/promises';
2
- import { extname, join } from 'node:path';
3
- // Same shape as load-files.ts's own walker, kept separate rather than shared: this one
4
- // filters against a list of extensions (snippet pull needs more than one), that one
5
- // against a single extension, and reconciling the two would cost more than the ~15 duplicated
6
- // lines it would save.
7
- async function walk(dir, extensions) {
8
- let entries;
9
- try {
10
- entries = await readdir(dir, { withFileTypes: true });
11
- }
12
- catch (error) {
13
- if (error.code === 'ENOENT')
14
- return [];
15
- throw error;
16
- }
17
- const relativePaths = [];
18
- for (const entry of entries) {
19
- if (entry.isDirectory()) {
20
- // join() builds the real OS path to actually walk, but the returned identity is always
21
- // '/'-joined regardless of platform: it's matched against `keep`, filenames the server
22
- // sends with '/', never the OS separator. join() here (backslash on Windows) would make
23
- // every nested file's identity never match, findOrphanedFiles() would then report a
24
- // live, kept file as orphaned and pull.ts would delete it.
25
- const nested = await walk(join(dir, entry.name), extensions);
26
- relativePaths.push(...nested.map((relativePath) => `${entry.name}/${relativePath}`));
27
- }
28
- else if (extensions.includes(extname(entry.name))) {
29
- relativePaths.push(entry.name);
30
- }
31
- }
32
- return relativePaths;
33
- }
1
+ import { extname } from 'node:path';
2
+ import { readdirTolerant, walkFiles } from './readdir-tolerant.js';
34
3
  export async function findOrphanedFiles(dir, keep, matcher) {
35
- let files;
36
- if (matcher.recursive) {
37
- files = await walk(dir, matcher.extensions);
38
- }
39
- else {
40
- try {
41
- files = await readdir(dir);
42
- }
43
- catch (error) {
44
- if (error.code === 'ENOENT')
45
- return [];
46
- throw error;
47
- }
48
- }
4
+ // walkFiles already returns '/'-joined identities regardless of platform: matched against
5
+ // `keep`, filenames the server sends with '/', never the OS separator.
6
+ const files = matcher.recursive ? await walkFiles(dir) : await readdirTolerant(dir);
49
7
  return files.filter((file) => {
50
8
  const ext = extname(file);
51
9
  if (!matcher.extensions.includes(ext))
@@ -1,52 +1,14 @@
1
- import { readdir, readFile } from 'node:fs/promises';
1
+ import { readFile } from 'node:fs/promises';
2
2
  import { extname, join } from 'node:path';
3
- // Recursion for the api resource type only (path-param route files can live in
4
- // subdirectories, e.g. api/invoice-pdf/[order_id].php); every other resource type stays
5
- // flat. withFileTypes avoids a second stat() per entry to tell files from directories.
6
- async function walk(dir, extension) {
7
- let entries;
8
- try {
9
- entries = await readdir(dir, { withFileTypes: true });
10
- }
11
- catch (error) {
12
- if (error.code === 'ENOENT')
13
- return [];
14
- throw error;
15
- }
16
- const filePaths = [];
17
- for (const entry of entries) {
18
- const entryPath = join(dir, entry.name);
19
- if (entry.isDirectory()) {
20
- filePaths.push(...(await walk(entryPath, extension)));
21
- }
22
- else if (extname(entry.name) === extension) {
23
- filePaths.push(entryPath);
24
- }
25
- }
26
- return filePaths;
27
- }
3
+ import { readdirTolerant, walkFiles } from './readdir-tolerant.js';
28
4
  // Loads every `<extension>` file of a directory through `parse`. One file is read in
29
5
  // isolation: a corrupted or hand-broken file (unreadable, bad JSON, failed validation) must
30
6
  // only skip that file, never abort loading the rest of the directory, so `parse` throwing is
31
7
  // reported through `onSkip` and the loop moves on. ENOENT on the directory itself means
32
8
  // "nothing to load", not an error.
33
9
  export async function loadFiles(dir, options) {
34
- let filePaths;
35
- if (options.recursive) {
36
- filePaths = await walk(dir, options.extension);
37
- }
38
- else {
39
- let entries;
40
- try {
41
- entries = await readdir(dir);
42
- }
43
- catch (error) {
44
- if (error.code === 'ENOENT')
45
- return [];
46
- throw error;
47
- }
48
- filePaths = entries.filter((entry) => extname(entry) === options.extension).map((entry) => join(dir, entry));
49
- }
10
+ const entries = options.recursive ? await walkFiles(dir) : await readdirTolerant(dir);
11
+ const filePaths = entries.filter((entry) => extname(entry) === options.extension).map((entry) => join(dir, entry));
50
12
  const items = [];
51
13
  for (const filePath of filePaths) {
52
14
  try {
@@ -29,6 +29,7 @@ type RemotePhpFile = {
29
29
  error?: string;
30
30
  filename: string;
31
31
  public?: boolean;
32
+ revision?: string;
32
33
  };
33
34
  type PushResult = {
34
35
  pruned: string[];