From 4ec1df9f8cf7512cd1eb696d8f1eb181c714e64c Mon Sep 17 00:00:00 2001 From: Ramachandra Avuthu Date: Mon, 6 Apr 2026 12:46:37 -0400 Subject: [PATCH 1/8] msm implementation --- .../actions/global-publish/global-publish.css | 328 +++++++++++++++++ .../actions/global-publish/global-publish.js | 335 ++++++++++++++++++ .../actions/global-publish/utils.js | 73 ++++ blocks/edit/da-prepare/da-prepare.js | 6 + blocks/edit/da-title/da-title.css | 24 ++ blocks/edit/da-title/da-title.js | 26 +- blocks/shared/msm.js | 68 ++++ 7 files changed, 859 insertions(+), 1 deletion(-) create mode 100644 blocks/edit/da-prepare/actions/global-publish/global-publish.css create mode 100644 blocks/edit/da-prepare/actions/global-publish/global-publish.js create mode 100644 blocks/edit/da-prepare/actions/global-publish/utils.js create mode 100644 blocks/shared/msm.js diff --git a/blocks/edit/da-prepare/actions/global-publish/global-publish.css b/blocks/edit/da-prepare/actions/global-publish/global-publish.css new file mode 100644 index 000000000..45fb450ab --- /dev/null +++ b/blocks/edit/da-prepare/actions/global-publish/global-publish.css @@ -0,0 +1,328 @@ +:host { + display: flex; + flex-direction: column; + gap: 16px; + width: 400px; + margin: 0 24px 24px; + + p { margin: 0; } +} + +.loading, +.no-satellites { + font-size: 14px; + font-style: italic; + color: var(--s2-gray-600, #717171); +} + +/* --- Action row (side-by-side dropdowns) --- */ + +.action-row { + display: flex; + gap: 8px; + + .form-row { + flex: 0 0 calc(50% - 4px); + min-width: 0; + } +} + +/* --- Form row (rollout-inspired) --- */ + +.form-row { + display: flex; + flex-direction: column; + gap: 4px; + + > label { + font-size: var(--s2-body-xs-size, 12px); + display: block; + color: rgb(80 80 80); + margin-bottom: 0; + } +} + +/* --- Select (matches sl/components.css) --- */ + +.select-wrapper { + position: relative; + + &::after { + content: ""; + position: absolute; + top: 0; + right: 0; + width: 32px; + height: 32px; + background: no-repeat center / 18px url("https://da.live/nx/public/icons/Smock_ChevronDown_18_N.svg"); + pointer-events: none; + } + + &:has(select:disabled)::after { + display: none; + } +} + +.action-select { + display: block; + width: 100%; + padding: 0 32px 0 12px; + background: var(--s2-gray-100, #e9e9e9); + font-family: var(--font-family, "Adobe Clean", adobe-clean, "Trebuchet MS", sans-serif); + font-size: 14px; + line-height: 28px; + border: 2px solid var(--s2-gray-100, #e9e9e9); + border-radius: 8px; + outline-color: var(--s2-blue-900, #3b63fb); + outline-offset: 0; + transition: outline-offset 0.2s; + box-sizing: border-box; + appearance: none; + -webkit-appearance: none; + cursor: pointer; + color: var(--s2-gray-800, #292929); + + &:focus-visible { + outline-offset: 4px; + } + + &:disabled { + opacity: 1; + background: var(--s2-gray-75, #f3f3f3); + border-color: var(--s2-gray-75, #f3f3f3); + color: var(--s2-gray-500, #8f8f8f); + cursor: default; + } +} + +/* --- Two-column grid --- */ + +.satellite-grid { + display: flex; + gap: 16px; +} + +.satellite-column { + flex: 1; + min-width: 0; + display: flex; + flex-direction: column; +} + +.column-heading { + font-size: 11px; + font-weight: 600; + text-transform: uppercase; + letter-spacing: 0.04em; + color: var(--s2-gray-500, #929292); + padding-bottom: 4px; + border-bottom: 1px solid var(--s2-gray-200, #e1e1e1); + margin-bottom: 2px; +} + +/* --- Satellite list --- */ + +.satellite-list { + list-style: none; + padding: 0; + margin: 0; + max-height: 260px; + overflow-y: auto; + + &::-webkit-scrollbar { width: 5px; } + &::-webkit-scrollbar-track { background: transparent; } + &::-webkit-scrollbar-thumb { + background: var(--s2-gray-300, #d1d1d1); + border-radius: 3px; + &:hover { background: var(--s2-gray-500, #999); } + } +} + +.sat-row { + display: flex; + align-items: center; + gap: 8px; + padding: 6px 4px; + border-radius: 4px; + transition: background-color 0.1s; + + &:hover:not(.out-of-scope) { background: var(--s2-gray-100, #f5f5f5); } + + &.out-of-scope { + opacity: 0.38; + } + + label { + display: flex; + align-items: center; + gap: 8px; + flex: 1; + min-width: 0; + cursor: pointer; + font-size: 14px; + color: var(--s2-gray-900, #292929); + } + + label span { + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + } +} + +/* --- S2 checkbox (matches spectrum-two.css tokens) --- */ + +.sat-row input[type="checkbox"] { + appearance: none; + -webkit-appearance: none; + width: 14px; + height: 14px; + margin: 0; + border: 2px solid var(--s2-gray-600, #717171); + border-radius: 2px; + cursor: pointer; + flex-shrink: 0; + position: relative; + transition: border 0.13s ease-in-out; + + &:checked { + border-color: var(--s2-gray-800, #292929); + border-width: 7px; + background: var(--s2-gray-50, #f8f8f8); + + &::after { + content: ''; + position: absolute; + inset: 0; + top: -2px; + left: -3px; + /* checkmark: 2px white strokes */ + width: 4px; + height: 8px; + margin: auto; + border: solid var(--s2-gray-50, #f8f8f8); + border-width: 0 2px 2px 0; + transform: rotate(45deg); + } + } + + &:focus-visible { + outline: 2px solid var(--s2-blue-900, #3b63fb); + outline-offset: 2px; + } + + &:disabled { opacity: 0.4; cursor: default; } + + &:hover:not(:disabled) { + border-color: var(--s2-gray-700, #505050); + } + + &:checked:hover:not(:disabled) { + border-color: var(--s2-gray-800, #292929); + } +} + +/* --- Status icons --- */ + +.result-icon { + width: 16px; + height: 16px; + flex-shrink: 0; +} + +.result-icon.success { color: #0d6e31; } +.result-icon.error { color: var(--s2-red-900, #d31510); } +.result-icon.pending { + color: var(--s2-gray-600, #717171); + animation: spin 1s linear infinite; +} + +@keyframes spin { + from { transform: rotate(0deg); } + to { transform: rotate(360deg); } +} + +/* --- Icon button (open-in-editor) --- */ + +.icon-btn { + display: flex; + align-items: center; + justify-content: center; + width: 24px; + height: 24px; + flex-shrink: 0; + border: none; + border-radius: 4px; + background: none; + color: var(--s2-gray-500, #929292); + cursor: pointer; + padding: 0; + text-decoration: none; + transition: background-color 0.15s, color 0.15s; + + svg { width: 14px; height: 14px; fill: currentColor; } + &:hover { + background: var(--s2-gray-200, #e1e1e1); + color: var(--s2-gray-900, #292929); + } +} + +/* --- Footer actions --- */ + +.form-actions { + display: flex; + justify-content: flex-end; +} + +/* --- Confirm dialog --- */ + +.confirm-box { + padding: 12px; + background: #fef9ee; + border: 1px solid #f0dca0; + border-radius: 8px; + font-size: 14px; + color: var(--s2-gray-900, #292929); + + p { margin: 0 0 10px; line-height: 1.4; } + + .confirm-actions { + display: flex; + gap: 8px; + justify-content: flex-end; + } +} + +.confirm-btn { + appearance: none; + border: 1px solid var(--s2-gray-400, #d1d1d1); + border-radius: 4px; + background: #fff; + color: var(--s2-gray-800, #3e3e3e); + font-size: 13px; + font-weight: 500; + font-family: inherit; + padding: 5px 12px; + cursor: pointer; + white-space: nowrap; + transition: background-color 0.15s, border-color 0.15s; + + &:hover { + background: var(--s2-gray-100, #f5f5f5); + border-color: var(--s2-gray-500, #929292); + } + + &:focus-visible { + outline: 2px solid var(--s2-blue-900, #1473e6); + outline-offset: 2px; + } + + &.danger { + color: var(--s2-red-900, #d31510); + border-color: #f0c8c2; + &:hover { + background: #fef0ee; + border-color: var(--s2-red-900, #d31510); + } + } +} diff --git a/blocks/edit/da-prepare/actions/global-publish/global-publish.js b/blocks/edit/da-prepare/actions/global-publish/global-publish.js new file mode 100644 index 000000000..038a3a604 --- /dev/null +++ b/blocks/edit/da-prepare/actions/global-publish/global-publish.js @@ -0,0 +1,335 @@ +import { LitElement, html, nothing } from 'da-lit'; +import getSheet from '../../../../shared/sheet.js'; +import { getSatellites, checkOverrides } from '../../../../shared/msm.js'; +import { + previewSatellite, + publishSatellite, + createOverride, + deleteOverride, + mergeFromBase, +} from './utils.js'; + +const sheet = await getSheet(import.meta.url.replace('js', 'css')); + +const STATUS = { pending: 'pending', success: 'success', error: 'error' }; +const SYNC_MODE = { override: 'override', merge: 'merge' }; + +const ACTIONS = { + preview: { label: 'Preview', scope: 'inherited' }, + publish: { label: 'Publish', scope: 'inherited' }, + break: { label: 'Cancel inheritance', scope: 'inherited' }, + sync: { label: 'Sync to satellite', scope: 'custom' }, + reset: { label: 'Resume inheritance', scope: 'custom' }, +}; + +class DaGlobalPublish extends LitElement { + static properties = { + details: { attribute: false }, + _satellites: { state: true }, + _selected: { state: true }, + _loading: { state: true }, + _busy: { state: true }, + _confirmAction: { state: true }, + _action: { state: true }, + _syncMode: { state: true }, + }; + + connectedCallback() { + super.connectedCallback(); + this.shadowRoot.adoptedStyleSheets = [sheet]; + this._selected = new Set(); + this._action = 'preview'; + this._syncMode = SYNC_MODE.merge; + this._busy = false; + this.loadSatellites(); + } + + async loadSatellites() { + const { org, site, path } = this.details; + this._loading = 'Loading satellites\u2026'; + + const satellites = await getSatellites(org, site); + + if (!satellites || !Object.keys(satellites).length) { + this._satellites = []; + this._loading = undefined; + return; + } + + this._loading = 'Checking overrides\u2026'; + const results = await checkOverrides(org, satellites, path); + this._satellites = results.map((sat) => ({ ...sat, status: undefined })); + this._loading = undefined; + } + + get _inherited() { + return this._satellites?.filter((s) => !s.hasOverride) || []; + } + + get _custom() { + return this._satellites?.filter((s) => s.hasOverride) || []; + } + + get _targets() { + const scope = ACTIONS[this._action]?.scope; + const pool = scope === 'custom' ? this._custom : this._inherited; + return pool.filter((s) => this._selected.has(s.site)); + } + + get _canApply() { + return !this._busy && this._targets.length > 0; + } + + handleToggle(site) { + const next = new Set(this._selected); + if (next.has(site)) next.delete(site); + else next.add(site); + this._selected = next; + } + + updateSatStatus(site, status) { + this._satellites = this._satellites.map( + (s) => (s.site === site ? { ...s, status } : s), + ); + } + + async apply() { + if (!this._canApply) return; + + if (this._action === 'reset') { + const names = this._targets.map((s) => s.label).join(', '); + this._confirmAction = { message: `Resume inheritance for ${names}? This deletes local overrides.` }; + return; + } + + await this.runAction(this._action); + } + + cancelConfirm() { + this._confirmAction = undefined; + } + + async doConfirmedAction() { + this._confirmAction = undefined; + await this.runAction('reset'); + } + + async runAction(action) { + this._busy = true; + const { org, site, path } = this.details; + const targets = this._targets; + + targets.forEach((s) => this.updateSatStatus(s.site, STATUS.pending)); + + switch (action) { + case 'preview': + case 'publish': { + const fn = action === 'publish' ? publishSatellite : previewSatellite; + await Promise.allSettled(targets.map(async (sat) => { + const result = await fn(org, sat.site, path); + this.updateSatStatus(sat.site, result.error ? STATUS.error : STATUS.success); + })); + break; + } + + case 'break': + await Promise.allSettled(targets.map(async (sat) => { + const result = await createOverride(org, site, sat.site, path); + if (result.error) { + this.updateSatStatus(sat.site, STATUS.error); + } else { + this._satellites = this._satellites.map( + (s) => (s.site === sat.site + ? { ...s, hasOverride: true, status: STATUS.success } + : s), + ); + } + })); + break; + + case 'sync': + if (this._syncMode === SYNC_MODE.merge) { + await Promise.allSettled(targets.map(async (sat) => { + const result = await mergeFromBase(org, site, sat.site, path); + if (result.error) { + this.updateSatStatus(sat.site, STATUS.error); + } else { + this._satellites = this._satellites.map( + (s) => (s.site === sat.site + ? { ...s, editUrl: result.editUrl, status: STATUS.success } + : s), + ); + } + })); + } else { + await Promise.allSettled(targets.map(async (sat) => { + const result = await createOverride(org, site, sat.site, path); + this.updateSatStatus(sat.site, result.error ? STATUS.error : STATUS.success); + })); + } + break; + + case 'reset': + await Promise.allSettled(targets.map(async (sat) => { + const result = await deleteOverride(org, sat.site, path); + if (result.error) { + this.updateSatStatus(sat.site, STATUS.error); + } else { + this._satellites = this._satellites.map( + (s) => (s.site === sat.site + ? { ...s, hasOverride: false, status: STATUS.success } + : s), + ); + this._selected = new Set([...this._selected, sat.site]); + } + })); + break; + + default: + break; + } + + this._busy = false; + } + + renderStatusIcon(sat) { + if (!sat.status) return nothing; + if (sat.status === STATUS.pending) { + return html` + + `; + } + if (sat.status === STATUS.success) { + return html` + + `; + } + return html` + + `; + } + + renderSatellite(sat) { + const scope = ACTIONS[this._action]?.scope; + const outOfScope = (scope === 'inherited') === sat.hasOverride; + + return html` +
  • + + ${this.renderStatusIcon(sat)} + ${sat.editUrl ? html` + + + ` : nothing} +
  • `; + } + + renderConfirm() { + if (!this._confirmAction) return nothing; + return html` +
    +

    ${this._confirmAction.message}

    +
    + + +
    +
    `; + } + + renderActionControls() { + return html` +
    +
    + +
    + +
    +
    + ${this._action === 'sync' ? html` +
    + +
    + +
    +
    ` : nothing} +
    `; + } + + renderList() { + const inherited = this._inherited; + const custom = this._custom; + + return html` +
    + ${inherited.length ? html` +
    +

    Inherited

    +
      + ${inherited.map((sat) => this.renderSatellite(sat))} +
    +
    ` : nothing} + ${custom.length ? html` +
    +

    Custom

    +
      + ${custom.map((sat) => this.renderSatellite(sat))} +
    +
    ` : nothing} +
    `; + } + + render() { + if (this._loading) { + return html`

    ${this._loading}

    `; + } + + if (!this._satellites?.length) { + return html`

    No satellite sites configured for this base.

    `; + } + + return html` + ${this.renderActionControls()} + ${this.renderList()} + ${this.renderConfirm()} +
    + this.apply()} + ?disabled=${!this._canApply}>Apply +
    `; + } +} + +customElements.define('da-global-publish', DaGlobalPublish); + +export default function render(details) { + const cmp = document.createElement('da-global-publish'); + cmp.details = details; + return cmp; +} diff --git a/blocks/edit/da-prepare/actions/global-publish/utils.js b/blocks/edit/da-prepare/actions/global-publish/utils.js new file mode 100644 index 000000000..8b47a35d2 --- /dev/null +++ b/blocks/edit/da-prepare/actions/global-publish/utils.js @@ -0,0 +1,73 @@ +import { DA_ORIGIN } from '../../../../shared/constants.js'; +import { daFetch } from '../../../../shared/utils.js'; +import { getNx } from '../../../../../scripts/utils.js'; + +const AEM_ADMIN = 'https://admin.hlx.page'; + +export async function previewSatellite(org, satellite, pagePath) { + const aemPath = pagePath.replace('.html', ''); + const url = `${AEM_ADMIN}/preview/${org}/${satellite}/main${aemPath}`; + const resp = await daFetch(url, { method: 'POST' }); + if (!resp.ok) { + const xError = resp.headers?.get('x-error') || `Preview failed (${resp.status})`; + return { error: xError }; + } + return resp.json(); +} + +export async function publishSatellite(org, satellite, pagePath) { + const aemPath = pagePath.replace('.html', ''); + const url = `${AEM_ADMIN}/live/${org}/${satellite}/main${aemPath}`; + const resp = await daFetch(url, { method: 'POST' }); + if (!resp.ok) { + const xError = resp.headers?.get('x-error') || `Publish failed (${resp.status})`; + return { error: xError }; + } + return resp.json(); +} + +export async function createOverride(org, base, satellite, pagePath) { + const basePath = `${DA_ORIGIN}/source/${org}/${base}${pagePath}.html`; + const resp = await daFetch(basePath); + if (!resp.ok) return { error: `Failed to fetch base content (${resp.status})` }; + + const html = await resp.text(); + const blob = new Blob([html], { type: 'text/html' }); + const formData = new FormData(); + formData.append('data', blob); + + const satPath = `${DA_ORIGIN}/source/${org}/${satellite}${pagePath}.html`; + const saveResp = await daFetch(satPath, { method: 'PUT', body: formData }); + if (!saveResp.ok) return { error: `Failed to create override (${saveResp.status})` }; + return { ok: true }; +} + +export async function deleteOverride(org, satellite, pagePath) { + const satPath = `${DA_ORIGIN}/source/${org}/${satellite}${pagePath}.html`; + const resp = await daFetch(satPath, { method: 'DELETE' }); + if (!resp.ok) return { error: `Failed to delete override (${resp.status})` }; + return { ok: true }; +} + +let mergeCopyFn; +export function setMergeCopy(fn) { mergeCopyFn = fn; } + +export async function mergeFromBase(org, base, satellite, pagePath) { + try { + const mergeCopy = mergeCopyFn + || (await import(`${getNx()}/blocks/loc/project/index.js`)).mergeCopy; + + const url = { + source: `/${org}/${base}${pagePath}.html`, + destination: `/${org}/${satellite}${pagePath}.html`, + }; + + const result = await mergeCopy(url, 'MSM Merge'); + if (!result?.ok) return { error: 'Merge failed' }; + + const editUrl = `${window.location.origin}/edit#/${org}/${satellite}${pagePath}`; + return { ok: true, editUrl }; + } catch (e) { + return { error: e.message || 'Merge failed' }; + } +} diff --git a/blocks/edit/da-prepare/da-prepare.js b/blocks/edit/da-prepare/da-prepare.js index 2817d1858..5fa7e70e0 100644 --- a/blocks/edit/da-prepare/da-prepare.js +++ b/blocks/edit/da-prepare/da-prepare.js @@ -27,6 +27,12 @@ const OOTB_ACTIONS = [ icon: '/blocks/edit/img/S2_Icon_Target_20_N.svg#S2_Icon_Target', optional: true, }, + { + title: 'Multi-site Manager', + render: async (details) => (await import('./actions/global-publish/global-publish.js')).default(details), + icon: '/blocks/edit/img/S2_Icon_GlobeGrid_20_N.svg#S2_Icon_GlobeGrid', + optional: true, + }, ]; export default class DaPrepare extends LitElement { diff --git a/blocks/edit/da-title/da-title.css b/blocks/edit/da-title/da-title.css index d3b565346..0fb4b2b4b 100644 --- a/blocks/edit/da-title/da-title.css +++ b/blocks/edit/da-title/da-title.css @@ -10,6 +10,30 @@ h1 { position: relative; margin: 0 0 21px; + display: flex; + align-items: center; + gap: 8px; + flex-wrap: wrap; +} + +.msm-badge { + font-size: 11px; + font-weight: 600; + padding: 2px 8px; + border-radius: 4px; + white-space: nowrap; + cursor: default; + vertical-align: middle; +} + +.msm-badge-inherited { + color: #147b3a; + background: #e6f4ea; +} + +.msm-badge-overridden { + color: #0d57af; + background: #e8f0fe; } button { diff --git a/blocks/edit/da-title/da-title.js b/blocks/edit/da-title/da-title.js index 093819d6e..0beebc09d 100644 --- a/blocks/edit/da-title/da-title.js +++ b/blocks/edit/da-title/da-title.js @@ -8,6 +8,7 @@ import { getAemHrefs, } from '../utils/helpers.js'; import { delay, fetchDaConfigs, getFirstSheet } from '../../shared/utils.js'; +import { getBaseSite, isPageLocal } from '../../shared/msm.js'; import inlinesvg from '../../shared/inlinesvg.js'; import getSheet from '../../shared/sheet.js'; @@ -43,6 +44,7 @@ export default class DaTitle extends LitElement { _actions: { state: true }, _status: { state: true }, _dialog: { state: true }, + _msmBadge: { state: true }, }; constructor() { @@ -87,6 +89,7 @@ export default class DaTitle extends LitElement { this._scheduled = undefined; this._configs = undefined; this._actions = {}; + this._msmBadge = undefined; } // Run setup after a short delay. @@ -111,9 +114,19 @@ export default class DaTitle extends LitElement { if (path) { this._aemHrefs = await getAemHrefs({ path: fullpath }); this._scheduled = await this.getSchedule(org, site, path); + this.checkMsmStatus(org, site, path); } } + async checkMsmStatus(org, site, pagePath) { + const baseSite = await getBaseSite(org, site); + if (!baseSite) return; + const local = await isPageLocal(org, site, pagePath); + this._msmBadge = local + ? { type: 'overridden', baseSite } + : { type: 'inherited', baseSite }; + } + async getSchedule(org, site, path) { const { getExistingSchedule } = await this._lazyMods.get('da-schedule'); return getExistingSchedule(org, site, path); @@ -324,6 +337,17 @@ export default class DaTitle extends LitElement { return !this.permissions.some((permission) => permission === 'write'); } + renderMsmBadge() { + if (!this._msmBadge) return nothing; + const { type, baseSite } = this._msmBadge; + const isInherited = type === 'inherited'; + const label = isInherited ? 'Inherited' : 'Overridden'; + const title = isInherited + ? `This page is inherited from ${baseSite}` + : `This page overrides the base (${baseSite})`; + return html`${label}`; + } + renderActions() { if (!this._actions?.available) return nothing; @@ -394,7 +418,7 @@ export default class DaTitle extends LitElement { ${this.details.parentName} -

    ${this.details.name}

    +

    ${this.details.name}${this.renderMsmBadge()}

    ${this.collabStatus ? this.renderCollab() : nothing} diff --git a/blocks/shared/msm.js b/blocks/shared/msm.js new file mode 100644 index 000000000..c7ac49368 --- /dev/null +++ b/blocks/shared/msm.js @@ -0,0 +1,68 @@ +import { DA_ORIGIN } from './constants.js'; +import { daFetch } from './utils.js'; + +const configCache = {}; + +async function fetchSiteConfig(org, site) { + const key = `${org}/${site}`; + if (configCache[key]) return configCache[key]; + + const resp = await daFetch(`${DA_ORIGIN}/source/${org}/${site}/.da/msm.json`); + if (!resp.ok) return null; + const json = await resp.json(); + const rows = json.data || []; + if (!rows.length) return null; + + let config; + if (rows[0].satellite !== undefined) { + const satellites = rows.reduce((acc, row) => { + acc[row.satellite] = { label: row.satelliteLabel }; + return acc; + }, {}); + config = { role: 'base', satellites }; + } else if (rows[0].base !== undefined) { + config = { role: 'satellite', base: rows[0].base, baseLabel: rows[0].baseLabel }; + } else { + return null; + } + + configCache[key] = config; + return config; +} + +export async function getSatellites(org, baseSite) { + const config = await fetchSiteConfig(org, baseSite); + if (!config) return {}; + if (config.role === 'base') return config.satellites; + return {}; +} + +export async function getBaseSite(org, satellite) { + const config = await fetchSiteConfig(org, satellite); + if (!config) return null; + if (config.role === 'satellite') return config.base; + return null; +} + +export async function isPageLocal(org, site, pagePath) { + const resp = await daFetch( + `${DA_ORIGIN}/source/${org}/${site}${pagePath}.html`, + { method: 'HEAD' }, + ); + return resp.ok; +} + +export async function checkOverrides(org, satellites, pagePath) { + const entries = Object.entries(satellites); + const results = await Promise.all( + entries.map(async ([site, info]) => { + const local = await isPageLocal(org, site, pagePath); + return { site, label: info.label, hasOverride: local }; + }), + ); + return results; +} + +export function clearMsmCache() { + Object.keys(configCache).forEach((key) => { delete configCache[key]; }); +} From f5fd616cb2cf3e34ec4d1da2a8456bc992c26235 Mon Sep 17 00:00:00 2001 From: Ramachandra Avuthu Date: Mon, 6 Apr 2026 15:12:47 -0400 Subject: [PATCH 2/8] Satellite site can control the sync features and inheritance --- .../actions/global-publish/global-publish.css | 18 ++ .../actions/global-publish/global-publish.js | 156 +++++++++++++++++- blocks/edit/da-title/da-title.css | 24 --- blocks/edit/da-title/da-title.js | 26 +-- 4 files changed, 166 insertions(+), 58 deletions(-) diff --git a/blocks/edit/da-prepare/actions/global-publish/global-publish.css b/blocks/edit/da-prepare/actions/global-publish/global-publish.css index 45fb450ab..00deb99f0 100644 --- a/blocks/edit/da-prepare/actions/global-publish/global-publish.css +++ b/blocks/edit/da-prepare/actions/global-publish/global-publish.css @@ -15,6 +15,24 @@ color: var(--s2-gray-600, #717171); } +/* --- Satellite status line --- */ + +.sat-status-line { + display: flex; + align-items: center; + gap: 8px; + font-size: 14px; + color: var(--s2-gray-800, #292929); +} + +.sat-status-label { + color: var(--s2-gray-600, #717171); +} + +.sat-status-value { + font-weight: 600; +} + /* --- Action row (side-by-side dropdowns) --- */ .action-row { diff --git a/blocks/edit/da-prepare/actions/global-publish/global-publish.js b/blocks/edit/da-prepare/actions/global-publish/global-publish.js index 038a3a604..3fc1ed7ff 100644 --- a/blocks/edit/da-prepare/actions/global-publish/global-publish.js +++ b/blocks/edit/da-prepare/actions/global-publish/global-publish.js @@ -1,6 +1,6 @@ import { LitElement, html, nothing } from 'da-lit'; import getSheet from '../../../../shared/sheet.js'; -import { getSatellites, checkOverrides } from '../../../../shared/msm.js'; +import { getSatellites, getBaseSite, isPageLocal, checkOverrides } from '../../../../shared/msm.js'; import { previewSatellite, publishSatellite, @@ -32,6 +32,10 @@ class DaGlobalPublish extends LitElement { _confirmAction: { state: true }, _action: { state: true }, _syncMode: { state: true }, + _role: { state: true }, + _baseSite: { state: true }, + _hasOverride: { state: true }, + _satStatus: { state: true }, }; connectedCallback() { @@ -46,19 +50,30 @@ class DaGlobalPublish extends LitElement { async loadSatellites() { const { org, site, path } = this.details; - this._loading = 'Loading satellites\u2026'; + this._loading = 'Loading configuration\u2026'; const satellites = await getSatellites(org, site); - if (!satellites || !Object.keys(satellites).length) { - this._satellites = []; + if (satellites && Object.keys(satellites).length) { + this._role = 'base'; + this._loading = 'Checking overrides\u2026'; + const results = await checkOverrides(org, satellites, path); + this._satellites = results.map((sat) => ({ ...sat, status: undefined })); this._loading = undefined; return; } - this._loading = 'Checking overrides\u2026'; - const results = await checkOverrides(org, satellites, path); - this._satellites = results.map((sat) => ({ ...sat, status: undefined })); + const baseSite = await getBaseSite(org, site); + if (baseSite) { + this._role = 'satellite'; + this._baseSite = baseSite; + this._action = 'sync-from-base'; + this._hasOverride = await isPageLocal(org, site, path); + this._loading = undefined; + return; + } + + this._satellites = []; this._loading = undefined; } @@ -94,6 +109,11 @@ class DaGlobalPublish extends LitElement { } async apply() { + if (this._role === 'satellite') { + this.applySatelliteAction(); + return; + } + if (!this._canApply) return; if (this._action === 'reset') { @@ -110,8 +130,13 @@ class DaGlobalPublish extends LitElement { } async doConfirmedAction() { + const { confirmedAction } = this._confirmAction || {}; this._confirmAction = undefined; - await this.runAction('reset'); + if (confirmedAction === 'resume-inheritance') { + await this.runSatelliteAction('resume-inheritance'); + } else { + await this.runAction('reset'); + } } async runAction(action) { @@ -192,6 +217,52 @@ class DaGlobalPublish extends LitElement { this._busy = false; } + applySatelliteAction() { + if (this._busy) return; + + if (this._action === 'resume-inheritance') { + this._confirmAction = { + message: 'Resume inheritance? This deletes the local override.', + confirmedAction: 'resume-inheritance', + }; + return; + } + + this.runSatelliteAction(this._action); + } + + async runSatelliteAction(action) { + this._busy = true; + this._satStatus = STATUS.pending; + const { org, site, path } = this.details; + + try { + let result; + if (action === 'sync-from-base') { + result = this._syncMode === SYNC_MODE.merge + ? await mergeFromBase(org, this._baseSite, site, path) + : await createOverride(org, this._baseSite, site, path); + } else if (action === 'resume-inheritance') { + result = await deleteOverride(org, site, path); + } + + if (result?.error) { + this._satStatus = STATUS.error; + } else { + this._satStatus = STATUS.success; + if (action === 'resume-inheritance') { + this._hasOverride = false; + } else { + this._hasOverride = true; + } + } + } catch { + this._satStatus = STATUS.error; + } + + this._busy = false; + } + renderStatusIcon(sat) { if (!sat.status) return nothing; if (sat.status === STATUS.pending) { @@ -305,13 +376,80 @@ class DaGlobalPublish extends LitElement {
    `; } + renderSatelliteStatusIcon() { + if (!this._satStatus) return nothing; + if (this._satStatus === STATUS.pending) { + return html` + + `; + } + if (this._satStatus === STATUS.success) { + return html` + + `; + } + return html` + + `; + } + + renderSatelliteView() { + const canResume = this._action === 'resume-inheritance' && !this._hasOverride; + + return html` +
    + Base site: + ${this._baseSite} + ${this.renderSatelliteStatusIcon()} +
    +
    +
    + +
    + +
    +
    + ${this._action === 'sync-from-base' ? html` +
    + +
    + +
    +
    ` : nothing} +
    + ${this.renderConfirm()} +
    + this.apply()} + ?disabled=${this._busy || canResume}>Apply +
    `; + } + render() { if (this._loading) { return html`

    ${this._loading}

    `; } + if (this._role === 'satellite') { + return this.renderSatelliteView(); + } + if (!this._satellites?.length) { - return html`

    No satellite sites configured for this base.

    `; + return html`

    No satellite sites configured.

    `; } return html` diff --git a/blocks/edit/da-title/da-title.css b/blocks/edit/da-title/da-title.css index 0fb4b2b4b..d3b565346 100644 --- a/blocks/edit/da-title/da-title.css +++ b/blocks/edit/da-title/da-title.css @@ -10,30 +10,6 @@ h1 { position: relative; margin: 0 0 21px; - display: flex; - align-items: center; - gap: 8px; - flex-wrap: wrap; -} - -.msm-badge { - font-size: 11px; - font-weight: 600; - padding: 2px 8px; - border-radius: 4px; - white-space: nowrap; - cursor: default; - vertical-align: middle; -} - -.msm-badge-inherited { - color: #147b3a; - background: #e6f4ea; -} - -.msm-badge-overridden { - color: #0d57af; - background: #e8f0fe; } button { diff --git a/blocks/edit/da-title/da-title.js b/blocks/edit/da-title/da-title.js index 0beebc09d..093819d6e 100644 --- a/blocks/edit/da-title/da-title.js +++ b/blocks/edit/da-title/da-title.js @@ -8,7 +8,6 @@ import { getAemHrefs, } from '../utils/helpers.js'; import { delay, fetchDaConfigs, getFirstSheet } from '../../shared/utils.js'; -import { getBaseSite, isPageLocal } from '../../shared/msm.js'; import inlinesvg from '../../shared/inlinesvg.js'; import getSheet from '../../shared/sheet.js'; @@ -44,7 +43,6 @@ export default class DaTitle extends LitElement { _actions: { state: true }, _status: { state: true }, _dialog: { state: true }, - _msmBadge: { state: true }, }; constructor() { @@ -89,7 +87,6 @@ export default class DaTitle extends LitElement { this._scheduled = undefined; this._configs = undefined; this._actions = {}; - this._msmBadge = undefined; } // Run setup after a short delay. @@ -114,19 +111,9 @@ export default class DaTitle extends LitElement { if (path) { this._aemHrefs = await getAemHrefs({ path: fullpath }); this._scheduled = await this.getSchedule(org, site, path); - this.checkMsmStatus(org, site, path); } } - async checkMsmStatus(org, site, pagePath) { - const baseSite = await getBaseSite(org, site); - if (!baseSite) return; - const local = await isPageLocal(org, site, pagePath); - this._msmBadge = local - ? { type: 'overridden', baseSite } - : { type: 'inherited', baseSite }; - } - async getSchedule(org, site, path) { const { getExistingSchedule } = await this._lazyMods.get('da-schedule'); return getExistingSchedule(org, site, path); @@ -337,17 +324,6 @@ export default class DaTitle extends LitElement { return !this.permissions.some((permission) => permission === 'write'); } - renderMsmBadge() { - if (!this._msmBadge) return nothing; - const { type, baseSite } = this._msmBadge; - const isInherited = type === 'inherited'; - const label = isInherited ? 'Inherited' : 'Overridden'; - const title = isInherited - ? `This page is inherited from ${baseSite}` - : `This page overrides the base (${baseSite})`; - return html`${label}`; - } - renderActions() { if (!this._actions?.available) return nothing; @@ -418,7 +394,7 @@ export default class DaTitle extends LitElement { ${this.details.parentName} -

    ${this.details.name}${this.renderMsmBadge()}

    +

    ${this.details.name}

    ${this.collabStatus ? this.renderCollab() : nothing} From e4266cb22ca9f8c90939abf3517292a3c76afca3 Mon Sep 17 00:00:00 2001 From: Ramachandra Avuthu Date: Mon, 6 Apr 2026 15:53:47 -0400 Subject: [PATCH 3/8] renamed global publish to msm --- .../global-publish.css => msm/msm.css} | 148 ++++++++++---- .../global-publish.js => msm/msm.js} | 189 ++++++++++++------ .../actions/{global-publish => msm}/utils.js | 0 blocks/edit/da-prepare/da-prepare.js | 2 +- 4 files changed, 237 insertions(+), 102 deletions(-) rename blocks/edit/da-prepare/actions/{global-publish/global-publish.css => msm/msm.css} (72%) rename blocks/edit/da-prepare/actions/{global-publish/global-publish.js => msm/msm.js} (71%) rename blocks/edit/da-prepare/actions/{global-publish => msm}/utils.js (100%) diff --git a/blocks/edit/da-prepare/actions/global-publish/global-publish.css b/blocks/edit/da-prepare/actions/msm/msm.css similarity index 72% rename from blocks/edit/da-prepare/actions/global-publish/global-publish.css rename to blocks/edit/da-prepare/actions/msm/msm.css index 00deb99f0..884982561 100644 --- a/blocks/edit/da-prepare/actions/global-publish/global-publish.css +++ b/blocks/edit/da-prepare/actions/msm/msm.css @@ -2,12 +2,17 @@ display: flex; flex-direction: column; gap: 16px; - width: 400px; + width: 500px; + min-height: 280px; margin: 0 24px 24px; p { margin: 0; } } +:host(:has(.picker-menu)) { + min-height: 320px; +} + .loading, .no-satellites { font-size: 14px; @@ -37,10 +42,11 @@ .action-row { display: flex; - gap: 8px; + align-items: flex-start; + gap: 16px; .form-row { - flex: 0 0 calc(50% - 4px); + flex: 0 0 calc(50% - 8px); min-width: 0; } } @@ -60,64 +66,132 @@ } } -/* --- Select (matches sl/components.css) --- */ +/* --- Picker trigger --- */ -.select-wrapper { +.picker-wrapper { position: relative; - - &::after { - content: ""; - position: absolute; - top: 0; - right: 0; - width: 32px; - height: 32px; - background: no-repeat center / 18px url("https://da.live/nx/public/icons/Smock_ChevronDown_18_N.svg"); - pointer-events: none; - } - - &:has(select:disabled)::after { - display: none; - } } -.action-select { - display: block; +.picker-trigger { + display: flex; + align-items: center; + justify-content: space-between; + gap: 8px; width: 100%; - padding: 0 32px 0 12px; - background: var(--s2-gray-100, #e9e9e9); + height: 32px; + padding: 0 10px; + background: var(--s2-gray-75, #f8f8f8); font-family: var(--font-family, "Adobe Clean", adobe-clean, "Trebuchet MS", sans-serif); font-size: 14px; - line-height: 28px; - border: 2px solid var(--s2-gray-100, #e9e9e9); + color: var(--s2-gray-800, #292929); + border: 1px solid var(--s2-gray-300, #d1d1d1); border-radius: 8px; - outline-color: var(--s2-blue-900, #3b63fb); - outline-offset: 0; - transition: outline-offset 0.2s; - box-sizing: border-box; - appearance: none; - -webkit-appearance: none; cursor: pointer; - color: var(--s2-gray-800, #292929); + box-sizing: border-box; + transition: border-color 0.15s, background-color 0.15s; + + &:hover:not(:disabled) { + border-color: var(--s2-gray-500, #929292); + } + + &.open { + border-color: var(--s2-blue-900, #3b63fb); + } &:focus-visible { - outline-offset: 4px; + outline: 2px solid var(--s2-blue-900, #3b63fb); + outline-offset: 2px; } &:disabled { - opacity: 1; background: var(--s2-gray-75, #f3f3f3); - border-color: var(--s2-gray-75, #f3f3f3); - color: var(--s2-gray-500, #8f8f8f); + border-color: var(--s2-gray-200, #e1e1e1); + color: var(--s2-gray-400, #b8b8b8); cursor: default; } } +.picker-label { + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.picker-chevron { + width: 10px; + height: 10px; + flex-shrink: 0; + color: var(--s2-gray-600, #717171); + transition: transform 0.15s; + + .open & { transform: rotate(180deg); } + :disabled & { color: var(--s2-gray-400, #b8b8b8); } +} + +/* --- Picker menu (floating overlay) --- */ + +.picker-menu { + position: absolute; + top: calc(100% + 4px); + left: 0; + right: 0; + z-index: 10; + list-style: none; + margin: 0; + padding: 6px; + background: #fff; + border: 1px solid var(--s2-gray-200, #e1e1e1); + border-radius: 8px; + box-shadow: 0 4px 16px rgb(0 0 0 / 12%); +} + +.picker-group-header { + font-size: 11px; + font-weight: 600; + text-transform: uppercase; + letter-spacing: 0.04em; + color: var(--s2-gray-500, #929292); + padding: 8px 10px 4px; + user-select: none; + + &:first-child { padding-top: 4px; } +} + +.picker-item { + display: flex; + align-items: center; + gap: 8px; + padding: 6px 10px; + font-size: 14px; + color: var(--s2-gray-800, #292929); + border-radius: 6px; + cursor: pointer; + user-select: none; + transition: background-color 0.1s; + + &:hover { background: var(--s2-gray-100, #f5f5f5); } + + &.selected { + font-weight: 600; + + .picker-checkmark { visibility: visible; } + } +} + +.picker-checkmark { + width: 12px; + height: 12px; + flex-shrink: 0; + visibility: hidden; + color: var(--s2-gray-800, #292929); +} + /* --- Two-column grid --- */ .satellite-grid { display: flex; gap: 16px; + margin-top: 16px; } .satellite-column { diff --git a/blocks/edit/da-prepare/actions/global-publish/global-publish.js b/blocks/edit/da-prepare/actions/msm/msm.js similarity index 71% rename from blocks/edit/da-prepare/actions/global-publish/global-publish.js rename to blocks/edit/da-prepare/actions/msm/msm.js index 3fc1ed7ff..201d97ddd 100644 --- a/blocks/edit/da-prepare/actions/global-publish/global-publish.js +++ b/blocks/edit/da-prepare/actions/msm/msm.js @@ -22,7 +22,7 @@ const ACTIONS = { reset: { label: 'Resume inheritance', scope: 'custom' }, }; -class DaGlobalPublish extends LitElement { +class DaMsm extends LitElement { static properties = { details: { attribute: false }, _satellites: { state: true }, @@ -36,18 +36,50 @@ class DaGlobalPublish extends LitElement { _baseSite: { state: true }, _hasOverride: { state: true }, _satStatus: { state: true }, + _openPicker: { state: true }, }; connectedCallback() { super.connectedCallback(); this.shadowRoot.adoptedStyleSheets = [sheet]; + this._loading = 'Loading\u2026'; this._selected = new Set(); this._action = 'preview'; this._syncMode = SYNC_MODE.merge; this._busy = false; + this._openPicker = null; + this._handleOutsidePickerClick = this._handleOutsidePickerClick.bind(this); this.loadSatellites(); } + disconnectedCallback() { + super.disconnectedCallback(); + document.removeEventListener('pointerdown', this._handleOutsidePickerClick); + } + + _handleOutsidePickerClick(e) { + if (!e.composedPath().includes(this)) { + this._openPicker = null; + document.removeEventListener('pointerdown', this._handleOutsidePickerClick); + } + } + + togglePicker(name) { + if (this._openPicker === name) { + this._openPicker = null; + document.removeEventListener('pointerdown', this._handleOutsidePickerClick); + } else { + this._openPicker = name; + document.addEventListener('pointerdown', this._handleOutsidePickerClick); + } + } + + selectPickerOption(name, value, setter) { + setter(value); + this._openPicker = null; + document.removeEventListener('pointerdown', this._handleOutsidePickerClick); + } + async loadSatellites() { const { org, site, path } = this.details; this._loading = 'Loading configuration\u2026'; @@ -313,43 +345,85 @@ class DaGlobalPublish extends LitElement {
    `; } + renderPicker(name, label, value, options, setter) { + const isOpen = this._openPicker === name; + const selectedLabel = options + .flatMap((o) => o.items || [o]) + .find((o) => o.value === value)?.label || ''; + + return html` +
    + +
    + + ${isOpen ? html` +
      + ${options.map((group) => { + if (group.items) { + return html` + + ${group.items.map((opt) => html` +
    • this.selectPickerOption(name, opt.value, setter)}> + + + + ${opt.label} +
    • `)}`; + } + return html` +
    • this.selectPickerOption(name, group.value, setter)}> + + + + ${group.label} +
    • `; + })} +
    ` : nothing} +
    +
    `; + } + renderActionControls() { + const actionOptions = [ + { heading: 'Inherited sites', items: [ + { value: 'preview', label: 'Preview' }, + { value: 'publish', label: 'Publish' }, + { value: 'break', label: 'Cancel inheritance' }, + ] }, + { heading: 'Custom sites', items: [ + { value: 'sync', label: 'Sync to satellite' }, + { value: 'reset', label: 'Resume inheritance' }, + ] }, + ]; + + const syncOptions = [ + { value: 'merge', label: 'Merge' }, + { value: 'override', label: 'Override' }, + ]; + return html`
    -
    - -
    - -
    -
    - ${this._action === 'sync' ? html` -
    - -
    - -
    -
    ` : nothing} + ${this.renderPicker('action', 'Action', this._action, actionOptions, + (v) => { this._action = v; })} + ${this._action === 'sync' ? this.renderPicker('syncMode', 'Sync mode', this._syncMode, syncOptions, + (v) => { this._syncMode = v; }) : nothing}
    `; } @@ -396,6 +470,16 @@ class DaGlobalPublish extends LitElement { renderSatelliteView() { const canResume = this._action === 'resume-inheritance' && !this._hasOverride; + const satActionOptions = [ + { value: 'sync-from-base', label: 'Sync from Base' }, + { value: 'resume-inheritance', label: 'Resume inheritance' }, + ]; + + const syncOptions = [ + { value: 'merge', label: 'Merge' }, + { value: 'override', label: 'Override' }, + ]; + return html`
    Base site: @@ -403,33 +487,10 @@ class DaGlobalPublish extends LitElement { ${this.renderSatelliteStatusIcon()}
    -
    - -
    - -
    -
    - ${this._action === 'sync-from-base' ? html` -
    - -
    - -
    -
    ` : nothing} + ${this.renderPicker('action', 'Action', this._action, satActionOptions, + (v) => { this._action = v; this._satStatus = undefined; })} + ${this._action === 'sync-from-base' ? this.renderPicker('syncMode', 'Sync mode', this._syncMode, syncOptions, + (v) => { this._syncMode = v; }) : nothing}
    ${this.renderConfirm()}
    @@ -464,10 +525,10 @@ class DaGlobalPublish extends LitElement { } } -customElements.define('da-global-publish', DaGlobalPublish); +customElements.define('da-msm', DaMsm); export default function render(details) { - const cmp = document.createElement('da-global-publish'); + const cmp = document.createElement('da-msm'); cmp.details = details; return cmp; } diff --git a/blocks/edit/da-prepare/actions/global-publish/utils.js b/blocks/edit/da-prepare/actions/msm/utils.js similarity index 100% rename from blocks/edit/da-prepare/actions/global-publish/utils.js rename to blocks/edit/da-prepare/actions/msm/utils.js diff --git a/blocks/edit/da-prepare/da-prepare.js b/blocks/edit/da-prepare/da-prepare.js index 5fa7e70e0..25c4b13a9 100644 --- a/blocks/edit/da-prepare/da-prepare.js +++ b/blocks/edit/da-prepare/da-prepare.js @@ -29,7 +29,7 @@ const OOTB_ACTIONS = [ }, { title: 'Multi-site Manager', - render: async (details) => (await import('./actions/global-publish/global-publish.js')).default(details), + render: async (details) => (await import('./actions/msm/msm.js')).default(details), icon: '/blocks/edit/img/S2_Icon_GlobeGrid_20_N.svg#S2_Icon_GlobeGrid', optional: true, }, From f2a7f9a2cb2b2c6d0ae15385bde252b30f1fd753 Mon Sep 17 00:00:00 2001 From: Ramachandra Avuthu Date: Mon, 6 Apr 2026 21:05:41 -0400 Subject: [PATCH 4/8] read msm mapping from Org config and preview/publish the page from base site to the satellite upon resume inheritance action --- blocks/edit/da-prepare/actions/msm/msm.js | 77 ++++++++++++++++----- blocks/edit/da-prepare/actions/msm/utils.js | 12 ++++ blocks/shared/msm.js | 66 +++++++++++++----- 3 files changed, 122 insertions(+), 33 deletions(-) diff --git a/blocks/edit/da-prepare/actions/msm/msm.js b/blocks/edit/da-prepare/actions/msm/msm.js index 201d97ddd..9cc6d975f 100644 --- a/blocks/edit/da-prepare/actions/msm/msm.js +++ b/blocks/edit/da-prepare/actions/msm/msm.js @@ -7,6 +7,7 @@ import { createOverride, deleteOverride, mergeFromBase, + getSatellitePageStatus, } from './utils.js'; const sheet = await getSheet(import.meta.url.replace('js', 'css')); @@ -228,10 +229,17 @@ class DaMsm extends LitElement { case 'reset': await Promise.allSettled(targets.map(async (sat) => { + const pageStatus = await getSatellitePageStatus(org, sat.site, path); const result = await deleteOverride(org, sat.site, path); if (result.error) { this.updateSatStatus(sat.site, STATUS.error); } else { + if (pageStatus.live) { + await previewSatellite(org, sat.site, path); + await publishSatellite(org, sat.site, path); + } else if (pageStatus.preview) { + await previewSatellite(org, sat.site, path); + } this._satellites = this._satellites.map( (s) => (s.site === sat.site ? { ...s, hasOverride: false, status: STATUS.success } @@ -275,7 +283,16 @@ class DaMsm extends LitElement { ? await mergeFromBase(org, this._baseSite, site, path) : await createOverride(org, this._baseSite, site, path); } else if (action === 'resume-inheritance') { + const pageStatus = await getSatellitePageStatus(org, site, path); result = await deleteOverride(org, site, path); + if (!result?.error) { + if (pageStatus.live) { + await previewSatellite(org, site, path); + await publishSatellite(org, site, path); + } else if (pageStatus.preview) { + await previewSatellite(org, site, path); + } + } } if (result?.error) { @@ -402,15 +419,21 @@ class DaMsm extends LitElement { renderActionControls() { const actionOptions = [ - { heading: 'Inherited sites', items: [ - { value: 'preview', label: 'Preview' }, - { value: 'publish', label: 'Publish' }, - { value: 'break', label: 'Cancel inheritance' }, - ] }, - { heading: 'Custom sites', items: [ - { value: 'sync', label: 'Sync to satellite' }, - { value: 'reset', label: 'Resume inheritance' }, - ] }, + { + heading: 'Inherited sites', + items: [ + { value: 'preview', label: 'Preview' }, + { value: 'publish', label: 'Publish' }, + { value: 'break', label: 'Cancel inheritance' }, + ], + }, + { + heading: 'Custom sites', + items: [ + { value: 'sync', label: 'Sync to satellite' }, + { value: 'reset', label: 'Resume inheritance' }, + ], + }, ]; const syncOptions = [ @@ -420,10 +443,20 @@ class DaMsm extends LitElement { return html`
    - ${this.renderPicker('action', 'Action', this._action, actionOptions, - (v) => { this._action = v; })} - ${this._action === 'sync' ? this.renderPicker('syncMode', 'Sync mode', this._syncMode, syncOptions, - (v) => { this._syncMode = v; }) : nothing} + ${this.renderPicker( +'action', +'Action', +this._action, +actionOptions, +(v) => { this._action = v; }, +)} + ${this._action === 'sync' ? this.renderPicker( +'syncMode', +'Sync mode', +this._syncMode, +syncOptions, +(v) => { this._syncMode = v; }, +) : nothing}
    `; } @@ -487,10 +520,20 @@ class DaMsm extends LitElement { ${this.renderSatelliteStatusIcon()}
    - ${this.renderPicker('action', 'Action', this._action, satActionOptions, - (v) => { this._action = v; this._satStatus = undefined; })} - ${this._action === 'sync-from-base' ? this.renderPicker('syncMode', 'Sync mode', this._syncMode, syncOptions, - (v) => { this._syncMode = v; }) : nothing} + ${this.renderPicker( +'action', +'Action', +this._action, +satActionOptions, +(v) => { this._action = v; this._satStatus = undefined; }, +)} + ${this._action === 'sync-from-base' ? this.renderPicker( +'syncMode', +'Sync mode', +this._syncMode, +syncOptions, +(v) => { this._syncMode = v; }, +) : nothing}
    ${this.renderConfirm()}
    diff --git a/blocks/edit/da-prepare/actions/msm/utils.js b/blocks/edit/da-prepare/actions/msm/utils.js index 8b47a35d2..24681786d 100644 --- a/blocks/edit/da-prepare/actions/msm/utils.js +++ b/blocks/edit/da-prepare/actions/msm/utils.js @@ -42,6 +42,18 @@ export async function createOverride(org, base, satellite, pagePath) { return { ok: true }; } +export async function getSatellitePageStatus(org, satellite, pagePath) { + const aemPath = pagePath.replace('.html', ''); + const url = `${AEM_ADMIN}/status/${org}/${satellite}/main${aemPath}`; + const resp = await daFetch(url); + if (!resp.ok) return { preview: false, live: false }; + const json = await resp.json(); + return { + preview: json.preview?.status === 200, + live: json.live?.status === 200, + }; +} + export async function deleteOverride(org, satellite, pagePath) { const satPath = `${DA_ORIGIN}/source/${org}/${satellite}${pagePath}.html`; const resp = await daFetch(satPath, { method: 'DELETE' }); diff --git a/blocks/shared/msm.js b/blocks/shared/msm.js index c7ac49368..4114fbb57 100644 --- a/blocks/shared/msm.js +++ b/blocks/shared/msm.js @@ -1,30 +1,63 @@ import { DA_ORIGIN } from './constants.js'; import { daFetch } from './utils.js'; +const orgCache = {}; const configCache = {}; +async function fetchOrgMsmRows(org) { + if (orgCache[org] !== undefined) return orgCache[org]; + + const resp = await daFetch(`${DA_ORIGIN}/config/${org}/`); + if (!resp.ok) { + orgCache[org] = []; + return []; + } + const json = await resp.json(); + orgCache[org] = json?.msm?.data || []; + return orgCache[org]; +} + +function resolveConfig(rows, site) { + const hasBaseCol = rows[0].base !== undefined; + + if (hasBaseCol) { + const baseRows = rows.filter((row) => row.base === site); + const satelliteRows = baseRows.filter((row) => row.satellite); + if (satelliteRows.length) { + const baseEntry = baseRows.find((row) => !row.satellite); + const satellites = satelliteRows.reduce((acc, row) => { + acc[row.satellite] = { label: row.title }; + return acc; + }, {}); + return { role: 'base', baseLabel: baseEntry?.title, satellites }; + } + const satRow = rows.find((row) => row.satellite === site); + if (satRow) { + const baseEntry = rows.find((row) => row.base === satRow.base && !row.satellite); + return { role: 'satellite', base: satRow.base, baseLabel: baseEntry?.title }; + } + return null; + } + + const isSatellite = rows.some((row) => row.satellite === site); + if (isSatellite) return null; + + const satellites = rows.reduce((acc, row) => { + if (row.satellite) acc[row.satellite] = { label: row.title }; + return acc; + }, {}); + return Object.keys(satellites).length ? { role: 'base', satellites } : null; +} + async function fetchSiteConfig(org, site) { const key = `${org}/${site}`; if (configCache[key]) return configCache[key]; - const resp = await daFetch(`${DA_ORIGIN}/source/${org}/${site}/.da/msm.json`); - if (!resp.ok) return null; - const json = await resp.json(); - const rows = json.data || []; + const rows = await fetchOrgMsmRows(org); if (!rows.length) return null; - let config; - if (rows[0].satellite !== undefined) { - const satellites = rows.reduce((acc, row) => { - acc[row.satellite] = { label: row.satelliteLabel }; - return acc; - }, {}); - config = { role: 'base', satellites }; - } else if (rows[0].base !== undefined) { - config = { role: 'satellite', base: rows[0].base, baseLabel: rows[0].baseLabel }; - } else { - return null; - } + const config = resolveConfig(rows, site); + if (!config) return null; configCache[key] = config; return config; @@ -64,5 +97,6 @@ export async function checkOverrides(org, satellites, pagePath) { } export function clearMsmCache() { + Object.keys(orgCache).forEach((key) => { delete orgCache[key]; }); Object.keys(configCache).forEach((key) => { delete configCache[key]; }); } From 76356e1be745f2279128ccad717c56dca679f454 Mon Sep 17 00:00:00 2001 From: Ramachandra Avuthu Date: Mon, 6 Apr 2026 21:34:15 -0400 Subject: [PATCH 5/8] Show a link to edit the page on custom sites --- blocks/edit/da-prepare/actions/msm/msm.js | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/blocks/edit/da-prepare/actions/msm/msm.js b/blocks/edit/da-prepare/actions/msm/msm.js index 9cc6d975f..4ec4c70fe 100644 --- a/blocks/edit/da-prepare/actions/msm/msm.js +++ b/blocks/edit/da-prepare/actions/msm/msm.js @@ -343,8 +343,8 @@ class DaMsm extends LitElement { ${sat.label} ${this.renderStatusIcon(sat)} - ${sat.editUrl ? html` - + ${sat.hasOverride ? html` + ` : nothing} `; From adb8f581af8cb337959c82b084a04fe6cb842ed7 Mon Sep 17 00:00:00 2001 From: Ramachandra Avuthu Date: Mon, 6 Apr 2026 21:45:16 -0400 Subject: [PATCH 6/8] readme added --- blocks/edit/da-prepare/actions/msm/README.md | 29 ++++++++++++++++++++ 1 file changed, 29 insertions(+) create mode 100644 blocks/edit/da-prepare/actions/msm/README.md diff --git a/blocks/edit/da-prepare/actions/msm/README.md b/blocks/edit/da-prepare/actions/msm/README.md new file mode 100644 index 000000000..6a4d96d33 --- /dev/null +++ b/blocks/edit/da-prepare/actions/msm/README.md @@ -0,0 +1,29 @@ +## Multi-Site Manager (MSM) +MSM enables base/satellite site relationships where satellite sites inherit content from a base site and can optionally override individual pages. + +### Configuration +MSM is configured via an `msm` sheet in the org-level DA config (`/config#/{org}/`). Each row defines a base-satellite relationship: + +| base | satellite | title | +| :--- | :--- | :--- | +| `my-base` | | Base Site | +| `my-base` | `satellite-1` | Satellite Site 1 | +| `my-base` | `satellite-2` | Satellite Site 2 | + +- The `base` column identifies the base site repo name. +- Rows with an empty `satellite` column define the base site entry and its display title. +- Rows with a `satellite` value define satellite sites that inherit from that base. + +### Features + +**Base site view** — when editing a page on the base site, the MSM panel shows all satellites split into inherited and custom (override) lists. Available actions: +- **Preview / Publish** — push the base page to inherited satellite sites via AEM. +- **Cancel inheritance** — copy the base page to a satellite, creating a local override. +- **Sync to satellite** — push updates to custom satellites via merge or full override. +- **Resume inheritance** — delete the satellite override so it falls back to the base. Automatically previews/publishes the page from the base based on the satellite's prior AEM status. + +Custom satellites always show an "Open in editor" link so base authors can inspect overrides. + +**Satellite site view** — when editing a page on a satellite site, the MSM panel shows the base site and offers: +- **Sync from Base** — pull latest base content via merge or full override. +- **Resume inheritance** — delete the local override. Automatically previews/publishes from the base based on prior AEM status. \ No newline at end of file From 2a4fe87f6b155f4b0b7f85544d5bb99aaecc86f0 Mon Sep 17 00:00:00 2001 From: Ramachandra Avuthu Date: Mon, 6 Apr 2026 23:02:33 -0400 Subject: [PATCH 7/8] moved the shared file inside msm as the da-title is not using it anymore --- .../da-prepare/actions/msm/config.js} | 18 ++++-------------- blocks/edit/da-prepare/actions/msm/msm.js | 10 +++++++--- 2 files changed, 11 insertions(+), 17 deletions(-) rename blocks/{shared/msm.js => edit/da-prepare/actions/msm/config.js} (85%) diff --git a/blocks/shared/msm.js b/blocks/edit/da-prepare/actions/msm/config.js similarity index 85% rename from blocks/shared/msm.js rename to blocks/edit/da-prepare/actions/msm/config.js index 4114fbb57..7b4775a13 100644 --- a/blocks/shared/msm.js +++ b/blocks/edit/da-prepare/actions/msm/config.js @@ -1,20 +1,11 @@ -import { DA_ORIGIN } from './constants.js'; -import { daFetch } from './utils.js'; +import { DA_ORIGIN } from '../../../../shared/constants.js'; +import { daFetch, fetchDaConfigs } from '../../../../shared/utils.js'; -const orgCache = {}; const configCache = {}; async function fetchOrgMsmRows(org) { - if (orgCache[org] !== undefined) return orgCache[org]; - - const resp = await daFetch(`${DA_ORIGIN}/config/${org}/`); - if (!resp.ok) { - orgCache[org] = []; - return []; - } - const json = await resp.json(); - orgCache[org] = json?.msm?.data || []; - return orgCache[org]; + const [orgConfig] = await Promise.all(fetchDaConfigs({ org })); + return orgConfig?.msm?.data || []; } function resolveConfig(rows, site) { @@ -97,6 +88,5 @@ export async function checkOverrides(org, satellites, pagePath) { } export function clearMsmCache() { - Object.keys(orgCache).forEach((key) => { delete orgCache[key]; }); Object.keys(configCache).forEach((key) => { delete configCache[key]; }); } diff --git a/blocks/edit/da-prepare/actions/msm/msm.js b/blocks/edit/da-prepare/actions/msm/msm.js index 4ec4c70fe..3cb78a854 100644 --- a/blocks/edit/da-prepare/actions/msm/msm.js +++ b/blocks/edit/da-prepare/actions/msm/msm.js @@ -1,6 +1,6 @@ import { LitElement, html, nothing } from 'da-lit'; import getSheet from '../../../../shared/sheet.js'; -import { getSatellites, getBaseSite, isPageLocal, checkOverrides } from '../../../../shared/msm.js'; +import { getSatellites, getBaseSite, isPageLocal, checkOverrides } from './config.js'; import { previewSatellite, publishSatellite, @@ -135,6 +135,10 @@ class DaMsm extends LitElement { this._selected = next; } + clearStatuses() { + this._satellites = this._satellites?.map((s) => ({ ...s, status: undefined })); + } + updateSatStatus(site, status) { this._satellites = this._satellites.map( (s) => (s.site === site ? { ...s, status } : s), @@ -245,7 +249,6 @@ class DaMsm extends LitElement { ? { ...s, hasOverride: false, status: STATUS.success } : s), ); - this._selected = new Set([...this._selected, sat.site]); } })); break; @@ -254,6 +257,7 @@ class DaMsm extends LitElement { break; } + this._selected = new Set(); this._busy = false; } @@ -448,7 +452,7 @@ class DaMsm extends LitElement { 'Action', this._action, actionOptions, -(v) => { this._action = v; }, +(v) => { this._action = v; this.clearStatuses(); }, )} ${this._action === 'sync' ? this.renderPicker( 'syncMode', From 2b0b3e63941f868ff00247b65e6ef11b08af582e Mon Sep 17 00:00:00 2001 From: Ramachandra Avuthu Date: Mon, 6 Apr 2026 23:10:00 -0400 Subject: [PATCH 8/8] moved the utils methods to helpers folder --- .../actions/msm/{ => helpers}/config.js | 4 +- .../actions/msm/{ => helpers}/utils.js | 6 +- blocks/edit/da-prepare/actions/msm/msm.js | 68 +++++++++---------- 3 files changed, 39 insertions(+), 39 deletions(-) rename blocks/edit/da-prepare/actions/msm/{ => helpers}/config.js (95%) rename blocks/edit/da-prepare/actions/msm/{ => helpers}/utils.js (94%) diff --git a/blocks/edit/da-prepare/actions/msm/config.js b/blocks/edit/da-prepare/actions/msm/helpers/config.js similarity index 95% rename from blocks/edit/da-prepare/actions/msm/config.js rename to blocks/edit/da-prepare/actions/msm/helpers/config.js index 7b4775a13..952bdbd88 100644 --- a/blocks/edit/da-prepare/actions/msm/config.js +++ b/blocks/edit/da-prepare/actions/msm/helpers/config.js @@ -1,5 +1,5 @@ -import { DA_ORIGIN } from '../../../../shared/constants.js'; -import { daFetch, fetchDaConfigs } from '../../../../shared/utils.js'; +import { DA_ORIGIN } from '../../../../../shared/constants.js'; +import { daFetch, fetchDaConfigs } from '../../../../../shared/utils.js'; const configCache = {}; diff --git a/blocks/edit/da-prepare/actions/msm/utils.js b/blocks/edit/da-prepare/actions/msm/helpers/utils.js similarity index 94% rename from blocks/edit/da-prepare/actions/msm/utils.js rename to blocks/edit/da-prepare/actions/msm/helpers/utils.js index 24681786d..a0e6ecef8 100644 --- a/blocks/edit/da-prepare/actions/msm/utils.js +++ b/blocks/edit/da-prepare/actions/msm/helpers/utils.js @@ -1,6 +1,6 @@ -import { DA_ORIGIN } from '../../../../shared/constants.js'; -import { daFetch } from '../../../../shared/utils.js'; -import { getNx } from '../../../../../scripts/utils.js'; +import { DA_ORIGIN } from '../../../../../shared/constants.js'; +import { daFetch } from '../../../../../shared/utils.js'; +import { getNx } from '../../../../../../scripts/utils.js'; const AEM_ADMIN = 'https://admin.hlx.page'; diff --git a/blocks/edit/da-prepare/actions/msm/msm.js b/blocks/edit/da-prepare/actions/msm/msm.js index 3cb78a854..bb8449bb2 100644 --- a/blocks/edit/da-prepare/actions/msm/msm.js +++ b/blocks/edit/da-prepare/actions/msm/msm.js @@ -1,6 +1,6 @@ import { LitElement, html, nothing } from 'da-lit'; import getSheet from '../../../../shared/sheet.js'; -import { getSatellites, getBaseSite, isPageLocal, checkOverrides } from './config.js'; +import { getSatellites, getBaseSite, isPageLocal, checkOverrides } from './helpers/config.js'; import { previewSatellite, publishSatellite, @@ -8,19 +8,19 @@ import { deleteOverride, mergeFromBase, getSatellitePageStatus, -} from './utils.js'; +} from './helpers/utils.js'; const sheet = await getSheet(import.meta.url.replace('js', 'css')); const STATUS = { pending: 'pending', success: 'success', error: 'error' }; const SYNC_MODE = { override: 'override', merge: 'merge' }; -const ACTIONS = { - preview: { label: 'Preview', scope: 'inherited' }, - publish: { label: 'Publish', scope: 'inherited' }, - break: { label: 'Cancel inheritance', scope: 'inherited' }, - sync: { label: 'Sync to satellite', scope: 'custom' }, - reset: { label: 'Resume inheritance', scope: 'custom' }, +const ACTION_SCOPE = { + preview: 'inherited', + publish: 'inherited', + break: 'inherited', + sync: 'custom', + reset: 'custom', }; class DaMsm extends LitElement { @@ -119,7 +119,7 @@ class DaMsm extends LitElement { } get _targets() { - const scope = ACTIONS[this._action]?.scope; + const scope = ACTION_SCOPE[this._action]; const pool = scope === 'custom' ? this._custom : this._inherited; return pool.filter((s) => this._selected.has(s.site)); } @@ -334,7 +334,7 @@ class DaMsm extends LitElement { } renderSatellite(sat) { - const scope = ACTIONS[this._action]?.scope; + const scope = ACTION_SCOPE[this._action]; const outOfScope = (scope === 'inherited') === sat.hasOverride; return html` @@ -448,19 +448,19 @@ class DaMsm extends LitElement { return html`
    ${this.renderPicker( -'action', -'Action', -this._action, -actionOptions, -(v) => { this._action = v; this.clearStatuses(); }, -)} + 'action', + 'Action', + this._action, + actionOptions, + (v) => { this._action = v; this.clearStatuses(); }, + )} ${this._action === 'sync' ? this.renderPicker( -'syncMode', -'Sync mode', -this._syncMode, -syncOptions, -(v) => { this._syncMode = v; }, -) : nothing} + 'syncMode', + 'Sync mode', + this._syncMode, + syncOptions, + (v) => { this._syncMode = v; }, + ) : nothing}
    `; } @@ -525,19 +525,19 @@ syncOptions,
    ${this.renderPicker( -'action', -'Action', -this._action, -satActionOptions, -(v) => { this._action = v; this._satStatus = undefined; }, -)} + 'action', + 'Action', + this._action, + satActionOptions, + (v) => { this._action = v; this._satStatus = undefined; }, + )} ${this._action === 'sync-from-base' ? this.renderPicker( -'syncMode', -'Sync mode', -this._syncMode, -syncOptions, -(v) => { this._syncMode = v; }, -) : nothing} + 'syncMode', + 'Sync mode', + this._syncMode, + syncOptions, + (v) => { this._syncMode = v; }, + ) : nothing}
    ${this.renderConfirm()}