@loopress/cli 0.26.0 → 0.28.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 +275 -177
- package/dist/commands/app/pull.js +17 -4
- package/dist/commands/app/push.d.ts +3 -0
- package/dist/commands/app/push.js +29 -5
- package/dist/commands/composer/push.js +1 -19
- package/dist/commands/diff.js +1 -1
- package/dist/commands/init.js +7 -1
- package/dist/commands/page/push.js +2 -2
- package/dist/commands/plugin/add.d.ts +8 -1
- package/dist/commands/plugin/add.js +10 -5
- package/dist/commands/plugin/audit.js +4 -5
- package/dist/commands/plugin/pull.d.ts +3 -1
- package/dist/commands/plugin/pull.js +17 -9
- package/dist/commands/plugin/push.d.ts +5 -1
- package/dist/commands/plugin/push.js +90 -37
- package/dist/commands/plugin/status.d.ts +1 -0
- package/dist/commands/plugin/status.js +21 -2
- package/dist/commands/project/config.js +12 -8
- package/dist/commands/promote.d.ts +2 -0
- package/dist/commands/promote.js +70 -8
- package/dist/commands/pull.js +8 -4
- package/dist/commands/push.d.ts +1 -0
- package/dist/commands/push.js +42 -4
- package/dist/commands/template/diff.d.ts +10 -0
- package/dist/commands/template/diff.js +13 -0
- package/dist/commands/template/list.d.ts +13 -0
- package/dist/commands/template/list.js +23 -0
- package/dist/commands/template/push.d.ts +20 -0
- package/dist/commands/template/push.js +42 -0
- package/dist/commands/theme/add.d.ts +8 -1
- package/dist/commands/theme/add.js +4 -2
- package/dist/commands/theme/pull.d.ts +3 -1
- package/dist/commands/theme/pull.js +10 -4
- package/dist/commands/theme/style/diff.d.ts +2 -0
- package/dist/commands/{theme-styles → theme/style}/diff.js +2 -1
- package/dist/commands/{theme-styles → theme/style}/pull.d.ts +1 -1
- package/dist/commands/{theme-styles → theme/style}/pull.js +3 -3
- package/dist/commands/{theme-styles → theme/style}/push.d.ts +1 -1
- package/dist/commands/{theme-styles → theme/style}/push.js +5 -5
- package/dist/commands/theme/style/rollback.d.ts +2 -0
- package/dist/commands/theme/style/rollback.js +6 -0
- package/dist/lib/api-client.js +26 -1
- package/dist/lib/app-build.d.ts +5 -0
- package/dist/lib/app-build.js +69 -0
- package/dist/lib/app-manifest.d.ts +4 -0
- package/dist/lib/app-manifest.js +8 -0
- package/dist/lib/base.js +11 -2
- package/dist/lib/bootstrap-full-install.js +45 -14
- package/dist/lib/dev-watch.d.ts +1 -1
- package/dist/lib/dev-watch.js +4 -1
- package/dist/lib/diff-command.d.ts +1 -0
- package/dist/lib/diff-command.js +6 -4
- 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 +43 -1
- package/dist/lib/rollback-command.d.ts +1 -0
- package/dist/lib/rollback-command.js +8 -6
- package/dist/lib/rotate-app-password.js +2 -2
- package/dist/lib/snapshot-store.js +14 -0
- package/dist/lib/validate-local.js +34 -7
- package/dist/lib/wp-client.d.ts +4 -1
- package/dist/lib/wp-client.js +56 -3
- package/dist/types/plugin.d.ts +8 -1
- package/dist/types/plugin.js +4 -1
- package/dist/types/project-config.generated.d.ts +19 -2
- package/dist/utils/composer.d.ts +14 -0
- package/dist/utils/composer.js +136 -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 +12 -0
- package/dist/utils/page-format.js +41 -26
- package/dist/utils/plugins.d.ts +9 -4
- package/dist/utils/plugins.js +16 -7
- package/dist/utils/resource-dirs.d.ts +2 -0
- package/dist/utils/resource-dirs.js +4 -0
- package/dist/utils/seo-format.d.ts +2 -2
- package/dist/utils/template-format.d.ts +30 -0
- package/dist/utils/template-format.js +36 -0
- package/oclif.manifest.json +536 -320
- package/package.json +7 -4
- package/schemas/project-config.schema.json +25 -4
- package/dist/commands/theme-styles/diff.d.ts +0 -2
- package/dist/commands/theme-styles/rollback.d.ts +0 -2
- package/dist/commands/theme-styles/rollback.js +0 -5
package/dist/utils/composer.js
CHANGED
|
@@ -1,6 +1,8 @@
|
|
|
1
1
|
import { existsSync } from 'node:fs';
|
|
2
2
|
import { readFile } from 'node:fs/promises';
|
|
3
3
|
import { join } from 'node:path';
|
|
4
|
+
import writeFileAtomic from 'write-file-atomic';
|
|
5
|
+
import { isExactVersion } from './version.js';
|
|
4
6
|
export async function readComposerJson() {
|
|
5
7
|
const path = join(process.cwd(), 'composer.json');
|
|
6
8
|
if (!existsSync(path))
|
|
@@ -31,3 +33,137 @@ export function getComposerManagedSlugs(composerJson) {
|
|
|
31
33
|
.filter((pkg) => pkg.startsWith('wpackagist-plugin/'))
|
|
32
34
|
.map((pkg) => pkg.slice('wpackagist-plugin/'.length));
|
|
33
35
|
}
|
|
36
|
+
// A slug WPackagist can't serve makes `composer update` fail for the whole file, libraries
|
|
37
|
+
// included. WPackagist mirrors WordPress.org, whose API answers a clean 404 for premium and
|
|
38
|
+
// custom plugins/themes. Any other failure throws before composer.json is written.
|
|
39
|
+
async function isOnWordPressOrg(kind, slug) {
|
|
40
|
+
const url = `https://api.wordpress.org/${kind}s/info/1.2/?action=${kind}_information&slug=${encodeURIComponent(slug)}`;
|
|
41
|
+
const fail = (why) => new Error(`Could not check whether the ${kind} "${slug}" is on WordPress.org (${why}), so composer.json was not modified. ` +
|
|
42
|
+
'Loopress only adds packages WPackagist can serve. Retry once api.wordpress.org is reachable.');
|
|
43
|
+
let response;
|
|
44
|
+
try {
|
|
45
|
+
response = await fetch(url);
|
|
46
|
+
}
|
|
47
|
+
catch (error) {
|
|
48
|
+
throw fail(`network error: ${error.message}`);
|
|
49
|
+
}
|
|
50
|
+
if (response.status === 404)
|
|
51
|
+
return false;
|
|
52
|
+
if (!response.ok)
|
|
53
|
+
throw fail(`HTTP ${response.status}`);
|
|
54
|
+
return true;
|
|
55
|
+
}
|
|
56
|
+
// Rewrites a JSON file the way it was indented (Composer itself writes 4 spaces), so a pull only
|
|
57
|
+
// shows the entries it changed in the git diff. A file with no indentation to copy gets 4 spaces.
|
|
58
|
+
function stringifyLike(original, value) {
|
|
59
|
+
const indent = /^([ \t]+)"/m.exec(original)?.[1] ?? ' '.repeat(4);
|
|
60
|
+
return JSON.stringify(value, null, indent) + (original.endsWith('\n') ? '\n' : '');
|
|
61
|
+
}
|
|
62
|
+
// A package already declared in composer.json: `pin` means the pull may move it (an exact pin,
|
|
63
|
+
// or already live), `skip` gives the reason it must stay as is.
|
|
64
|
+
function classifyDeclared(declared, live, inRequire) {
|
|
65
|
+
if (!inRequire)
|
|
66
|
+
return { skip: 'declared in require-dev' };
|
|
67
|
+
if (declared === live || isExactVersion(declared))
|
|
68
|
+
return { pin: declared };
|
|
69
|
+
return { skip: `keeps constraint ${declared}, live ${live}` };
|
|
70
|
+
}
|
|
71
|
+
// Sorts every installed package into the result, and returns the undeclared slugs that still
|
|
72
|
+
// need a WordPress.org lookup before they can be added.
|
|
73
|
+
function planComposerPull(composerJson, prefix, incoming) {
|
|
74
|
+
const require = composerJson.require ?? {};
|
|
75
|
+
const allPackages = { ...require, ...composerJson['require-dev'] };
|
|
76
|
+
const providedBy = new Map(Object.keys(allPackages)
|
|
77
|
+
.filter((name) => !name.startsWith(prefix) && name.includes('/'))
|
|
78
|
+
.map((name) => [name.split('/', 2)[1], name]));
|
|
79
|
+
const result = { added: [], merged: {}, skipped: [], updated: [] };
|
|
80
|
+
const candidates = [];
|
|
81
|
+
for (const [slug, version] of Object.entries(incoming)) {
|
|
82
|
+
const name = prefix + slug;
|
|
83
|
+
const provider = providedBy.get(slug);
|
|
84
|
+
if (Object.hasOwn(allPackages, name)) {
|
|
85
|
+
const outcome = classifyDeclared(allPackages[name], version, Object.hasOwn(require, name));
|
|
86
|
+
if ('skip' in outcome) {
|
|
87
|
+
result.skipped.push({ reason: outcome.skip, slug });
|
|
88
|
+
continue;
|
|
89
|
+
}
|
|
90
|
+
result.merged[slug] = version;
|
|
91
|
+
if (outcome.pin !== version)
|
|
92
|
+
result.updated.push({ from: outcome.pin, slug, to: version });
|
|
93
|
+
}
|
|
94
|
+
else if (provider) {
|
|
95
|
+
result.skipped.push({ reason: `provided by ${provider}`, slug });
|
|
96
|
+
}
|
|
97
|
+
else {
|
|
98
|
+
candidates.push(slug);
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
return { candidates, result };
|
|
102
|
+
}
|
|
103
|
+
// `lps plugin pull` / `lps theme pull` on a project that has a composer.json: pin installed
|
|
104
|
+
// packages to their live versions as `wpackagist-<kind>/<slug>` under `require`, leaving every
|
|
105
|
+
// other key untouched. The server adds the WPackagist repository and composer/installers on push.
|
|
106
|
+
// - A declared version constraint (^9.4, *, ...) is the user's intent: never overwritten, only an
|
|
107
|
+
// exact pin moves to the live version.
|
|
108
|
+
// - Slugs already declared in require-dev stay there instead of being duplicated into require.
|
|
109
|
+
// - A slug provided by another package (acme/<slug>) or absent from WordPress.org is skipped.
|
|
110
|
+
export async function pullIntoComposerJson(path, kind, incoming, { dryRun, log }) {
|
|
111
|
+
const raw = await readFile(path, 'utf8');
|
|
112
|
+
const composerJson = JSON.parse(raw);
|
|
113
|
+
const prefix = `wpackagist-${kind}/`;
|
|
114
|
+
const { candidates, result } = planComposerPull(composerJson, prefix, incoming);
|
|
115
|
+
const found = await Promise.all(candidates.map(async (slug) => isOnWordPressOrg(kind, slug)));
|
|
116
|
+
for (const [i, slug] of candidates.entries()) {
|
|
117
|
+
if (found[i]) {
|
|
118
|
+
result.added.push(slug);
|
|
119
|
+
result.merged[slug] = incoming[slug];
|
|
120
|
+
}
|
|
121
|
+
else {
|
|
122
|
+
result.skipped.push({ reason: 'not on WordPress.org', slug });
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
if (!dryRun) {
|
|
126
|
+
const require = { ...composerJson.require };
|
|
127
|
+
for (const [slug, version] of Object.entries(result.merged))
|
|
128
|
+
require[prefix + slug] = version;
|
|
129
|
+
// Atomic: an interrupted write must never leave the user's committed composer.json truncated.
|
|
130
|
+
await writeFileAtomic(path, stringifyLike(raw, { ...composerJson, require }), 'utf8');
|
|
131
|
+
}
|
|
132
|
+
log(`${dryRun ? '[dry-run] Would pin' : 'Pinned'} ${Object.keys(result.merged).length} ${kind}s in composer.json`);
|
|
133
|
+
if (result.added.length > 0)
|
|
134
|
+
log(` + Added: ${result.added.join(', ')}`);
|
|
135
|
+
for (const u of result.updated)
|
|
136
|
+
log(` ~ Updated: ${u.slug} ${u.from} → ${u.to}`);
|
|
137
|
+
for (const s of result.skipped)
|
|
138
|
+
log(` - Skipped: ${s.slug} (${s.reason})`);
|
|
139
|
+
if (!dryRun && (result.added.length > 0 || result.updated.length > 0))
|
|
140
|
+
log('Run `lps composer push` to apply.');
|
|
141
|
+
return result;
|
|
142
|
+
}
|
|
143
|
+
// Split a composer.json `require` map into the three intent namespaces the sync endpoint
|
|
144
|
+
// understands. `composer/installers` is owned by the server scaffold, never sent.
|
|
145
|
+
export function toIntent(require) {
|
|
146
|
+
const intent = { libraries: {}, plugins: {}, themes: {} };
|
|
147
|
+
for (const [name, constraint] of Object.entries(require)) {
|
|
148
|
+
if (name === 'composer/installers')
|
|
149
|
+
continue;
|
|
150
|
+
if (name.startsWith('wpackagist-plugin/')) {
|
|
151
|
+
intent.plugins[name.slice('wpackagist-plugin/'.length)] = constraint;
|
|
152
|
+
}
|
|
153
|
+
else if (name.startsWith('wpackagist-theme/')) {
|
|
154
|
+
intent.themes[name.slice('wpackagist-theme/'.length)] = constraint;
|
|
155
|
+
}
|
|
156
|
+
else {
|
|
157
|
+
intent.libraries[name] = constraint;
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
return intent;
|
|
161
|
+
}
|
|
162
|
+
// The `wpackagist-<kind>/<slug>` entries of a composer.json `require`, keyed by slug, with the
|
|
163
|
+
// version or constraint as written.
|
|
164
|
+
export function wpackagistRequire(composerJson, kind) {
|
|
165
|
+
const prefix = `wpackagist-${kind}/`;
|
|
166
|
+
return Object.fromEntries(Object.entries(composerJson.require ?? {})
|
|
167
|
+
.filter(([name]) => name.startsWith(prefix))
|
|
168
|
+
.map(([name, version]) => [name.slice(prefix.length), version]));
|
|
169
|
+
}
|
|
@@ -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
|
+
}
|
|
@@ -14,6 +14,9 @@ export type Page = {
|
|
|
14
14
|
export type RemotePage = Page & {
|
|
15
15
|
link: string;
|
|
16
16
|
};
|
|
17
|
+
export type PushedPage = RemotePage & {
|
|
18
|
+
frontPage?: 'set' | 'unset';
|
|
19
|
+
};
|
|
17
20
|
export type PageProblem = {
|
|
18
21
|
file: string;
|
|
19
22
|
message: string;
|
|
@@ -21,7 +24,16 @@ export type PageProblem = {
|
|
|
21
24
|
export declare function formatPageProblems(problems: PageProblem[]): string;
|
|
22
25
|
export declare function titleFromSlug(slug: string): string;
|
|
23
26
|
export declare function parsePageFile(slug: string, raw: string): Page;
|
|
27
|
+
export declare function parseHtmlHeader(raw: string, keys: readonly string[]): {
|
|
28
|
+
body: string;
|
|
29
|
+
meta: Record<string, string>;
|
|
30
|
+
};
|
|
24
31
|
export declare function readLocalPages(dir: string): Promise<{
|
|
25
32
|
pages: Page[];
|
|
26
33
|
problems: PageProblem[];
|
|
27
34
|
}>;
|
|
35
|
+
export declare function readLocalHtmlFiles<T>(dir: string, parse: (slug: string, raw: string) => T): Promise<{
|
|
36
|
+
files: T[];
|
|
37
|
+
problems: PageProblem[];
|
|
38
|
+
}>;
|
|
39
|
+
export declare function frontPageNote(page: PushedPage): string;
|
|
@@ -13,6 +13,10 @@ function parseBoolean(key, value) {
|
|
|
13
13
|
throw new Error(`"${key}" must be "true" or "false", got "${value}"`);
|
|
14
14
|
return value === 'true';
|
|
15
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
|
+
};
|
|
16
20
|
export function formatPageProblems(problems) {
|
|
17
21
|
return problems.map((problem) => `${problem.file}: ${problem.message}`).join('\n ');
|
|
18
22
|
}
|
|
@@ -31,27 +35,11 @@ export function titleFromSlug(slug) {
|
|
|
31
35
|
//
|
|
32
36
|
// A comment rather than YAML front matter so the file stays valid HTML, previewable as is in a
|
|
33
37
|
// 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
|
|
35
|
-
//
|
|
36
|
-
//
|
|
37
|
-
// default template, the same silent no-op as `full-width`/`hide-title` on a theme that doesn't
|
|
38
|
-
// support them.
|
|
38
|
+
// typo never silently falls back to a default. `template` is a template slug the active theme
|
|
39
|
+
// offers for pages (one it ships, or a custom one from templates/); it isn't validated here,
|
|
40
|
+
// WordPress itself refuses an unknown one ("Invalid page template") and fails that page's push.
|
|
39
41
|
export function parsePageFile(slug, raw) {
|
|
40
|
-
const
|
|
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
|
-
}
|
|
42
|
+
const { body, meta } = parseHtmlHeader(raw, HEADER_KEYS);
|
|
55
43
|
const status = meta.status ?? 'draft';
|
|
56
44
|
if (!PAGE_STATUSES.includes(status)) {
|
|
57
45
|
throw new Error(`status "${status}" is not one of ${PAGE_STATUSES.join(', ')}`);
|
|
@@ -59,28 +47,52 @@ export function parsePageFile(slug, raw) {
|
|
|
59
47
|
return {
|
|
60
48
|
fullWidth: parseBoolean('full-width', meta['full-width']),
|
|
61
49
|
hideTitle: parseBoolean('hide-title', meta['hide-title']),
|
|
62
|
-
html:
|
|
50
|
+
html: body,
|
|
63
51
|
slug,
|
|
64
52
|
status: status,
|
|
65
53
|
template: meta.template ?? '',
|
|
66
54
|
title: meta.title || titleFromSlug(slug),
|
|
67
55
|
};
|
|
68
56
|
}
|
|
57
|
+
// Splits the optional `key: value` HTML comment header off `raw`. Shared with templates/.
|
|
58
|
+
export function parseHtmlHeader(raw, keys) {
|
|
59
|
+
const header = /^\s*<!--([\s\S]*?)-->\r?\n?/.exec(raw);
|
|
60
|
+
const meta = {};
|
|
61
|
+
if (!header)
|
|
62
|
+
return { body: raw, meta };
|
|
63
|
+
for (const line of header[1].split(/\r?\n/)) {
|
|
64
|
+
if (line.trim() === '')
|
|
65
|
+
continue;
|
|
66
|
+
const colon = line.indexOf(':');
|
|
67
|
+
if (colon === -1)
|
|
68
|
+
throw new Error(`header line "${line.trim()}" is not a "key: value" pair`);
|
|
69
|
+
const key = line.slice(0, colon).trim();
|
|
70
|
+
if (!keys.includes(key))
|
|
71
|
+
throw new Error(`unknown header key "${key}" (allowed: ${keys.join(', ')})`);
|
|
72
|
+
meta[key] = line.slice(colon + 1).trim();
|
|
73
|
+
}
|
|
74
|
+
return { body: raw.slice(header[0].length), meta };
|
|
75
|
+
}
|
|
69
76
|
// Reads every page of `dir` and reports every problem at once instead of skipping bad files
|
|
70
77
|
// like the other resources do: a page push is declarative, so anything unexpected in the
|
|
71
78
|
// directory (a `.php`, a subdirectory, a bad header) must stop it before any network call.
|
|
72
79
|
// Dotfiles (.DS_Store, editor droppings) are ignored. A missing directory is "no pages".
|
|
73
80
|
export async function readLocalPages(dir) {
|
|
81
|
+
const { files, problems } = await readLocalHtmlFiles(dir, parsePageFile);
|
|
82
|
+
return { pages: files, problems };
|
|
83
|
+
}
|
|
84
|
+
// The directory rules of readLocalPages(), for any flat folder of `<slug>.html` files.
|
|
85
|
+
export async function readLocalHtmlFiles(dir, parse) {
|
|
74
86
|
let entries;
|
|
75
87
|
try {
|
|
76
88
|
entries = await readdir(dir, { withFileTypes: true });
|
|
77
89
|
}
|
|
78
90
|
catch (error) {
|
|
79
91
|
if (error.code === 'ENOENT')
|
|
80
|
-
return {
|
|
92
|
+
return { files: [], problems: [] };
|
|
81
93
|
throw error;
|
|
82
94
|
}
|
|
83
|
-
const
|
|
95
|
+
const files = [];
|
|
84
96
|
const problems = [];
|
|
85
97
|
entries.sort((a, b) => a.name.localeCompare(b.name));
|
|
86
98
|
for (const entry of entries) {
|
|
@@ -88,7 +100,7 @@ export async function readLocalPages(dir) {
|
|
|
88
100
|
continue;
|
|
89
101
|
const filePath = join(dir, entry.name);
|
|
90
102
|
if (!entry.isFile()) {
|
|
91
|
-
problems.push({ file: filePath, message: `subdirectories are not supported, keep every
|
|
103
|
+
problems.push({ file: filePath, message: `subdirectories are not supported, keep every file at the top of ${dir}` });
|
|
92
104
|
continue;
|
|
93
105
|
}
|
|
94
106
|
if (extname(entry.name) !== '.html') {
|
|
@@ -101,11 +113,14 @@ export async function readLocalPages(dir) {
|
|
|
101
113
|
continue;
|
|
102
114
|
}
|
|
103
115
|
try {
|
|
104
|
-
|
|
116
|
+
files.push(parse(slug, await readFile(filePath, 'utf8')));
|
|
105
117
|
}
|
|
106
118
|
catch (error) {
|
|
107
119
|
problems.push({ file: filePath, message: error.message });
|
|
108
120
|
}
|
|
109
121
|
}
|
|
110
|
-
return {
|
|
122
|
+
return { files, problems };
|
|
123
|
+
}
|
|
124
|
+
export function frontPageNote(page) {
|
|
125
|
+
return page.frontPage === undefined ? '' : ` (${FRONT_PAGE_NOTES[page.frontPage]})`;
|
|
111
126
|
}
|
package/dist/utils/plugins.d.ts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { type InstalledPlugin, type PluginManifest, type WpNativePlugin } from '../types/plugin.js';
|
|
1
|
+
import { type InstalledPlugin, type PluginManifest, type PluginPin, type WpNativePlugin } from '../types/plugin.js';
|
|
2
2
|
export type PluginDiff = {
|
|
3
3
|
collisions: Array<{
|
|
4
4
|
installedVersion: string;
|
|
@@ -9,6 +9,10 @@ export type PluginDiff = {
|
|
|
9
9
|
file: string;
|
|
10
10
|
slug: string;
|
|
11
11
|
}>;
|
|
12
|
+
toDeactivate: Array<{
|
|
13
|
+
file: string;
|
|
14
|
+
slug: string;
|
|
15
|
+
}>;
|
|
12
16
|
toInstall: Array<{
|
|
13
17
|
slug: string;
|
|
14
18
|
version: string;
|
|
@@ -21,16 +25,17 @@ export type PluginDiff = {
|
|
|
21
25
|
toRemove: string[];
|
|
22
26
|
untrackedActive: string[];
|
|
23
27
|
};
|
|
24
|
-
export type MergeResult = {
|
|
28
|
+
export type MergeResult<P extends PluginPin = PluginPin> = {
|
|
25
29
|
added: string[];
|
|
26
|
-
merged:
|
|
30
|
+
merged: Record<string, P>;
|
|
27
31
|
updated: Array<{
|
|
28
32
|
from: string;
|
|
29
33
|
slug: string;
|
|
30
34
|
to: string;
|
|
31
35
|
}>;
|
|
32
36
|
};
|
|
33
|
-
export declare function
|
|
37
|
+
export declare function describePin(pin: PluginPin): string;
|
|
38
|
+
export declare function mergePluginManifest<P extends PluginPin>(existing: Record<string, P>, incoming: Record<string, P>): MergeResult<P>;
|
|
34
39
|
export declare function parseInstalledPlugins(raw: WpNativePlugin[]): InstalledPlugin[];
|
|
35
40
|
export declare function diffPlugins(manifest: PluginManifest, installed: InstalledPlugin[], managedSlugs?: Set<string>): PluginDiff;
|
|
36
41
|
export declare function lockedWpackagistSlugs(composerLock: null | string, kind: 'plugin' | 'theme'): Set<string>;
|
package/dist/utils/plugins.js
CHANGED
|
@@ -1,3 +1,9 @@
|
|
|
1
|
+
import { pinActive, pinVersion, } from '../types/plugin.js';
|
|
2
|
+
import { isExactVersion } from './version.js';
|
|
3
|
+
// "1.7.2", or "1.7.2 (inactive)" for a pin that keeps the plugin inactive.
|
|
4
|
+
export function describePin(pin) {
|
|
5
|
+
return pinActive(pin) ? pinVersion(pin) : `${pinVersion(pin)} (inactive)`;
|
|
6
|
+
}
|
|
1
7
|
// Loopress must never manage itself: pulling it into loopress.json would make a later
|
|
2
8
|
// `plugin push` try to reinstall it from WordPress.org, where it doesn't exist, potentially
|
|
3
9
|
// clobbering the plugin's own directory in the process. Checked against every slug this
|
|
@@ -8,8 +14,8 @@ export function mergePluginManifest(existing, incoming) {
|
|
|
8
14
|
const merged = { ...existing, ...incoming };
|
|
9
15
|
const added = Object.keys(incoming).filter((s) => !Object.hasOwn(existing, s));
|
|
10
16
|
const updated = Object.keys(incoming)
|
|
11
|
-
.filter((s) => Object.hasOwn(existing, s) && existing[s] !== incoming[s])
|
|
12
|
-
.map((s) => ({ from: existing[s], slug: s, to: incoming[s] }));
|
|
17
|
+
.filter((s) => Object.hasOwn(existing, s) && describePin(existing[s]) !== describePin(incoming[s]))
|
|
18
|
+
.map((s) => ({ from: describePin(existing[s]), slug: s, to: describePin(incoming[s]) }));
|
|
13
19
|
return { added, merged, updated };
|
|
14
20
|
}
|
|
15
21
|
// WordPress core identifies each plugin by a `<folder>/<file>` id (or a bare `<file>` for a
|
|
@@ -47,9 +53,11 @@ managedSlugs = new Set()) {
|
|
|
47
53
|
const toInstall = [];
|
|
48
54
|
const toPin = [];
|
|
49
55
|
const toActivate = [];
|
|
56
|
+
const toDeactivate = [];
|
|
50
57
|
const collisions = [];
|
|
51
58
|
const inSync = [];
|
|
52
|
-
for (const [slug,
|
|
59
|
+
for (const [slug, pin] of Object.entries(manifest)) {
|
|
60
|
+
const wanted = pinVersion(pin);
|
|
53
61
|
const live = installedMap.get(slug);
|
|
54
62
|
if (!live) {
|
|
55
63
|
toInstall.push({ slug, version: wanted });
|
|
@@ -61,12 +69,13 @@ managedSlugs = new Set()) {
|
|
|
61
69
|
collisions.push({ installedVersion: live.version, slug });
|
|
62
70
|
continue;
|
|
63
71
|
}
|
|
64
|
-
if (wanted
|
|
72
|
+
if (isExactVersion(wanted) && live.version !== wanted) {
|
|
65
73
|
toPin.push({ from: live.version, slug, to: wanted });
|
|
66
74
|
continue;
|
|
67
75
|
}
|
|
68
|
-
if (
|
|
69
|
-
|
|
76
|
+
if (live.active !== pinActive(pin)) {
|
|
77
|
+
;
|
|
78
|
+
(live.active ? toDeactivate : toActivate).push({ file: live.file, slug });
|
|
70
79
|
continue;
|
|
71
80
|
}
|
|
72
81
|
inSync.push(slug);
|
|
@@ -75,7 +84,7 @@ managedSlugs = new Set()) {
|
|
|
75
84
|
const untrackedActive = installed
|
|
76
85
|
.filter((p) => p.active && !Object.hasOwn(manifest, p.slug) && !managedSlugs.has(p.slug))
|
|
77
86
|
.map((p) => p.slug);
|
|
78
|
-
return { collisions, inSync, toActivate, toInstall, toPin, toRemove, untrackedActive };
|
|
87
|
+
return { collisions, inSync, toActivate, toDeactivate, toInstall, toPin, toRemove, untrackedActive };
|
|
79
88
|
}
|
|
80
89
|
// wpackagist-plugin/<slug> or wpackagist-theme/<slug> entries from a composer.lock string.
|
|
81
90
|
export function lockedWpackagistSlugs(composerLock, kind) {
|
|
@@ -8,8 +8,10 @@ export declare const RESOURCE_DIR_DEFAULTS: {
|
|
|
8
8
|
readonly menu: "menus";
|
|
9
9
|
readonly options: "options";
|
|
10
10
|
readonly page: "pages";
|
|
11
|
+
readonly part: "parts";
|
|
11
12
|
readonly seo: "seo";
|
|
12
13
|
readonly snippets: "snippets";
|
|
14
|
+
readonly template: "templates";
|
|
13
15
|
readonly themeStyles: "theme";
|
|
14
16
|
};
|
|
15
17
|
export type ResourceDirKind = keyof typeof RESOURCE_DIR_DEFAULTS;
|
|
@@ -10,8 +10,10 @@ export const RESOURCE_DIR_DEFAULTS = {
|
|
|
10
10
|
menu: 'menus',
|
|
11
11
|
options: 'options',
|
|
12
12
|
page: 'pages',
|
|
13
|
+
part: 'parts',
|
|
13
14
|
seo: 'seo',
|
|
14
15
|
snippets: 'snippets',
|
|
16
|
+
template: 'templates',
|
|
15
17
|
themeStyles: 'theme',
|
|
16
18
|
};
|
|
17
19
|
const CONFIG_KEY = {
|
|
@@ -23,8 +25,10 @@ const CONFIG_KEY = {
|
|
|
23
25
|
menu: 'menuDir',
|
|
24
26
|
options: 'optionsDir',
|
|
25
27
|
page: 'pageDir',
|
|
28
|
+
part: 'partDir',
|
|
26
29
|
seo: 'seoDir',
|
|
27
30
|
snippets: 'snippetsDir',
|
|
31
|
+
template: 'templateDir',
|
|
28
32
|
themeStyles: 'themeStylesDir',
|
|
29
33
|
};
|
|
30
34
|
// Resolves where a resource's files live: an explicit override wins, otherwise the
|
|
@@ -17,13 +17,13 @@ export type RemoteSeoSettings = {
|
|
|
17
17
|
settings: Record<string, unknown>;
|
|
18
18
|
};
|
|
19
19
|
export type SeoRedirect = {
|
|
20
|
-
createdAt:
|
|
20
|
+
createdAt: null | string;
|
|
21
21
|
headerCode: number;
|
|
22
22
|
hits: number;
|
|
23
23
|
id: number;
|
|
24
24
|
sources: unknown;
|
|
25
25
|
status: string;
|
|
26
|
-
updatedAt:
|
|
26
|
+
updatedAt: null | string;
|
|
27
27
|
urlTo: string;
|
|
28
28
|
};
|
|
29
29
|
export declare function redirectFileBase(redirect: SeoRedirect): string;
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
import { type PageProblem } from './page-format.js';
|
|
2
|
+
export declare const CHILD_THEME_ENDPOINT = "loopress/v1/child-theme";
|
|
3
|
+
export type Template = {
|
|
4
|
+
html: string;
|
|
5
|
+
postTypes?: string[];
|
|
6
|
+
slug: string;
|
|
7
|
+
title?: string;
|
|
8
|
+
};
|
|
9
|
+
export type Part = {
|
|
10
|
+
area: string;
|
|
11
|
+
html: string;
|
|
12
|
+
slug: string;
|
|
13
|
+
title: string;
|
|
14
|
+
};
|
|
15
|
+
export type ChildTheme = {
|
|
16
|
+
active: boolean;
|
|
17
|
+
customized: string[];
|
|
18
|
+
exists: boolean;
|
|
19
|
+
parent: string;
|
|
20
|
+
parts: Part[];
|
|
21
|
+
stylesheet: string;
|
|
22
|
+
templates: Template[];
|
|
23
|
+
};
|
|
24
|
+
export declare function parseTemplateFile(slug: string, raw: string): Template;
|
|
25
|
+
export declare function parsePartFile(slug: string, raw: string): Part;
|
|
26
|
+
export declare function readLocalTemplates(templatesDir: string, partsDir: string): Promise<{
|
|
27
|
+
parts: Part[];
|
|
28
|
+
problems: PageProblem[];
|
|
29
|
+
templates: Template[];
|
|
30
|
+
}>;
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
import { parseHtmlHeader, readLocalHtmlFiles, titleFromSlug } from './page-format.js';
|
|
2
|
+
export const CHILD_THEME_ENDPOINT = 'loopress/v1/child-theme';
|
|
3
|
+
// A block delimiter as the first line (`<!-- wp:template-part ... /-->`, the usual one) is markup,
|
|
4
|
+
// not a header.
|
|
5
|
+
const BLOCK_START = /^\s*<!--\s*\/?wp:/;
|
|
6
|
+
// `{"slug":"header","theme":"twentytwentyfive"}` makes WordPress load the named theme's part, so
|
|
7
|
+
// the child's own parts/header.html would be silently ignored.
|
|
8
|
+
const THEMED_PART = /<!--\s*wp:template-part\s+\{[^}]*"theme"\s*:/;
|
|
9
|
+
function parseFile(raw, keys) {
|
|
10
|
+
const parsed = BLOCK_START.test(raw) ? { body: raw, meta: {} } : parseHtmlHeader(raw, keys);
|
|
11
|
+
if (THEMED_PART.test(parsed.body)) {
|
|
12
|
+
throw new Error('a template-part block has a "theme" attribute, remove it: WordPress would load that theme\'s part instead of the one in parts/');
|
|
13
|
+
}
|
|
14
|
+
return parsed;
|
|
15
|
+
}
|
|
16
|
+
export function parseTemplateFile(slug, raw) {
|
|
17
|
+
const { body, meta } = parseFile(raw, ['title', 'postTypes']);
|
|
18
|
+
const template = { html: body, slug };
|
|
19
|
+
if (meta.title)
|
|
20
|
+
template.title = meta.title;
|
|
21
|
+
if (meta.postTypes)
|
|
22
|
+
template.postTypes = meta.postTypes.split(',').map((type) => type.trim()).filter(Boolean);
|
|
23
|
+
return template;
|
|
24
|
+
}
|
|
25
|
+
// `area` defaults from the name, the way themes name their parts: header, header-large-title...
|
|
26
|
+
export function parsePartFile(slug, raw) {
|
|
27
|
+
const { body, meta } = parseFile(raw, ['title', 'area']);
|
|
28
|
+
const nameArea = /^(header|footer)(-|$)/.exec(slug)?.[1] ?? 'uncategorized';
|
|
29
|
+
return { area: meta.area || nameArea, html: body, slug, title: meta.title || titleFromSlug(slug) };
|
|
30
|
+
}
|
|
31
|
+
// Same directory rules as pages/ (flat, `.html` only, strict slugs), every problem at once.
|
|
32
|
+
export async function readLocalTemplates(templatesDir, partsDir) {
|
|
33
|
+
const templates = await readLocalHtmlFiles(templatesDir, parseTemplateFile);
|
|
34
|
+
const parts = await readLocalHtmlFiles(partsDir, parsePartFile);
|
|
35
|
+
return { parts: parts.files, problems: [...templates.problems, ...parts.problems], templates: templates.files };
|
|
36
|
+
}
|