@loopress/cli 0.25.0 → 0.27.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 (38) hide show
  1. package/README.md +197 -101
  2. package/dist/commands/diff.js +1 -1
  3. package/dist/commands/init.js +6 -1
  4. package/dist/commands/page/diff.d.ts +2 -0
  5. package/dist/commands/page/diff.js +5 -0
  6. package/dist/commands/page/list.d.ts +8 -0
  7. package/dist/commands/page/list.js +22 -0
  8. package/dist/commands/page/push.d.ts +20 -0
  9. package/dist/commands/page/push.js +60 -0
  10. package/dist/commands/plugin/add.d.ts +8 -1
  11. package/dist/commands/plugin/add.js +4 -2
  12. package/dist/commands/promote.js +3 -2
  13. package/dist/commands/pull.js +6 -2
  14. package/dist/commands/push.js +8 -3
  15. package/dist/commands/theme/add.d.ts +8 -1
  16. package/dist/commands/theme/add.js +4 -2
  17. package/dist/lib/api-client.js +24 -1
  18. package/dist/lib/dev-watch.d.ts +1 -1
  19. package/dist/lib/dev-watch.js +3 -1
  20. package/dist/lib/json-delegation.d.ts +1 -0
  21. package/dist/lib/json-delegation.js +18 -0
  22. package/dist/lib/load-snippets.js +1 -1
  23. package/dist/lib/php-files-command.js +4 -3
  24. package/dist/lib/readdir-tolerant.js +15 -5
  25. package/dist/lib/resource-state.js +24 -0
  26. package/dist/lib/snapshot-store.js +14 -0
  27. package/dist/lib/validate-local.js +33 -7
  28. package/dist/lib/wp-client.js +31 -2
  29. package/dist/types/project-config.generated.d.ts +4 -0
  30. package/dist/utils/loopress-config.d.ts +1 -0
  31. package/dist/utils/loopress-config.js +14 -0
  32. package/dist/utils/page-format.d.ts +31 -0
  33. package/dist/utils/page-format.js +118 -0
  34. package/dist/utils/resource-dirs.d.ts +1 -0
  35. package/dist/utils/resource-dirs.js +2 -0
  36. package/oclif.manifest.json +506 -298
  37. package/package.json +5 -3
  38. package/schemas/project-config.schema.json +5 -0
@@ -1,20 +1,25 @@
1
1
  import { readFile } from 'node:fs/promises';
2
2
  import { join } from 'node:path';
3
3
  import { configManager } from '../config/project-config.manager.js';
4
+ import { parseLocalOption } from '../utils/option-format.js';
5
+ import { readLocalPages } from '../utils/page-format.js';
4
6
  import { parseType } from '../utils/snippet-format.js';
5
7
  import { loadSnippets } from './load-snippets.js';
6
8
  import { readdirTolerant } from './readdir-tolerant.js';
7
9
  // Directories holding one JSON document per resource. Snippets (code file plus JSON sidecar)
8
- // and api routes (`.php`, not JSON) are checked separately.
9
- const JSON_RESOURCE_DIRS = ['acfDir', 'formDir', 'seoDir', 'menuDir'];
10
+ // and api routes (`.php`, not JSON) are checked separately, options too (a stricter shape).
11
+ const JSON_RESOURCE_DIRS = ['acfDir', 'formDir', 'seoDir', 'menuDir', 'themeStylesDir'];
10
12
  const DEFAULT_DIR = {
11
13
  acfDir: 'acf',
12
14
  apiDir: 'api',
13
15
  formDir: 'forms',
14
16
  hooksDir: 'hooks',
15
17
  menuDir: 'menus',
18
+ optionsDir: 'options',
19
+ pageDir: 'pages',
16
20
  seoDir: 'seo',
17
21
  snippetsDir: 'snippets',
22
+ themeStylesDir: 'theme',
18
23
  };
19
24
  // Inspects the local tracked files under `cwd` without contacting WordPress: JSON files parse
20
25
  // and have the right shape, snippet sidecars use a known type, `loopress.json` points at a
