@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/lib/index.js ADDED
@@ -0,0 +1,655 @@
1
+ import { renderNavigationStub } from '@pterodoc/core/render';
2
+ export { renderNavigationStub } from '@pterodoc/core/render';
3
+ import { USER_AGENT, TargetError, isBlockedByDefault, joinPath, segments, slugify, titleCase } from '@pterodoc/core/util';
4
+
5
+ /**
6
+ * A small WordPress REST client, scoped to what publishing needs.
7
+ *
8
+ * Authentication is an Application Password over HTTP Basic. `fetch` and the
9
+ * clock are injectable so the whole sync can be tested without a network.
10
+ */
11
+ /** Fields needed to compare a remote page with a rendered one. */
12
+ const PAGE_FIELDS = 'id,parent,slug,status,link,title,menu_order,template';
13
+ /** Those, plus the content only fetched when a page is about to be compared. */
14
+ const FULL_PAGE_FIELDS = `${PAGE_FIELDS},content,excerpt,meta`;
15
+ /** The retry policy used when a site configures none. */
16
+ const DEFAULT_RETRY = { attempts: 4, baseDelayMs: 1000, maxDelayMs: 30_000 };
17
+ class WpClient {
18
+ baseUrl;
19
+ user;
20
+ appPassword;
21
+ lang;
22
+ methodOverride;
23
+ retry;
24
+ doFetch;
25
+ sleep;
26
+ log;
27
+ /** How many requests have been made, for the run summary. */
28
+ requestCount = 0;
29
+ constructor(options) {
30
+ this.baseUrl = options.baseUrl.replace(/\/+$/, '');
31
+ this.user = options.user ?? '';
32
+ this.appPassword = (options.appPassword ?? '').replace(/\s+/g, '');
33
+ this.lang = options.lang ?? '';
34
+ this.methodOverride = options.methodOverride === true;
35
+ this.retry = options.retry ?? DEFAULT_RETRY;
36
+ this.doFetch = options.fetch ?? globalThis.fetch;
37
+ this.sleep = options.sleep ?? ((ms) => new Promise((resolve) => setTimeout(resolve, ms)));
38
+ this.log = options.log ?? (() => { });
39
+ }
40
+ /** Headers every request carries. */
41
+ headers() {
42
+ const token = Buffer.from(`${this.user}:${this.appPassword}`, 'utf8').toString('base64');
43
+ return {
44
+ Authorization: `Basic ${token}`,
45
+ Accept: 'application/json',
46
+ 'User-Agent': USER_AGENT,
47
+ };
48
+ }
49
+ /**
50
+ * Perform one REST call, retrying only what is worth retrying.
51
+ */
52
+ async request(method, path, options = {}) {
53
+ const url = new URL(`${this.baseUrl}/wp-json/wp/v2${path}`);
54
+ for (const [key, value] of Object.entries(options.query ?? {})) {
55
+ if (value !== undefined && value !== '')
56
+ url.searchParams.set(key, String(value));
57
+ }
58
+ if (this.lang)
59
+ url.searchParams.set('lang', this.lang);
60
+ const headers = this.headers();
61
+ const init = { method, headers, redirect: 'error' };
62
+ if (options.form) {
63
+ init.body = options.form;
64
+ }
65
+ else if (options.body !== undefined) {
66
+ headers['Content-Type'] = 'application/json';
67
+ init.body = JSON.stringify(options.body);
68
+ }
69
+ if (this.methodOverride && method === 'DELETE') {
70
+ init.method = 'POST';
71
+ headers['X-HTTP-Method-Override'] = 'DELETE';
72
+ }
73
+ let lastError;
74
+ let retryAfterMs;
75
+ for (let attempt = 0; attempt <= this.retry.attempts; attempt += 1) {
76
+ if (attempt > 0) {
77
+ // Retry-After applies to the response that carried it, not to every
78
+ // later attempt, so it is consumed rather than remembered.
79
+ const backoff = Math.min(this.retry.baseDelayMs * 2 ** (attempt - 1), this.retry.maxDelayMs);
80
+ const wait = retryAfterMs ?? backoff;
81
+ retryAfterMs = undefined;
82
+ this.log(`retry ${attempt}/${this.retry.attempts} in ${wait}ms: ${method} ${url.pathname}`);
83
+ await this.sleep(wait);
84
+ }
85
+ this.requestCount += 1;
86
+ let response;
87
+ try {
88
+ response = await this.doFetch(url.href, init);
89
+ }
90
+ catch (error) {
91
+ const message = error instanceof Error ? error.message : String(error);
92
+ lastError = new TargetError(/redirect/i.test(message)
93
+ ? `${method} ${url.pathname} was redirected. Check that WP_URL matches the site exactly, including https and www.`
94
+ : `${method} ${url.pathname} failed: ${message}`, { status: 0, method, url: url.href });
95
+ if (/redirect/i.test(message))
96
+ throw lastError;
97
+ continue;
98
+ }
99
+ const text = await response.text();
100
+ if (response.ok) {
101
+ try {
102
+ return { data: (text ? JSON.parse(text) : null), headers: response.headers };
103
+ }
104
+ catch {
105
+ throw new TargetError(`${method} ${url.pathname} returned a success body that is not JSON.`, {
106
+ status: response.status,
107
+ method,
108
+ url: url.href,
109
+ bodySnippet: text.slice(0, 200),
110
+ });
111
+ }
112
+ }
113
+ let code;
114
+ let message = `${response.status} ${response.statusText}`;
115
+ const contentType = response.headers.get('content-type') ?? '';
116
+ if (contentType.includes('json')) {
117
+ try {
118
+ const parsed = JSON.parse(text);
119
+ code = parsed.code;
120
+ if (parsed.message)
121
+ message = `${message}: ${parsed.message}`;
122
+ }
123
+ catch {
124
+ /* fall through to the snippet */
125
+ }
126
+ }
127
+ else if (text) {
128
+ message = `${message} (the response was not JSON; a firewall or security plugin may be blocking the REST API)`;
129
+ }
130
+ lastError = new TargetError(`${method} ${url.pathname} — ${message}`, {
131
+ status: response.status,
132
+ code,
133
+ method,
134
+ url: url.href,
135
+ bodySnippet: text.slice(0, 200),
136
+ });
137
+ const retryAfter = Number(response.headers.get('retry-after'));
138
+ if (Number.isFinite(retryAfter) && retryAfter > 0)
139
+ retryAfterMs = retryAfter * 1000;
140
+ if (response.status !== 429 && response.status < 500)
141
+ throw lastError;
142
+ }
143
+ throw lastError ?? new TargetError(`${method} ${url.pathname} failed.`, { status: 0, method, url: url.href });
144
+ }
145
+ /** Follow `X-WP-TotalPages` to the end of a collection. */
146
+ async listAll(path, query) {
147
+ const all = [];
148
+ let page = 1;
149
+ let totalPages = 1;
150
+ do {
151
+ const { data, headers } = await this.request('GET', path, {
152
+ query: { ...query, per_page: 100, page },
153
+ });
154
+ if (Array.isArray(data))
155
+ all.push(...data);
156
+ const header = Number(headers.get('x-wp-totalpages'));
157
+ totalPages = Number.isFinite(header) && header > 0 ? header : 1;
158
+ page += 1;
159
+ } while (page <= totalPages);
160
+ return all;
161
+ }
162
+ }
163
+
164
+ /**
165
+ * The WordPress media library.
166
+ *
167
+ * A file's identity is the hash of its contents, carried in the media slug.
168
+ * Keeping the identity on the server rather than in a local cache is what lets
169
+ * a fresh checkout, or a CI runner that has never seen the site, avoid
170
+ * uploading everything again.
171
+ */
172
+ /** The slug that identifies a file uploaded by pterodoc. */
173
+ function mediaSlug(prefix, hash) {
174
+ return `${prefix}-${hash}`;
175
+ }
176
+ /** Read the hash back out of a slug, when the slug is one of ours. */
177
+ function hashFromSlug(prefix, slug) {
178
+ const marker = `${prefix}-`;
179
+ if (!slug.startsWith(marker))
180
+ return undefined;
181
+ const hash = slug.slice(marker.length);
182
+ return /^[0-9a-f]{16}$/.test(hash) ? hash : undefined;
183
+ }
184
+ /**
185
+ * Everything pterodoc has already uploaded to this site, by content hash.
186
+ */
187
+ async function loadMediaIndex(client, prefix) {
188
+ const found = await client.listAll('/media', {
189
+ search: `${prefix}-`,
190
+ _fields: 'id,slug,source_url,mime_type',
191
+ });
192
+ const byHash = new Map();
193
+ for (const item of found) {
194
+ const hash = hashFromSlug(prefix, item.slug);
195
+ if (!hash || byHash.has(hash))
196
+ continue;
197
+ byHash.set(hash, {
198
+ id: item.id,
199
+ hash,
200
+ url: item.source_url,
201
+ filename: item.slug,
202
+ mime: item.mime_type,
203
+ });
204
+ }
205
+ return byHash;
206
+ }
207
+ /**
208
+ * Upload one file.
209
+ *
210
+ * WordPress derives a slug from the filename on create, so the identifying
211
+ * slug has to be set in a second request.
212
+ */
213
+ async function uploadMedia(client, upload, prefix) {
214
+ const slug = mediaSlug(prefix, upload.hash);
215
+ const form = new FormData();
216
+ const bytes = upload.bytes;
217
+ const view = new Uint8Array(bytes.byteLength);
218
+ view.set(bytes);
219
+ form.append('file', new File([view], upload.filename, { type: upload.mime }));
220
+ if (upload.title)
221
+ form.append('title', upload.title);
222
+ if (upload.alt)
223
+ form.append('alt_text', upload.alt);
224
+ let created;
225
+ try {
226
+ ({ data: created } = await client.request('POST', '/media', { form }));
227
+ }
228
+ catch (error) {
229
+ if (error instanceof TargetError && error.status === 400 && isBlockedByDefault(upload.mime)) {
230
+ throw new TargetError(`${upload.filename} was refused. WordPress blocks ${upload.mime} uploads unless a plugin allows them, because such a file can carry script.`, { status: error.status, code: error.code, method: error.method, url: error.url });
231
+ }
232
+ throw error;
233
+ }
234
+ const { data: updated } = await client.request('POST', `/media/${created.id}`, {
235
+ body: { slug, ...(upload.title ? { title: upload.title } : {}), ...(upload.alt ? { alt_text: upload.alt } : {}) },
236
+ });
237
+ return {
238
+ id: updated.id,
239
+ hash: upload.hash,
240
+ url: updated.source_url || created.source_url,
241
+ filename: upload.filename,
242
+ mime: updated.mime_type || upload.mime,
243
+ };
244
+ }
245
+
246
+ /**
247
+ * WordPress pages: finding them, comparing them, writing them, removing them.
248
+ *
249
+ * A page's identity is its parent and its slug, which is what makes a re-run
250
+ * rewrite only what actually differs.
251
+ */
252
+ /** Convert a WordPress page into the shape the reconciler compares. */
253
+ function toRemotePage(page) {
254
+ return {
255
+ id: page.id,
256
+ parent: page.parent,
257
+ slug: page.slug,
258
+ status: page.status,
259
+ link: page.link,
260
+ title: page.title?.raw ?? page.title?.rendered ?? '',
261
+ ...(page.content?.raw !== undefined ? { content: page.content.raw } : {}),
262
+ ...(page.excerpt?.raw !== undefined ? { excerpt: page.excerpt.raw } : {}),
263
+ menuOrder: page.menu_order,
264
+ template: page.template,
265
+ meta: page.meta,
266
+ };
267
+ }
268
+ /** Fetch every page on the site. */
269
+ async function fetchPageIndex(client) {
270
+ const pages = await client.listAll('/pages', {
271
+ status: 'any',
272
+ context: 'edit',
273
+ _fields: PAGE_FIELDS,
274
+ });
275
+ return pages.map(toRemotePage);
276
+ }
277
+ /** Fetch one page with the fields needed to compare it. */
278
+ async function fetchPage(client, id) {
279
+ const { data } = await client.request('GET', `/pages/${id}`, {
280
+ query: { context: 'edit', _fields: FULL_PAGE_FIELDS },
281
+ });
282
+ return toRemotePage(data);
283
+ }
284
+ /**
285
+ * Find a page by its position in the tree.
286
+ *
287
+ * The index is searched when one was supplied, because a whole-site index is
288
+ * one request where per-page lookups are hundreds.
289
+ */
290
+ async function findPage(client, parent, slug, index, log = () => { }) {
291
+ let candidates;
292
+ if (index) {
293
+ candidates = index.filter((page) => page.parent === parent && page.slug === slug);
294
+ }
295
+ else {
296
+ const { data } = await client.request('GET', '/pages', {
297
+ query: { parent, slug, status: 'any', context: 'edit', per_page: 100, _fields: PAGE_FIELDS },
298
+ });
299
+ candidates = (Array.isArray(data) ? data : []).map(toRemotePage);
300
+ }
301
+ if (candidates.length > 1) {
302
+ log(`${candidates.length} pages share parent ${parent} and slug "${slug}"; using id ${candidates[0].id}.`);
303
+ }
304
+ return candidates[0];
305
+ }
306
+ /** Create a page, checking that WordPress honoured the slug we asked for. */
307
+ async function createPage(client, input) {
308
+ const { data } = await client.request('POST', '/pages', { body: input });
309
+ if (input.slug && data.slug !== input.slug) {
310
+ throw new TargetError(`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}.`, { status: 200, method: 'POST', url: '/pages' });
311
+ }
312
+ return toRemotePage(data);
313
+ }
314
+ /** Update a page. */
315
+ async function updatePage(client, id, input) {
316
+ const { data } = await client.request('POST', `/pages/${id}`, { body: input });
317
+ return toRemotePage(data);
318
+ }
319
+ /**
320
+ * Move a page to the trash.
321
+ *
322
+ * Never a permanent delete: recovering from a mistaken prune should not
323
+ * require a database backup.
324
+ */
325
+ async function trashPage(client, id) {
326
+ await client.request('DELETE', `/pages/${id}`);
327
+ }
328
+ /** Normalise a value for comparison, so whitespace alone is not a difference. */
329
+ const normalise = (value) => String(value ?? '').replace(/\r\n/g, '\n').trim();
330
+ /**
331
+ * Which fields of an existing page differ from the rendered one.
332
+ *
333
+ * @param remote The page as WordPress holds it.
334
+ * @param rendered The page as pterodoc would publish it.
335
+ * @param context The expected parent, slug, status and template.
336
+ */
337
+ function diffPage(remote, rendered, context) {
338
+ const changed = [];
339
+ if (normalise(remote.title) !== normalise(rendered.title))
340
+ changed.push('title');
341
+ if (normalise(remote.content) !== normalise(rendered.content))
342
+ changed.push('content');
343
+ if (normalise(remote.excerpt) !== normalise(rendered.excerpt))
344
+ changed.push('excerpt');
345
+ if (remote.status !== context.status)
346
+ changed.push('status');
347
+ if (!context.isRoot && remote.menuOrder !== rendered.menuOrder)
348
+ changed.push('menu_order');
349
+ if ((remote.template ?? '') !== context.template)
350
+ changed.push('template');
351
+ if (remote.parent !== context.parentId)
352
+ changed.push('parent');
353
+ if (remote.slug !== context.slug)
354
+ changed.push('slug');
355
+ // Metadata is only compared where the site actually exposes the field, so a
356
+ // site without the SEO plugin does not report a difference on every run.
357
+ for (const [key, value] of Object.entries(rendered.meta)) {
358
+ if (remote.meta && key in remote.meta && normalise(remote.meta[key]) !== normalise(value)) {
359
+ changed.push(`meta.${key}`);
360
+ }
361
+ }
362
+ return changed;
363
+ }
364
+ /**
365
+ * Pages below a root that no rendered page accounts for.
366
+ *
367
+ * Deepest first, so a parent is never trashed before its children.
368
+ */
369
+ function computePrune(index, rootId, keepIds) {
370
+ const childrenOf = new Map();
371
+ for (const page of index) {
372
+ const siblings = childrenOf.get(page.parent);
373
+ if (siblings)
374
+ siblings.push(page);
375
+ else
376
+ childrenOf.set(page.parent, [page]);
377
+ }
378
+ const owned = [];
379
+ const walk = (parentId, depth) => {
380
+ for (const page of childrenOf.get(parentId) ?? []) {
381
+ owned.push({ page, depth });
382
+ walk(page.id, depth + 1);
383
+ }
384
+ };
385
+ walk(rootId, 0);
386
+ return owned
387
+ .filter(({ page }) => !keepIds.has(page.id))
388
+ .sort((a, b) => b.depth - a.depth)
389
+ .map(({ page }) => page);
390
+ }
391
+
392
+ /**
393
+ * Where pages live on WordPress.
394
+ *
395
+ * URL policy belongs to the target: the renderer asks for a path and gets one
396
+ * back, without knowing whether the site nests pages, uses a subdirectory
397
+ * install, or publishes versions under their own segment.
398
+ */
399
+ /**
400
+ * Segments that come before a page's own path.
401
+ *
402
+ * A version or a locale only earns a segment when it is not the primary one,
403
+ * so a single-version, single-locale site publishes exactly where it did
404
+ * before any of this existed.
405
+ */
406
+ function prefixSegments(policy, context) {
407
+ const parts = [...policy.rootSegments, ...policy.baseSegments];
408
+ if (policy.primaryLocale !== undefined && context.locale !== policy.primaryLocale) {
409
+ parts.push(slugify(context.locale));
410
+ }
411
+ if (policy.primaryVersion !== undefined && context.versionName !== policy.primaryVersion) {
412
+ parts.push(slugify(context.versionName));
413
+ }
414
+ return parts;
415
+ }
416
+ /** The absolute site path of a page. */
417
+ function hrefFor(policy, treePath, context) {
418
+ return joinPath([...prefixSegments(policy, context), ...segments(treePath)]);
419
+ }
420
+ /**
421
+ * The path pages hang from, and the slug of the page that owns the tree.
422
+ *
423
+ * Everything above the owned page is created once if missing and never edited;
424
+ * the owned page is the documentation root itself.
425
+ */
426
+ function splitOwnership(policy) {
427
+ const all = [...policy.rootSegments, ...policy.baseSegments];
428
+ const rootSlug = all[all.length - 1];
429
+ if (rootSlug === undefined) {
430
+ throw new Error('There is nowhere to publish: the root path and the base are both empty.');
431
+ }
432
+ return { stubSegments: all.slice(0, -1), rootSlug };
433
+ }
434
+
435
+ /**
436
+ * Finding out whether the pterodoc WordPress plugin is installed.
437
+ *
438
+ * The plugin registers one option and exposes it over REST, so asking for the
439
+ * site's settings answers both questions at once: whether it is there, and what
440
+ * it is configured with. That second half matters, because the plugin styles a
441
+ * site by class prefix and a prefix that disagrees with the one pterodoc writes
442
+ * is a setup that looks broken for no visible reason.
443
+ */
444
+ /**
445
+ * Ask a site whether the plugin is installed.
446
+ *
447
+ * Never throws: an unreachable or unauthorised site is a thing to report in a
448
+ * diagnostic, not a reason to fail one.
449
+ *
450
+ * @param client A client for the site.
451
+ */
452
+ async function detectPlugin(client) {
453
+ try {
454
+ const { data } = await client.request('GET', '/settings');
455
+ const settings = data.pterodoc_settings;
456
+ if (!settings)
457
+ return { installed: false };
458
+ return {
459
+ installed: true,
460
+ ...(settings.classPrefix ? { classPrefix: settings.classPrefix } : {}),
461
+ };
462
+ }
463
+ catch (error) {
464
+ const status = error instanceof TargetError ? error.status : undefined;
465
+ if (status === 401 || status === 403) {
466
+ return {
467
+ installed: false,
468
+ unknown: 'these credentials may not read the site settings, so the plugin could not be checked',
469
+ };
470
+ }
471
+ return {
472
+ installed: false,
473
+ unknown: error instanceof Error ? error.message : 'the site could not be asked',
474
+ };
475
+ }
476
+ }
477
+
478
+ /** The WordPress target. */
479
+ /** What WordPress can do. */
480
+ const WORDPRESS_CAPABILITIES = {
481
+ // The navigation block lists children of a page id, so ids must exist first.
482
+ needsIdsBeforeRender: true,
483
+ supportsMedia: true,
484
+ supportsPrune: true,
485
+ supportsHierarchy: true,
486
+ supportsExcerpt: true,
487
+ supportsMeta: true,
488
+ supportsTemplates: true,
489
+ supportsDrafts: true,
490
+ };
491
+ /** Content a page holds between being created and being rendered. */
492
+ const PLACEHOLDER = '<!-- wp:paragraph -->\n<p>Publishing…</p>\n<!-- /wp:paragraph -->';
493
+ /** Create the WordPress target. */
494
+ function createWordpressTarget(options, deps = {}) {
495
+ const log = deps.log ?? (() => { });
496
+ const { stubSegments, rootSlug } = splitOwnership(options.policy);
497
+ // The documentation root has no path of its own in the tree, so its slug is
498
+ // the last segment of the configured path rather than anything the model
499
+ // supplied.
500
+ const slugFor = (page) => page.path === '' ? rootSlug : page.slug;
501
+ const makeClient = (locale) => new WpClient({
502
+ baseUrl: options.url,
503
+ user: options.user,
504
+ appPassword: options.appPassword,
505
+ // A site with one language per subtree wants each subtree tagged.
506
+ lang: options.lang || (options.policy.primaryLocale && locale !== options.policy.primaryLocale ? locale : ''),
507
+ methodOverride: options.methodOverride,
508
+ retry: options.retry ?? DEFAULT_RETRY,
509
+ ...(deps.fetch ? { fetch: deps.fetch } : {}),
510
+ ...(deps.sleep ? { sleep: deps.sleep } : {}),
511
+ log,
512
+ });
513
+ return {
514
+ name: 'wordpress',
515
+ capabilities: WORDPRESS_CAPABILITIES,
516
+ rootPath: hrefFor(options.policy, '', { versionName: '', locale: options.policy.primaryLocale ?? '' }),
517
+ hrefFor(treePath, context) {
518
+ return hrefFor(options.policy, treePath, context);
519
+ },
520
+ async open(context) {
521
+ const client = makeClient(context.locale);
522
+ const dryRun = context.dryRun;
523
+ let index;
524
+ return {
525
+ async loadIndex() {
526
+ index ??= await fetchPageIndex(client);
527
+ return index;
528
+ },
529
+ async ensureRootParent() {
530
+ const created = [];
531
+ let parentId = 0;
532
+ for (const slug of stubSegments) {
533
+ if (parentId === null) {
534
+ created.push({ path: `/${slug}/`, id: null });
535
+ continue;
536
+ }
537
+ const existing = await findPage(client, parentId, slug, index, log);
538
+ if (existing) {
539
+ if (existing.status === 'trash') {
540
+ throw new TargetError(`The page "/${slug}/" is in the trash. Restore it, or delete it permanently, and run again.`, { status: 409, method: 'GET', url: `/pages?slug=${slug}` });
541
+ }
542
+ parentId = existing.id;
543
+ continue;
544
+ }
545
+ if (dryRun) {
546
+ created.push({ path: `/${slug}/`, id: null });
547
+ parentId = null;
548
+ continue;
549
+ }
550
+ // A page created only so the documentation has a parent: it lists
551
+ // what is below it and claims nothing else.
552
+ const page = await createPage(client, {
553
+ title: titleCase(slug),
554
+ slug,
555
+ parent: parentId,
556
+ status: 'publish',
557
+ content: PLACEHOLDER,
558
+ });
559
+ await updatePage(client, page.id, { content: renderNavigationStub(page.id) });
560
+ created.push({ path: `/${slug}/`, id: page.id });
561
+ parentId = page.id;
562
+ }
563
+ return { id: parentId, created };
564
+ },
565
+ async ensurePage(request) {
566
+ const warnings = [];
567
+ if (request.parentId === null)
568
+ return { id: null, created: true, warnings };
569
+ const slug = request.isRoot ? rootSlug : request.slug;
570
+ const existing = await findPage(client, request.parentId, slug, index, log);
571
+ if (existing) {
572
+ if (existing.status === 'trash') {
573
+ warnings.push(`${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.`);
574
+ }
575
+ return { id: existing.id, created: false, warnings };
576
+ }
577
+ if (dryRun)
578
+ return { id: null, created: true, warnings };
579
+ // Created as a draft: a placeholder must never appear in navigation.
580
+ const created = await createPage(client, {
581
+ title: request.title,
582
+ slug,
583
+ parent: request.parentId,
584
+ status: 'draft',
585
+ menu_order: request.menuOrder,
586
+ content: PLACEHOLDER,
587
+ });
588
+ return { id: created.id, created: true, warnings };
589
+ },
590
+ async fetchPage(id) {
591
+ return fetchPage(client, id);
592
+ },
593
+ diffPage(remote, rendered, parentId) {
594
+ return diffPage(remote, rendered, {
595
+ parentId,
596
+ status: options.status,
597
+ template: options.template,
598
+ isRoot: rendered.path === '',
599
+ slug: slugFor(rendered),
600
+ });
601
+ },
602
+ async writePage(id, page, parentId) {
603
+ const warnings = [];
604
+ const body = {
605
+ title: page.title,
606
+ content: page.content,
607
+ excerpt: page.excerpt,
608
+ parent: parentId,
609
+ slug: slugFor(page),
610
+ status: options.status,
611
+ menu_order: page.menuOrder,
612
+ template: options.template,
613
+ };
614
+ if (Object.keys(page.meta).length > 0)
615
+ body.meta = page.meta;
616
+ try {
617
+ await updatePage(client, id, body);
618
+ }
619
+ catch (error) {
620
+ // A locked-down site may reject the metadata or the template. The
621
+ // page itself matters more than either, so try again without them.
622
+ const status = error.status;
623
+ if (status === 400 && (body.meta || body.template)) {
624
+ delete body.meta;
625
+ delete body.template;
626
+ warnings.push(`${page.path || '(root)'}: WordPress refused the template or the metadata, so the page was published without them.`);
627
+ await updatePage(client, id, body);
628
+ }
629
+ else
630
+ throw error;
631
+ }
632
+ return { warnings };
633
+ },
634
+ computePrune(pages, rootId, keepIds) {
635
+ return computePrune(pages, rootId, keepIds);
636
+ },
637
+ async removePage(page) {
638
+ await trashPage(client, page.id);
639
+ },
640
+ async loadMediaIndex() {
641
+ return loadMediaIndex(client, options.mediaSlugPrefix);
642
+ },
643
+ async uploadMedia(upload) {
644
+ return uploadMedia(client, upload, options.mediaSlugPrefix);
645
+ },
646
+ requestCount() {
647
+ return client.requestCount;
648
+ },
649
+ };
650
+ },
651
+ };
652
+ }
653
+
654
+ export { DEFAULT_RETRY, PLACEHOLDER, WORDPRESS_CAPABILITIES, WpClient, createWordpressTarget, detectPlugin, hrefFor, prefixSegments, splitOwnership };
655
+ //# sourceMappingURL=index.js.map