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 / AutoLaunch / functions / nav.js

nav.js in Extendify 3.2.1, at src/AutoLaunch/functions/nav.js

349 lines 13.5 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 import { pageNames } from '@shared/lib/pages';
2 import apiFetch from '@wordpress/api-fetch';
3 import { __ } from '@wordpress/i18n';
4 import { addQueryArgs } from '@wordpress/url';
5
6 export const createNavigation = async ({ content = '', title, slug }) => {
7 const existing = await apiFetch({
8 path: addQueryArgs('extendify/v1/auto-launch/get-navigation', { slug }),
9 }).catch(() => undefined);
10
11 if (existing?.id) return existing;
12
13 return await apiFetch({
14 path: 'extendify/v1/auto-launch/create-navigation',
15 method: 'POST',
16 data: { title, slug, content },
17 });
18 };
19
20 export const updateNavigation = (id, content) =>
21 apiFetch({
22 path: `wp/v2/navigation/${id}`,
23 method: 'POST',
24 data: { content },
25 });
26
27 const navLink = (attributes) =>
28 `<!-- wp:navigation-link ${JSON.stringify(attributes)} /-->`;
29
30 const pluginPageLink = (page) =>
31 navLink({
32 label: page.title?.rendered ?? page.name,
33 id: page.id,
34 type: page.type,
35 url: page.link,
36 kind: page.id ? 'post-type' : 'custom',
37 isTopLevelLink: true,
38 });
39
40 const anchorLink = ({ label, url }) =>
41 navLink({ label, type: 'custom', url, kind: 'custom', isTopLevelLink: true });
42
43 export const addSectionLinksToNav = async (
44 navigationId,
45 homePatterns = [],
46 pluginPages = [],
47 createdPages = [],
48 { orderedSlugs = [] } = {},
49 ) => {
50 // Extract plugin page slugs for comparison
51 const pluginPageTitles = pluginPages.map(({ title }) =>
52 title?.rendered?.toLowerCase(),
53 );
54
55 const pages =
56 createdPages
57 ?.filter((page) => page?.slug !== 'home')
58 ?.map((page) => page.slug)
59 ?.filter(Boolean) ?? [];
60
61 const resolve = (pattern) => {
62 const patternType = pattern.patternTypes?.[0];
63 const lookup =
64 Object.values(pageNames).find(({ alias }) =>
65 alias.includes(patternType),
66 ) || {};
67 return { label: lookup.title, slug: lookup.slug };
68 };
69
70 const sectionPatterns = homePatterns.filter((pattern) => {
71 const { slug } = resolve(pattern);
72 return slug && !pluginPageTitles.includes(slug);
73 });
74
75 const seen = new Set();
76
77 const sectionsNavigationLinks = sectionPatterns.map((pattern) => {
78 const { label, slug } = resolve(pattern);
79 if (!slug) return '';
80 if (seen.has(slug)) return '';
81 seen.add(slug);
82
83 const url = pages.includes(slug)
84 ? `${window.extSharedData.homeUrl}/${slug}`
85 : `${window.extSharedData.homeUrl}/#${slug}`;
86
87 return anchorLink({ label, url });
88 });
89
90 const pluginPagesNavigationLinks = pluginPages.map(pluginPageLink);
91
92 // When an ordered slug list is provided, interleave plugin pages by slug
93 // so e.g. "shop" lands where the design preview placed it.
94 let navigationLinks;
95 if (orderedSlugs.length) {
96 const bySlug = new Map();
97 sectionsNavigationLinks.forEach((link, i) => {
98 const slug = resolve(sectionPatterns[i]).slug;
99 if (slug) bySlug.set(slug, link);
100 });
101 pluginPages.forEach((page, i) => {
102 if (page.slug) bySlug.set(page.slug, pluginPagesNavigationLinks[i]);
103 });
104 const ordered = orderedSlugs
105 .map((slug) => bySlug.get(slug))
106 .filter(Boolean);
107 const placed = new Set(orderedSlugs.filter((s) => bySlug.has(s)));
108 const extras = [
109 ...sectionsNavigationLinks.filter(
110 (_, i) => !placed.has(resolve(sectionPatterns[i]).slug),
111 ),
112 ...pluginPagesNavigationLinks.filter(
113 (_, i) => !placed.has(pluginPages[i].slug),
114 ),
115 ];
116 navigationLinks = [...ordered, ...extras].join('');
117 } else {
118 navigationLinks = sectionsNavigationLinks
119 .concat(pluginPagesNavigationLinks)
120 .join('');
121 }
122
123 await updateNavigation(navigationId, navigationLinks);
124 };
125
126 // Full-page single-page: the design build's page list IS the menu, in order —
127 // the same list the preview nav used — and the BE baked matching #section
128 // anchors into the page HTML. Build straight from it; no pattern derivation.
129 export const addSectionLinksFromDesign = async (
130 navigationId,
131 designPages = [],
132 pluginPages = [],
133 ) => {
134 const { homeUrl } = window.extSharedData;
135 const pluginBySlug = new Map(pluginPages.map((page) => [page.slug, page]));
136
137 const sectionLink = ({ slug, name }) =>
138 anchorLink({ label: name, url: `${homeUrl}/#${slug}` });
139
140 const seen = new Set();
141 const links = [];
142 for (const { slug, name } of designPages) {
143 if (!slug || slug === 'home' || seen.has(slug)) continue;
144 seen.add(slug);
145 const pluginPage = pluginBySlug.get(slug);
146 links.push(
147 pluginPage ? pluginPageLink(pluginPage) : sectionLink({ slug, name }),
148 );
149 }
150 // An active plugin page the design didn't place still belongs in the nav.
151 for (const page of pluginPages) {
152 if (!page.slug || seen.has(page.slug)) continue;
153 seen.add(page.slug);
154 links.push(pluginPageLink(page));
155 }
156
157 await updateNavigation(navigationId, links.join(''));
158 };
159
160 export const addPageLinksToNav = async (
161 navigationId,
162 allPages,
163 createdPages,
164 pluginPages = [],
165 { orderedSlugs = [] } = {},
166 ) => {
167 // Because WP may have changed the slug and permalink (i.e., because of different languages),
168 // we are using the `originalSlug` property to match the original pages with the updated ones.
169 const findCreatedPage = ({ slug }) =>
170 createdPages.find(({ originalSlug: s }) => s === slug) || {};
171
172 const filteredCreatedPages = allPages
173 .filter((p) => findCreatedPage(p)?.id) // make sure its a page
174 .filter(({ slug }) => slug !== 'home') // exclude home page
175 .map((page) => findCreatedPage(page));
176
177 // Plugin pages use `slug`, created pages use `originalSlug`
178 const getSlug = (page) => page.originalSlug ?? page.slug;
179 const getOrder = (page) => {
180 const slug = getSlug(page);
181 return (
182 pageNames[slug]?.navOrder ??
183 Object.values(pageNames).find((p) => p.alias?.includes(slug))?.navOrder ??
184 Object.keys(pageNames).length + 1
185 );
186 };
187 const seen = new Set();
188 const mergedPages = [...filteredCreatedPages, ...pluginPages].filter(
189 (page) => {
190 const slug = getSlug(page);
191 if (!slug) return true;
192 if (seen.has(slug)) return false;
193 seen.add(slug);
194 return true;
195 },
196 );
197
198 let finalPages;
199 if (orderedSlugs.length) {
200 const indexOf = (p) => orderedSlugs.indexOf(getSlug(p));
201 const ordered = mergedPages
202 .filter((p) => indexOf(p) !== -1)
203 .sort((a, b) => indexOf(a) - indexOf(b));
204 const extras = mergedPages.filter((p) => indexOf(p) === -1);
205 finalPages = [...ordered, ...extras];
206 } else {
207 const contactPage = mergedPages.find((page) => {
208 const slug = getSlug(page);
209 return slug === 'contact' || pageNames.contact?.alias?.includes(slug);
210 });
211
212 const sortedPages = mergedPages
213 .filter((page) => page !== contactPage)
214 .sort((a, b) => getOrder(a) - getOrder(b));
215
216 finalPages = contactPage
217 ? (() => {
218 const index =
219 sortedPages.length === 5 ? 5 : Math.min(4, sortedPages.length);
220 return [
221 ...sortedPages.slice(0, index),
222 contactPage,
223 ...sortedPages.slice(index),
224 ];
225 })()
226 : sortedPages;
227 }
228
229 const pageLinks = finalPages.map(pluginPageLink);
230
231 const topLevelLinks = pageLinks.slice(0, 5).join('');
232 const submenuLinks = pageLinks.slice(5);
233 // We want a max of 6 top-level links, but if 7+, then move the last
234 // two+ to a submenu.
235 const additionalLinks =
236 submenuLinks.length > 1
237 ? ` <!-- wp:navigation-submenu ${JSON.stringify({
238 // translators: "More" here is used for a navigation menu item that contains additional links.
239 label: __('More', 'extendify-local'),
240 url: '#',
241 kind: 'custom',
242 })} --> ${submenuLinks.join('')} <!-- /wp:navigation-submenu -->`
243 : submenuLinks.join(''); // only 1 link here
244
245 await updateNavigation(navigationId, topLevelLinks + additionalLinks);
246 };
247
248 const getNavAttributes = (headerCode) => {
249 try {
250 return JSON.parse(headerCode.match(/<!-- wp:navigation([\s\S]*?)-->/)[1]);
251 } catch (_e) {
252 return {};
253 }
254 };
255
256 export const updateNavAttributes = (headerCode, attributes) => {
257 const newAttributes = JSON.stringify({
258 ...getNavAttributes(headerCode),
259 ...attributes,
260 });
261 return headerCode.replace(
262 // biome-ignore lint: don't want to refactor and test this regex now
263 /(<!--\s*wp:navigation\b[^>]*>)([^]*?)(<!--\s*\/wp:navigation\s*-->)/gi,
264 `<!-- wp:navigation ${newAttributes} /-->`,
265 );
266 };
267
268 const getNavExtrasBlock = (launchDecisions) => {
269 switch (launchDecisions?.navExtras) {
270 case 'button': {
271 const label =
272 launchDecisions?.navButtonLabel || __('Get Started', 'extendify-local');
273 return `<!-- wp:buttons {"className":"ext-nav-extras-btn"} -->
274 <div class="wp-block-buttons ext-nav-extras-btn"><!-- wp:button {"className":"is-style-ext-preset\u002d\u002dbutton\u002d\u002dnatural-1\u002d\u002dbutton-1","style":{"spacing":{"padding":{"left":"20px","right":"20px","top":"8px","bottom":"8px"}},"typography":{"lineHeight":1.6}},"fontSize":"small"} -->
275 <div class="wp-block-button is-style-ext-preset--button--natural-1--button-1"><a class="wp-block-button__link has-small-font-size has-custom-font-size wp-element-button" href="#extendify-navbar-cta" style="padding-top:8px;padding-right:20px;padding-bottom:8px;padding-left:20px;line-height:1.6">${label}</a></div>
276 <!-- /wp:button --></div>
277 <!-- /wp:buttons -->`;
278 }
279 case 'phone-number':
280 return `<!-- wp:group {"className":"ext-nav-extras-phone","style":{"spacing":{"blockGap":"6px"}},"layout":{"type":"flex","flexWrap":"nowrap","verticalAlignment":"center"}} -->
281 <div class="wp-block-group ext-nav-extras-phone"><!-- wp:image {"sizeSlug":"large","style":{"layout":{"selfStretch":"fixed","flexSize":"21px"},"color":{"duotone":"var:preset|duotone|primary-foreground"},"spacing":{"margin":{"bottom":"6px"}}}} -->
282 <figure class="wp-block-image size-large" style="margin-bottom:6px"><img src="data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHdpZHRoPSIzMiIgaGVpZ2h0PSIzMiIgZmlsbD0iIzAwMDAwMCIgdmlld0JveD0iMCAwIDI1NiAyNTYiPjxwYXRoIGQ9Ik0yMjQsMTU0LjhsLTQ3LjA5LTIxLjExLS4xOC0uMDhhMTkuOTQsMTkuOTQsMCwwLDAtMTksMS43NSwxMy4wOCwxMy4wOCwwLDAsMC0xLjEyLjg0bC0yMi4zMSwxOWMtMTMtNy4wNS0yNi40My0yMC4zNy0zMy40OS0zMy4yMWwxOS4wNi0yMi42NmExMS43NiwxMS43NiwwLDAsMCwuODUtMS4xNSwyMCwyMCwwLDAsMCwxLjY2LTE4LjgzLDEuNDIsMS40MiwwLDAsMS0uMDgtLjE4TDEwMS4yLDMyQTIwLjA2LDIwLjA2LDAsMCwwLDgwLjQyLDIwLjE1LDYwLjI3LDYwLjI3LDAsMCwwLDI4LDgwYzAsODEuNjEsNjYuMzksMTQ4LDE0OCwxNDhhNjAuMjcsNjAuMjcsMCwwLDAsNTkuODUtNTIuNDJBMjAuMDYsMjAuMDYsMCwwLDAsMjI0LDE1NC44Wk0xNzYsMjA0QTEyNC4xNSwxMjQuMTUsMCwwLDEsNTIsODAsMzYuMjksMzYuMjksMCwwLDEsODAuNDgsNDQuNDZsMTguODIsNDJMODAuMTQsMTA5LjI4YTEyLDEyLDAsMCwwLS44NiwxLjE2QTIwLDIwLDAsMCwwLDc4LDEzMC4wOGM5LjQyLDE5LjI4LDI4LjgzLDM4LjU2LDQ4LjMxLDQ4QTIwLDIwLDAsMCwwLDE0NiwxNzYuNjNhMTEuNjMsMTEuNjMsMCwwLDAsMS4xMS0uODVsMjIuNDMtMTkuMDcsNDIsMTguODFBMzYuMjksMzYuMjksMCwwLDEsMTc2LDIwNFoiPjwvcGF0aD48L3N2Zz4=" alt=""/></figure>
283 <!-- /wp:image -->
284
285 <!-- wp:paragraph {"className":"no-underline","style":{"elements":{"link":{"color":{"text":"var:preset|color|primary"}}},"typography":{"fontSize":"18px","fontStyle":"normal","fontWeight":"700","textDecoration":"none"}},"textColor":"primary"} -->
286 <p class="no-underline has-primary-color has-text-color has-link-color" style="font-size:18px;font-style:normal;font-weight:700;text-decoration:none"><a href="tel:206-555-0100" data-type="tel" data-id="tel:206-555-0100">206-555-0100</a></p>
287 <!-- /wp:paragraph --></div>
288 <!-- /wp:group -->`;
289 case 'social-icons':
290 return `<!-- wp:social-links {"iconColor":"foreground","iconColorValue":"var(--wp--preset--color--foreground)","size":"has-small-icon-size","className":"is-style-logos-only ext-hidden tablet:ext-flex ext-nav-extras-social","style":{"spacing":{"blockGap":"1rem"}},"layout":{"type":"flex","flexWrap":"nowrap","justifyContent":"right"}} -->
291 <ul class="wp-block-social-links has-small-icon-size has-icon-color is-style-logos-only ext-hidden tablet:ext-flex ext-nav-extras-social"><!-- wp:social-link {"url":"https://www.instagram.com/","service":"instagram"} /-->
292
293 <!-- wp:social-link {"url":"https://www.facebook.com/","service":"facebook"} /-->
294
295 <!-- wp:social-link {"url":"https://x.com/","service":"x"} /--></ul>
296 <!-- /wp:social-links -->`;
297 default:
298 return null;
299 }
300 };
301
302 // Woo auto-inserts these after the nav, but WP suppresses them when AutoLaunch
303 // saves the header directly — insert them ourselves so they render. fontSize
304 // matches Extendable's nav (`small`).
305 export const injectWooCommerceIcons = (headerCode) => {
306 // The fetched header can already carry them: WP materializes hooked blocks
307 // into REST responses once Woo is active (e.g. on a re-launch).
308 if (/wp:woocommerce\/(mini-cart|customer-account)/.test(headerCode)) {
309 return headerCode;
310 }
311
312 // After updateNavAttributes the nav block is always self-closing.
313 const navBlock = /<!--\s*wp:navigation\b[^>]*?\/-->/i;
314 if (!navBlock.test(headerCode)) return headerCode;
315
316 const icons = [
317 '<!-- wp:woocommerce/customer-account {"displayStyle":"icon_only","iconStyle":"line","iconClass":"wc-block-customer-account__account-icon","fontSize":"small"} /-->',
318 '<!-- wp:woocommerce/mini-cart {"fontSize":"small"} /-->',
319 ].join('\n');
320
321 return headerCode.replace(navBlock, (nav) => `${nav}\n${icons}`);
322 };
323
324 export const injectNavExtras = (headerCode, launchDecisions) => {
325 const navExtras = launchDecisions?.navExtras;
326 if (!navExtras || navExtras === 'none') return headerCode;
327
328 const block = getNavExtrasBlock(launchDecisions);
329 if (!block) return headerCode;
330
331 // Strip any existing ext-nav-extras-* block so we don't stack with what the
332 // header may already ship with.
333 const stripped = headerCode.replace(
334 /<!--\s*wp:([\w-]+)\b[^>]*ext-nav-extras-[\w-]+[^>]*-->[\s\S]*?<!--\s*\/wp:\1\s*-->\s*/g,
335 '',
336 );
337
338 const markerIdx = stripped.indexOf('ext-nav-extras');
339 if (markerIdx === -1) return stripped;
340
341 const groupCloseMatch = stripped
342 .slice(markerIdx)
343 .match(/<!--\s*\/wp:group\s*-->/);
344 if (!groupCloseMatch) return stripped;
345
346 const insertAt = markerIdx + groupCloseMatch.index;
347 return `${stripped.slice(0, insertAt)}${block}\n${stripped.slice(insertAt)}`;
348 };
349