Skip to content
Open

Msm #871

Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
29 changes: 29 additions & 0 deletions blocks/edit/da-prepare/actions/msm/README.md
Original file line number Diff line number Diff line change
@@ -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.
92 changes: 92 additions & 0 deletions blocks/edit/da-prepare/actions/msm/helpers/config.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,92 @@
import { DA_ORIGIN } from '../../../../../shared/constants.js';
import { daFetch, fetchDaConfigs } from '../../../../../shared/utils.js';

const configCache = {};

async function fetchOrgMsmRows(org) {
const [orgConfig] = await Promise.all(fetchDaConfigs({ org }));
return orgConfig?.msm?.data || [];
}

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 rows = await fetchOrgMsmRows(org);
if (!rows.length) return null;

const config = resolveConfig(rows, site);
if (!config) 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]; });
}
85 changes: 85 additions & 0 deletions blocks/edit/da-prepare/actions/msm/helpers/utils.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,85 @@
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 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' });
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' };
}
}
Loading
Loading