PluginProbe
Extendify / 3.2.1
Extendify v3.2.1
3.2.1 3.2.0 3.1.6 3.1.5 3.1.4 3.1.3 3.1.2 3.1.1 3.1.0 3.0.6 3.0.5 3.0.4 trunk 0.1.0 0.10.0 0.10.1 0.10.2 0.11.0 0.11.1 0.2.0 0.3.0 0.3.1 0.4.0 0.5.0 0.6.0 All 127 releases
extendify / src / Launch / api / WPApi.js

WPApi.js in Extendify 3.2.1, at src/Launch/api/WPApi.js

579 lines 15.9 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 import { Axios as api } from '@launch/api/axios';
2 import {
3 fetchFontFaceFile,
4 makeFontFaceFormData,
5 makeFontFamilyFormData,
6 } from '@launch/lib/fonts-helpers';
7 import { pageNames } from '@shared/lib/pages';
8 import { sleep } from '@shared/lib/utils';
9 import apiFetch from '@wordpress/api-fetch';
10 import { createBlock, parse, serialize } from '@wordpress/blocks';
11 import { __ } from '@wordpress/i18n';
12 import { addQueryArgs } from '@wordpress/url';
13
14 const { wpRoot } = window.extOnbData;
15
16 export const updateOption = (option, value) =>
17 api.post('launch/options', { option, value });
18
19 export const updatePattern = (option, value) =>
20 api.post('launch/save-pattern', { option, value });
21
22 export const getOption = async (option) => {
23 const { data } = await api.get('launch/options', {
24 params: { option },
25 });
26 return data;
27 };
28
29 export const createPage = (pageData) =>
30 api.post(`${wpRoot}wp/v2/pages`, pageData);
31
32 export const updatePage = (pageData) =>
33 api.post(`${wpRoot}wp/v2/pages/${pageData.id}`, pageData);
34
35 export const getPageById = (pageId) =>
36 api.get(`${wpRoot}wp/v2/pages/${pageId}`);
37
38 export const createPost = (postData) =>
39 api.post(`${wpRoot}wp/v2/posts`, postData);
40
41 export const uploadMedia = (formData) =>
42 api.post(`${wpRoot}wp/v2/media`, formData);
43
44 export const createCategory = (CategoryData) =>
45 api.post(`${wpRoot}wp/v2/categories`, CategoryData);
46
47 export const createTag = (tagData) => api.post(`${wpRoot}wp/v2/tags`, tagData);
48
49 export const createNavigation = async (
50 content = '',
51 title = __('Header Navigation', 'extendify-local'),
52 slug = 'site-navigation',
53 ) => {
54 const payload = await apiFetch({
55 path: 'extendify/v1/launch/create-navigation',
56 method: 'POST',
57 data: {
58 title,
59 slug,
60 content,
61 },
62 });
63
64 return payload.id;
65 };
66
67 export const updateNavigation = async (id, content) => {
68 const payload = await apiFetch({
69 path: `wp/v2/navigation/${id}`,
70 method: 'POST',
71 data: {
72 content,
73 },
74 });
75
76 return payload.id;
77 };
78
79 export const updateTemplatePart = (part, content) =>
80 api.post(`${wpRoot}wp/v2/template-parts/${part}`, {
81 slug: `${part}`,
82 theme: 'extendable',
83 type: 'wp_template_part',
84 status: 'publish',
85 // See: https://github.com/extendify/company-product/issues/833#issuecomment-1804179527
86 // translators: Launch is the product name. Unless otherwise specified by the glossary, do not translate this name.
87 description: __('Added by Launch', 'extendify-local'),
88 content,
89 });
90
91 const allowedHeaders = ['header', 'header-with-center-nav-and-social'];
92 const allowedFooters = [
93 'footer',
94 'footer-social-icons',
95 'footer-with-center-logo-and-menu',
96 ];
97 const allowedNavFooters = [
98 'footer-with-nav',
99 'footer-with-center-logo-social-nav',
100 ];
101
102 // finds the core/heading in the pattern and replaces it with a core/post-title block
103 const transformHeadingToPostTitle = (rawHTML) => {
104 let done = false;
105
106 const walk = (block) => {
107 if (done) return block;
108
109 if (block.name === 'core/heading') {
110 done = true;
111 const attrs = {
112 level: block.attributes.level,
113 textAlign: block.attributes.textAlign,
114 textColor: block.attributes.textColor,
115 backgroundColor: block.attributes.backgroundColor,
116 isLink: block.attributes.isLink,
117 linkTarget: block.attributes.linkTarget,
118 rel: block.attributes.rel,
119 };
120
121 if (block.attributes.fontSize) {
122 attrs.fontSize = block.attributes.fontSize;
123 }
124
125 const customSize = block.attributes.style?.typography?.fontSize;
126 const linkStyle = block.attributes.style?.elements?.link;
127
128 if (customSize || linkStyle) {
129 attrs.style = {};
130
131 if (customSize) {
132 attrs.style.typography = { fontSize: customSize };
133 }
134 if (linkStyle) {
135 attrs.style.elements = { link: linkStyle };
136 }
137 }
138
139 return createBlock('core/post-title', attrs);
140 }
141
142 if (block.innerBlocks?.length) {
143 block.innerBlocks = block.innerBlocks.map(walk);
144 }
145 return block;
146 };
147
148 return serialize(parse(rawHTML).map(walk));
149 };
150
151 // Replace the page-title pattern in “page-with-title” template with the incoming page-title pattern
152 export const updatePageTitlePattern = async (pageTitlePattern) => {
153 const updatedPattern = transformHeadingToPostTitle(pageTitlePattern);
154
155 const templateContent = `
156 <!-- wp:template-part {"slug":"header","tagName":"header"} /-->
157 <!-- wp:group {"tagName":"main","style":{"spacing":{"margin":{"top":"0px","bottom":"0px"},"blockGap":"0"}}} -->
158 <main class="wp-block-group" style="margin-top:0px;margin-bottom:0px">
159 ${updatedPattern}
160 <!-- wp:post-content {"layout":{"type":"constrained"}} /-->
161 </main>
162 <!-- /wp:group -->
163 <!-- wp:template-part {"slug":"footer","tagName":"footer"} /-->
164 `;
165
166 try {
167 await apiFetch({
168 path: '/wp/v2/templates/extendable/page-with-title',
169 method: 'POST',
170 data: {
171 slug: 'page-with-title',
172 theme: 'extendable',
173 type: 'wp_template',
174 status: 'publish',
175 description: __('Added by Launch', 'extendify-local'),
176 content: templateContent,
177 },
178 });
179 return true;
180 } catch {
181 return false;
182 }
183 };
184
185 export const getHeadersAndFooters = async (hasFooterNav = false) => {
186 let patterns = await getTemplateParts();
187 patterns = patterns?.filter((p) => p.theme === 'extendable');
188 const headers = patterns?.filter((p) => allowedHeaders.includes(p?.slug));
189
190 let footerSlugsToUse = allowedFooters;
191
192 if (hasFooterNav) {
193 const navFooters = patterns?.filter((p) =>
194 allowedNavFooters.includes(p?.slug),
195 );
196 // Use navFooters only if any are found; otherwise fall back to allowedFooters
197 if (navFooters.length > 0) {
198 footerSlugsToUse = allowedNavFooters;
199 }
200 }
201
202 const footers = patterns?.filter((p) => footerSlugsToUse.includes(p?.slug));
203 return { headers, footers };
204 };
205
206 const getTemplateParts = () => api.get(`${wpRoot}wp/v2/template-parts`);
207
208 export const getThemeVariations = async () => {
209 const variations = await api.get(
210 `${wpRoot}wp/v2/global-styles/themes/extendable/variations`,
211 );
212
213 if (!Array.isArray(variations)) {
214 throw new Error('Could not get theme variations');
215 }
216
217 // Filter out color and typography presets, and keep only main style variations.
218 const mainStyleVariations = variations.filter((variation) => {
219 const settingsKeys = Object.keys(variation.settings || {});
220 const stylesKeys = Object.keys(variation.styles || {});
221 const combinedKeys = new Set([...settingsKeys, ...stylesKeys]);
222 return combinedKeys.has('color') && combinedKeys.has('typography');
223 });
224
225 // Use slug from theme if available, otherwise generate one from the title
226 const variationsWithSlugs = mainStyleVariations.map((variation) => {
227 if (variation.slug) return variation;
228 const slug = variation.title.toLowerCase().trim().replace(/\s+/, '-');
229 return { ...variation, slug };
230 });
231
232 // Randomize
233 return [...variationsWithSlugs].sort(() => Math.random() - 0.5);
234 };
235
236 export const updateThemeVariation = (id, variation) =>
237 api.post(`${wpRoot}wp/v2/global-styles/${id}`, {
238 id,
239 settings: variation.settings,
240 styles: variation.styles,
241 });
242
243 export const getThemeGlobalStyles = () =>
244 api.get(`${wpRoot}wp/v2/global-styles/themes/extendable?context=edit`);
245
246 export const updateGlobalStyles = (globalStylesPostID, stylesData) =>
247 api.post(`${wpRoot}wp/v2/global-styles/${globalStylesPostID}`, stylesData);
248
249 export const addSectionLinksToNav = async (
250 navigationId,
251 homePatterns = [],
252 pluginPages = [],
253 createdPages = [],
254 ) => {
255 // Extract plugin page slugs for comparison
256 const pluginPageTitles = pluginPages.map(({ title }) =>
257 title?.rendered?.toLowerCase(),
258 );
259
260 const pages =
261 createdPages
262 ?.filter((page) => page?.slug !== 'home')
263 ?.map((page) => page.slug)
264 ?.filter(Boolean) ?? [];
265
266 // ['about-us', 'services', 'contact-us']
267 const sections = homePatterns
268 .map(({ patternTypes }) => patternTypes?.[0])
269 .filter(Boolean)
270 // Filter out any pattern type that has a page created by 3rd party plugins.
271 .filter((patternType) => {
272 const { slug } =
273 Object.values(pageNames).find(({ alias }) =>
274 alias.includes(patternType),
275 ) || {};
276 return slug && !pluginPageTitles.includes(slug);
277 });
278
279 const seen = new Set();
280
281 const sectionsNavigationLinks = sections.map((patternType) => {
282 const { title, slug } =
283 Object.values(pageNames).find(({ alias }) =>
284 alias.includes(patternType),
285 ) || {};
286 if (!slug) return '';
287 if (seen.has(slug)) return '';
288 seen.add(slug);
289
290 const url = pages.includes(slug)
291 ? `${window.extSharedData.homeUrl}/${slug}`
292 : `${window.extSharedData.homeUrl}/#${slug}`;
293
294 const attributes = JSON.stringify({
295 label: title,
296 type: 'custom',
297 url,
298 kind: 'custom',
299 isTopLevelLink: true,
300 });
301
302 return `<!-- wp:navigation-link ${attributes} /-->`;
303 });
304
305 const pluginPagesNavigationLinks = pluginPages.map(
306 ({ title, id, type, link }) => {
307 const attributes = JSON.stringify({
308 label: title.rendered,
309 id,
310 type,
311 url: link,
312 kind: id ? 'post-type' : 'custom',
313 isTopLevelLink: true,
314 });
315
316 return `<!-- wp:navigation-link ${attributes} /-->`;
317 },
318 );
319
320 const navigationLinks = sectionsNavigationLinks
321 .concat(pluginPagesNavigationLinks)
322 .join('');
323
324 await updateNavigation(navigationId, navigationLinks);
325 };
326
327 export const addPageLinksToNav = async (
328 navigationId,
329 allPages,
330 createdPages,
331 pluginPages = [],
332 ) => {
333 // Because WP may have changed the slug and permalink (i.e., because of different languages),
334 // we are using the `originalSlug` property to match the original pages with the updated ones.
335 const findCreatedPage = ({ slug }) =>
336 createdPages.find(({ originalSlug: s }) => s === slug) || {};
337
338 const filteredCreatedPages = allPages
339 .filter((p) => findCreatedPage(p)?.id) // make sure its a page
340 .filter(({ slug }) => slug !== 'home') // exclude home page
341 .map((page) => findCreatedPage(page));
342
343 // Plugin pages use `slug`, created pages use `originalSlug`
344 const getSlug = (page) => page.originalSlug ?? page.slug;
345 const getOrder = (page) => {
346 const slug = getSlug(page);
347 return (
348 pageNames[slug]?.navOrder ??
349 Object.values(pageNames).find((p) => p.alias?.includes(slug))?.navOrder ??
350 Object.keys(pageNames).length + 1
351 );
352 };
353
354 const mergedPages = [...filteredCreatedPages, ...pluginPages];
355 const contactPage = mergedPages.find((page) => {
356 const slug = getSlug(page);
357 return slug === 'contact' || pageNames.contact?.alias?.includes(slug);
358 });
359
360 const sortedPages = mergedPages
361 .filter((page) => page !== contactPage)
362 .sort((a, b) => getOrder(a) - getOrder(b));
363
364 // Re-insert contact page at the correct position
365 const finalPages = contactPage
366 ? (() => {
367 // Top-level links: 5 if 7+ pages, or 6 if exactly 6 pages (no submenu for a single extra link)
368 // Note: sortedPages.length is checked AFTER removing contact, so length 5 means 6 total pages
369 const index =
370 sortedPages.length === 5 ? 5 : Math.min(4, sortedPages.length);
371 return [
372 ...sortedPages.slice(0, index),
373 contactPage,
374 ...sortedPages.slice(index),
375 ];
376 })()
377 : sortedPages;
378
379 const pageLinks = finalPages.map(({ id, title, link, type }) => {
380 const attributes = JSON.stringify({
381 label: title.rendered,
382 id,
383 type,
384 url: link,
385 kind: id ? 'post-type' : 'custom',
386 isTopLevelLink: true,
387 });
388
389 return `<!-- wp:navigation-link ${attributes} /-->`;
390 });
391
392 const topLevelLinks = pageLinks.slice(0, 5).join('');
393 const submenuLinks = pageLinks.slice(5);
394 // We want a max of 6 top-level links, but if 7+, then move the last
395 // two+ to a submenu.
396 const additionalLinks =
397 submenuLinks.length > 1
398 ? ` <!-- wp:navigation-submenu ${JSON.stringify({
399 // translators: "More" here is used for a navigation menu item that contains additional links.
400 label: __('More', 'extendify-local'),
401 url: '#',
402 kind: 'custom',
403 })} --> ${submenuLinks.join('')} <!-- /wp:navigation-submenu -->`
404 : submenuLinks.join(''); // only 1 link here
405
406 await updateNavigation(navigationId, topLevelLinks + additionalLinks);
407 };
408
409 const getNavAttributes = (headerCode) => {
410 try {
411 return JSON.parse(headerCode.match(/<!-- wp:navigation([\s\S]*?)-->/)[1]);
412 } catch (_e) {
413 return {};
414 }
415 };
416
417 export const updateNavAttributes = (headerCode, attributes) => {
418 const newAttributes = JSON.stringify({
419 ...getNavAttributes(headerCode),
420 ...attributes,
421 });
422 return headerCode.replace(
423 // biome-ignore lint: don't want to refactor and test this regex now
424 /(<!--\s*wp:navigation\b[^>]*>)([^]*?)(<!--\s*\/wp:navigation\s*-->)/gi,
425 `<!-- wp:navigation ${newAttributes} /-->`,
426 );
427 };
428
429 export const getActivePlugins = () => api.get('launch/active-plugins');
430
431 export const prefetchAssistData = async () =>
432 await api.get('launch/prefetch-assist-data');
433
434 export const processPlaceholders = (patterns) =>
435 apiFetch({
436 path: '/extendify/v1/shared/process-placeholders',
437 method: 'POST',
438 data: { patterns },
439 });
440
441 export const postLaunchFunctions = () =>
442 apiFetch({
443 path: '/extendify/v1/launch/post-launch-functions',
444 method: 'POST',
445 });
446
447 export const registerFontFamily = async (fontFamily) => {
448 try {
449 const existingFontFamily = (
450 await apiFetch({
451 path: addQueryArgs('/wp/v2/font-families', {
452 slug: fontFamily.slug,
453 _embed: true,
454 }),
455 method: 'GET',
456 })
457 )?.[0];
458
459 if (existingFontFamily) {
460 return {
461 id: existingFontFamily.id,
462 ...existingFontFamily.font_family_settings,
463 fontFace: existingFontFamily._embedded.font_faces.map(
464 ({ id, font_face_settings }) => ({
465 id,
466 ...font_face_settings,
467 }),
468 ),
469 };
470 }
471
472 const newFontFamily = await apiFetch({
473 path: '/wp/v2/font-families',
474 method: 'POST',
475 body: makeFontFamilyFormData(fontFamily),
476 });
477
478 return {
479 id: newFontFamily.id,
480 ...newFontFamily.font_family_settings,
481 fontFace: newFontFamily.fontFaces,
482 };
483 } catch (error) {
484 console.error('Failed to register font family:', error.message);
485 return;
486 }
487 };
488
489 export const registerFontFace = async ({ fontFamilyId, ...fontFace }) => {
490 const max_retries = 2;
491
492 const fontFaceSlug = `${fontFace.fontFamilySlug}-${fontFace.fontWeight}`;
493
494 for (let attempt = 0; attempt <= max_retries; attempt++) {
495 try {
496 // Add delay of 1 second if this is not the first attempt
497 if (attempt > 0) await sleep(1000);
498
499 const response = await apiFetch({
500 path: `/wp/v2/font-families/${fontFamilyId}/font-faces`,
501 method: 'POST',
502 body: makeFontFaceFormData(fontFace),
503 });
504
505 return {
506 id: response.id,
507 ...response.font_face_settings,
508 };
509 } catch (error) {
510 if (attempt <= max_retries) {
511 console.error(
512 `Failed attempt to upload font file ${fontFaceSlug}:`,
513 error.message,
514 );
515 continue;
516 }
517
518 console.error(
519 `Failed to upload font file ${fontFaceSlug} after ${max_retries + 1} attempts.`,
520 );
521
522 return;
523 }
524 }
525 };
526
527 export const installFontFamily = async (fontFamily) => {
528 const fontFaceDownloadRequests = fontFamily.fontFace.map(async (fontFace) => {
529 const file = await fetchFontFaceFile(fontFace.src);
530 if (!file) return;
531 return { ...fontFace, file };
532 });
533
534 const fontFacesWithFile = (
535 await Promise.all(fontFaceDownloadRequests)
536 ).filter(Boolean);
537
538 // If we don't have any font file to install, we don't register the font family.
539 if (!fontFacesWithFile.length) return;
540
541 const registeredFontFamily = await registerFontFamily(fontFamily);
542
543 // If we couldn't register the font family, we don't register the font faces.
544 if (!registeredFontFamily) return;
545
546 // If font family has font faces, it means it was already registered
547 // and doesn't need to be installed.
548 if (registeredFontFamily?.fontFace?.length) {
549 return registeredFontFamily;
550 }
551
552 const fontFaces = fontFacesWithFile.map((fontFace) => ({
553 fontFamilyId: registeredFontFamily.id,
554 fontFamilySlug: registeredFontFamily.slug,
555 ...fontFace,
556 }));
557
558 const registeredFontFaces = [];
559
560 for (const fontFace of fontFaces) {
561 registeredFontFaces.push(await registerFontFace(fontFace));
562 }
563
564 return {
565 ...registeredFontFamily,
566 fontFace: registeredFontFaces.filter(Boolean),
567 };
568 };
569
570 export const installFontFamilies = async (fontFamilies) => {
571 const installedFontFamilies = [];
572
573 for (const fontFamily of fontFamilies) {
574 installedFontFamilies.push(await installFontFamily(fontFamily));
575 }
576
577 return installedFontFamilies.filter(Boolean);
578 };
579