@loopress/cli 0.25.0 → 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.
- package/README.md +189 -99
- package/dist/commands/diff.js +1 -1
- package/dist/commands/init.js +1 -0
- 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/push.js +2 -1
- package/dist/lib/dev-watch.d.ts +1 -1
- package/dist/lib/dev-watch.js +2 -1
- package/dist/lib/resource-state.js +24 -0
- package/dist/lib/validate-local.js +5 -0
- package/dist/types/project-config.generated.d.ts +4 -0
- package/dist/utils/page-format.d.ts +27 -0
- package/dist/utils/page-format.js +111 -0
- package/dist/utils/resource-dirs.d.ts +1 -0
- package/dist/utils/resource-dirs.js +2 -0
- package/oclif.manifest.json +494 -300
- package/package.json +1 -1
- package/schemas/project-config.schema.json +5 -0
package/dist/commands/diff.js
CHANGED
|
@@ -7,7 +7,7 @@ const RESOURCES = [...RESOURCE_STATE_PROVIDERS.map((provider) => provider.resour
|
|
|
7
7
|
export default class Diff extends DiffCommand {
|
|
8
8
|
static description = [
|
|
9
9
|
'Show what differs between your local tracked files and a WordPress environment, or between two environments.',
|
|
10
|
-
'Covers snippets, forms, ACF, API routes, Hooks, SEO, Options, Menus, Theme styles, and Composer. Plugins and themes have their own `lps plugin status` / `lps theme status`.',
|
|
10
|
+
'Covers snippets, forms, ACF, API routes, Hooks, pages, SEO, Options, Menus, Theme styles, and Composer. Plugins and themes have their own `lps plugin status` / `lps theme status`.',
|
|
11
11
|
'Exit code: 0 in sync, 1 on drift, 2 when a resource could not be compared, so it doubles as a CI drift gate.',
|
|
12
12
|
].join(' ');
|
|
13
13
|
static enableJsonFlag = true;
|
package/dist/commands/init.js
CHANGED
|
@@ -20,6 +20,7 @@ const FEATURES = [
|
|
|
20
20
|
{ dir: 'api', key: 'apiDir', label: 'Custom API routes' },
|
|
21
21
|
{ dir: 'hooks', key: 'hooksDir', label: 'Hooks (actions, filters, cron)' },
|
|
22
22
|
{ dir: 'apps', key: 'appsDir', label: 'Single-page apps' },
|
|
23
|
+
{ dir: 'pages', key: 'pageDir', label: 'Static pages' },
|
|
23
24
|
];
|
|
24
25
|
export default class Init extends Command {
|
|
25
26
|
static description = 'Initialize a loopress.json config file in the current directory';
|
|
@@ -0,0 +1,5 @@
|
|
|
1
|
+
import { resourceDiffCommand } from '../../lib/diff-command.js';
|
|
2
|
+
export default resourceDiffCommand('page', {
|
|
3
|
+
description: 'Show what differs in static pages between your local files and a WordPress environment, or between two environments',
|
|
4
|
+
pathNoun: 'pages directory',
|
|
5
|
+
});
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
import { LoopressCommand } from '../../lib/base.js';
|
|
2
|
+
import { type RemotePage } from '../../utils/page-format.js';
|
|
3
|
+
export default class List extends LoopressCommand {
|
|
4
|
+
static description: string;
|
|
5
|
+
static enableJsonFlag: boolean;
|
|
6
|
+
static examples: string[];
|
|
7
|
+
run(): Promise<Array<Omit<RemotePage, 'html'>>>;
|
|
8
|
+
}
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
import { LoopressCommand } from '../../lib/base.js';
|
|
2
|
+
import { PAGES_ENDPOINT } from '../../utils/page-format.js';
|
|
3
|
+
import { pluralize } from '../../utils/pluralize.js';
|
|
4
|
+
export default class List extends LoopressCommand {
|
|
5
|
+
static description = 'List the static pages managed by Loopress on WordPress';
|
|
6
|
+
static enableJsonFlag = true;
|
|
7
|
+
static examples = ['$ lps page list'];
|
|
8
|
+
async run() {
|
|
9
|
+
const pages = await this.wp.get(PAGES_ENDPOINT);
|
|
10
|
+
// The HTML is only needed by `page diff`, it would drown the JSON output here.
|
|
11
|
+
const listed = pages.map(({ html: _html, ...page }) => page);
|
|
12
|
+
if (listed.length === 0) {
|
|
13
|
+
this.log('No pages managed by Loopress');
|
|
14
|
+
return listed;
|
|
15
|
+
}
|
|
16
|
+
this.log(`Found ${pluralize(listed.length, 'page')}:`);
|
|
17
|
+
this.log('');
|
|
18
|
+
for (const page of listed)
|
|
19
|
+
this.log(` ${page.slug} ${page.status} ${page.link}`);
|
|
20
|
+
return listed;
|
|
21
|
+
}
|
|
22
|
+
}
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
import { PushCommand } from '../../lib/push-command.js';
|
|
2
|
+
type PushResult = {
|
|
3
|
+
pushed: string[];
|
|
4
|
+
status: 'dry-run' | 'success';
|
|
5
|
+
};
|
|
6
|
+
export default class Push extends PushCommand {
|
|
7
|
+
static args: {
|
|
8
|
+
slug: import("@oclif/core/interfaces").Arg<string | undefined, Record<string, unknown>>;
|
|
9
|
+
};
|
|
10
|
+
static description: string;
|
|
11
|
+
static enableJsonFlag: boolean;
|
|
12
|
+
static examples: string[];
|
|
13
|
+
static flags: {
|
|
14
|
+
yes: import("@oclif/core/interfaces").BooleanFlag<boolean>;
|
|
15
|
+
'dry-run': import("@oclif/core/interfaces").BooleanFlag<boolean>;
|
|
16
|
+
};
|
|
17
|
+
run(): Promise<PushResult>;
|
|
18
|
+
private pushPage;
|
|
19
|
+
}
|
|
20
|
+
export {};
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
import { Args } from '@oclif/core';
|
|
2
|
+
import { PushCommand } from '../../lib/push-command.js';
|
|
3
|
+
import { formatPageProblems, PAGES_ENDPOINT, readLocalPages } from '../../utils/page-format.js';
|
|
4
|
+
import { pluralize } from '../../utils/pluralize.js';
|
|
5
|
+
import { resolveResourceDir } from '../../utils/resource-dirs.js';
|
|
6
|
+
export default class Push extends PushCommand {
|
|
7
|
+
static args = {
|
|
8
|
+
slug: Args.string({ description: 'Push only this page (its file name without .html). Pushes every page when omitted.' }),
|
|
9
|
+
};
|
|
10
|
+
static description = 'Push static HTML pages (pages/<slug>.html) to WordPress. The `status` header (draft or publish, draft by default) is applied on every push.';
|
|
11
|
+
static enableJsonFlag = true;
|
|
12
|
+
static examples = ['$ lps page push', '$ lps page push legal-notice', '$ lps page push --dry-run'];
|
|
13
|
+
static flags = {
|
|
14
|
+
...PushCommand.dryRunFlag,
|
|
15
|
+
...PushCommand.yesFlag,
|
|
16
|
+
};
|
|
17
|
+
async run() {
|
|
18
|
+
const { args } = await this.parse(Push);
|
|
19
|
+
const path = resolveResourceDir('page', this.localConfig);
|
|
20
|
+
this.log(`Pushing pages to ${this.siteConfig.url}`);
|
|
21
|
+
this.log(`Pages path: ${path}`);
|
|
22
|
+
const { pages, problems } = await readLocalPages(path);
|
|
23
|
+
if (problems.length > 0) {
|
|
24
|
+
this.error(`Nothing was pushed, fix these pages first:\n ${formatPageProblems(problems)}`);
|
|
25
|
+
}
|
|
26
|
+
const selected = args.slug === undefined ? pages : pages.filter((page) => page.slug === args.slug);
|
|
27
|
+
if (args.slug !== undefined && selected.length === 0) {
|
|
28
|
+
this.error(`No page "${args.slug}" in ${path} (expected ${args.slug}.html).`);
|
|
29
|
+
}
|
|
30
|
+
this.log(`Found ${pluralize(selected.length, 'page')} to push`);
|
|
31
|
+
const pushed = [];
|
|
32
|
+
await this.runPushTasks(selected, (page) => page.slug, async (page, task) => {
|
|
33
|
+
await this.pushPage(page, task);
|
|
34
|
+
pushed.push(page.slug);
|
|
35
|
+
});
|
|
36
|
+
if (this.failedCount > 0) {
|
|
37
|
+
this.error(`${pluralize(this.failedCount, 'page')} failed to push.`);
|
|
38
|
+
}
|
|
39
|
+
if (this.dryRun)
|
|
40
|
+
return { pushed, status: 'dry-run' };
|
|
41
|
+
await this.recordSuccess();
|
|
42
|
+
this.log('All pages pushed.');
|
|
43
|
+
return { pushed, status: 'success' };
|
|
44
|
+
}
|
|
45
|
+
async pushPage(page, task) {
|
|
46
|
+
if (this.dryRun) {
|
|
47
|
+
if (task)
|
|
48
|
+
task.output = `[dry-run] Would push: ${page.slug} (${page.status})`;
|
|
49
|
+
return;
|
|
50
|
+
}
|
|
51
|
+
try {
|
|
52
|
+
const result = await this.wp.put(PAGES_ENDPOINT, page);
|
|
53
|
+
if (task)
|
|
54
|
+
task.output = `Pushed: ${page.slug} (${result.status}) ${result.link}`;
|
|
55
|
+
}
|
|
56
|
+
catch (error) {
|
|
57
|
+
this.reportTaskFailure(`Failed to push ${page.slug}: ${error.message}`, error, task);
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
}
|
package/dist/commands/push.js
CHANGED
|
@@ -10,13 +10,14 @@ const PUSH_TARGETS = [
|
|
|
10
10
|
{ commandId: 'api:push', label: 'API routes' },
|
|
11
11
|
{ commandId: 'hook:push', label: 'hooks' },
|
|
12
12
|
{ commandId: 'form:push', label: 'forms' },
|
|
13
|
+
{ commandId: 'page:push', label: 'pages' },
|
|
13
14
|
{ commandId: 'seo:push', label: 'SEO' },
|
|
14
15
|
{ commandId: 'menu:push', label: 'menus' },
|
|
15
16
|
{ commandId: 'option:push', label: 'options' },
|
|
16
17
|
{ commandId: 'snippet:push', label: 'snippets' },
|
|
17
18
|
];
|
|
18
19
|
export default class Push extends LoopressCommand {
|
|
19
|
-
static description = 'Push all local content, plugins, composer dependencies, ACF, API routes, hooks, forms, SEO, menus, options, and snippets, to WordPress';
|
|
20
|
+
static description = 'Push all local content, plugins, composer dependencies, ACF, API routes, hooks, forms, pages, SEO, menus, options, and snippets, to WordPress';
|
|
20
21
|
static enableJsonFlag = true;
|
|
21
22
|
static examples = ['$ lps push', '$ lps push --env staging', '$ lps push --dry-run'];
|
|
22
23
|
static flags = {
|
package/dist/lib/dev-watch.d.ts
CHANGED
|
@@ -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;
|
package/dist/lib/dev-watch.js
CHANGED
|
@@ -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
|
};
|
|
@@ -4,6 +4,7 @@ import { ACF_OBJECT_TYPES, acfEndpoint, getAcfKey } from '../utils/acf-format.js
|
|
|
4
4
|
import { FORM_ENDPOINT, getFormId } from '../utils/form-format.js';
|
|
5
5
|
import { getMenuSlug, MENU_ENDPOINT, MENU_LOCATIONS_ENDPOINT } from '../utils/menu-format.js';
|
|
6
6
|
import { optionEndpoint, parseLocalOption } from '../utils/option-format.js';
|
|
7
|
+
import { formatPageProblems, PAGES_ENDPOINT, readLocalPages } from '../utils/page-format.js';
|
|
7
8
|
import { DEFAULT_POST_TYPES, SEO_REDIRECTS_ENDPOINT, SEO_SETTINGS_ENDPOINT, seoPostMetaEndpoint, } from '../utils/seo-format.js';
|
|
8
9
|
import { normalizeSnippet, SNIPPETS_ENDPOINT, stripPhpOpeningTag } from '../utils/snippet-format.js';
|
|
9
10
|
import { canonicalGlobalStyles, getActiveThemeGlobalStyles, globalStylesEndpoint, THEME_STYLES_FILE_SUFFIX, } from '../utils/theme-styles-format.js';
|
|
@@ -194,6 +195,28 @@ const hookProvider = phpFilesProvider({
|
|
|
194
195
|
resource: 'hook',
|
|
195
196
|
title: 'Hooks',
|
|
196
197
|
});
|
|
198
|
+
// ---- Pages ------------------------------------------------------------------------------
|
|
199
|
+
// Keyed by slug (the page's identity on both sides). `link` is server-derived and never
|
|
200
|
+
// compared. A local problem (a `.php` in pages/, a bad header) fails the whole resource rather
|
|
201
|
+
// than hiding that page, the same stop-everything stance as `page push`.
|
|
202
|
+
function canonicalPage(page) {
|
|
203
|
+
return { html: page.html, status: page.status, title: page.title };
|
|
204
|
+
}
|
|
205
|
+
const pageProvider = {
|
|
206
|
+
dirKind: 'page',
|
|
207
|
+
async local(dir) {
|
|
208
|
+
const { pages, problems } = await readLocalPages(dir);
|
|
209
|
+
if (problems.length > 0)
|
|
210
|
+
throw new Error(formatPageProblems(problems));
|
|
211
|
+
return new Map(pages.map((page) => [page.slug, canonicalPage(page)]));
|
|
212
|
+
},
|
|
213
|
+
async remote(wp) {
|
|
214
|
+
const pages = await wp.get(PAGES_ENDPOINT);
|
|
215
|
+
return new Map(pages.map((page) => [page.slug, canonicalPage(page)]));
|
|
216
|
+
},
|
|
217
|
+
resource: 'page',
|
|
218
|
+
title: 'Pages',
|
|
219
|
+
};
|
|
197
220
|
// ---- SEO --------------------------------------------------------------------------------
|
|
198
221
|
function canonicalRedirect(redirect) {
|
|
199
222
|
return omit(redirect, REDIRECT_VOLATILE_KEYS);
|
|
@@ -403,6 +426,7 @@ export const RESOURCE_STATE_PROVIDERS = [
|
|
|
403
426
|
acfProvider,
|
|
404
427
|
apiProvider,
|
|
405
428
|
hookProvider,
|
|
429
|
+
pageProvider,
|
|
406
430
|
seoProvider,
|
|
407
431
|
menuProvider,
|
|
408
432
|
optionsProvider,
|
|
@@ -1,6 +1,7 @@
|
|
|
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 { readLocalPages } from '../utils/page-format.js';
|
|
4
5
|
import { parseType } from '../utils/snippet-format.js';
|
|
5
6
|
import { loadSnippets } from './load-snippets.js';
|
|
6
7
|
import { readdirTolerant } from './readdir-tolerant.js';
|
|
@@ -13,6 +14,7 @@ const DEFAULT_DIR = {
|
|
|
13
14
|
formDir: 'forms',
|
|
14
15
|
hooksDir: 'hooks',
|
|
15
16
|
menuDir: 'menus',
|
|
17
|
+
pageDir: 'pages',
|
|
16
18
|
seoDir: 'seo',
|
|
17
19
|
snippetsDir: 'snippets',
|
|
18
20
|
};
|
|
@@ -38,6 +40,9 @@ export async function validateLocal(cwd) {
|
|
|
38
40
|
checked += await checkSnippets(resolve('snippetsDir'), problems);
|
|
39
41
|
checked += await checkPhpDir(resolve('apiDir'), problems, 'API route file is empty');
|
|
40
42
|
checked += await checkPhpDir(resolve('hooksDir'), problems, 'Hook file is empty');
|
|
43
|
+
const pages = await readLocalPages(resolve('pageDir'));
|
|
44
|
+
checked += pages.pages.length + pages.problems.length;
|
|
45
|
+
problems.push(...pages.problems);
|
|
41
46
|
return { checked, problems, valid: problems.length === 0 };
|
|
42
47
|
}
|
|
43
48
|
// Recursive: both api/ and hooks/ files can live in a nested path-segment directory
|
|
@@ -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
|
*/
|
|
@@ -0,0 +1,27 @@
|
|
|
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 PageProblem = {
|
|
18
|
+
file: string;
|
|
19
|
+
message: string;
|
|
20
|
+
};
|
|
21
|
+
export declare function formatPageProblems(problems: PageProblem[]): string;
|
|
22
|
+
export declare function titleFromSlug(slug: string): string;
|
|
23
|
+
export declare function parsePageFile(slug: string, raw: string): Page;
|
|
24
|
+
export declare function readLocalPages(dir: string): Promise<{
|
|
25
|
+
pages: Page[];
|
|
26
|
+
problems: PageProblem[];
|
|
27
|
+
}>;
|
|
@@ -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
|
+
}
|
|
@@ -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',
|