@@ -35,9 +40,15 @@ export async function validateLocal(cwd) {
35
40
  for (const key of JSON_RESOURCE_DIRS) {
36
41
  checked += await checkJsonDir(resolve(key), problems);
37
42
  }
43
+ // Same reader `option push` uses: a file missing "name", "autoload" or "value" is skipped
44
+ // there with a warning, so it's a problem here.
45
+ checked += await checkJsonDir(resolve('optionsDir'), problems, parseLocalOption);
38
46
  checked += await checkSnippets(resolve('snippetsDir'), problems);
39
47
  checked += await checkPhpDir(resolve('apiDir'), problems, 'API route file is empty');
40
48
  checked += await checkPhpDir(resolve('hooksDir'), problems, 'Hook file is empty');
49
+ const pages = await readLocalPages(resolve('pageDir'));
50
+ checked += pages.pages.length + pages.problems.length;
51
+ problems.push(...pages.problems);
41
52
  return { checked, problems, valid: problems.length === 0 };
42
53
  }
43
54
  // Recursive: both api/ and hooks/ files can live in a nested path-segment directory
@@ -86,15 +97,23 @@ async function checkComposerJson(cwd, problems) {
86
97
  }
87
98
  return 1;
88
99
  }
89
- async function checkJsonDir(dir, problems) {
90
- const files = (await readdirTolerant(dir)).filter((file) => file.endsWith('.json'));
100
+ // Recursive: ACF keeps one subdirectory per object type (acf/field-groups/*.json) and SEO its
101
+ // post meta and redirects (seo/post-meta/<type>/*.json, seo/redirects/*.json); a top-level-only
102
+ // scan silently never checked any of those. `shape` runs on files that are JSON objects and
103
+ // throws to report a resource-specific problem.
104
+ async function checkJsonDir(dir, problems, shape) {
105
+ const entries = await readdirTolerant(dir, { recursive: true, withFileTypes: true });
91
106
  let checked = 0;
92
- for (const file of files) {
107
+ for (const entry of entries) {
108
+ if (!entry.isFile() || !entry.name.endsWith('.json'))
109
+ continue;
93
110
  checked += 1;
94
- const filePath = join(dir, file);
111
+ // parentPath, not dir: see checkPhpDir.
112
+ const filePath = join(entry.parentPath, entry.name);
113
+ const raw = await readFile(filePath, 'utf8');
95
114
  let parsed;
96
115
  try {
97
- parsed = JSON.parse(await readFile(filePath, 'utf8'));
116
+ parsed = JSON.parse(raw);
98
117
  }
99
118
  catch (error) {
100
119
  problems.push({ file: filePath, message: `not valid JSON: ${error.message}` });
@@ -102,6 +121,13 @@ async function checkJsonDir(dir, problems) {
102
121
  }
103
122
  if (typeof parsed !== 'object' || parsed === null || Array.isArray(parsed)) {
104
123
  problems.push({ file: filePath, message: 'expected a JSON object' });
124
+ continue;
125
+ }
126
+ try {
127
+ shape?.(raw);
128
+ }
129
+ catch (error) {
130
+ problems.push({ file: filePath, message: error.message });
105
131
  }
106
132
  }
107
133
  return checked;
@@ -107,6 +107,13 @@ export function formatWpError(error, url, timeoutMs = REQUEST_TIMEOUT_MS) {
107
107
  const err = error;
108
108
  const status = err.response?.statusCode;
109
109
  if (status === 401 || status === 403) {
110
+ // A Loopress controller's own `{"error": ...}` on a 403 is a deliberate refusal of this one
111
+ // request (e.g. OptionsController protecting a secret-looking option), not bad credentials:
112
+ // pointing at `lps project config` there sends the user the wrong way. WordPress core's own
113
+ // auth failures use `{code, message}` instead and keep the credentials hint.
114
+ const refusal = extractLoopressError(err.response?.body);
115
+ if (refusal)
116
+ return `Request refused (${status}) on ${url}: ${refusal}`;
110
117
  return `Authentication failed (${status}) on ${url}. Check your credentials with \`lps project config\`.`;
111
118
  }
112
119
  if (status === 404) {
@@ -124,6 +131,28 @@ export function formatWpError(error, url, timeoutMs = REQUEST_TIMEOUT_MS) {
124
131
  }
125
132
  return `Request to ${url} failed: ${err.message ?? String(error)}`;
126
133
  }
134
+ function extractLoopressError(body) {
135
+ if (!body)
136
+ return undefined;
137
+ try {
138
+ const { error } = JSON.parse(body);
139
+ return typeof error === 'string' && error.trim() ? decodeEntities(error) : undefined;
140
+ }
141
+ catch {
142
+ return undefined;
143
+ }
144
+ }
145
+ // Plugin exception messages go through esc_html() (WordPress coding standards require it for
146
+ // anything thrown), so quotes and ampersands reach the JSON body as entities. They're meant for
147
+ // a terminal here, not an HTML page. `&amp;` last, so "&amp;quot;" stays a literal "&quot;".
148
+ function decodeEntities(text) {
149
+ return text
150
+ .replaceAll('&quot;', '"')
151
+ .replaceAll('&#039;', "'")
152
+ .replaceAll('&lt;', '<')
153
+ .replaceAll('&gt;', '>')
154
+ .replaceAll('&amp;', '&');
155
+ }
127
156
  // The Loopress plugin's own controllers reply with `{"error": "..."}`; a WP_Error-based
128
157
  // core response (e.g. an uncaught fatal formatted by WordPress itself) uses `{"message": "..."}`.
129
158
  // Surfacing this is what makes a deliberately clear server-side error (e.g. "Multiple snippet
@@ -131,7 +160,7 @@ export function formatWpError(error, url, timeoutMs = REQUEST_TIMEOUT_MS) {
131
160
  //
132
161
  // Some controllers (ComposerController::sync(), notably) pair a short, generic `error` (e.g.
133
162
  // "Sync failed.") with the real detail in a separate `output` field (the raw Composer trace),
134
- // specifically so a caller that only reads `error` doesn't see it — which is exactly what this
163
+ // specifically so a caller that only reads `error` doesn't see it, which is exactly what this
135
164
  // function used to do, hiding the one piece of text that actually explains the failure.
136
165
  function extractServerErrorMessage(body) {
137
166
  if (!body)
@@ -145,7 +174,7 @@ function extractServerErrorMessage(body) {
145
174
  if (parsed.code === 'rest_no_route')
146
175
  return undefined;
147
176
  const reason = parsed.error ?? parsed.message;
148
- const summary = typeof reason === 'string' && reason.trim() ? reason : undefined;
177
+ const summary = typeof reason === 'string' && reason.trim() ? decodeEntities(reason) : undefined;
149
178
  const detail = typeof parsed.output === 'string' && parsed.output.trim() ? parsed.output.trim() : undefined;
150
179
  if (summary && detail)
151
180
  return `${summary}\n${detail}`;
@@ -35,6 +35,10 @@ export interface LoopressProjectConfiguration {
35
35
  * Directory where single-page app bundles are read from and written to, relative to rootDir. Each subdirectory is one app: a loopress.app.json plus a built dist/ folder.
36
36
  */
37
37
  appsDir?: string;
38
+ /**
39
+ * Directory where static HTML pages (one <slug>.html per WordPress page, managed by Loopress) are read from, relative to rootDir.
40
+ */
41
+ pageDir?: string;
38
42
  /**
39
43
  * Directory where explicitly tracked WordPress options (wp_options rows) are read from and written to, relative to rootDir. Only options added via `lps option add` live here, this is never the whole options table.
40
44
  */
@@ -2,3 +2,4 @@ import { type LoopressProjectConfiguration } from '../types/project-config.gener
2
2
  export type LoopressLocalConfig = LoopressProjectConfiguration;
3
3
  export declare function readLocalConfig(): Promise<LoopressLocalConfig>;
4
4
  export declare function writeLocalConfig(config: LoopressLocalConfig): Promise<void>;
5
+ export declare function ensureLfGitattributes(): Promise<boolean>;
@@ -20,3 +20,17 @@ export async function writeLocalConfig(config) {
20
20
  const configPath = join(process.cwd(), 'loopress.json');
21
21
  await writeFile(configPath, JSON.stringify(config, null, 2) + '\n', 'utf8');
22
22
  }
23
+ // Git for Windows checks text files out as CRLF by default (core.autocrlf=true), so the same
24
+ // snippet would differ byte for byte between a Windows checkout and what `pull` writes (LF).
25
+ // Pinning LF in the repo keeps synced files identical on every OS. Returns false when the rule
26
+ // is already there, so re-running `lps init` never duplicates it. The rule goes first: in
27
+ // .gitattributes a later line wins, so existing exceptions (`*.bat text eol=crlf`) keep theirs.
28
+ const LF_RULE = '* text=auto eol=lf';
29
+ export async function ensureLfGitattributes() {
30
+ const path = join(process.cwd(), '.gitattributes');
31
+ const current = existsSync(path) ? await readFile(path, 'utf8') : '';
32
+ if (current.split(/\r?\n/).some((line) => line.trim() === LF_RULE))
33
+ return false;
34
+ await writeFile(path, `${LF_RULE}\n${current}`, 'utf8');
35
+ return true;
36
+ }
@@ -0,0 +1,31 @@
1
+ export declare const PAGES_ENDPOINT = "loopress/v1/pages";
2
+ export declare const PAGE_SLUG_PATTERN: RegExp;
3
+ export declare const PAGE_STATUSES: readonly ["draft", "publish"];
4
+ export type PageStatus = (typeof PAGE_STATUSES)[number];
5
+ export type Page = {
6
+ fullWidth: boolean;
7
+ hideTitle: boolean;
8
+ html: string;
9
+ slug: string;
10
+ status: PageStatus;
11
+ template: string;
12
+ title: string;
13
+ };
14
+ export type RemotePage = Page & {
15
+ link: string;
16
+ };
17
+ export type PushedPage = RemotePage & {
18
+ frontPage?: 'set' | 'unset';
19
+ };
20
+ export type PageProblem = {
21
+ file: string;
22
+ message: string;
23
+ };
24
+ export declare function formatPageProblems(problems: PageProblem[]): string;
25
+ export declare function titleFromSlug(slug: string): string;
26
+ export declare function parsePageFile(slug: string, raw: string): Page;
27
+ export declare function readLocalPages(dir: string): Promise<{
28
+ pages: Page[];
29
+ problems: PageProblem[];
30
+ }>;
31
+ export declare function frontPageNote(page: PushedPage): string;
@@ -0,0 +1,118 @@
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
+ const FRONT_PAGE_NOTES = {
17
+ set: 'now the site front page',
18
+ unset: 'no longer the front page, the site shows its latest posts',
19
+ };
20
+ export function formatPageProblems(problems) {
21
+ return problems.map((problem) => `${problem.file}: ${problem.message}`).join('\n ');
22
+ }
23
+ // `legal-notice` -> `Legal notice`.
24
+ export function titleFromSlug(slug) {
25
+ const words = slug.split('-').filter(Boolean).join(' ');
26
+ return words.charAt(0).toUpperCase() + words.slice(1);
27
+ }
28
+ // A page file is plain HTML, optionally opened by an HTML comment of `key: value` lines:
29
+ //
30
+ // <!--
31
+ // title: Legal notice
32
+ // status: publish
33
+ // template: page-no-title
34
+ // -->
35
+ //
36
+ // A comment rather than YAML front matter so the file stays valid HTML, previewable as is in a
37
+ // browser. The header is stripped from what gets pushed. Throws on an unknown key or status so a
38
+ // typo never silently falls back to a default. `template` is the active theme's own template
39
+ // slug (a block theme's templates/<slug>.html, or a classic theme's page-<slug>.php); unlike
40
+ // `status`, it's never validated here, an unknown slug just makes WordPress fall back to the
41
+ // default template, the same silent no-op as `full-width`/`hide-title` on a theme that doesn't
42
+ // support them.
43
+ export function parsePageFile(slug, raw) {
44
+ const header = /^\s*<!--([\s\S]*?)-->\r?\n?/.exec(raw);
45
+ const meta = {};
46
+ if (header) {
47
+ for (const line of header[1].split(/\r?\n/)) {
48
+ if (line.trim() === '')
49
+ continue;
50
+ const colon = line.indexOf(':');
51
+ if (colon === -1)
52
+ throw new Error(`header line "${line.trim()}" is not a "key: value" pair`);
53
+ const key = line.slice(0, colon).trim();
54
+ if (!HEADER_KEYS.includes(key))
55
+ throw new Error(`unknown header key "${key}" (allowed: ${HEADER_KEYS.join(', ')})`);
56
+ meta[key] = line.slice(colon + 1).trim();
57
+ }
58
+ }
59
+ const status = meta.status ?? 'draft';
60
+ if (!PAGE_STATUSES.includes(status)) {
61
+ throw new Error(`status "${status}" is not one of ${PAGE_STATUSES.join(', ')}`);
62
+ }
63
+ return {
64
+ fullWidth: parseBoolean('full-width', meta['full-width']),
65
+ hideTitle: parseBoolean('hide-title', meta['hide-title']),
66
+ html: header ? raw.slice(header[0].length) : raw,
67
+ slug,
68
+ status: status,
69
+ template: meta.template ?? '',
70
+ title: meta.title || titleFromSlug(slug),
71
+ };
72
+ }
73
+ // Reads every page of `dir` and reports every problem at once instead of skipping bad files
74
+ // like the other resources do: a page push is declarative, so anything unexpected in the
75
+ // directory (a `.php`, a subdirectory, a bad header) must stop it before any network call.
76
+ // Dotfiles (.DS_Store, editor droppings) are ignored. A missing directory is "no pages".
77
+ export async function readLocalPages(dir) {
78
+ let entries;
79
+ try {
80
+ entries = await readdir(dir, { withFileTypes: true });
81
+ }
82
+ catch (error) {
83
+ if (error.code === 'ENOENT')
84
+ return { pages: [], problems: [] };
85
+ throw error;
86
+ }
87
+ const pages = [];
88
+ const problems = [];
89
+ entries.sort((a, b) => a.name.localeCompare(b.name));
90
+ for (const entry of entries) {
91
+ if (entry.name.startsWith('.'))
92
+ continue;
93
+ const filePath = join(dir, entry.name);
94
+ if (!entry.isFile()) {
95
+ problems.push({ file: filePath, message: `subdirectories are not supported, keep every page at the top of ${dir}` });
96
+ continue;
97
+ }
98
+ if (extname(entry.name) !== '.html') {
99
+ problems.push({ file: filePath, message: `only .html files are allowed in ${dir}` });
100
+ continue;
101
+ }
102
+ const slug = basename(entry.name, '.html');
103
+ if (!PAGE_SLUG_PATTERN.test(slug)) {
104
+ problems.push({ file: filePath, message: 'the file name must be lowercase letters and digits separated by single hyphens (e.g. "legal-notice.html")' });
105
+ continue;
106
+ }
107
+ try {
108
+ pages.push(parsePageFile(slug, await readFile(filePath, 'utf8')));
109
+ }
110
+ catch (error) {
111
+ problems.push({ file: filePath, message: error.message });
112
+ }
113
+ }
114
+ return { pages, problems };
115
+ }
116
+ export function frontPageNote(page) {
117
+ return page.frontPage === undefined ? '' : ` (${FRONT_PAGE_NOTES[page.frontPage]})`;
118
+ }
@@ -7,6 +7,7 @@ export declare const RESOURCE_DIR_DEFAULTS: {
7
7
  readonly hooks: "hooks";
8
8
  readonly menu: "menus";
9
9
  readonly options: "options";
10
+ readonly page: "pages";
10
11
  readonly seo: "seo";
11
12
  readonly snippets: "snippets";
12
13
  readonly themeStyles: "theme";
@@ -9,6 +9,7 @@ export const RESOURCE_DIR_DEFAULTS = {
9
9
  hooks: 'hooks',
10
10
  menu: 'menus',
11
11
  options: 'options',
12
+ page: 'pages',
12
13
  seo: 'seo',
13
14
  snippets: 'snippets',
14
15
  themeStyles: 'theme',
@@ -21,6 +22,7 @@ const CONFIG_KEY = {
21
22
  hooks: 'hooksDir',
22
23
  menu: 'menuDir',
23
24
  options: 'optionsDir',
25
+ page: 'pageDir',
24
26
  seo: 'seoDir',
25
27
  snippets: 'snippetsDir',
26
28
  themeStyles: 'themeStylesDir',