diff --git a/blocks/canvas/editor-utils/block-slash.js b/blocks/canvas/editor-utils/block-slash.js index 3161a20df..7e4e9782a 100644 --- a/blocks/canvas/editor-utils/block-slash.js +++ b/blocks/canvas/editor-utils/block-slash.js @@ -109,7 +109,7 @@ export function checkBlockLibraryConfigured({ org, site } = {}) { } return (async () => { try { - const { getBlocksExtension } = await import('../ew-panel-extensions/helpers.js'); + const { getBlocksExtension } = await import('../../shared/block-library.js'); store.hasLibrary = !!(await getBlocksExtension(org, site)); } catch { store.hasLibrary = false; @@ -138,7 +138,7 @@ export function ensureBlockLibrary({ org, site } = {}) { store.state = 'loading'; return (async () => { try { - const { loadBlockLibrary } = await import('../ew-panel-extensions/helpers.js'); + const { loadBlockLibrary } = await import('../../shared/block-library.js'); const { ext, blocks } = await loadBlockLibrary(org, site); if (!ext) { store.entries = []; diff --git a/blocks/canvas/ew-block-library-modal/ew-block-library-modal.js b/blocks/canvas/ew-block-library-modal/ew-block-library-modal.js index e8db46054..b43cbfe8a 100644 --- a/blocks/canvas/ew-block-library-modal/ew-block-library-modal.js +++ b/blocks/canvas/ew-block-library-modal/ew-block-library-modal.js @@ -2,10 +2,10 @@ import { LitElement, html, nothing } from 'da-lit'; import { getNx, getNx2 } from '../../../scripts/utils.js'; import getSheet from '../../shared/sheet.js'; import { - loadBlockLibrary, getItemPreviewUrl, getPreviewStatus, } from '../ew-panel-extensions/helpers.js'; +import { loadBlockLibrary } from '../../shared/block-library.js'; const nx = getNx(); await import(`${nx}/blocks/shared/dialog/dialog.js`); diff --git a/blocks/canvas/ew-page-outline/ew-page-outline.js b/blocks/canvas/ew-page-outline/ew-page-outline.js index 35ed2088c..b17d201a9 100644 --- a/blocks/canvas/ew-page-outline/ew-page-outline.js +++ b/blocks/canvas/ew-page-outline/ew-page-outline.js @@ -15,7 +15,7 @@ import { moveContentItem, moveSection, } from '../editor-utils/blocks.js'; -import { fetchExtensions } from '../ew-panel-extensions/helpers.js'; +import { fetchExtensions } from '../../shared/block-library.js'; const DELETE_ICON_SRC = '/img/icons/s2-icon-delete-20-n.svg'; const ADD_BLOCK_ICON_SRC = '/img/icons/s2-icon-tableadd-20-n.svg'; diff --git a/blocks/canvas/ew-panel-extensions/ew-panel-library.js b/blocks/canvas/ew-panel-extensions/ew-panel-library.js index c3f3510b4..d00ae5e2e 100644 --- a/blocks/canvas/ew-panel-extensions/ew-panel-library.js +++ b/blocks/canvas/ew-panel-extensions/ew-panel-library.js @@ -2,7 +2,6 @@ import { LitElement, html, nothing } from 'da-lit'; import { getNx, getNx2 } from '../../../scripts/utils.js'; import getSheet from '../../shared/sheet.js'; import { - fetchBlocks, fetchItems, insertBlock, insertText, @@ -10,6 +9,7 @@ import { getPreviewStatus, getItemPreviewUrl, } from './helpers.js'; +import { fetchBlocks } from '../../shared/block-library.js'; import { getExtensionsBridge } from '../editor-utils/extensions-bridge.js'; const { loadStyle, hashChange } = await import(`${getNx()}/utils/utils.js`); diff --git a/blocks/canvas/ew-panel-extensions/helpers.js b/blocks/canvas/ew-panel-extensions/helpers.js index 9ddc33acd..ec8f48a8f 100644 --- a/blocks/canvas/ew-panel-extensions/helpers.js +++ b/blocks/canvas/ew-panel-extensions/helpers.js @@ -4,200 +4,15 @@ import { getNx, getNx2Api } from '../../../scripts/utils.js'; import { daFetch } from '../../shared/utils.js'; import { htmlToProse } from '../../edit/utils/helpers.js'; import { getExtensionsBridge } from '../editor-utils/extensions-bridge.js'; +import { fetchExtensions, getBlocksExtension } from '../../shared/block-library.js'; const { hashChange } = await import(`${getNx()}/utils/utils.js`); const { fetchDaConfigs, getFirstSheet } = await import(`${getNx()}/utils/daConfig.js`); const ref = new URLSearchParams(window.location.search).get('ref') || 'main'; -const AEM_ORIGINS = ['hlx.page', 'hlx.live', 'aem.page', 'aem.live']; const REPLACE_CONTENT = ''; -// --------------------------------------------------------------------------- -// Block HTML parsing — ported from da-live helpers/index.js -// --------------------------------------------------------------------------- - -function isHeading(el) { - return ['H1', 'H2', 'H3', 'H4', 'H5', 'H6'].includes(el?.nodeName); -} - -function getBlockName(className) { - const [name, ...rest] = (className || '').split(' '); - return { name, variants: rest.length ? rest.join(', ') : undefined }; -} - -function getBlockTableHtml(block) { - const { name, variants } = getBlockName(block.className); - const rows = [...block.children]; - const maxCols = rows.reduce((n, row) => Math.max(n, row.children.length), 0) || 1; - - const table = document.createElement('table'); - table.setAttribute('border', '1'); - - const headerRow = document.createElement('tr'); - const th = document.createElement('td'); - th.setAttribute('colspan', String(maxCols)); - th.textContent = variants ? `${name} (${variants})` : name; - headerRow.append(th); - table.append(headerRow); - - rows.forEach((row) => { - const tr = document.createElement('tr'); - const cells = [...row.children]; - cells.forEach((col, i) => { - const td = document.createElement('td'); - // Pad only the last cell so the row's total width equals maxCols. - // Spanning every cell (the old behavior) made short multi-cell rows - // wider than maxCols, forcing ProseMirror to insert empty cells into - // every other row to keep the table rectangular. - if (cells.length < maxCols && i === cells.length - 1) { - td.setAttribute('colspan', String(maxCols - i)); - } - td.innerHTML = col.innerHTML; - tr.append(td); - }); - table.append(tr); - }); - - return table; -} - -function decorateImages(element, path) { - try { - const { origin } = new URL(path); - element.querySelectorAll('img').forEach((img) => { - if (img.getAttribute('src')?.startsWith('./')) { - img.src = `${origin}/${img.src.split('/').pop()}`; - } - const ratio = img.width > 200 ? 200 / img.width : 1; - img.width = Math.round(img.width * ratio); - img.height = Math.round(img.height * ratio); - }); - } catch { /* leave images as-is */ } -} - -async function fetchAndParseHtml(path, isAemHosted) { - try { - const resp = await daFetch(`${path}${isAemHosted ? '.plain.html' : ''}`, { noRedirect: true }); - if (!resp.ok) return null; - return new window.DOMParser().parseFromString(await resp.text(), 'text/html'); - } catch { return null; } -} - -function getSectionsAndBlocks(doc) { - return [...doc.querySelectorAll('body > div, main > div')].reduce((acc, section) => { - const hr = document.createElement('hr'); - hr.dataset.issection = 'true'; - acc.push(hr, ...section.querySelectorAll(':scope > *')); - return acc; - }, []); -} - -function processGroupBlock(block) { - const container = document.createElement('div'); - [...block.children].forEach((child) => { - container.append(child.tagName === 'DIV' ? getBlockTableHtml(child) : child.cloneNode(true)); - }); - return container; -} - -function groupBlocks(elements) { - return elements.reduce((state, el) => { - if (el.classList?.contains('library-container-start')) { - const blockGroup = document.createElement('div'); - blockGroup.dataset.isgroup = 'true'; - if (isHeading(el.previousElementSibling)) { - blockGroup.dataset.groupheading = el.previousElementSibling.textContent; - } - state.currentGroup = { blockGroup }; - } else if (el.classList?.contains('library-container-end') && state.currentGroup) { - const { blockGroup } = state.currentGroup; - if (el.nextElementSibling?.classList.contains('library-metadata')) { - blockGroup.append(el.nextElementSibling.cloneNode(true)); - } - state.blocks.push(blockGroup); - state.currentGroup = null; - } else if (state.currentGroup) { - state.currentGroup.blockGroup.append(el.cloneNode(true)); - } else if ( - el.nodeName === 'DIV' - && !el.dataset?.issection - && !el.classList?.contains('library-metadata') - ) { - state.blocks.push(el); - } - return state; - }, { blocks: [], currentGroup: null }).blocks; -} - -function getLibraryMetadata(el) { - return [...el.childNodes].reduce((acc, row) => { - if (row.children) { - const key = row.children[0]?.textContent.trim().toLowerCase(); - const val = row.children[1]?.textContent.trim(); - if (key && val) acc[key] = val; - } - return acc; - }, {}); -} - -// Metadata can sit immediately before or after the block it describes, or be -// nested inside it (nested case also covers any position within a -// library-container-start/end group, since group children are flattened into -// the group's own subtree during parsing). -function findMetaEl(block) { - if (block.nextElementSibling?.classList.contains('library-metadata')) { - return block.nextElementSibling; - } - if (block.previousElementSibling?.classList.contains('library-metadata')) { - return block.previousElementSibling; - } - return block.querySelector('.library-metadata'); -} - -function transformBlock(block) { - // Skip a preceding metadata sibling so a `heading, metadata, block` layout - // still resolves the name from the heading. - const headingSib = block.previousElementSibling?.classList.contains('library-metadata') - ? block.previousElementSibling.previousElementSibling - : block.previousElementSibling; - let item; - if (block.dataset.groupheading) { - item = { name: block.dataset.groupheading }; - } else if (isHeading(headingSib) && headingSib.textContent) { - item = { name: headingSib.textContent }; - } else { - item = getBlockName(block.className || ''); - } - - // Extract and strip metadata before generating the block's dom, so it never - // leaks into the content that gets copied/inserted or previewed. - const metaEl = findMetaEl(block); - if (metaEl) { - const md = getLibraryMetadata(metaEl); - if (md.name) item.name = md.name; - if (md.searchtags) item.tags = md.searchtags; - if (md.description) item.description = md.description; - metaEl.remove(); - } - - item.dom = block.dataset?.isgroup ? processGroupBlock(block) : getBlockTableHtml(block); - return item; -} - -export async function getBlockVariants(path) { - let isAemHosted = false; - try { - isAemHosted = AEM_ORIGINS.some((o) => new URL(path).origin.endsWith(o)); - } catch { /* relative path */ } - - const doc = await fetchAndParseHtml(path, isAemHosted); - if (!doc) return []; - - decorateImages(doc.body, path); - return groupBlocks(getSectionsAndBlocks(doc)).map(transformBlock); -} - // --------------------------------------------------------------------------- // Extension config // --------------------------------------------------------------------------- @@ -221,22 +36,6 @@ function sortLibraryExtensions(list) { return [...list].sort((a, b) => orderOf(a.name) - orderOf(b.name)); } -function getIsPluginAllowed(plugRef) { - const pluginRef = plugRef || 'main'; - if (pluginRef === 'main') return true; - if (ref === 'local') return true; - return pluginRef === ref; -} - -function calculateSources(org, site, sheetPath) { - return sheetPath.split(',').map((p) => { - const trimmed = p.trim(); - if (!trimmed.startsWith('/')) return trimmed; - if (ref === 'local') return `http://localhost:3000${trimmed}`; - return `https://${ref}--${site}--${org}.aem.live${trimmed}`; - }); -} - function mergePlugin(list, plugin) { let idx = list.findIndex((p) => p.name === 'templates'); if (idx === -1) idx = list.findIndex((p) => p.name === 'blocks'); @@ -247,105 +46,19 @@ function mergePlugin(list, plugin) { } } -export async function fetchExtensions(org, site) { - const configs = await Promise.all(fetchDaConfigs({ org, site })); - const validConfigs = configs.filter((conf) => !conf?.error).reverse(); - if (!validConfigs.length) return []; - - const rows = validConfigs.flatMap((conf) => conf?.library?.data || []); - if (!rows.length) return []; - - const seen = new Set(); - const extensions = rows.reduce((acc, row) => { - if (!row.title || !getIsPluginAllowed(row.ref)) return acc; - const name = row.title.trim().toLowerCase().replaceAll(' ', '-'); - if (seen.has(name)) return acc; - seen.add(name); - acc.push({ - name, - title: row.title.trim(), - sources: calculateSources(org, site, row.path), - experience: row.experience || 'inline', - format: row.format || '', - icon: row.icon || '', - ootb: OOTB_PLUGINS.has(name), - }); - return acc; - }, []); - +// AEM Assets is a canvas-only panel plugin, layered on top of the shared library +// extensions when the site has a repository configured. +async function addAemAssetsPlugin(extensions, org, site) { try { + const configs = await Promise.all(fetchDaConfigs({ org, site })); + const validConfigs = configs.filter((conf) => !conf?.error); const entries = validConfigs.flatMap((conf) => getFirstSheet(conf) || []); const hasRepo = entries.find((entry) => entry.key === 'aem.repositoryId')?.value; - if (hasRepo) { - const { getAssetsPlugin } = await import('./aem-assets.js'); - const plugin = getAssetsPlugin({ org, site }); - if (plugin) mergePlugin(extensions, plugin); - } + if (!hasRepo) return; + const { getAssetsPlugin } = await import('./aem-assets.js'); + const plugin = getAssetsPlugin({ org, site }); + if (plugin) mergePlugin(extensions, plugin); } catch { /* proceed without assets */ } - - return extensions; -} - -/** Resolve the configured "blocks" library extension for an org/site, or null. */ -export async function getBlocksExtension(org, site) { - if (!org || !site) return null; - const extensions = await fetchExtensions(org, site); - return extensions?.find((ext) => ext.name === 'blocks') || null; -} - -// --------------------------------------------------------------------------- -// Data fetching -// --------------------------------------------------------------------------- - -export async function fetchBlocks(sources) { - const blocks = []; - for (const url of sources) { - try { - const resp = await daFetch(url, { noRedirect: true }); - if (resp.ok) { - const json = await resp.json(); - const data = getFirstSheet(json) ?? (Array.isArray(json) ? json : []); - data.forEach((row) => { - if (row.name && row.path) { - blocks.push({ ...row, loadVariants: getBlockVariants(row.path) }); - } - }); - } - } catch { /* skip failed source */ } - } - return blocks; -} - -const blockLibraryCache = new Map(); - -/** - * Load — and memoize per org/site — the configured blocks library: the resolved - * "blocks" extension plus its fetched blocks (each carrying a lazy `loadVariants` - * promise). Shared by the slash-menu prefetch and the block-library modal so the - * library (and every variant's HTML) is fetched and parsed at most once. - * Resolves to `{ ext: null, blocks: [] }` when no library is configured. - */ -export function loadBlockLibrary(org, site) { - if (!org || !site) return Promise.resolve({ ext: null, blocks: [] }); - const key = `${org}/${site}`; - if (!blockLibraryCache.has(key)) { - const pending = (async () => { - const ext = await getBlocksExtension(org, site); - if (!ext) return { ext: null, blocks: [] }; - const blocks = await fetchBlocks(ext.sources); - return { ext, blocks }; - })().catch((err) => { - // Don't cache transient failures — allow a later retry. - blockLibraryCache.delete(key); - throw err; - }); - blockLibraryCache.set(key, pending); - } - return blockLibraryCache.get(key); -} - -export function resetBlockLibraryCache() { - blockLibraryCache.clear(); } const librarySheetCache = new Map(); @@ -623,6 +336,7 @@ export function extensionToPanelView(ext, section) { */ export async function getCanvasToolPanelViews({ org, site }) { const extensions = await fetchExtensions(org, site); + await addAemAssetsPlugin(extensions, org, site); const library = sortLibraryExtensions(extensions.filter(isLibraryExtension)); const thirdParty = extensions.filter((ext) => !isLibraryExtension(ext)); diff --git a/blocks/edit/prose/plugins/imageFocalPoint.js b/blocks/edit/prose/plugins/imageFocalPoint.js index b7c046263..83c745251 100644 --- a/blocks/edit/prose/plugins/imageFocalPoint.js +++ b/blocks/edit/prose/plugins/imageFocalPoint.js @@ -1,7 +1,8 @@ import { Plugin, PluginKey } from 'da-y-wrapper'; import inlinesvg from '../../../shared/inlinesvg.js'; import { openFocalPointDialog } from './focalPointDialog.js'; -import { loadLibrary } from '../../da-library/helpers/helpers.js'; +import { loadBlockLibrary } from '../../../shared/block-library.js'; +import getPathDetails from '../../../shared/pathDetails.js'; import { getTableInfo, isInTableCell } from './tableUtils.js'; const imageFocalPointKey = new PluginKey('imageFocalPoint'); @@ -12,10 +13,9 @@ async function getBlocksData() { if (!blocksDataPromise) { blocksDataPromise = (async () => { try { - const libraryList = await loadLibrary(); - const blocksInfo = libraryList.find((l) => l.name === 'blocks'); - if (!blocksInfo?.loadItems) return []; - return await blocksInfo.loadItems; + const { org, site } = getPathDetails(); + const { blocks } = await loadBlockLibrary(org, site); + return blocks; } catch (error) { // eslint-disable-next-line no-console console.warn('Failed to load blocks data for focal point:', error); diff --git a/blocks/shared/block-library.js b/blocks/shared/block-library.js new file mode 100644 index 000000000..ac7b5ab6b --- /dev/null +++ b/blocks/shared/block-library.js @@ -0,0 +1,289 @@ +import { daFetch, fetchDaConfigs, getFirstSheet } from './utils.js'; + +// Editor-agnostic block-library loader. Reads the site's library config, fetches +// block/variant HTML and parses it into plain DOM (never ProseMirror nodes, so it +// carries no schema dependency and works from any editor context). Shared by the +// canvas tool panels and the edit prose plugins (e.g. image focal point). + +const ref = new URLSearchParams(window.location.search).get('ref') || 'main'; +const AEM_ORIGINS = ['hlx.page', 'hlx.live', 'aem.page', 'aem.live']; +const OOTB_PLUGINS = new Set(['blocks', 'templates', 'icons', 'placeholders']); + +// --------------------------------------------------------------------------- +// Block HTML parsing +// --------------------------------------------------------------------------- + +function isHeading(el) { + return ['H1', 'H2', 'H3', 'H4', 'H5', 'H6'].includes(el?.nodeName); +} + +function getBlockName(className) { + const [name, ...rest] = (className || '').split(' '); + return { name, variants: rest.length ? rest.join(', ') : undefined }; +} + +function getBlockTableHtml(block) { + const { name, variants } = getBlockName(block.className); + const rows = [...block.children]; + const maxCols = rows.reduce((n, row) => Math.max(n, row.children.length), 0) || 1; + + const table = document.createElement('table'); + table.setAttribute('border', '1'); + + const headerRow = document.createElement('tr'); + const th = document.createElement('td'); + th.setAttribute('colspan', String(maxCols)); + th.textContent = variants ? `${name} (${variants})` : name; + headerRow.append(th); + table.append(headerRow); + + rows.forEach((row) => { + const tr = document.createElement('tr'); + const cells = [...row.children]; + cells.forEach((col, i) => { + const td = document.createElement('td'); + if (cells.length < maxCols && i === cells.length - 1) { + td.setAttribute('colspan', String(maxCols - i)); + } + td.innerHTML = col.innerHTML; + tr.append(td); + }); + table.append(tr); + }); + + return table; +} + +function decorateImages(element, path) { + try { + const { origin } = new URL(path); + element.querySelectorAll('img').forEach((img) => { + if (img.getAttribute('src')?.startsWith('./')) { + img.src = `${origin}/${img.src.split('/').pop()}`; + } + const ratio = img.width > 200 ? 200 / img.width : 1; + img.width = Math.round(img.width * ratio); + img.height = Math.round(img.height * ratio); + }); + } catch { /* leave images as-is */ } +} + +async function fetchAndParseHtml(path, isAemHosted) { + try { + const resp = await daFetch(`${path}${isAemHosted ? '.plain.html' : ''}`, { noRedirect: true }); + if (!resp.ok) return null; + return new window.DOMParser().parseFromString(await resp.text(), 'text/html'); + } catch { return null; } +} + +function getSectionsAndBlocks(doc) { + return [...doc.querySelectorAll('body > div, main > div')].reduce((acc, section) => { + const hr = document.createElement('hr'); + hr.dataset.issection = 'true'; + acc.push(hr, ...section.querySelectorAll(':scope > *')); + return acc; + }, []); +} + +function processGroupBlock(block) { + const container = document.createElement('div'); + [...block.children].forEach((child) => { + container.append(child.tagName === 'DIV' ? getBlockTableHtml(child) : child.cloneNode(true)); + }); + return container; +} + +function groupBlocks(elements) { + return elements.reduce((state, el) => { + if (el.classList?.contains('library-container-start')) { + const blockGroup = document.createElement('div'); + blockGroup.dataset.isgroup = 'true'; + if (isHeading(el.previousElementSibling)) { + blockGroup.dataset.groupheading = el.previousElementSibling.textContent; + } + state.currentGroup = { blockGroup }; + } else if (el.classList?.contains('library-container-end') && state.currentGroup) { + const { blockGroup } = state.currentGroup; + if (el.nextElementSibling?.classList.contains('library-metadata')) { + blockGroup.append(el.nextElementSibling.cloneNode(true)); + } + state.blocks.push(blockGroup); + state.currentGroup = null; + } else if (state.currentGroup) { + state.currentGroup.blockGroup.append(el.cloneNode(true)); + } else if ( + el.nodeName === 'DIV' + && !el.dataset?.issection + && !el.classList?.contains('library-metadata') + ) { + state.blocks.push(el); + } + return state; + }, { blocks: [], currentGroup: null }).blocks; +} + +function getLibraryMetadata(el) { + return [...el.childNodes].reduce((acc, row) => { + if (row.children) { + const key = row.children[0]?.textContent.trim().toLowerCase(); + const val = row.children[1]?.textContent.trim(); + if (key && val) acc[key] = val; + } + return acc; + }, {}); +} + +function transformBlock(block) { + const headingSib = block.previousElementSibling?.classList.contains('library-metadata') + ? block.previousElementSibling.previousElementSibling + : block.previousElementSibling; + let item; + if (block.dataset.groupheading) { + item = { name: block.dataset.groupheading }; + } else if (isHeading(headingSib) && headingSib.textContent) { + item = { name: headingSib.textContent }; + } else { + item = getBlockName(block.className || ''); + } + + let metaEl = block.nextElementSibling?.classList.contains('library-metadata') + ? block.nextElementSibling + : null; + if (!metaEl && block.previousElementSibling?.classList.contains('library-metadata')) { + metaEl = block.previousElementSibling; + } + if (!metaEl) metaEl = block.querySelector('.library-metadata'); + if (metaEl) { + const md = getLibraryMetadata(metaEl); + if (md.name) item.name = md.name; + if (md.searchtags) item.tags = md.searchtags; + if (md.description) item.description = md.description; + metaEl.remove(); + } + item.dom = block.dataset?.isgroup ? processGroupBlock(block) : getBlockTableHtml(block); + return item; +} + +export async function getBlockVariants(path) { + let isAemHosted = false; + try { + isAemHosted = AEM_ORIGINS.some((o) => new URL(path).origin.endsWith(o)); + } catch { /* relative path */ } + + const doc = await fetchAndParseHtml(path, isAemHosted); + if (!doc) return []; + + decorateImages(doc.body, path); + return groupBlocks(getSectionsAndBlocks(doc)).map(transformBlock); +} + +// --------------------------------------------------------------------------- +// Extension config +// --------------------------------------------------------------------------- + +function getIsPluginAllowed(plugRef) { + const pluginRef = plugRef || 'main'; + if (pluginRef === 'main') return true; + if (ref === 'local') return true; + return pluginRef === ref; +} + +function calculateSources(org, site, sheetPath) { + return sheetPath.split(',').map((p) => { + const trimmed = p.trim(); + if (!trimmed.startsWith('/')) return trimmed; + if (ref === 'local') return `http://localhost:3000${trimmed}`; + return `https://${ref}--${site}--${org}.aem.live${trimmed}`; + }); +} + +// Parses the library config rows into extension descriptors. The AEM Assets +// plugin is a canvas panel concern and is layered on top by the caller. +export async function fetchExtensions(org, site) { + const configs = await Promise.all(fetchDaConfigs({ org, site })); + const validConfigs = configs.filter((conf) => !conf?.error).reverse(); + if (!validConfigs.length) return []; + + const rows = validConfigs.flatMap((conf) => conf?.library?.data || []); + if (!rows.length) return []; + + const seen = new Set(); + return rows.reduce((acc, row) => { + if (!row.title || !getIsPluginAllowed(row.ref)) return acc; + const name = row.title.trim().toLowerCase().replaceAll(' ', '-'); + if (seen.has(name)) return acc; + seen.add(name); + acc.push({ + name, + title: row.title.trim(), + sources: calculateSources(org, site, row.path), + experience: row.experience || 'inline', + format: row.format || '', + icon: row.icon || '', + ootb: OOTB_PLUGINS.has(name), + }); + return acc; + }, []); +} + +/** Resolve the configured "blocks" library extension for an org/site, or null. */ +export async function getBlocksExtension(org, site) { + if (!org || !site) return null; + const extensions = await fetchExtensions(org, site); + return extensions?.find((ext) => ext.name === 'blocks') || null; +} + +// --------------------------------------------------------------------------- +// Data fetching +// --------------------------------------------------------------------------- + +export async function fetchBlocks(sources) { + const blocks = []; + for (const url of sources) { + try { + const resp = await daFetch(url, { noRedirect: true }); + if (resp.ok) { + const json = await resp.json(); + const data = getFirstSheet(json) ?? (Array.isArray(json) ? json : []); + data.forEach((row) => { + if (row.name && row.path) { + blocks.push({ ...row, loadVariants: getBlockVariants(row.path) }); + } + }); + } + } catch { /* skip failed source */ } + } + return blocks; +} + +const blockLibraryCache = new Map(); + +/** + * Load — and memoize per org/site — the configured blocks library: the resolved + * "blocks" extension plus its fetched blocks (each carrying a lazy `loadVariants` + * promise). Shared by the slash-menu prefetch and the block-library modal so the + * library (and every variant's HTML) is fetched and parsed at most once. + * Resolves to `{ ext: null, blocks: [] }` when no library is configured. + */ +export function loadBlockLibrary(org, site) { + if (!org || !site) return Promise.resolve({ ext: null, blocks: [] }); + const key = `${org}/${site}`; + if (!blockLibraryCache.has(key)) { + const pending = (async () => { + const ext = await getBlocksExtension(org, site); + if (!ext) return { ext: null, blocks: [] }; + const blocks = await fetchBlocks(ext.sources); + return { ext, blocks }; + })().catch((err) => { + // Don't cache transient failures — allow a later retry. + blockLibraryCache.delete(key); + throw err; + }); + blockLibraryCache.set(key, pending); + } + return blockLibraryCache.get(key); +} + +export function resetBlockLibraryCache() { + blockLibraryCache.clear(); +} diff --git a/test/unit/blocks/canvas/editor-utils/block-slash.test.js b/test/unit/blocks/canvas/editor-utils/block-slash.test.js index 4b46fc678..a2e49376d 100644 --- a/test/unit/blocks/canvas/editor-utils/block-slash.test.js +++ b/test/unit/blocks/canvas/editor-utils/block-slash.test.js @@ -140,8 +140,16 @@ describe('block-slash store', () => { let ensureBlockLibrary; let checkBlockLibraryConfigured; let resetBlockLibraryCache; + let savedFetch; before(async () => { + // No blocks library is configured in these tests; stub the config fetch so the + // shared loader resolves to "no extensions" without hitting the network. + savedFetch = window.fetch; + window.fetch = async (url, opts) => (String(url).includes('/config/') + ? { ok: true, json: async () => ({}) } + : savedFetch(url, opts)); + const mod = await import('../../../../../blocks/canvas/editor-utils/block-slash.js'); ingestBlocks = mod.ingestBlocks; blockItemsForQuery = mod.blockItemsForQuery; @@ -151,9 +159,11 @@ describe('block-slash store', () => { resetBlockLibrary = mod.resetBlockLibrary; ensureBlockLibrary = mod.ensureBlockLibrary; checkBlockLibraryConfigured = mod.checkBlockLibraryConfigured; - ({ resetBlockLibraryCache } = await import('../../../../../blocks/canvas/ew-panel-extensions/helpers.js')); + ({ resetBlockLibraryCache } = await import('../../../../../blocks/shared/block-library.js')); }); + after(() => { window.fetch = savedFetch; }); + afterEach(() => { resetBlockLibrary(); resetBlockLibraryCache(); @@ -243,7 +253,7 @@ describe('block-slash store', () => { }); it('ensureBlockLibrary goes loading then settles empty when no blocks extension is configured', async () => { - // The test fixture's fetchDaConfigs returns {}, so fetchExtensions yields no 'blocks' + // The stubbed config fetch returns no library, so fetchExtensions yields no 'blocks' // extension — this exercises the no-library branch. const pending = ensureBlockLibrary({ org: 'testorg', site: 'testsite' }); expect(getState()).to.equal('loading'); diff --git a/test/unit/blocks/canvas/editor-utils/command-defs.test.js b/test/unit/blocks/canvas/editor-utils/command-defs.test.js index fccd6875e..94c3300e5 100644 --- a/test/unit/blocks/canvas/editor-utils/command-defs.test.js +++ b/test/unit/blocks/canvas/editor-utils/command-defs.test.js @@ -13,6 +13,18 @@ let resetBlockLibrary; let ensureBlockLibrary; let checkBlockLibraryConfigured; let resetBlockLibraryCache; +let savedFetch; + +before(() => { + // No blocks library is configured in these tests; stub the config fetch so the + // shared loader resolves to "no extensions" without hitting the network. + savedFetch = window.fetch; + window.fetch = async (url, opts) => (String(url).includes('/config/') + ? { ok: true, json: async () => ({}) } + : savedFetch(url, opts)); +}); + +after(() => { window.fetch = savedFetch; }); before(async () => { const cmd = await import('../../../../../blocks/canvas/editor-utils/command-defs.js'); @@ -25,7 +37,7 @@ before(async () => { resetBlockLibrary = bs.resetBlockLibrary; ensureBlockLibrary = bs.ensureBlockLibrary; checkBlockLibraryConfigured = bs.checkBlockLibraryConfigured; - ({ resetBlockLibraryCache } = await import('../../../../../blocks/canvas/ew-panel-extensions/helpers.js')); + ({ resetBlockLibraryCache } = await import('../../../../../blocks/shared/block-library.js')); }); afterEach(() => { @@ -100,8 +112,8 @@ describe('slashMenuItemsForQuery', () => { }); it('shows static commands immediately and a loading hint while the library fetches', async () => { - // ensureBlockLibrary flips the store to "loading" synchronously; the fixture - // then settles it empty. During loading, "/h" should still offer headings. + // ensureBlockLibrary flips the store to "loading" synchronously; the stubbed + // config fetch then settles it empty. During loading, "/h" should still offer headings. const pending = ensureBlockLibrary({ org: 'someorg', site: 'somesite' }); const items = slashMenuItemsForQuery('h'); expect(sections(items)).to.include('Loading blocks…'); diff --git a/test/unit/blocks/canvas/ew-panel-extensions/helpers.test.js b/test/unit/blocks/canvas/ew-panel-extensions/helpers.test.js index 681832c59..b3f46f4ce 100644 --- a/test/unit/blocks/canvas/ew-panel-extensions/helpers.test.js +++ b/test/unit/blocks/canvas/ew-panel-extensions/helpers.test.js @@ -9,8 +9,8 @@ let getPreviewStatus; before(async () => { const mod = await import('../../../../../blocks/canvas/ew-panel-extensions/helpers.js'); - getBlockVariants = mod.getBlockVariants; extensionToPanelView = mod.extensionToPanelView; + ({ getBlockVariants } = await import('../../../../../blocks/shared/block-library.js')); getPreviewStatus = mod.getPreviewStatus; });