@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.
- package/README.md +197 -101
- package/dist/commands/diff.js +1 -1
- package/dist/commands/init.js +6 -1
- package/dist/commands/page/diff.d.ts +2 -0
- package/dist/commands/page/diff.js +5 -0
- package/dist/commands/page/list.d.ts +8 -0
- package/dist/commands/page/list.js +22 -0
- package/dist/commands/page/push.d.ts +20 -0
- package/dist/commands/page/push.js +60 -0
- package/dist/commands/plugin/add.d.ts +8 -1
- package/dist/commands/plugin/add.js +4 -2
- package/dist/commands/promote.js +3 -2
- package/dist/commands/pull.js +6 -2
- package/dist/commands/push.js +8 -3
- package/dist/commands/theme/add.d.ts +8 -1
- package/dist/commands/theme/add.js +4 -2
- package/dist/lib/api-client.js +24 -1
- package/dist/lib/dev-watch.d.ts +1 -1
- package/dist/lib/dev-watch.js +3 -1
- package/dist/lib/json-delegation.d.ts +1 -0
- package/dist/lib/json-delegation.js +18 -0
- package/dist/lib/load-snippets.js +1 -1
- package/dist/lib/php-files-command.js +4 -3
- package/dist/lib/readdir-tolerant.js +15 -5
- package/dist/lib/resource-state.js +24 -0
- package/dist/lib/snapshot-store.js +14 -0
- package/dist/lib/validate-local.js +33 -7
- package/dist/lib/wp-client.js +31 -2
- package/dist/types/project-config.generated.d.ts +4 -0
- package/dist/utils/loopress-config.d.ts +1 -0
- package/dist/utils/loopress-config.js +14 -0
- package/dist/utils/page-format.d.ts +31 -0
- package/dist/utils/page-format.js +118 -0
- package/dist/utils/resource-dirs.d.ts +1 -0
- package/dist/utils/resource-dirs.js +2 -0
- package/oclif.manifest.json +506 -298
- package/package.json +5 -3
- 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
|
-
|
|
90
|
-
|
|
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
|
|
107
|
+
for (const entry of entries) {
|
|
108
|
+
if (!entry.isFile() || !entry.name.endsWith('.json'))
|
|
109
|
+
continue;
|
|
93
110
|
checked += 1;
|
|
94
|
-
|
|
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(
|
|
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;
|
package/dist/lib/wp-client.js
CHANGED
|
@@ -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. `&` last, so "&quot;" stays a literal """.
|
|
148
|
+
function decodeEntities(text) {
|
|
149
|
+
return text
|
|
150
|
+
.replaceAll('"', '"')
|
|
151
|
+
.replaceAll(''', "'")
|
|
152
|
+
.replaceAll('<', '<')
|
|
153
|
+
.replaceAll('>', '>')
|
|
154
|
+
.replaceAll('&', '&');
|
|
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
|
|
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
|
+
}
|
|
@@ -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',
|