@pterodoc/wordpress 0.2.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/LICENCE.md +10 -0
- package/README.md +10 -0
- package/lib/client.d.ts +76 -0
- package/lib/client.d.ts.map +1 -0
- package/lib/index.d.ts +49 -0
- package/lib/index.d.ts.map +1 -0
- package/lib/index.js +655 -0
- package/lib/index.js.map +1 -0
- package/lib/media.d.ts +26 -0
- package/lib/media.d.ts.map +1 -0
- package/lib/pages.d.ts +87 -0
- package/lib/pages.d.ts.map +1 -0
- package/lib/plugin.d.ts +35 -0
- package/lib/plugin.d.ts.map +1 -0
- package/lib/url.d.ts +45 -0
- package/lib/url.d.ts.map +1 -0
- package/package.json +24 -0
- package/src/client.ts +215 -0
- package/src/index.ts +275 -0
- package/src/media.ts +106 -0
- package/src/pages.ts +201 -0
- package/src/plugin.ts +69 -0
- package/src/url.ts +69 -0
package/src/index.ts
ADDED
|
@@ -0,0 +1,275 @@
|
|
|
1
|
+
/** The WordPress target. */
|
|
2
|
+
|
|
3
|
+
import { renderNavigationStub } from '@pterodoc/core/render';
|
|
4
|
+
import type {
|
|
5
|
+
EnsureRequest,
|
|
6
|
+
EnsureResult,
|
|
7
|
+
MediaRef,
|
|
8
|
+
MediaUpload,
|
|
9
|
+
RemotePage,
|
|
10
|
+
RenderedPage,
|
|
11
|
+
Target,
|
|
12
|
+
TargetCapabilities,
|
|
13
|
+
TargetSession,
|
|
14
|
+
} from '@pterodoc/core/target';
|
|
15
|
+
import { TargetError, titleCase } from '@pterodoc/core/util';
|
|
16
|
+
import { DEFAULT_RETRY, WpClient, type RetryPolicy } from './client';
|
|
17
|
+
import { loadMediaIndex, uploadMedia } from './media';
|
|
18
|
+
import {
|
|
19
|
+
computePrune,
|
|
20
|
+
createPage,
|
|
21
|
+
diffPage,
|
|
22
|
+
fetchPage,
|
|
23
|
+
fetchPageIndex,
|
|
24
|
+
findPage,
|
|
25
|
+
trashPage,
|
|
26
|
+
updatePage,
|
|
27
|
+
type PageInput,
|
|
28
|
+
} from './pages';
|
|
29
|
+
import { hrefFor, splitOwnership, type WordpressUrlPolicy } from './url';
|
|
30
|
+
|
|
31
|
+
/** What WordPress can do. */
|
|
32
|
+
export const WORDPRESS_CAPABILITIES: TargetCapabilities = {
|
|
33
|
+
// The navigation block lists children of a page id, so ids must exist first.
|
|
34
|
+
needsIdsBeforeRender: true,
|
|
35
|
+
supportsMedia: true,
|
|
36
|
+
supportsPrune: true,
|
|
37
|
+
supportsHierarchy: true,
|
|
38
|
+
supportsExcerpt: true,
|
|
39
|
+
supportsMeta: true,
|
|
40
|
+
supportsTemplates: true,
|
|
41
|
+
supportsDrafts: true,
|
|
42
|
+
};
|
|
43
|
+
|
|
44
|
+
/** Content a page holds between being created and being rendered. */
|
|
45
|
+
const PLACEHOLDER = '<!-- wp:paragraph -->\n<p>Publishing…</p>\n<!-- /wp:paragraph -->';
|
|
46
|
+
|
|
47
|
+
/** How to reach and shape a WordPress site. */
|
|
48
|
+
export interface WordpressTargetOptions {
|
|
49
|
+
/** Site origin. */
|
|
50
|
+
url: string;
|
|
51
|
+
/** Username of the Application Password. */
|
|
52
|
+
user: string;
|
|
53
|
+
/** The Application Password. */
|
|
54
|
+
appPassword: string;
|
|
55
|
+
/** Where the tree hangs and how versions and locales are addressed. */
|
|
56
|
+
policy: WordpressUrlPolicy;
|
|
57
|
+
/** Status applied to every synced page. */
|
|
58
|
+
status: 'publish' | 'draft' | 'private';
|
|
59
|
+
/** Page template slug, or empty for the theme default. */
|
|
60
|
+
template: string;
|
|
61
|
+
/** Polylang language code. */
|
|
62
|
+
lang: string;
|
|
63
|
+
/** Prefix of the slug that identifies uploaded media. */
|
|
64
|
+
mediaSlugPrefix: string;
|
|
65
|
+
/** Send DELETE as POST with an override header. */
|
|
66
|
+
methodOverride: boolean;
|
|
67
|
+
/** Retry policy. */
|
|
68
|
+
retry?: RetryPolicy;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/** Injected so the target can be exercised without a network. */
|
|
72
|
+
export interface WordpressTargetDeps {
|
|
73
|
+
fetch?: typeof globalThis.fetch;
|
|
74
|
+
sleep?: (ms: number) => Promise<void>;
|
|
75
|
+
log?: (message: string) => void;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/** Create the WordPress target. */
|
|
79
|
+
export function createWordpressTarget(
|
|
80
|
+
options: WordpressTargetOptions,
|
|
81
|
+
deps: WordpressTargetDeps = {},
|
|
82
|
+
): Target {
|
|
83
|
+
const log = deps.log ?? ((): void => {});
|
|
84
|
+
const { stubSegments, rootSlug } = splitOwnership(options.policy);
|
|
85
|
+
|
|
86
|
+
// The documentation root has no path of its own in the tree, so its slug is
|
|
87
|
+
// the last segment of the configured path rather than anything the model
|
|
88
|
+
// supplied.
|
|
89
|
+
const slugFor = (page: { path: string; slug: string }): string =>
|
|
90
|
+
page.path === '' ? rootSlug : page.slug;
|
|
91
|
+
|
|
92
|
+
const makeClient = (locale: string): WpClient =>
|
|
93
|
+
new WpClient({
|
|
94
|
+
baseUrl: options.url,
|
|
95
|
+
user: options.user,
|
|
96
|
+
appPassword: options.appPassword,
|
|
97
|
+
// A site with one language per subtree wants each subtree tagged.
|
|
98
|
+
lang: options.lang || (options.policy.primaryLocale && locale !== options.policy.primaryLocale ? locale : ''),
|
|
99
|
+
methodOverride: options.methodOverride,
|
|
100
|
+
retry: options.retry ?? DEFAULT_RETRY,
|
|
101
|
+
...(deps.fetch ? { fetch: deps.fetch } : {}),
|
|
102
|
+
...(deps.sleep ? { sleep: deps.sleep } : {}),
|
|
103
|
+
log,
|
|
104
|
+
});
|
|
105
|
+
|
|
106
|
+
return {
|
|
107
|
+
name: 'wordpress',
|
|
108
|
+
capabilities: WORDPRESS_CAPABILITIES,
|
|
109
|
+
rootPath: hrefFor(options.policy, '', { versionName: '', locale: options.policy.primaryLocale ?? '' }),
|
|
110
|
+
|
|
111
|
+
hrefFor(treePath, context) {
|
|
112
|
+
return hrefFor(options.policy, treePath, context);
|
|
113
|
+
},
|
|
114
|
+
|
|
115
|
+
async open(context): Promise<TargetSession> {
|
|
116
|
+
const client = makeClient(context.locale);
|
|
117
|
+
const dryRun = context.dryRun;
|
|
118
|
+
let index: RemotePage[] | undefined;
|
|
119
|
+
|
|
120
|
+
return {
|
|
121
|
+
async loadIndex(): Promise<RemotePage[]> {
|
|
122
|
+
index ??= await fetchPageIndex(client);
|
|
123
|
+
return index;
|
|
124
|
+
},
|
|
125
|
+
|
|
126
|
+
async ensureRootParent(): Promise<{
|
|
127
|
+
id: number | null;
|
|
128
|
+
created: { path: string; id: number | null }[];
|
|
129
|
+
}> {
|
|
130
|
+
const created: { path: string; id: number | null }[] = [];
|
|
131
|
+
let parentId: number | null = 0;
|
|
132
|
+
|
|
133
|
+
for (const slug of stubSegments) {
|
|
134
|
+
if (parentId === null) {
|
|
135
|
+
created.push({ path: `/${slug}/`, id: null });
|
|
136
|
+
continue;
|
|
137
|
+
}
|
|
138
|
+
const existing = await findPage(client, parentId, slug, index, log);
|
|
139
|
+
if (existing) {
|
|
140
|
+
if (existing.status === 'trash') {
|
|
141
|
+
throw new TargetError(
|
|
142
|
+
`The page "/${slug}/" is in the trash. Restore it, or delete it permanently, and run again.`,
|
|
143
|
+
{ status: 409, method: 'GET', url: `/pages?slug=${slug}` },
|
|
144
|
+
);
|
|
145
|
+
}
|
|
146
|
+
parentId = existing.id;
|
|
147
|
+
continue;
|
|
148
|
+
}
|
|
149
|
+
if (dryRun) {
|
|
150
|
+
created.push({ path: `/${slug}/`, id: null });
|
|
151
|
+
parentId = null;
|
|
152
|
+
continue;
|
|
153
|
+
}
|
|
154
|
+
// A page created only so the documentation has a parent: it lists
|
|
155
|
+
// what is below it and claims nothing else.
|
|
156
|
+
const page = await createPage(client, {
|
|
157
|
+
title: titleCase(slug),
|
|
158
|
+
slug,
|
|
159
|
+
parent: parentId,
|
|
160
|
+
status: 'publish',
|
|
161
|
+
content: PLACEHOLDER,
|
|
162
|
+
});
|
|
163
|
+
await updatePage(client, page.id, { content: renderNavigationStub(page.id) });
|
|
164
|
+
created.push({ path: `/${slug}/`, id: page.id });
|
|
165
|
+
parentId = page.id;
|
|
166
|
+
}
|
|
167
|
+
return { id: parentId, created };
|
|
168
|
+
},
|
|
169
|
+
|
|
170
|
+
async ensurePage(request: EnsureRequest): Promise<EnsureResult> {
|
|
171
|
+
const warnings: string[] = [];
|
|
172
|
+
if (request.parentId === null) return { id: null, created: true, warnings };
|
|
173
|
+
|
|
174
|
+
const slug = request.isRoot ? rootSlug : request.slug;
|
|
175
|
+
const existing = await findPage(client, request.parentId, slug, index, log);
|
|
176
|
+
if (existing) {
|
|
177
|
+
if (existing.status === 'trash') {
|
|
178
|
+
warnings.push(
|
|
179
|
+
`${request.path || '(root)'} matches a page in the trash. It will be republished; restore or delete it permanently if that is not what you want.`,
|
|
180
|
+
);
|
|
181
|
+
}
|
|
182
|
+
return { id: existing.id, created: false, warnings };
|
|
183
|
+
}
|
|
184
|
+
if (dryRun) return { id: null, created: true, warnings };
|
|
185
|
+
|
|
186
|
+
// Created as a draft: a placeholder must never appear in navigation.
|
|
187
|
+
const created = await createPage(client, {
|
|
188
|
+
title: request.title,
|
|
189
|
+
slug,
|
|
190
|
+
parent: request.parentId,
|
|
191
|
+
status: 'draft',
|
|
192
|
+
menu_order: request.menuOrder,
|
|
193
|
+
content: PLACEHOLDER,
|
|
194
|
+
});
|
|
195
|
+
return { id: created.id, created: true, warnings };
|
|
196
|
+
},
|
|
197
|
+
|
|
198
|
+
async fetchPage(id: number): Promise<RemotePage> {
|
|
199
|
+
return fetchPage(client, id);
|
|
200
|
+
},
|
|
201
|
+
|
|
202
|
+
diffPage(remote: RemotePage, rendered: RenderedPage, parentId: number): string[] {
|
|
203
|
+
return diffPage(remote, rendered, {
|
|
204
|
+
parentId,
|
|
205
|
+
status: options.status,
|
|
206
|
+
template: options.template,
|
|
207
|
+
isRoot: rendered.path === '',
|
|
208
|
+
slug: slugFor(rendered),
|
|
209
|
+
});
|
|
210
|
+
},
|
|
211
|
+
|
|
212
|
+
async writePage(id, page, parentId): Promise<{ warnings: string[] }> {
|
|
213
|
+
const warnings: string[] = [];
|
|
214
|
+
const body: PageInput = {
|
|
215
|
+
title: page.title,
|
|
216
|
+
content: page.content,
|
|
217
|
+
excerpt: page.excerpt,
|
|
218
|
+
parent: parentId,
|
|
219
|
+
slug: slugFor(page),
|
|
220
|
+
status: options.status,
|
|
221
|
+
menu_order: page.menuOrder,
|
|
222
|
+
template: options.template,
|
|
223
|
+
};
|
|
224
|
+
if (Object.keys(page.meta).length > 0) body.meta = page.meta;
|
|
225
|
+
|
|
226
|
+
try {
|
|
227
|
+
await updatePage(client, id, body);
|
|
228
|
+
} catch (error) {
|
|
229
|
+
// A locked-down site may reject the metadata or the template. The
|
|
230
|
+
// page itself matters more than either, so try again without them.
|
|
231
|
+
const status = (error as { status?: number }).status;
|
|
232
|
+
if (status === 400 && (body.meta || body.template)) {
|
|
233
|
+
delete body.meta;
|
|
234
|
+
delete body.template;
|
|
235
|
+
warnings.push(
|
|
236
|
+
`${page.path || '(root)'}: WordPress refused the template or the metadata, so the page was published without them.`,
|
|
237
|
+
);
|
|
238
|
+
await updatePage(client, id, body);
|
|
239
|
+
} else throw error;
|
|
240
|
+
}
|
|
241
|
+
return { warnings };
|
|
242
|
+
},
|
|
243
|
+
|
|
244
|
+
computePrune(pages, rootId, keepIds): RemotePage[] {
|
|
245
|
+
return computePrune(pages, rootId, keepIds);
|
|
246
|
+
},
|
|
247
|
+
|
|
248
|
+
async removePage(page: RemotePage): Promise<void> {
|
|
249
|
+
await trashPage(client, page.id);
|
|
250
|
+
},
|
|
251
|
+
|
|
252
|
+
async loadMediaIndex(): Promise<Map<string, MediaRef>> {
|
|
253
|
+
return loadMediaIndex(client, options.mediaSlugPrefix);
|
|
254
|
+
},
|
|
255
|
+
|
|
256
|
+
async uploadMedia(upload: MediaUpload): Promise<MediaRef> {
|
|
257
|
+
return uploadMedia(client, upload, options.mediaSlugPrefix);
|
|
258
|
+
},
|
|
259
|
+
|
|
260
|
+
requestCount(): number {
|
|
261
|
+
return client.requestCount;
|
|
262
|
+
},
|
|
263
|
+
};
|
|
264
|
+
},
|
|
265
|
+
};
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
export { splitOwnership, hrefFor, prefixSegments } from './url';
|
|
269
|
+
export type { WordpressUrlPolicy } from './url';
|
|
270
|
+
export { WpClient, DEFAULT_RETRY } from './client';
|
|
271
|
+
export { detectPlugin } from './plugin';
|
|
272
|
+
export type { PluginStatus } from './plugin';
|
|
273
|
+
export type { RetryPolicy, WpClientOptions } from './client';
|
|
274
|
+
export { renderNavigationStub };
|
|
275
|
+
export { PLACEHOLDER };
|
package/src/media.ts
ADDED
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The WordPress media library.
|
|
3
|
+
*
|
|
4
|
+
* A file's identity is the hash of its contents, carried in the media slug.
|
|
5
|
+
* Keeping the identity on the server rather than in a local cache is what lets
|
|
6
|
+
* a fresh checkout, or a CI runner that has never seen the site, avoid
|
|
7
|
+
* uploading everything again.
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
import { TargetError, isBlockedByDefault } from '@pterodoc/core/util';
|
|
11
|
+
import type { MediaRef, MediaUpload } from '@pterodoc/core/target';
|
|
12
|
+
import type { WpClient } from './client';
|
|
13
|
+
|
|
14
|
+
/** WordPress's media shape, narrowed to what is read. */
|
|
15
|
+
interface WpMedia {
|
|
16
|
+
id: number;
|
|
17
|
+
slug: string;
|
|
18
|
+
source_url: string;
|
|
19
|
+
mime_type: string;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
/** The slug that identifies a file uploaded by pterodoc. */
|
|
23
|
+
export function mediaSlug(prefix: string, hash: string): string {
|
|
24
|
+
return `${prefix}-${hash}`;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/** Read the hash back out of a slug, when the slug is one of ours. */
|
|
28
|
+
export function hashFromSlug(prefix: string, slug: string): string | undefined {
|
|
29
|
+
const marker = `${prefix}-`;
|
|
30
|
+
if (!slug.startsWith(marker)) return undefined;
|
|
31
|
+
const hash = slug.slice(marker.length);
|
|
32
|
+
return /^[0-9a-f]{16}$/.test(hash) ? hash : undefined;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* Everything pterodoc has already uploaded to this site, by content hash.
|
|
37
|
+
*/
|
|
38
|
+
export async function loadMediaIndex(
|
|
39
|
+
client: WpClient,
|
|
40
|
+
prefix: string,
|
|
41
|
+
): Promise<Map<string, MediaRef>> {
|
|
42
|
+
const found = await client.listAll<WpMedia>('/media', {
|
|
43
|
+
search: `${prefix}-`,
|
|
44
|
+
_fields: 'id,slug,source_url,mime_type',
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
const byHash = new Map<string, MediaRef>();
|
|
48
|
+
for (const item of found) {
|
|
49
|
+
const hash = hashFromSlug(prefix, item.slug);
|
|
50
|
+
if (!hash || byHash.has(hash)) continue;
|
|
51
|
+
byHash.set(hash, {
|
|
52
|
+
id: item.id,
|
|
53
|
+
hash,
|
|
54
|
+
url: item.source_url,
|
|
55
|
+
filename: item.slug,
|
|
56
|
+
mime: item.mime_type,
|
|
57
|
+
});
|
|
58
|
+
}
|
|
59
|
+
return byHash;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/**
|
|
63
|
+
* Upload one file.
|
|
64
|
+
*
|
|
65
|
+
* WordPress derives a slug from the filename on create, so the identifying
|
|
66
|
+
* slug has to be set in a second request.
|
|
67
|
+
*/
|
|
68
|
+
export async function uploadMedia(
|
|
69
|
+
client: WpClient,
|
|
70
|
+
upload: MediaUpload,
|
|
71
|
+
prefix: string,
|
|
72
|
+
): Promise<MediaRef> {
|
|
73
|
+
const slug = mediaSlug(prefix, upload.hash);
|
|
74
|
+
const form = new FormData();
|
|
75
|
+
const bytes = upload.bytes;
|
|
76
|
+
const view = new Uint8Array(bytes.byteLength);
|
|
77
|
+
view.set(bytes);
|
|
78
|
+
form.append('file', new File([view], upload.filename, { type: upload.mime }));
|
|
79
|
+
if (upload.title) form.append('title', upload.title);
|
|
80
|
+
if (upload.alt) form.append('alt_text', upload.alt);
|
|
81
|
+
|
|
82
|
+
let created: WpMedia;
|
|
83
|
+
try {
|
|
84
|
+
({ data: created } = await client.request<WpMedia>('POST', '/media', { form }));
|
|
85
|
+
} catch (error) {
|
|
86
|
+
if (error instanceof TargetError && error.status === 400 && isBlockedByDefault(upload.mime)) {
|
|
87
|
+
throw new TargetError(
|
|
88
|
+
`${upload.filename} was refused. WordPress blocks ${upload.mime} uploads unless a plugin allows them, because such a file can carry script.`,
|
|
89
|
+
{ status: error.status, code: error.code, method: error.method, url: error.url },
|
|
90
|
+
);
|
|
91
|
+
}
|
|
92
|
+
throw error;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
const { data: updated } = await client.request<WpMedia>('POST', `/media/${created.id}`, {
|
|
96
|
+
body: { slug, ...(upload.title ? { title: upload.title } : {}), ...(upload.alt ? { alt_text: upload.alt } : {}) },
|
|
97
|
+
});
|
|
98
|
+
|
|
99
|
+
return {
|
|
100
|
+
id: updated.id,
|
|
101
|
+
hash: upload.hash,
|
|
102
|
+
url: updated.source_url || created.source_url,
|
|
103
|
+
filename: upload.filename,
|
|
104
|
+
mime: updated.mime_type || upload.mime,
|
|
105
|
+
};
|
|
106
|
+
}
|
package/src/pages.ts
ADDED
|
@@ -0,0 +1,201 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* WordPress pages: finding them, comparing them, writing them, removing them.
|
|
3
|
+
*
|
|
4
|
+
* A page's identity is its parent and its slug, which is what makes a re-run
|
|
5
|
+
* rewrite only what actually differs.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import { TargetError } from '@pterodoc/core/util';
|
|
9
|
+
import type { RemotePage, RenderedPage } from '@pterodoc/core/target';
|
|
10
|
+
import { FULL_PAGE_FIELDS, PAGE_FIELDS, type WpClient } from './client';
|
|
11
|
+
|
|
12
|
+
/** WordPress's own page shape, narrowed to what is read. */
|
|
13
|
+
interface WpPage {
|
|
14
|
+
id: number;
|
|
15
|
+
parent: number;
|
|
16
|
+
slug: string;
|
|
17
|
+
status: string;
|
|
18
|
+
link: string;
|
|
19
|
+
title?: { raw?: string; rendered?: string };
|
|
20
|
+
content?: { raw?: string };
|
|
21
|
+
excerpt?: { raw?: string };
|
|
22
|
+
menu_order: number;
|
|
23
|
+
template: string;
|
|
24
|
+
meta?: Record<string, unknown>;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/** Convert a WordPress page into the shape the reconciler compares. */
|
|
28
|
+
export function toRemotePage(page: WpPage): RemotePage {
|
|
29
|
+
return {
|
|
30
|
+
id: page.id,
|
|
31
|
+
parent: page.parent,
|
|
32
|
+
slug: page.slug,
|
|
33
|
+
status: page.status,
|
|
34
|
+
link: page.link,
|
|
35
|
+
title: page.title?.raw ?? page.title?.rendered ?? '',
|
|
36
|
+
...(page.content?.raw !== undefined ? { content: page.content.raw } : {}),
|
|
37
|
+
...(page.excerpt?.raw !== undefined ? { excerpt: page.excerpt.raw } : {}),
|
|
38
|
+
menuOrder: page.menu_order,
|
|
39
|
+
template: page.template,
|
|
40
|
+
meta: page.meta,
|
|
41
|
+
};
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/** Fetch every page on the site. */
|
|
45
|
+
export async function fetchPageIndex(client: WpClient): Promise<RemotePage[]> {
|
|
46
|
+
const pages = await client.listAll<WpPage>('/pages', {
|
|
47
|
+
status: 'any',
|
|
48
|
+
context: 'edit',
|
|
49
|
+
_fields: PAGE_FIELDS,
|
|
50
|
+
});
|
|
51
|
+
return pages.map(toRemotePage);
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/** Fetch one page with the fields needed to compare it. */
|
|
55
|
+
export async function fetchPage(client: WpClient, id: number): Promise<RemotePage> {
|
|
56
|
+
const { data } = await client.request<WpPage>('GET', `/pages/${id}`, {
|
|
57
|
+
query: { context: 'edit', _fields: FULL_PAGE_FIELDS },
|
|
58
|
+
});
|
|
59
|
+
return toRemotePage(data);
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/**
|
|
63
|
+
* Find a page by its position in the tree.
|
|
64
|
+
*
|
|
65
|
+
* The index is searched when one was supplied, because a whole-site index is
|
|
66
|
+
* one request where per-page lookups are hundreds.
|
|
67
|
+
*/
|
|
68
|
+
export async function findPage(
|
|
69
|
+
client: WpClient,
|
|
70
|
+
parent: number,
|
|
71
|
+
slug: string,
|
|
72
|
+
index?: RemotePage[],
|
|
73
|
+
log: (message: string) => void = () => {},
|
|
74
|
+
): Promise<RemotePage | undefined> {
|
|
75
|
+
let candidates: RemotePage[];
|
|
76
|
+
if (index) {
|
|
77
|
+
candidates = index.filter((page) => page.parent === parent && page.slug === slug);
|
|
78
|
+
} else {
|
|
79
|
+
const { data } = await client.request<WpPage[]>('GET', '/pages', {
|
|
80
|
+
query: { parent, slug, status: 'any', context: 'edit', per_page: 100, _fields: PAGE_FIELDS },
|
|
81
|
+
});
|
|
82
|
+
candidates = (Array.isArray(data) ? data : []).map(toRemotePage);
|
|
83
|
+
}
|
|
84
|
+
if (candidates.length > 1) {
|
|
85
|
+
log(`${candidates.length} pages share parent ${parent} and slug "${slug}"; using id ${candidates[0]!.id}.`);
|
|
86
|
+
}
|
|
87
|
+
return candidates[0];
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
/** Body sent when creating or updating a page. */
|
|
91
|
+
export interface PageInput {
|
|
92
|
+
title?: string;
|
|
93
|
+
content?: string;
|
|
94
|
+
excerpt?: string;
|
|
95
|
+
parent?: number;
|
|
96
|
+
slug?: string;
|
|
97
|
+
status?: string;
|
|
98
|
+
menu_order?: number;
|
|
99
|
+
template?: string;
|
|
100
|
+
meta?: Record<string, unknown>;
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
/** Create a page, checking that WordPress honoured the slug we asked for. */
|
|
104
|
+
export async function createPage(client: WpClient, input: PageInput): Promise<RemotePage> {
|
|
105
|
+
const { data } = await client.request<WpPage>('POST', '/pages', { body: input });
|
|
106
|
+
if (input.slug && data.slug !== input.slug) {
|
|
107
|
+
throw new TargetError(
|
|
108
|
+
`WordPress stored the new page as "${data.slug}" rather than "${input.slug}". Another page, possibly one in the trash, already holds that slug. The page it created is id ${data.id}.`,
|
|
109
|
+
{ status: 200, method: 'POST', url: '/pages' },
|
|
110
|
+
);
|
|
111
|
+
}
|
|
112
|
+
return toRemotePage(data);
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
/** Update a page. */
|
|
116
|
+
export async function updatePage(
|
|
117
|
+
client: WpClient,
|
|
118
|
+
id: number,
|
|
119
|
+
input: PageInput,
|
|
120
|
+
): Promise<RemotePage> {
|
|
121
|
+
const { data } = await client.request<WpPage>('POST', `/pages/${id}`, { body: input });
|
|
122
|
+
return toRemotePage(data);
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
/**
|
|
126
|
+
* Move a page to the trash.
|
|
127
|
+
*
|
|
128
|
+
* Never a permanent delete: recovering from a mistaken prune should not
|
|
129
|
+
* require a database backup.
|
|
130
|
+
*/
|
|
131
|
+
export async function trashPage(client: WpClient, id: number): Promise<void> {
|
|
132
|
+
await client.request('DELETE', `/pages/${id}`);
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
/** Normalise a value for comparison, so whitespace alone is not a difference. */
|
|
136
|
+
const normalise = (value: unknown): string =>
|
|
137
|
+
String(value ?? '').replace(/\r\n/g, '\n').trim();
|
|
138
|
+
|
|
139
|
+
/**
|
|
140
|
+
* Which fields of an existing page differ from the rendered one.
|
|
141
|
+
*
|
|
142
|
+
* @param remote The page as WordPress holds it.
|
|
143
|
+
* @param rendered The page as pterodoc would publish it.
|
|
144
|
+
* @param context The expected parent, slug, status and template.
|
|
145
|
+
*/
|
|
146
|
+
export function diffPage(
|
|
147
|
+
remote: RemotePage,
|
|
148
|
+
rendered: RenderedPage,
|
|
149
|
+
context: { parentId: number; status: string; template: string; isRoot: boolean; slug: string },
|
|
150
|
+
): string[] {
|
|
151
|
+
const changed: string[] = [];
|
|
152
|
+
if (normalise(remote.title) !== normalise(rendered.title)) changed.push('title');
|
|
153
|
+
if (normalise(remote.content) !== normalise(rendered.content)) changed.push('content');
|
|
154
|
+
if (normalise(remote.excerpt) !== normalise(rendered.excerpt)) changed.push('excerpt');
|
|
155
|
+
if (remote.status !== context.status) changed.push('status');
|
|
156
|
+
if (!context.isRoot && remote.menuOrder !== rendered.menuOrder) changed.push('menu_order');
|
|
157
|
+
if ((remote.template ?? '') !== context.template) changed.push('template');
|
|
158
|
+
if (remote.parent !== context.parentId) changed.push('parent');
|
|
159
|
+
if (remote.slug !== context.slug) changed.push('slug');
|
|
160
|
+
|
|
161
|
+
// Metadata is only compared where the site actually exposes the field, so a
|
|
162
|
+
// site without the SEO plugin does not report a difference on every run.
|
|
163
|
+
for (const [key, value] of Object.entries(rendered.meta)) {
|
|
164
|
+
if (remote.meta && key in remote.meta && normalise(remote.meta[key]) !== normalise(value)) {
|
|
165
|
+
changed.push(`meta.${key}`);
|
|
166
|
+
}
|
|
167
|
+
}
|
|
168
|
+
return changed;
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
/**
|
|
172
|
+
* Pages below a root that no rendered page accounts for.
|
|
173
|
+
*
|
|
174
|
+
* Deepest first, so a parent is never trashed before its children.
|
|
175
|
+
*/
|
|
176
|
+
export function computePrune(
|
|
177
|
+
index: RemotePage[],
|
|
178
|
+
rootId: number,
|
|
179
|
+
keepIds: Set<number>,
|
|
180
|
+
): RemotePage[] {
|
|
181
|
+
const childrenOf = new Map<number, RemotePage[]>();
|
|
182
|
+
for (const page of index) {
|
|
183
|
+
const siblings = childrenOf.get(page.parent);
|
|
184
|
+
if (siblings) siblings.push(page);
|
|
185
|
+
else childrenOf.set(page.parent, [page]);
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
const owned: { page: RemotePage; depth: number }[] = [];
|
|
189
|
+
const walk = (parentId: number, depth: number): void => {
|
|
190
|
+
for (const page of childrenOf.get(parentId) ?? []) {
|
|
191
|
+
owned.push({ page, depth });
|
|
192
|
+
walk(page.id, depth + 1);
|
|
193
|
+
}
|
|
194
|
+
};
|
|
195
|
+
walk(rootId, 0);
|
|
196
|
+
|
|
197
|
+
return owned
|
|
198
|
+
.filter(({ page }) => !keepIds.has(page.id))
|
|
199
|
+
.sort((a, b) => b.depth - a.depth)
|
|
200
|
+
.map(({ page }) => page);
|
|
201
|
+
}
|
package/src/plugin.ts
ADDED
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Finding out whether the pterodoc WordPress plugin is installed.
|
|
3
|
+
*
|
|
4
|
+
* The plugin registers one option and exposes it over REST, so asking for the
|
|
5
|
+
* site's settings answers both questions at once: whether it is there, and what
|
|
6
|
+
* it is configured with. That second half matters, because the plugin styles a
|
|
7
|
+
* site by class prefix and a prefix that disagrees with the one pterodoc writes
|
|
8
|
+
* is a setup that looks broken for no visible reason.
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
import { TargetError } from '@pterodoc/core/util';
|
|
12
|
+
import type { WpClient } from './client';
|
|
13
|
+
|
|
14
|
+
/** What the site said about the plugin. */
|
|
15
|
+
export interface PluginStatus {
|
|
16
|
+
/** Whether the plugin answered. */
|
|
17
|
+
installed: boolean;
|
|
18
|
+
/**
|
|
19
|
+
* Why the answer is not known.
|
|
20
|
+
*
|
|
21
|
+
* Set when the site could not be asked — usually because the credentials
|
|
22
|
+
* belong to someone without `manage_options` — rather than when the plugin is
|
|
23
|
+
* known to be absent.
|
|
24
|
+
*/
|
|
25
|
+
unknown?: string;
|
|
26
|
+
/** The class prefix the plugin is styling, when it is installed. */
|
|
27
|
+
classPrefix?: string;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/** The shape of the site settings this reads. */
|
|
31
|
+
interface SiteSettings {
|
|
32
|
+
pterodoc_settings?: { classPrefix?: string };
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* Ask a site whether the plugin is installed.
|
|
37
|
+
*
|
|
38
|
+
* Never throws: an unreachable or unauthorised site is a thing to report in a
|
|
39
|
+
* diagnostic, not a reason to fail one.
|
|
40
|
+
*
|
|
41
|
+
* @param client A client for the site.
|
|
42
|
+
*/
|
|
43
|
+
export async function detectPlugin(client: WpClient): Promise<PluginStatus> {
|
|
44
|
+
try {
|
|
45
|
+
const { data } = await client.request<SiteSettings>('GET', '/settings');
|
|
46
|
+
const settings = data.pterodoc_settings;
|
|
47
|
+
|
|
48
|
+
if (!settings) return { installed: false };
|
|
49
|
+
|
|
50
|
+
return {
|
|
51
|
+
installed: true,
|
|
52
|
+
...(settings.classPrefix ? { classPrefix: settings.classPrefix } : {}),
|
|
53
|
+
};
|
|
54
|
+
} catch (error) {
|
|
55
|
+
const status = error instanceof TargetError ? error.status : undefined;
|
|
56
|
+
|
|
57
|
+
if (status === 401 || status === 403) {
|
|
58
|
+
return {
|
|
59
|
+
installed: false,
|
|
60
|
+
unknown: 'these credentials may not read the site settings, so the plugin could not be checked',
|
|
61
|
+
};
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
return {
|
|
65
|
+
installed: false,
|
|
66
|
+
unknown: error instanceof Error ? error.message : 'the site could not be asked',
|
|
67
|
+
};
|
|
68
|
+
}
|
|
69
|
+
}
|