diff --git a/WORKLOG.md b/WORKLOG.md index cecead2cb..89010fc55 100644 --- a/WORKLOG.md +++ b/WORKLOG.md @@ -1,5 +1,45 @@ # Worklog +## 2026-05-28 + +### nx2/utils/api.js — consistency refactor (api-refactor branch) + +Method-name + arg-shape alignment across the public surface, plus a return-shape simplification. + +**Renames** (object-form arg names unchanged otherwise): +- `source.load` → `source.get` +- `source.save({ data })` → `source.save({ body })` +- `config.put` → `config.save` +- `snapshot.update` → `snapshot.save` (aligns with AEM's documented `createSnapshot` upsert) +- `wrapActionResp` removed; `HLX6_ONLY` constant kept (still used by `config.getAggregated`). + +**Return-shape unification:** every namespace method now returns a raw augmented `Response` except `source.list` (which legitimately merges body + header continuation token + normalized items). Concrete changes: +- `source.delete/copy/move` no longer wrap into `{ ok, status }`. +- `source.getMetadata` returns `Response` directly; caller reads `resp.headers`. +- `status.get` returns `Response` (was: parsed JSON | undefined). +- `aem.*` drops the `returnJson` flag and the `204 → { ok, status: 204 }` wrapper on `unPreview` / `unPublish`. `callPath` no longer parses JSON. + +**Opt-in unwrappers added:** `asJson` / `asText`. Both return `{ ok, data, status, error }` where `data` is parsed (populated on non-ok when the body parses — matches axios), `status` is the HTTP code, `error` is one of `'no-response' | 'not-ok' | 'parse-failed'` or `null`. Considered `asOk` and dropped it — `const { ok } = await foo()` is the same length. + +**Other fixes:** +- `snapshot.addPath` / `snapshot.removePath` auto-prepend `/` to path (latent bug: callers passing `'index.html'` would build `…/{id}index.html`). New `normalizePath` helper handles string and array forms. +- `snapshot` review action gained no new args; bulk-`removePath` `POST {paths, delete:true}` shape kept although it's not in the published AEM spec — flagged in known-issues but left alone pending verification against the server. + +**File layout:** reorganized so the public namespaces are at the top in alphabetical order, then response helpers, then low-level (`daFetch` / `isHlx6` / `fromPath`), then internal helpers (constants, URL builders, `withArgs`, etc.). Internal helpers converted from arrow-consts to function declarations so hoisting lets the top-of-file exports reference them. `/* eslint-disable no-use-before-define */` at file top. + +**Gotcha discovered:** `chai.deep.equal(, {...})` hangs Chrome by traversing the `body` ReadableStream. One test (`source.delete sends DELETE and returns { ok, status } on 204`) hit this when `source.delete` switched to returning a `Response`. Fix is `expect(resp.ok)` / `expect(resp.status)` separately. Worth remembering — symptoms were "wtr reports 0 passed / 0 failed, Chrome never returns results." + +**Tests:** 90/90 in `test/nx2/utils/api.test.js`. Updated assertions for new shapes; dropped one `returnJson: false` test that no longer applies. + +**Docs:** `api.d.ts` and `api.md` updated; new `UnwrapResult` type, new return-values section, new helpers section. + +**Out of scope, flagged as future work:** +- No per-call `headers` / `opts` on most methods (biggest remaining gap — blocks `If-Match` / tracing / `Accept-Language`). +- No `AbortController` signal plumbing. +- No retry on 429/5xx. +- `org.listSites` vs `source.list({ org })` naming inconsistency. +- `source.delete/copy/move` have no bulk variants (unlike `aem.*` and `snapshot.addPath/removePath`). + ## 2026-05-11 ### Remove `/index` stripping from `nx2/utils/utils.js` diff --git a/nx/blocks/importer/index.js b/nx/blocks/importer/index.js index 576dcb84c..fa313c834 100644 --- a/nx/blocks/importer/index.js +++ b/nx/blocks/importer/index.js @@ -101,7 +101,7 @@ async function saveAllToDa(url, blob) { const body = blob; try { - const resp = await source.save({ org: toOrg, site: toRepo, path: formattedPath, data: body }); + const resp = await source.save({ org: toOrg, site: toRepo, path: formattedPath, body }); return resp.status; } catch { // eslint-disable-next-line no-console diff --git a/nx2/utils/api.d.ts b/nx2/utils/api.d.ts index b36dbf188..85e11a912 100644 --- a/nx2/utils/api.d.ts +++ b/nx2/utils/api.d.ts @@ -4,6 +4,15 @@ * Every namespace method accepts either an object form * `{ org, site, path, ...extras }` or a path-string form * `'/org/site/file/path'` (with extras passed as the second arg). + * + * Returns: all namespace methods return a raw `Response` augmented with + * `resp.permissions: string[]` — EXCEPT `source.list`, which merges body + * + header continuation token + normalized items into a `ListResult`. + * + * Opt-in helpers `asJson` / `asText` unwrap a method promise into + * `{ ok, data, status, error }`. `data` is the parsed body (populated on + * non-ok responses when parseable). For a plain boolean ok-check, destructure + * directly: `const { ok } = await foo()`. */ /** A `Response` augmented with parsed permission hints from x-da-(child-)actions. */ @@ -24,20 +33,6 @@ export interface ListResult { permissions?: string[]; } -/** Normalized return shape for `source.delete` / `source.copy` / `source.move`. */ -export interface ActionResult { - ok: boolean; - status: number; -} - -/** Normalized return shape for `source.getMetadata`. The value of a HEAD - * request IS the headers (doc-id, last-modified, etc.). */ -export interface MetadataResult { - ok: boolean; - status: number; - headers: Headers; -} - // ─── low-level ────────────────────────────────────────────────────────────── export function daFetch(args: { @@ -53,20 +48,45 @@ export function signout(): void; /** Split `/org/site/file/path` into `{ org, site, path }`. */ export function fromPath(fullPath: string): { org: string; site: string; path: string }; +// ─── response helpers ────────────────────────────────────────────────────── + +/** Failure reason returned by `asJson` / `asText` when `ok` is false. */ +export type UnwrapError = 'no-response' | 'not-ok' | 'parse-failed'; + +/** Flat result shape returned by `asJson` / `asText`. + * + * - `ok` mirrors `resp.ok`. + * - `data` is the parsed body. Populated even on non-ok when the error + * response had a parseable body (matches axios). `null` when the body + * could not be parsed or there is no response. + * - `status` is the HTTP status (`0` for no response). + * - `error` is `null` on success, otherwise an `UnwrapError` discriminator. + */ +export interface UnwrapResult { + ok: boolean; + data: T | null; + status: number; + error: UnwrapError | null; +} + +export function asJson(promise: Promise): Promise>; +export function asText(promise: Promise): Promise>; + + // ─── source ───────────────────────────────────────────────────────────────── export const source: { /** - * Load a document. Accepts either calling style: + * Get a document. Accepts either calling style: * - * - **Object:** `load({ org, site, path? })` - * - **Path:** `load('/org/site/file/path')` + * - **Object:** `get({ org, site, path? })` + * - **Path:** `get('/org/site/file/path')` * * Returns an augmented `Response` — use `resp.text()`, `resp.json()`, etc. * * @param arg Path string (`/org/site/file/path`) or `{ org, site, path? }` */ - load(arg: any): Promise; + get(arg: any): Promise; /** * List folder contents. Accepts either calling style: @@ -87,75 +107,40 @@ export const source: { /** * Save a document. Accepts either calling style: * - * - **Object:** `save({ org, site, path, data })` - * - **Path:** `save('/org/site/file/path', { data })` + * - **Object:** `save({ org, site, path, body })` + * - **Path:** `save('/org/site/file/path', { body })` * - * `data` is file contents (string, Blob, or File). On hlx6, `Content-Type` is - * set from the path extension (see `TYPE_MAP`). + * `body` is file contents (string, Blob, or File). `Content-Type` is set + * from the path extension via `TYPE_MAP`. On legacy DA, `body` is wrapped + * in a `multipart/form-data` field named `data`. * * Returns an augmented `Response`. - * - * @param arg Path string (`/org/site/file/path`) or `{ org, site, path, data }` - * @param pathExtras Path-form only — `{ data }` (required) */ save(arg: any, pathExtras?: object): Promise; /** - * HEAD request for document metadata. Accepts either calling style: - * - * - **Object:** `getMetadata({ org, site, path })` - * - **Path:** `getMetadata('/org/site/file/path')` - * - * Returns `{ ok, status, headers }` — `headers` is the raw `Headers` object. - * - * @param arg Path string (`/org/site/file/path`) or `{ org, site, path }` + * HEAD request for document metadata. Returns an augmented `Response` — + * the value is in `resp.headers` (doc-id, last-modified, etc.). */ - getMetadata(arg: any): Promise; + getMetadata(arg: any): Promise; /** - * Delete a document. Accepts either calling style: - * - * - **Object:** `delete({ org, site, path })` - * - **Path:** `delete('/org/site/file/path')` - * - * Returns `{ ok, status }` (204 on success, empty body). For recursive folder - * deletion use `deleteFolder`. - * - * @param arg Path string (`/org/site/file/path`) or `{ org, site, path }` + * Delete a document. Returns an augmented `Response` (204 on success, + * empty body). For recursive folder deletion use `deleteFolder`. */ - delete(arg: any): Promise; + delete(arg: any): Promise; /** - * Copy a document. Accepts either calling style: - * - * - **Object:** `copy({ org, site, path, destination, collision? })` - * - **Path:** `copy('/org/site/source/path', { destination, collision? })` - * - * `path` is the source file; `destination` is the target path (leading-slash). - * `collision` sets conflict policy when the destination exists (e.g. `'overwrite'`). - * - * Returns `{ ok, status }`. - * - * @param arg Path string (source `/org/site/file/path`) or object form above - * @param pathExtras Path-form only — `{ destination, collision? }` + * Copy a document. `path` is the source file; `destination` is the target + * path (leading-slash). `collision` sets conflict policy when the destination + * exists (e.g. `'overwrite'`). Returns an augmented `Response`. */ - copy(arg: any, pathExtras?: object): Promise; + copy(arg: any, pathExtras?: object): Promise; /** - * Move a document. Accepts either calling style: - * - * - **Object:** `move({ org, site, path, destination, collision? })` - * - **Path:** `move('/org/site/source/path', { destination, collision? })` - * - * `path` is the source file; `destination` is the target path (leading-slash). - * `collision` sets conflict policy when the destination exists (e.g. `'overwrite'`). - * - * Returns `{ ok, status }`. - * - * @param arg Path string (source `/org/site/file/path`) or object form above - * @param pathExtras Path-form only — `{ destination, collision? }` + * Move a document. Same shape as `copy`. Returns an augmented `Response`. */ - move(arg: any, pathExtras?: object): Promise; + move(arg: any, pathExtras?: object): Promise; /** * Create a folder. Accepts either calling style: @@ -230,7 +215,7 @@ export const versions: { export const config: { get(arg: { org: string; site?: string }): Promise; - put(arg: { + save(arg: { org: string; site?: string; /** Config payload (typically a JSON Blob or string). */ @@ -250,121 +235,31 @@ export const org: { // ─── status ──────────────────────────────────────────────────────────────── export const status: { - /** Single-path only. H6 has no bulk status endpoint. Returns parsed JSON - * (typically `{ preview, live, edit, ... }`) or `undefined` when the - * response is not ok or the body fails to parse. */ - get(arg: { org: string; site: string; path: string }): Promise; + /** Single-path only. H6 has no bulk status endpoint. Returns an augmented + * `Response` — parse with `await resp.json()` or `asJson(status.get(...))`. */ + get(arg: { org: string; site: string; path: string }): Promise; /** `fullPath` is a `/org/site/file/path` string. */ - get(fullPath: string): Promise; + get(fullPath: string): Promise; }; // ─── aem (preview + live) ─────────────────────────────────────────────────── -/** Parsed JSON from a single-path aem call when `returnJson` is true (default). */ -export type AemJson = unknown; - export const aem: { - /** - * GET preview status (single path only). Accepts either calling style: - * - * - **Object:** `getPreview({ org, site, path, returnJson? })` - * - **Path:** `getPreview('/org/site/file/path', { returnJson? })` - * - * Default: parsed JSON; `undefined` when the response is not ok or fails to parse. - * Set `returnJson: false` for the raw augmented `Response`. - * - * @param arg Path string (`/org/site/file/path`) or `{ org, site, path, returnJson? }` - * @param pathExtras Path-form only — `{ returnJson? }` - */ - getPreview(arg: any, pathExtras?: object): Promise; - - /** - * GET publish status (single path only). Accepts either calling style: - * - * - **Object:** `getPublish({ org, site, path, returnJson? })` - * - **Path:** `getPublish('/org/site/file/path', { returnJson? })` - * - * Default: parsed JSON; `undefined` when the response is not ok or fails to parse. - * Set `returnJson: false` for the raw augmented `Response`. - * - * @param arg Path string (`/org/site/file/path`) or `{ org, site, path, returnJson? }` - * @param pathExtras Path-form only — `{ returnJson? }` - */ - getPublish(arg: any, pathExtras?: object): Promise; - - /** - * Update preview. Accepts either calling style: - * - * - **Object:** `preview({ org, site, path, forceUpdate?, forceSync?, returnJson? })` - * - **Path:** `preview('/org/site/file/path', { forceUpdate?, forceSync?, returnJson? })` - * - * `path` as a string (or one-item array) hits the single-path endpoint. - * `path` as an array of length ≥ 2 routes to the bulk `/*` endpoint - * (always returns an augmented `Response`; `returnJson` does not apply). - * `forceUpdate` and `forceSync` are bulk-only — the server ignores them on single-path calls. - * - * Default: parsed JSON on single-path success; `undefined` when not ok. - * Set `returnJson: false` for the raw augmented `Response` on single-path calls. - * - * @param arg Path string, object form above, or bulk object with `path: string[]` - * @param pathExtras Path-form only — `{ forceUpdate?, forceSync?, returnJson? }` - */ - preview(arg: any, pathExtras?: object): Promise; - - /** - * Remove from preview. Accepts either calling style: - * - * - **Object:** `unPreview({ org, site, path, returnJson? })` - * - **Path:** `unPreview('/org/site/file/path', { returnJson? })` - * - * `path` as a string (or one-item array) → DELETE `/preview/{path}`. - * `path` as an array of length ≥ 2 → POST `/preview/.../*` with `{ paths, delete: true }` - * (always returns an augmented `Response`; `returnJson` does not apply). - * - * Default: `{ ok, status }` on single-path success (204); `undefined` otherwise. - * Set `returnJson: false` for the raw augmented `Response` on single-path calls. - * - * @param arg Path string, object form above, or bulk object with `path: string[]` - * @param pathExtras Path-form only — `{ returnJson? }` - */ - unPreview(arg: any, pathExtras?: object): Promise; - - /** - * Publish. Accepts either calling style: - * - * - **Object:** `publish({ org, site, path, forceUpdate?, forceSync?, returnJson? })` - * - **Path:** `publish('/org/site/file/path', { forceUpdate?, forceSync?, returnJson? })` - * - * `path` as a string (or one-item array) hits the single-path endpoint. - * `path` as an array of length ≥ 2 routes to the bulk `/*` endpoint - * (always returns an augmented `Response`; `returnJson` does not apply). - * `forceUpdate` and `forceSync` are bulk-only — the server ignores them on single-path calls. - * - * Default: parsed JSON on single-path success; `undefined` when not ok. - * Set `returnJson: false` for the raw augmented `Response` on single-path calls. - * - * @param arg Path string, object form above, or bulk object with `path: string[]` - * @param pathExtras Path-form only — `{ forceUpdate?, forceSync?, returnJson? }` - */ - publish(arg: any, pathExtras?: object): Promise; - - /** - * Unpublish. Accepts either calling style: - * - * - **Object:** `unPublish({ org, site, path, returnJson? })` - * - **Path:** `unPublish('/org/site/file/path', { returnJson? })` - * - * `path` as a string (or one-item array) → DELETE `/live/{path}`. - * `path` as an array of length ≥ 2 → POST `/live/.../*` with `{ paths, delete: true }` - * (always returns an augmented `Response`; `returnJson` does not apply). - * - * Default: `{ ok, status }` on single-path success (204); `undefined` otherwise. - * Set `returnJson: false` for the raw augmented `Response` on single-path calls. - * - * @param arg Path string, object form above, or bulk object with `path: string[]` - * @param pathExtras Path-form only — `{ returnJson? }` - */ - unPublish(arg: any, pathExtras?: object): Promise; + /** GET preview status (single path only). Returns augmented `Response`. */ + getPreview(arg: any, pathExtras?: object): Promise; + /** GET publish status (single path only). Returns augmented `Response`. */ + getPublish(arg: any, pathExtras?: object): Promise; + /** Update preview. `path` string → single-path POST. `path` string[] of 2+ → + * bulk POST to `/*` with `{ paths, forceUpdate?, forceSync? }` body. + * `forceUpdate` / `forceSync` are bulk-only. Returns augmented `Response`. */ + preview(arg: any, pathExtras?: object): Promise; + /** Remove from preview. `path` string → DELETE. Array of 2+ → POST `/*` + * with `{ paths, delete: true }`. Returns augmented `Response`. */ + unPreview(arg: any, pathExtras?: object): Promise; + /** Publish. Same shape as `preview`. Returns augmented `Response`. */ + publish(arg: any, pathExtras?: object): Promise; + /** Unpublish. Same shape as `unPreview`. Returns augmented `Response`. */ + unPublish(arg: any, pathExtras?: object): Promise; }; // ─── snapshot ─────────────────────────────────────────────────────────────── @@ -372,7 +267,7 @@ export const aem: { export const snapshot: { list(arg: { org: string; site: string }): Promise; get(arg: { org: string; site: string; snapshotId: string }): Promise; - update(arg: { + save(arg: { org: string; site: string; snapshotId: string; diff --git a/nx2/utils/api.js b/nx2/utils/api.js index c0922bb2e..0a8843288 100644 --- a/nx2/utils/api.js +++ b/nx2/utils/api.js @@ -1,3 +1,4 @@ +/* eslint-disable no-use-before-define */ import { HLX_ADMIN, AEM_API, DA_ADMIN, ALLOWED_TOKEN } from './utils.js'; const { loadIms, handleSignIn } = await (async () => { @@ -10,254 +11,187 @@ const { loadIms, handleSignIn } = await (async () => { } })(); -const SOURCE = 'source'; -const LIST = 'list'; -const CONFIG = 'config'; -const VERSIONS = 'versions'; -const REF = 'main'; - -const TYPE_MAP = { - '.html': 'text/html', - '.json': 'application/json', - '.link': 'application/json', - '.svg': 'image/svg+xml', - '.ico': 'image/x-icon', - '.jpg': 'image/jpeg', - '.jpeg': 'image/jpeg', - '.png': 'image/png', - '.gif': 'image/gif', - '.mp4': 'video/mp4', - '.pdf': 'application/pdf', -}; - -export const daFetch = async ({ url, opts = { method: 'GET' }, redirect = false }) => { - const { accessToken } = await loadIms(); - if (!accessToken) { - handleSignIn(); - return {}; - } - - opts.headers = opts.headers || {}; +// ============================================================================ +// Public API +// ---------------------------------------------------------------------------- +// Namespaces (alphabetical): +// aem combined preview + live operations (single or bulk) +// config org/site config get/save/delete +// jobs background job get/details/stop +// org org-level operations +// signout DA logout +// snapshot snapshot CRUD + review/publish +// source DA <-> AEM document operations (get/list/save/copy/move/...) +// status AEM status (preview/live) for a path +// versions document version list/get/create +// +// Response helpers: +// asJson unwrap a method promise to { ok, data, status, error } (JSON) +// asText unwrap a method promise to { ok, data, status, error } (text) +// +// Low-level: +// daFetch authenticated fetch (used by everything above) +// isHlx6 Helix 6 upgrade-status probe (cached) +// fromPath `/org/site/file/path` -> { org, site, path } +// +// All namespace methods return a raw `Response` (augmented with +// `resp.permissions`) EXCEPT `source.list`, which merges body + header +// continuation token + normalized items into `{ ok, items, continuationToken, +// permissions }`. See `source.list` notes for why. +// ============================================================================ - const canToken = ALLOWED_TOKEN.some((origin) => new URL(url).origin === origin); - if (canToken) { - opts.headers.Authorization = `Bearer ${accessToken.token}`; - if ([HLX_ADMIN, AEM_API].some((origin) => new URL(url).origin === origin)) { - opts.headers['x-content-source-authorization'] = `Bearer ${accessToken.token}`; - opts.headers.Authorization = `Bearer ${accessToken.token}`; - } - } +// aem: combined preview + live operations. +// preview/unPreview/publish/unPublish accept `path` as string or array (2+ -> bulk). +// preview/publish also accept optional `forceUpdate`/`forceSync` flags. +export const aem = { + getPreview: withArgs(({ org, site, path }) => callPath({ + api: 'preview', org, site, path, method: 'GET', + })), - const resp = await fetch(url, opts); - const { status } = resp; - if (status === 401 || status === 403) { - if (redirect) window.location = `${window.location.origin}/not-found`; - } + getPublish: withArgs(({ org, site, path }) => callPath({ + api: 'live', org, site, path, method: 'GET', + })), - // If child actions header is present, use it. - // This is a hint as to what can be done with the children. - if (resp.headers?.get('x-da-child-actions')) { - resp.permissions = resp.headers.get('x-da-child-actions').split('=').pop().split(','); - return resp; - } + preview: withArgs(({ org, site, path, forceUpdate, forceSync }) => callPath({ + api: 'preview', org, site, path, method: 'POST', forceUpdate, forceSync, + })), - // Use the self actions hint if child actions are not present. - if (resp.headers?.get('x-da-actions')) { - resp.permissions = resp.headers?.get('x-da-actions')?.split('=').pop().split(','); - return resp; - } + unPreview: withArgs(({ org, site, path }) => callPath({ + api: 'preview', org, site, path, method: 'DELETE', includeDelete: true, + })), - // TODO: HLX6 does not have this, so fake it for now. - resp.permissions ??= ['read', 'write']; + publish: withArgs(({ org, site, path, forceUpdate, forceSync }) => callPath({ + api: 'live', org, site, path, method: 'POST', forceUpdate, forceSync, + })), - return resp; + unPublish: withArgs(({ org, site, path }) => callPath({ + api: 'live', org, site, path, method: 'DELETE', includeDelete: true, + })), }; -const STORAGE_KEY = 'hlx6-upgrade'; - -export const isHlx6 = (() => { - const cache = {}; - - const fetchUpgradeStatus = async (path) => { - const lsCache = JSON.parse(localStorage.getItem(STORAGE_KEY)) ?? {}; - if (lsCache[path]) return true; - - const resp = await daFetch({ url: `${HLX_ADMIN}/ping${path}` }); - const upgraded = resp.headers.get('x-api-upgrade-available') !== null; - if (upgraded) { - lsCache[path] = true; - localStorage.setItem(STORAGE_KEY, JSON.stringify(lsCache)); - } - return upgraded; - }; - - return (org, site) => { - if (!site) return false; - - const path = `/${org}/${site}`; - cache[path] ??= fetchUpgradeStatus(path); - return cache[path]; - }; -})(); - -// DA-owned endpoints proxied between DA_ADMIN and AEM_API. -const getDaApiPath = async (api, org, site, path = '') => { - const hlx6 = await isHlx6(org, site); - - if (api === VERSIONS) { - if (hlx6) return `${AEM_API}/${org}/sites/${site}/source${path}/.versions`; - return `${DA_ADMIN}/versionsource/${org}/${site}${path}`; - } +// config: top-level org/site config. +export const config = { + get: withArgs(async ({ org, site }) => { + const url = await getDaApiPath(CONFIG, org, site); + return daFetch({ url }); + }), - if (api === CONFIG) { - // TODO: For now config is only supported on DA_ADMIN - // if (hlx6) { - // if (!site) return `${AEM_API}/${org}/config.json`; - // return `${AEM_API}/${org}/sites/${site}/config.json`; - // } - if (!site) return `${DA_ADMIN}/config/${org}/`; - return `${DA_ADMIN}/config/${org}/${site}/`; - } + save: withArgs(async ({ org, site, body }) => { + const url = await getDaApiPath(CONFIG, org, site); + const formData = new FormData(); + formData.append(CONFIG, body); + return daFetch({ url, opts: { method: 'PUT', body: formData } }); + }), - // HLX6 has no list api, so source formatting is used (with trailing slash). - if (api === LIST) { - if (!site) return `${DA_ADMIN}/list/${org}`; - return `${DA_ADMIN}/list/${org}/${site}${path}`; - } + delete: withArgs(async ({ org, site }) => { + const url = await getDaApiPath(CONFIG, org, site); + return daFetch({ url, opts: { method: 'DELETE' } }); + }), - // SOURCE - if (hlx6) return `${AEM_API}/${org}/sites/${site}/source${path}`; - return `${DA_ADMIN}/source/${org}/${site}${path}`; + getAggregated: withArgs(async ({ org, site }) => { + const hlx6 = await isHlx6(org, site); + if (!hlx6) return { ...HLX6_ONLY }; + const url = `${AEM_API}/${org}/aggregated/${site}/config.json`; + return daFetch({ url }); + }), }; -// AEM-only endpoints. New API origin or legacy admin.hlx.page with ref=main. -const getAemApiPath = async (api, org, site, path = '') => { - const hlx6 = await isHlx6(org, site); +// jobs: background job control. +export const jobs = { + get: async ({ org, site, topic, name }) => { + const tail = name ? `/${topic}/${name}` : `/${topic}`; + const url = await getAemApiPath('jobs', org, site, tail); + return daFetch({ url }); + }, - if (hlx6) { - if (api === 'jobs') return `${AEM_API}/${org}/sites/${site}/jobs${path}`; - if (api === 'snapshots') return `${AEM_API}/${org}/sites/${site}/snapshots${path}`; - return `${AEM_API}/${org}/sites/${site}/${api}${path}`; - } + details: async ({ org, site, topic, name }) => { + const url = await getAemApiPath('jobs', org, site, `/${topic}/${name}/details`); + return daFetch({ url }); + }, - // Legacy: singular forms for jobs/snapshots, ref in path. - if (api === 'jobs') return `${HLX_ADMIN}/job/${org}/${site}/${REF}${path}`; - if (api === 'snapshots') return `${HLX_ADMIN}/snapshot/${org}/${site}/${REF}${path}`; - return `${HLX_ADMIN}/${api}/${org}/${site}/${REF}${path}`; + stop: async ({ org, site, topic, name }) => { + const url = await getAemApiPath('jobs', org, site, `/${topic}/${name}`); + return daFetch({ url, opts: { method: 'DELETE' } }); + }, }; -const HLX6_ONLY = { error: 'Requires Helix 6 upgrade', status: 501 }; - -// Convert a `/org/site/file/path` string into `{ org, site, path }`. -export const fromPath = (str) => { - const [, org, site, ...parts] = str.split('/'); - return { org, site, path: parts.length ? `/${parts.join('/')}` : '' }; +// org: organization-level operations. New-API only; no hlx6 detection +// (no site to probe). The endpoint will 404 on non-migrated orgs. +const orgNs = { + listSites: async ({ org }) => daFetch({ url: `${AEM_API}/${org}/sites` }), }; +export { orgNs as org }; -// Normalize a delete/copy/move response into `{ ok, status }`. -function wrapActionResp(resp) { - return { ok: !!resp?.ok, status: resp?.status ?? 0 }; -} - -function hlx6ToDaList(parentPath, items) { - return items.map((item) => { - const contentType = item['content-type']; - - // Only HLX6 has a content type - if (!contentType) return item; +export const signout = () => { + daFetch({ url: `${DA_ADMIN}/logout` }); +}; - // Normalize folder - const isFolder = item.name.endsWith('/'); - let name = isFolder ? item.name.slice(0, -1) : item.name; +// snapshot: snapshot CRUD and review/publish actions. +export const snapshot = { + list: async ({ org, site }) => { + const url = await getAemApiPath('snapshots', org, site); + return daFetch({ url }); + }, - // Set the path before extension removal - const path = `${parentPath}/${name}`; + get: async ({ org, site, snapshotId }) => { + const url = await getAemApiPath('snapshots', org, site, `/${snapshotId}`); + return daFetch({ url }); + }, - // Remove extension for display - const nameSplit = name.split('.'); - name = nameSplit.length > 1 ? nameSplit[0] : name; + save: async ({ org, site, snapshotId, body }) => { + const url = await getAemApiPath('snapshots', org, site, `/${snapshotId}`); + const opts = body ? jsonOpts('POST', body) : { method: 'POST' }; + return daFetch({ url, opts }); + }, - // Scaffold out the basics - const daItem = { name, path, contentType }; + delete: async ({ org, site, snapshotId }) => { + const url = await getAemApiPath('snapshots', org, site, `/${snapshotId}`); + return daFetch({ url, opts: { method: 'DELETE' } }); + }, - const ext = nameSplit.length > 1 && nameSplit.pop(); - if (ext) daItem.ext = ext; + addPath: async ({ org, site, snapshotId, path }) => { + const normalized = normalizePath(path); + if (Array.isArray(normalized) && normalized.length >= 2) { + const url = await getAemApiPath('snapshots', org, site, `/${snapshotId}/*`); + return daFetch({ url, opts: jsonOpts('POST', { paths: normalized }) }); + } + const single = Array.isArray(normalized) ? normalized[0] : normalized; + const url = await getAemApiPath('snapshots', org, site, `/${snapshotId}${single}`); + return daFetch({ url, opts: { method: 'POST' } }); + }, - const lastModified = item['last-modified']; - if (lastModified) { - const unixTime = Math.floor(new Date(lastModified).getTime()); - daItem.lastModified = unixTime; + removePath: async ({ org, site, snapshotId, path }) => { + const normalized = normalizePath(path); + if (Array.isArray(normalized) && normalized.length >= 2) { + const url = await getAemApiPath('snapshots', org, site, `/${snapshotId}/*`); + return daFetch({ url, opts: jsonOpts('POST', { paths: normalized, delete: true }) }); } + const single = Array.isArray(normalized) ? normalized[0] : normalized; + const url = await getAemApiPath('snapshots', org, site, `/${snapshotId}${single}`); + return daFetch({ url, opts: { method: 'DELETE' } }); + }, - return daItem; - }); -} + publish: async ({ org, site, snapshotId }) => { + const url = new URL(await getAemApiPath('snapshots', org, site, `/${snapshotId}`)); + url.searchParams.set('publish', 'true'); + return daFetch({ url: url.toString(), opts: { method: 'POST' } }); + }, -// HOF: wraps a method body so it receives resolved args. The first arg -// can be either `{ org, site, path, ...extras }` or a `/org/site/file/path` -// string; `extras` (second positional) merges in when arg is a string. -// `org` is required; `site` is required by most methods but optional for a -// few (e.g., `source.list({ org })` lists at the org level on legacy DA). -// Bad input is logged but still passed through — the resulting fetch -// fails naturally and callers handle non-ok responses as usual. -const withArgs = (fn) => (arg = {}, extras = {}) => { - const args = typeof arg === 'string' - ? { ...fromPath(arg), ...extras } - : arg; - if (!args.org) { - // eslint-disable-next-line no-console - console.error('api: invalid args - pass /org/site/... string or { org, site, path }', arg); - } - if (typeof args.path === 'string' && !args.path.startsWith('/')) { - args.path = `/${args.path}`; - } - return fn(args); + review: async ({ org, site, snapshotId, action }) => { + const url = new URL(await getAemApiPath('snapshots', org, site, `/${snapshotId}`)); + url.searchParams.set('review', action); + return daFetch({ url: url.toString(), opts: { method: 'POST' } }); + }, }; -const jsonOpts = (method, payload) => ({ - method, - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify(payload), -}); - -// Dispatcher for AEM ops that accept path as string or array. -// Array of length >= 2 routes to the bulk /* endpoint with { paths, delete? }. -// `forceUpdate`/`forceSync` are bulk-only (server ignores them on single-path). -const callPath = async ({ - api, org, site, path, method, includeDelete = false, forceUpdate, forceSync, returnJson = true, -}) => { - if (Array.isArray(path) && path.length >= 2) { - const url = await getAemApiPath(api, org, site, '/*'); - const payload = { paths: path }; - if (includeDelete) payload.delete = true; - if (forceUpdate) payload.forceUpdate = true; - if (forceSync) payload.forceSync = true; - return daFetch({ url, opts: jsonOpts('POST', payload) }); - } - const single = Array.isArray(path) ? path[0] : path; - const url = await getAemApiPath(api, org, site, single); - const resp = await daFetch({ url, opts: { method } }); - if (!returnJson) return resp; - if (!resp.ok) return undefined; - try { - return resp.json(); - } catch { - return undefined; - } -}; - -export const signout = () => { - daFetch({ url: `${DA_ADMIN}/logout` }); -}; - -// source: DA <-> AEM document operations. First arg is either -// { org, site, path, ...extras } or a `/org/site/file/path` string. -// `extras` (second arg) merges with parsed args when arg is a string. -export const source = { - load: withArgs(async ({ org, site, path }) => { - const url = await getDaApiPath(SOURCE, org, site, path); - return daFetch({ url }); - }), +// source: DA <-> AEM document operations. First arg is either +// { org, site, path, ...extras } or a `/org/site/file/path` string. +// `extras` (second arg) merges with parsed args when arg is a string. +export const source = { + get: withArgs(async ({ org, site, path }) => { + const url = await getDaApiPath(SOURCE, org, site, path); + return daFetch({ url }); + }), // Returns `{ ok, items, continuationToken, permissions }`. Pagination // continues when the server returns a `da-continuation-token` header; pass @@ -293,81 +227,68 @@ export const source = { return { ok: true, items, continuationToken: nextToken, permissions }; }), - save: withArgs(async ({ org, site, path, data }) => { + save: withArgs(async ({ org, site, path, body }) => { const hlx6 = await isHlx6(org, site); const url = await getDaApiPath(SOURCE, org, site, path); const opts = { method: 'POST' }; + const ext = Object.keys(TYPE_MAP).find((e) => path.endsWith(e)); if (hlx6) { - const ext = Object.keys(TYPE_MAP).find((e) => path.endsWith(e)); - opts.body = data; + opts.body = body; if (ext) opts.headers = { 'Content-Type': TYPE_MAP[ext] }; return daFetch({ url, opts }); } const formData = new FormData(); - formData.append('data', data); + formData.append('data', new Blob([body], { type: TYPE_MAP[ext] })); opts.body = formData; return daFetch({ url, opts }); }), - // Returns `{ ok, status, headers }`. HEAD request — the value is the - // headers (doc-id, last-modified, etc.). `headers` is the raw Headers - // object so callers can call `.get(name)` on it as usual. + // HEAD request — the value is in the response headers (doc-id, last-modified, etc.). getMetadata: withArgs(async ({ org, site, path }) => { const url = await getDaApiPath(SOURCE, org, site, path); - const resp = await daFetch({ url, opts: { method: 'HEAD' } }); - return { ok: !!resp?.ok, status: resp?.status ?? 0, headers: resp?.headers }; + return daFetch({ url, opts: { method: 'HEAD' } }); }), - // Returns `{ ok, status }`. Deletes a single document (204, no body). delete: withArgs(async ({ org, site, path }) => { const url = await getDaApiPath(SOURCE, org, site, path); - const resp = await daFetch({ url, opts: { method: 'DELETE' } }); - return wrapActionResp(resp); + return daFetch({ url, opts: { method: 'DELETE' } }); }), - // Returns `{ ok, status }`. copy: withArgs(async ({ org, site, path, destination, collision, }) => { const hlx6 = await isHlx6(org, site); - let resp; if (hlx6) { const url = new URL(await getDaApiPath(SOURCE, org, site, destination)); url.searchParams.set('source', path); if (collision) url.searchParams.set('collision', collision); - resp = await daFetch({ url: url.toString(), opts: { method: 'PUT' } }); - } else { - const formData = new FormData(); - formData.append('destination', destination); - resp = await daFetch({ - url: `${DA_ADMIN}/copy/${org}/${site}${path}`, - opts: { method: 'POST', body: formData }, - }); + return daFetch({ url: url.toString(), opts: { method: 'PUT' } }); } - return wrapActionResp(resp); + const formData = new FormData(); + formData.append('destination', destination); + return daFetch({ + url: `${DA_ADMIN}/copy/${org}/${site}${path}`, + opts: { method: 'POST', body: formData }, + }); }), - // Returns `{ ok, status }`. move: withArgs(async ({ org, site, path, destination, collision, }) => { const hlx6 = await isHlx6(org, site); - let resp; if (hlx6) { const url = new URL(await getDaApiPath(SOURCE, org, site, destination)); url.searchParams.set('source', path); url.searchParams.set('move', 'true'); if (collision) url.searchParams.set('collision', collision); - resp = await daFetch({ url: url.toString(), opts: { method: 'PUT' } }); - } else { - const formData = new FormData(); - formData.append('destination', destination); - resp = await daFetch({ - url: `${DA_ADMIN}/move/${org}/${site}${path}`, - opts: { method: 'POST', body: formData }, - }); + return daFetch({ url: url.toString(), opts: { method: 'PUT' } }); } - return wrapActionResp(resp); + const formData = new FormData(); + formData.append('destination', destination); + return daFetch({ + url: `${DA_ADMIN}/move/${org}/${site}${path}`, + opts: { method: 'POST', body: formData }, + }); }), createFolder: withArgs(async ({ org, site, path }) => { @@ -381,6 +302,14 @@ export const source = { }), }; +// status: single-path only. H6 has no bulk status endpoint. +export const status = { + get: withArgs(async ({ org, site, path }) => { + const url = await getAemApiPath('status', org, site, path); + return daFetch({ url }); + }), +}; + // versions: list/get/create document versions. export const versions = { list: withArgs(async ({ org, site, path }) => { @@ -424,168 +353,286 @@ export const versions = { }), }; -// config: top-level org/site config. -export const config = { - get: async ({ org, site }) => { - const url = await getDaApiPath(CONFIG, org, site); - return daFetch({ url }); - }, +// ---------------------------------------------------------------------------- +// Response helpers — opt-in unwrappers for the common parse-or-fail patterns. +// Pass any namespace method's returned promise (or a resolved Response). +// +// Both return a flat object: `{ ok, data, status, error }`. +// - `ok` — `resp.ok` (true for 2xx) +// - `data` — parsed body (JSON / text). Populated even on non-ok when +// the error response had a parseable body — matches axios. +// `null` when the body could not be parsed or there is no response. +// - `status` — HTTP status code (`0` when there is no response at all). +// - `error` — `null` on success; otherwise one of: +// `'no-response'` — daFetch returned `{}` (no auth token) +// `'not-ok'` — response arrived but `resp.ok` is false +// `'parse-failed'` — body failed to parse (json/text) +// +// Callers branch on `ok` for the success path, and can inspect `status` / +// `error` / `data` for failure handling without losing information. +// ---------------------------------------------------------------------------- + +async function unwrap(promise, parser) { + const resp = await promise; + if (!resp || typeof resp.status !== 'number') { + return { ok: false, data: null, status: 0, error: 'no-response' }; + } + let data = null; + let error = null; + try { + data = await resp[parser](); + } catch { + error = 'parse-failed'; + } + if (!resp.ok && !error) error = 'not-ok'; + return { ok: !!resp.ok, data, status: resp.status, error }; +} - put: async ({ org, site, body }) => { - const url = await getDaApiPath(CONFIG, org, site); - const formData = new FormData(); - formData.append(CONFIG, body); - return daFetch({ url, opts: { method: 'POST', body: formData } }); - }, +// 2xx -> { ok: true, data: , status, error: null } +// Non-ok -> { ok: false, data: , status, error } +export const asJson = (promise) => unwrap(promise, 'json'); - delete: async ({ org, site }) => { - const url = await getDaApiPath(CONFIG, org, site); - return daFetch({ url, opts: { method: 'DELETE' } }); - }, +// 2xx -> { ok: true, data: , status, error: null } +// Non-ok -> { ok: false, data: , status, error } +export const asText = (promise) => unwrap(promise, 'text'); - getAggregated: async ({ org, site }) => { - const hlx6 = await isHlx6(org, site); - if (!hlx6) return { ...HLX6_ONLY }; - const url = `${AEM_API}/${org}/aggregated/${site}/config.json`; - return daFetch({ url }); - }, -}; +// ============================================================================ +// Low-level fetch + upgrade probe +// ============================================================================ -// org: organization-level operations. New-API only; no hlx6 detection -// (no site to probe). The endpoint will 404 on non-migrated orgs. -const orgNs = { - listSites: async ({ org }) => daFetch({ url: `${AEM_API}/${org}/sites` }), -}; -export { orgNs as org }; +export const daFetch = async ({ url, opts = { method: 'GET' }, redirect = false }) => { + const { accessToken } = await loadIms(); + if (!accessToken) { + handleSignIn(); + return {}; + } -// status: single-path only. H6 has no bulk status endpoint. -export const status = { - get: withArgs(async ({ org, site, path }) => { - const url = await getAemApiPath('status', org, site, path); - const resp = await daFetch({ url }); - if (!resp.ok) return undefined; - try { - return resp.json(); - } catch { - return undefined; + opts.headers = opts.headers || {}; + + const canToken = ALLOWED_TOKEN.some((origin) => new URL(url).origin === origin); + if (canToken) { + opts.headers.Authorization = `Bearer ${accessToken.token}`; + if ([HLX_ADMIN, AEM_API].some((origin) => new URL(url).origin === origin)) { + opts.headers['x-content-source-authorization'] = `Bearer ${accessToken.token}`; + opts.headers.Authorization = `Bearer ${accessToken.token}`; } - }), + } + + const resp = await fetch(url, opts); + if (resp.status === 401 || resp.status === 403) { + if (redirect) window.location = `${window.location.origin}/not-found`; + } + + // If child actions header is present, use it. + // This is a hint as to what can be done with the children. + if (resp.headers?.get('x-da-child-actions')) { + resp.permissions = resp.headers.get('x-da-child-actions').split('=').pop().split(','); + return resp; + } + + // Use the self actions hint if child actions are not present. + if (resp.headers?.get('x-da-actions')) { + resp.permissions = resp.headers?.get('x-da-actions')?.split('=').pop().split(','); + return resp; + } + + // TODO: HLX6 does not have this, so fake it for now. + resp.permissions ??= ['read', 'write']; + + return resp; }; -// aem: combined preview + live operations. -// preview/unPreview/publish/unPublish accept `path` as string or array (2+ -> bulk). -// preview/publish also accept optional `forceUpdate`/`forceSync` flags. -export const aem = { - getPreview: withArgs(({ org, site, path, returnJson = true }) => callPath({ - api: 'preview', org, site, path, method: 'GET', returnJson, - })), +export const isHlx6 = (() => { + const cache = {}; - getPublish: withArgs(({ org, site, path, returnJson = true }) => callPath({ - api: 'live', org, site, path, method: 'GET', returnJson, - })), + const fetchUpgradeStatus = async (path) => { + const lsCache = JSON.parse(localStorage.getItem(STORAGE_KEY)) ?? {}; + if (lsCache[path]) return true; - preview: withArgs(({ - org, site, path, forceUpdate, forceSync, returnJson = true, - }) => callPath({ - api: 'preview', org, site, path, method: 'POST', forceUpdate, forceSync, returnJson, - })), + const resp = await daFetch({ url: `${HLX_ADMIN}/ping${path}` }); + const upgraded = resp.headers.get('x-api-upgrade-available') !== null; + if (upgraded) { + lsCache[path] = true; + localStorage.setItem(STORAGE_KEY, JSON.stringify(lsCache)); + } + return upgraded; + }; - unPreview: withArgs(async ({ org, site, path, returnJson = true }) => { - const resp = await callPath({ - api: 'preview', org, site, path, method: 'DELETE', includeDelete: true, returnJson: false, - }); - if (!returnJson) return resp; - if (resp.ok && resp.status === 204) return { ok: true, status: 204 }; - return undefined; - }), + return (org, site) => { + if (!site) return false; - publish: withArgs(({ - org, site, path, forceUpdate, forceSync, returnJson = true, - }) => callPath({ - api: 'live', org, site, path, method: 'POST', forceUpdate, forceSync, returnJson, - })), + const path = `/${org}/${site}`; + cache[path] ??= fetchUpgradeStatus(path); + return cache[path]; + }; +})(); - unPublish: withArgs(async ({ org, site, path, returnJson = true }) => { - const resp = await callPath({ - api: 'live', org, site, path, method: 'DELETE', includeDelete: true, returnJson: false, - }); - if (!returnJson) return resp; - if (resp.ok && resp.status === 204) return { ok: true, status: 204 }; - return undefined; - }), +// Convert a `/org/site/file/path` string into `{ org, site, path }`. +export function fromPath(str) { + const [, org, site, ...parts] = str.split('/'); + return { org, site, path: parts.length ? `/${parts.join('/')}` : '' }; +} + +// ============================================================================ +// Internal helpers +// ============================================================================ + +const SOURCE = 'source'; +const LIST = 'list'; +const CONFIG = 'config'; +const VERSIONS = 'versions'; +const REF = 'main'; +const STORAGE_KEY = 'hlx6-upgrade'; +const HLX6_ONLY = { error: 'Requires Helix 6 upgrade', status: 501 }; + +const TYPE_MAP = { + '.html': 'text/html', + '.json': 'application/json', + '.link': 'application/json', + '.svg': 'image/svg+xml', + '.ico': 'image/x-icon', + '.jpg': 'image/jpeg', + '.jpeg': 'image/jpeg', + '.png': 'image/png', + '.gif': 'image/gif', + '.mp4': 'video/mp4', + '.pdf': 'application/pdf', }; -// snapshot: snapshot CRUD and review/publish actions. -export const snapshot = { - list: async ({ org, site }) => { - const url = await getAemApiPath('snapshots', org, site); - return daFetch({ url }); - }, +// DA-owned endpoints proxied between DA_ADMIN and AEM_API. +async function getDaApiPath(api, org, site, path = '') { + const hlx6 = await isHlx6(org, site); - get: async ({ org, site, snapshotId }) => { - const url = await getAemApiPath('snapshots', org, site, `/${snapshotId}`); - return daFetch({ url }); - }, + if (api === VERSIONS) { + if (hlx6) return `${AEM_API}/${org}/sites/${site}/source${path}/.versions`; + return `${DA_ADMIN}/versionsource/${org}/${site}${path}`; + } - update: async ({ org, site, snapshotId, body }) => { - const url = await getAemApiPath('snapshots', org, site, `/${snapshotId}`); - const opts = body ? jsonOpts('POST', body) : { method: 'POST' }; - return daFetch({ url, opts }); - }, + if (api === CONFIG) { + // TODO: For now config is only supported on DA_ADMIN + // if (hlx6) { + // if (!site) return `${AEM_API}/${org}/config.json`; + // return `${AEM_API}/${org}/sites/${site}/config.json`; + // } + if (!site) return `${DA_ADMIN}/config/${org}/`; + return `${DA_ADMIN}/config/${org}/${site}/`; + } - delete: async ({ org, site, snapshotId }) => { - const url = await getAemApiPath('snapshots', org, site, `/${snapshotId}`); - return daFetch({ url, opts: { method: 'DELETE' } }); - }, + // HLX6 has no list api, so source formatting is used (with trailing slash). + if (api === LIST) { + if (!site) return `${DA_ADMIN}/list/${org}`; + return `${DA_ADMIN}/list/${org}/${site}${path}`; + } - addPath: async ({ org, site, snapshotId, path }) => { - if (Array.isArray(path) && path.length >= 2) { - const url = await getAemApiPath('snapshots', org, site, `/${snapshotId}/*`); - return daFetch({ url, opts: jsonOpts('POST', { paths: path }) }); - } - const single = Array.isArray(path) ? path[0] : path; - const url = await getAemApiPath('snapshots', org, site, `/${snapshotId}${single}`); - return daFetch({ url, opts: { method: 'POST' } }); - }, + // SOURCE + if (hlx6) return `${AEM_API}/${org}/sites/${site}/source${path}`; + return `${DA_ADMIN}/source/${org}/${site}${path}`; +} - removePath: async ({ org, site, snapshotId, path }) => { - if (Array.isArray(path) && path.length >= 2) { - const url = await getAemApiPath('snapshots', org, site, `/${snapshotId}/*`); - return daFetch({ url, opts: jsonOpts('POST', { paths: path, delete: true }) }); +// AEM-only endpoints. New API origin or legacy admin.hlx.page with ref=main. +async function getAemApiPath(api, org, site, path = '') { + const hlx6 = await isHlx6(org, site); + + if (hlx6) { + if (api === 'jobs') return `${AEM_API}/${org}/sites/${site}/jobs${path}`; + if (api === 'snapshots') return `${AEM_API}/${org}/sites/${site}/snapshots${path}`; + return `${AEM_API}/${org}/sites/${site}/${api}${path}`; + } + + // Legacy: singular forms for jobs/snapshots, ref in path. + if (api === 'jobs') return `${HLX_ADMIN}/job/${org}/${site}/${REF}${path}`; + if (api === 'snapshots') return `${HLX_ADMIN}/snapshot/${org}/${site}/${REF}${path}`; + return `${HLX_ADMIN}/${api}/${org}/${site}/${REF}${path}`; +} + +// HOF: wraps a method body so it receives resolved args. The first arg +// can be either `{ org, site, path, ...extras }` or a `/org/site/file/path` +// string; `extras` (second positional) merges in when arg is a string. +// `org` is required; `site` is required by most methods but optional for a +// few (e.g., `source.list({ org })` lists at the org level on legacy DA). +// Bad input is logged but still passed through — the resulting fetch +// fails naturally and callers handle non-ok responses as usual. +function withArgs(fn) { + return (arg = {}, extras = {}) => { + const args = typeof arg === 'string' + ? { ...fromPath(arg), ...extras } + : arg; + if (!args.org) { + // eslint-disable-next-line no-console + console.error('api: invalid args - pass /org/site/... string or { org, site, path }', arg); } - const single = Array.isArray(path) ? path[0] : path; - const url = await getAemApiPath('snapshots', org, site, `/${snapshotId}${single}`); - return daFetch({ url, opts: { method: 'DELETE' } }); - }, + if (typeof args.path === 'string' && !args.path.startsWith('/')) { + args.path = `/${args.path}`; + } + return fn(args); + }; +} - publish: async ({ org, site, snapshotId }) => { - const url = new URL(await getAemApiPath('snapshots', org, site, `/${snapshotId}`)); - url.searchParams.set('publish', 'true'); - return daFetch({ url: url.toString(), opts: { method: 'POST' } }); - }, +// Ensure a path (or each path in an array) starts with `/`. Non-strings +// pass through untouched so callers handling unusual inputs aren't surprised. +function normalizePath(path) { + if (Array.isArray(path)) return path.map(normalizePath); + if (typeof path !== 'string') return path; + return path.startsWith('/') ? path : `/${path}`; +} - review: async ({ org, site, snapshotId, action }) => { - const url = new URL(await getAemApiPath('snapshots', org, site, `/${snapshotId}`)); - url.searchParams.set('review', action); - return daFetch({ url: url.toString(), opts: { method: 'POST' } }); - }, -}; +function jsonOpts(method, payload) { + return { + method, + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(payload), + }; +} -// jobs: background job control. -export const jobs = { - get: async ({ org, site, topic, name }) => { - const tail = name ? `/${topic}/${name}` : `/${topic}`; - const url = await getAemApiPath('jobs', org, site, tail); - return daFetch({ url }); - }, +// Dispatcher for AEM ops that accept path as string or array. +// Array of length >= 2 routes to the bulk /* endpoint with { paths, delete? }. +// `forceUpdate`/`forceSync` are bulk-only (server ignores them on single-path). +async function callPath({ + api, org, site, path, method, includeDelete = false, forceUpdate, forceSync, +}) { + if (Array.isArray(path) && path.length >= 2) { + const url = await getAemApiPath(api, org, site, '/*'); + const payload = { paths: path }; + if (includeDelete) payload.delete = true; + if (forceUpdate) payload.forceUpdate = true; + if (forceSync) payload.forceSync = true; + return daFetch({ url, opts: jsonOpts('POST', payload) }); + } + const single = Array.isArray(path) ? path[0] : path; + const url = await getAemApiPath(api, org, site, single); + return daFetch({ url, opts: { method } }); +} - details: async ({ org, site, topic, name }) => { - const url = await getAemApiPath('jobs', org, site, `/${topic}/${name}/details`); - return daFetch({ url }); - }, +function hlx6ToDaList(parentPath, items) { + return items.map((item) => { + const contentType = item['content-type']; - stop: async ({ org, site, topic, name }) => { - const url = await getAemApiPath('jobs', org, site, `/${topic}/${name}`); - return daFetch({ url, opts: { method: 'DELETE' } }); - }, -}; + // Only HLX6 has a content type + if (!contentType) return item; + + // Normalize folder + const isFolder = item.name.endsWith('/'); + let name = isFolder ? item.name.slice(0, -1) : item.name; + + // Set the path before extension removal + const path = `${parentPath}/${name}`; + + // Remove extension for display + const nameSplit = name.split('.'); + name = nameSplit.length > 1 ? nameSplit[0] : name; + + // Scaffold out the basics + const daItem = { name, path, contentType }; + + const ext = nameSplit.length > 1 && nameSplit.pop(); + if (ext) daItem.ext = ext; + + const lastModified = item['last-modified']; + if (lastModified) { + const unixTime = Math.floor(new Date(lastModified).getTime()); + daItem.lastModified = unixTime; + } + + return daItem; + }); +} diff --git a/nx2/utils/api.md b/nx2/utils/api.md index 99ac25d3e..b528c6b4f 100644 --- a/nx2/utils/api.md +++ b/nx2/utils/api.md @@ -2,7 +2,7 @@ A unified client for talking to **DA admin** (`admin.da.live`) and the **AEM admin API** in either its legacy form (`admin.hlx.page`, "helix5") or its new form (`api.aem.live`, "helix6"). Every method auto-routes by the per-site **hlx6** upgrade flag — once a site has been upgraded, calls flow to the new origin; otherwise they fall back to the legacy origin. -The module ships its low-level primitive (`daFetch`), an upgrade detector (`isHlx6`), helpers (`fromPath`, `signout`, `hlx6ToDaList`), and **eight namespaced surfaces**: `source`, `versions`, `config`, `org`, `status`, `aem`, `snapshot`, `jobs`. Type definitions live in `[api.d.ts](./api.d.ts)` — VSCode picks them up automatically and surfaces overloads, field-level docs, and inline shapes. +The module ships its low-level primitive (`daFetch`), an upgrade detector (`isHlx6`), helpers (`fromPath`, `signout`, `asJson`, `asText`), and **eight namespaced surfaces**: `source`, `versions`, `config`, `org`, `status`, `aem`, `snapshot`, `jobs`. Type definitions live in `[api.d.ts](./api.d.ts)` — VSCode picks them up automatically and surfaces overloads, field-level docs, and inline shapes. > **Routing model.** Some endpoints are owned by DA itself (`source`, `list`, `config`, `versions`) and DA proxies them to AEM when the site is upgraded. Others are AEM-only (`status`, `preview`, `live`, `snapshots`, `jobs`) and live on either `admin.hlx.page` (legacy) or `api.aem.live` (hlx6). The module hides this distinction; callers always pass `{ org, site, path }` and get a `Response` back. @@ -13,9 +13,11 @@ The module ships its low-level primitive (`daFetch`), an upgrade detector (`isHl ```js import { // Low-level - daFetch, isHlx6, signout, fromPath, hlx6ToDaList, + daFetch, isHlx6, signout, fromPath, // Namespaces source, versions, config, org, status, aem, snapshot, jobs, + // Response helpers + asJson, asText, } from '/nx2/utils/api.js'; ``` @@ -28,14 +30,14 @@ Most methods accept the first argument as **either** an object or a path string. **Object form** — pass parts explicitly: ```js -source.load({ org: 'adobe', site: 'aem-boilerplate', path: '/index.html' }); +source.get({ org: 'adobe', site: 'aem-boilerplate', path: '/index.html' }); ``` **Path-string form** — pass a `/org/site/file/path` string. The helper splits it for you. Method-specific extras go in a second positional argument: ```js -source.load('/adobe/aem-boilerplate/index.html'); -source.save('/adobe/aem-boilerplate/page.html', { data: '
' }); +source.get('/adobe/aem-boilerplate/index.html'); +source.save('/adobe/aem-boilerplate/page.html', { body: '
' }); versions.get('/adobe/aem-boilerplate/index.html', { versionId: 'abc' }); ``` @@ -47,18 +49,34 @@ versions.get('/adobe/aem-boilerplate/index.html', { versionId: 'abc' }); ## Return values -Methods fall into a few return-shape categories: +**Every namespace method returns a raw `Response`** — augmented by `daFetch` with `resp.permissions: string[]` (parsed from `x-da-child-actions` / `x-da-actions`, defaulted to `['read', 'write']`). Treat like any `fetch` result: `await resp.json()`, check `resp.ok`, read `resp.headers`, etc. -**Raw `Response`** — `source.load`, `source.save`, `source.createFolder`, `source.deleteFolder`, all of `versions`, `config`, `org`, `snapshot`, `jobs`, and bulk `aem` calls. The response is augmented by `daFetch` with `resp.permissions: string[]` — parsed from the `x-da-child-actions` header (preferred), `x-da-actions` (fallback), or defaulted to `['read', 'write']`. Treat like any `fetch` result: `await resp.json()`, check `resp.ok`, etc. +**Two exceptions:** +- **`source.list`** → `{ ok, items, continuationToken, permissions }`. Merges body (normalized items) + headers (continuation token) + permissions into one object — the only namespace method whose return shape isn't a `Response`. Pass `continuationToken` back into the same call to fetch the next page. +- **`config.getAggregated`** on a non-hlx6 site → `{ error: 'Requires Helix 6 upgrade', status: 501 }` sentinel. On hlx6 sites it returns a normal `Response` like everything else. -**Normalized objects** for `source` mutation/listing methods: -- `source.list` → `{ ok, items, continuationToken, permissions }` — `items` is the normalized list (legacy DA shape regardless of server). Pass `continuationToken` back into the same call to fetch the next page. -- `source.delete` / `source.copy` / `source.move` → `{ ok, status }` — single-resource operations; success is typically 204 (empty body) on legacy DA, 204 or 200 on hlx6. -- `source.getMetadata` → `{ ok, status, headers }` — the raw `Headers` object (HEAD request value is in the headers). +### Opt-in response helpers -**Parsed JSON or `undefined`** — `status.get` and `aem` single-path calls (the default `returnJson: true`). Returns the parsed body on success, `undefined` when the response is not ok or the body fails to parse. For `aem`, pass `returnJson: false` to get the raw augmented `Response` instead. **Bulk** `aem` calls (`path` as an array of length ≥ 2) always return a `Response` — `returnJson` does not apply. +Most call sites do `await resp.json()` (or check `resp.ok`) right after the call. Three small helpers cover the common patterns: -**Sentinel objects** — `config.getAggregated` is hlx6-only; calling it on a non-upgraded site returns `{ error: 'Requires Helix 6 upgrade', status: 501 }`. `aem.unPreview` / `aem.unPublish` return `{ ok: true, status: 204 }` on a successful single-path delete (default `returnJson: true`), and `undefined` otherwise. +```js +import { asJson, asText, source, config } from '/nx2/utils/api.js'; + +// Success: { ok: true, data: , status: 200, error: null } +// Failure: { ok: false, data: , status, error } +const { ok, data: cfg, status, error } = await asJson(config.get({ org, site })); +if (!ok) { + console.warn(`config.get failed (${status}, ${error})`, cfg); + return; +} +useConfig(cfg); + +const { data: html } = await asText(source.get(path)); +``` + +Both resolve a method's returned promise, await `.json()` / `.text()`, and return a flat result with `ok`, `data`, `status`, and `error`. `data` is populated even on non-ok responses when the body parses (matches axios) — so error JSON bodies aren't lost. + +For a plain boolean ok-check, destructure the method's return directly without a helper: `const { ok } = await source.delete(path);` --- @@ -96,13 +114,13 @@ Document CRUD on `source` paths. Bridges DA's `/source` and AEM's `/sites/{site} | Method | Signature | Notes | | -------------- | --------------------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| `load` | `({ org, site, path })` or `(fullPath)` | GET — raw `Response`. | +| `get` | `({ org, site, path })` or `(fullPath)` | GET — raw `Response`. | | `list` | `({ org, site, path?, continuationToken? })` or `(fullPath, { continuationToken? })` | List a folder. Returns `{ ok, items, continuationToken, permissions }` (normalized — items match legacy DA shape regardless of server). Pass `{ org }` (no site) to list at org level — DA-legacy only. Bulk lists paginate — pass `continuationToken` back into the next call. | -| `save` | `({ org, site, path, data })` or `(fullPath, { data })` | Upload — raw `Response`. POST for both branches. **DA-legacy**: wraps `data` in `multipart/form-data` field `data`. **hlx6**: sends `data` raw (string, Blob, or File); `Content-Type` is set from the path extension via `TYPE_MAP` and overrides any auto-applied Blob type. Extensions not in `TYPE_MAP` send no `Content-Type`. | -| `getMetadata` | `({ org, site, path })` or `(fullPath)` | HEAD — returns `{ ok, status, headers }`. `headers` is the raw `Headers` object (`doc-id`, `last-modified`, etc.). | -| `delete` | `({ org, site, path })` or `(fullPath)` | Delete a single document. Returns `{ ok, status }` (204 on success). For recursive folder deletion use `deleteFolder`. | -| `copy` | `({ org, site, path, destination, collision? })` or `(fullPath, { destination, collision? })` | `path` = source, `destination` = target. Returns `{ ok, status }`. **hlx6**: PUT to dest URL with `?source=…&collision=…` query. **DA**: POST `/copy/{org}/{site}{path}` with `multipart/form-data` field `destination`. | -| `move` | `({ org, site, path, destination, collision? })` or `(fullPath, { destination, collision? })` | Same shape as `copy` (and same return) but adds `?move=true` (hlx6) or POSTs to `/move/{org}/{site}{path}` (DA). | +| `save` | `({ org, site, path, body })` or `(fullPath, { body })` | Upload — raw `Response`. POST for both branches. **DA-legacy**: wraps `body` as a Blob in `multipart/form-data` field `data`, with the Blob's type set from the path extension via `TYPE_MAP`. **hlx6**: sends `body` raw (string, Blob, or File); `Content-Type` is set from the path extension via `TYPE_MAP` and overrides any auto-applied Blob type. Extensions not in `TYPE_MAP` send no `Content-Type`. | +| `getMetadata` | `({ org, site, path })` or `(fullPath)` | HEAD — raw `Response`. Value is in `resp.headers` (`doc-id`, `last-modified`, etc.). | +| `delete` | `({ org, site, path })` or `(fullPath)` | DELETE — raw `Response` (typically 204). For recursive folder deletion use `deleteFolder`. | +| `copy` | `({ org, site, path, destination, collision? })` or `(fullPath, { destination, collision? })` | Raw `Response`. `path` = source, `destination` = target. **hlx6**: PUT to dest URL with `?source=…&collision=…` query. **DA**: POST `/copy/{org}/{site}{path}` with `multipart/form-data` field `destination`. | +| `move` | `({ org, site, path, destination, collision? })` or `(fullPath, { destination, collision? })` | Same shape as `copy`. Raw `Response`. Adds `?move=true` (hlx6) or POSTs to `/move/{org}/{site}{path}` (DA). | | `createFolder` | `({ org, site, path })` or `(fullPath)` | POST on `${path}/` (trailing slash). | | `deleteFolder` | `({ org, site, path })` or `(fullPath)` | DELETE on `${path}/`. | @@ -112,7 +130,7 @@ Document CRUD on `source` paths. Bridges DA's `/source` and AEM's `/sites/{site} | Method | hlx6 | legacy DA | | -------------------------------------------- | ------------------------------------------------ | ---------------------------------------------------------------------------------------- | -| load / list / save / getMetadata / delete | `${AEM_API}/{org}/sites/{site}/source{path}` | `${DA_ADMIN}/source/{org}/{site}{path}` | +| get / list / save / getMetadata / delete | `${AEM_API}/{org}/sites/{site}/source{path}` | `${DA_ADMIN}/source/{org}/{site}{path}` | | list (org-only) | n/a | `${DA_ADMIN}/list/{org}` | | list (with site, legacy) | n/a | `${DA_ADMIN}/list/{org}/{site}{path}` | | copy / move | PUT to dest URL with `?source=&collision=&move=` | POST to `${DA_ADMIN}/copy/{org}/{site}{path}` (or `/move`) with `destination` form field | @@ -122,23 +140,27 @@ Document CRUD on `source` paths. Bridges DA's `/source` and AEM's `/sites/{site} ```js // Read -const resp = await source.load('/adobe/aem-boilerplate/index.html'); +const resp = await source.get('/adobe/aem-boilerplate/index.html'); const html = await resp.text(); -// Write (path string + data extra) -await source.save('/adobe/aem-boilerplate/page.html', { data: '
' }); +// Write (path string + body extra) +await source.save('/adobe/aem-boilerplate/page.html', { body: '
' }); // Upload a binary file (e.g., from ) -await source.save('/adobe/aem-boilerplate/img/logo.png', { data: file }); +await source.save('/adobe/aem-boilerplate/img/logo.png', { body: file }); // List a folder — returns { ok, items, continuationToken, permissions } const { ok, items } = await source.list('/adobe/aem-boilerplate/folder'); -// Delete a document — returns { ok, status } +// Delete a document — returns raw Response (204 on success) +const delResp = await source.delete('/adobe/aem-boilerplate/old.html'); +if (!delResp.ok) { /* handle */ } + +// Or destructure for a boolean const { ok: deleted } = await source.delete('/adobe/aem-boilerplate/old.html'); -// Copy — returns { ok, status } -const { ok: copied } = await source.copy({ +// Copy — returns raw Response +const copyResp = await source.copy({ org: 'adobe', site: 'aem-boilerplate', path: '/old.html', // source @@ -184,7 +206,7 @@ Org or site-level configuration JSON. The `site` argument is **optional** — om | Method | Signature | Notes | | --------------- | ------------------------ | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `get` | `({ org, site? })` | Read | -| `put` | `({ org, site?, body })` | Update. Sent as `multipart/form-data` with field `config`. **NOTE:** This wire shape currently doesn't match what the H5/H6 admin endpoints expect (JSON body) and may need realignment — see [Known issues](#known-issues). | +| `save` | `({ org, site?, body })` | Update. Sent as `multipart/form-data` with field `config`. **NOTE:** This wire shape currently doesn't match what the H5/H6 admin endpoints expect (JSON body) and may need realignment — see [Known issues](#known-issues). | | `delete` | `({ org, site? })` | DELETE | | `getAggregated` | `({ org, site })` | hlx6-only. Returns `{ error, status: 501 }` on legacy. Hits `${AEM_API}/{org}/aggregated/{site}/config.json`. | @@ -231,9 +253,9 @@ Organization-level operations. hlx6-only (no DA-legacy fallback exists at org le Resource status (preview + live combined view). **Single-path only** — H6 has no bulk status endpoint. -| Method | Signature | Notes | -| ------ | --------------------------------------- | --------------------------------------------------------------------------- | -| `get` | `({ org, site, path })` or `(fullPath)` | GET `/status/{path}`. Returns parsed JSON; `undefined` if not ok or unparseable. | +| Method | Signature | Notes | +| ------ | --------------------------------------- | ---------------------------------------------------- | +| `get` | `({ org, site, path })` or `(fullPath)` | GET `/status/{path}`. Returns raw `Response`. | ### URL shapes @@ -247,8 +269,10 @@ Resource status (preview + live combined view). **Single-path only** — H6 has ### Example ```js -const info = await status.get('/adobe/aem-boilerplate/index.html'); -if (!info) return; // not ok or unparseable +import { asJson, status } from '/nx2/utils/api.js'; + +const { ok, data: info } = await asJson(status.get('/adobe/aem-boilerplate/index.html')); +if (!ok) return; const { preview, live, edit } = info; ``` @@ -260,17 +284,17 @@ Combined preview + live (publish) operations. The `path` argument can be a **str `forceUpdate` and `forceSync` are **bulk-only** — server ignores them on single-path calls. -**Returns:** single-path methods default to **parsed JSON** (`returnJson: true`). Set `returnJson: false` for the raw `Response`. Bulk operations always return a `Response`. +**Returns:** all methods return a raw `Response`. Parse with `await resp.json()` or the `asJson` helper. -| Method | Signature | Notes | -| ------------ | ---------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------- | -| `getPreview` | `({ org, site, path, returnJson? })` or `(fullPath, { returnJson? })` | GET preview status (single only). Default: JSON; `undefined` if not ok. | -| `getPublish` | `({ org, site, path, returnJson? })` or `(fullPath, { returnJson? })` | GET publish status (single only). Default: JSON; `undefined` if not ok. | -| `preview` | `({ org, site, path, forceUpdate?, forceSync?, returnJson? })` | string → POST `/preview/{path}`. Array of 2+ → POST `/preview/.../*` with `{ paths, forceUpdate?, forceSync? }`. | -| `unPreview` | `({ org, site, path, returnJson? })` | string → DELETE `/preview/{path}`. Array of 2+ → POST `/preview/.../*` with `{ paths, delete: true }`. | -| `publish` | `({ org, site, path, forceUpdate?, forceSync?, returnJson? })` | string → POST `/live/{path}`. Array of 2+ → POST `/live/.../*` with `{ paths, forceUpdate?, forceSync? }`. | -| `unPublish` | `({ org, site, path, returnJson? })` | string → DELETE `/live/{path}`. Array of 2+ → POST `/live/.../*` with `{ paths, delete: true }`. | +| Method | Signature | Notes | +| ------------ | -------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------- | +| `getPreview` | `({ org, site, path })` or `(fullPath)` | GET preview status (single only). | +| `getPublish` | `({ org, site, path })` or `(fullPath)` | GET publish status (single only). | +| `preview` | `({ org, site, path, forceUpdate?, forceSync? })` | string → POST `/preview/{path}`. Array of 2+ → POST `/preview/.../*` with `{ paths, forceUpdate?, forceSync? }`. | +| `unPreview` | `({ org, site, path })` | string → DELETE `/preview/{path}`. Array of 2+ → POST `/preview/.../*` with `{ paths, delete: true }`. | +| `publish` | `({ org, site, path, forceUpdate?, forceSync? })` | string → POST `/live/{path}`. Array of 2+ → POST `/live/.../*` with `{ paths, forceUpdate?, forceSync? }`. | +| `unPublish` | `({ org, site, path })` | string → DELETE `/live/{path}`. Array of 2+ → POST `/live/.../*` with `{ paths, delete: true }`. | ### URL shapes @@ -285,17 +309,19 @@ Combined preview + live (publish) operations. The `path` argument can be a **str ### Examples ```js -// Single preview — returns parsed JSON by default -const previewJob = await aem.preview('/adobe/aem-boilerplate/index.html'); +import { asJson, aem } from '/nx2/utils/api.js'; + +// Single preview — returns raw Response; use asJson to parse +const { data: previewJob } = await asJson(aem.preview('/adobe/aem-boilerplate/index.html')); -// Single preview — raw Response when you need headers/permissions -const resp = await aem.preview('/adobe/aem-boilerplate/index.html', { returnJson: false }); +// Or work with the Response directly when you need headers/permissions +const resp = await aem.preview('/adobe/aem-boilerplate/index.html'); if (resp.ok) { /* … */ } -// GET preview status (parsed JSON) -const status = await aem.getPreview({ org, site, path: '/index.html' }); +// GET preview status +const { data: status } = await asJson(aem.getPreview({ org, site, path: '/index.html' })); -// Bulk publish with extras — always returns Response +// Bulk publish with extras const bulkResp = await aem.publish({ org, site, path: ['/a.html', '/b.html', '/c.html'], @@ -318,10 +344,10 @@ Snapshot CRUD plus review/publish actions. Snapshots are AEM-only. New API uses | ------------ | ------------------------------------- | ------------------------------------------------------------------------------------------------------------- | | `list` | `({ org, site })` | List all snapshots | | `get` | `({ org, site, snapshotId })` | Retrieve manifest | -| `update` | `({ org, site, snapshotId, body? })` | POST manifest | +| `save` | `({ org, site, snapshotId, body? })` | POST manifest (create-or-update; aligns with AEM's `createSnapshot` operation). | | `delete` | `({ org, site, snapshotId })` | DELETE | -| `addPath` | `({ org, site, snapshotId, path })` | string → POST `…/{snapshotId}{path}`. Array of 2+ → POST `…/{snapshotId}/*` with `{ paths }`. | -| `removePath` | `({ org, site, snapshotId, path })` | string → DELETE `…/{snapshotId}{path}`. Array of 2+ → POST `…/{snapshotId}/*` with `{ paths, delete: true }`. | +| `addPath` | `({ org, site, snapshotId, path })` | `path` is auto-normalized to a leading slash. String → POST `…/{snapshotId}{path}`. Array of 2+ → POST `…/{snapshotId}/*` with `{ paths }`. | +| `removePath` | `({ org, site, snapshotId, path })` | `path` is auto-normalized to a leading slash. String → DELETE `…/{snapshotId}{path}`. Array of 2+ → POST `…/{snapshotId}/*` with `{ paths, delete: true }`. | | `publish` | `({ org, site, snapshotId })` | POST `?publish=true` | | `review` | `({ org, site, snapshotId, action })` | POST `?review=…`. `action`: `'request'` | `'approve'` | `'reject'` | @@ -330,7 +356,7 @@ Snapshot CRUD plus review/publish actions. Snapshots are AEM-only. New API uses ```js // Create + populate + publish a snapshot -await snapshot.update({ +await snapshot.save({ org, site, snapshotId: 'snap-1', body: { title: 'Launch candidate' }, }); await snapshot.addPath({ @@ -389,11 +415,26 @@ fromPath('/adobe/aem-boilerplate/index.html'); // → { org: 'adobe', site: 'aem-boilerplate', path: '/index.html' } ``` -### `hlx6ToDaList(parentPath, items)` +### `asJson(promise)` / `asText(promise)` + +Opt-in unwrappers. Each awaits a namespace method's returned promise, attempts to parse the body, and returns a flat result: + +```ts +{ ok: boolean, data: T | null, status: number, error: null | 'no-response' | 'not-ok' | 'parse-failed' } +``` + +- On success (`resp.ok`): `{ ok: true, data: , status, error: null }`. +- On failure: `{ ok: false, data: , status, error: }`. The body is still parsed when possible, so error JSON (e.g., `{ error: 'bad request' }`) surfaces in `data`. -Normalizes a folder listing returned by hlx6's source bus into the shape DA's `/list` endpoint produces. Folders get their trailing slash stripped, file extensions get extracted into `ext`, `last-modified` becomes a unix timestamp at `lastModified`. Items without a `content-type` (i.e., DA's existing format) pass through unchanged. +```js +const { ok, data: cfg, status, error } = await asJson(config.get({ org, site })); +if (!ok) { console.warn(`failed (${status}, ${error})`, cfg); return; } +useConfig(cfg); + +const { data: html } = await asText(source.get(path)); +``` -Useful when you want to render a folder listing without caring whether the site is hlx6 or legacy. +For a boolean ok-check on a `Response`-returning method, destructure directly without a helper: `const { ok } = await source.delete(path);` ### `signout()` @@ -414,43 +455,39 @@ const resp = await daFetch({ ## Error handling -No method throws on HTTP failure. The branching idiom depends on the return category (see [Return values](#return-values)): - -**Raw `Response` methods** — branch on `resp.ok` / `resp.status`: +No method throws on HTTP failure. Branch on `resp.ok` (or `resp.status`): ```js -const resp = await source.load(path); -if (!resp.ok) { - // 4xx/5xx — handle as appropriate - return; -} -const json = await resp.json(); +const resp = await source.get(path); +if (!resp.ok) return; // 4xx/5xx +const html = await resp.text(); ``` -**Normalized `source` methods** — branch on `ok`: +Or use the opt-in helpers to collapse the parse + ok-check into one line: ```js -const { ok, items } = await source.list(path); -if (!ok) return; +const { ok: htmlOk, data: html, status } = await asText(source.get(path)); +if (!htmlOk) return; // inspect `status` / `error` if needed + +const { ok: cfgOk, data: cfg } = await asJson(config.get({ org, site })); +if (!cfgOk) return; + +const { ok: deleted } = await source.delete(path); // boolean ``` -**Parsed-JSON methods** (`status.get`, `aem` single-path default) — check for `undefined`: +`source.list` is the one method that doesn't return a `Response` — branch on the wrapper's `ok`: ```js -const data = await aem.getPreview({ org, site, path: '/index.html' }); -if (data === undefined) { - // non-ok or unparseable body - return; -} +const { ok, items } = await source.list(path); +if (!ok) return; ``` **Special return shapes:** - `daFetch` returns `{}` (empty object) when no IMS access token is available. - `config.getAggregated` returns `{ error: 'Requires Helix 6 upgrade', status: 501 }` when the site isn't hlx6. -- `aem.unPreview` / `aem.unPublish` return `{ ok: true, status: 204 }` on a successful single-path delete (default `returnJson: true`), and `undefined` otherwise. -`**console.error` on bad args:** when an invalid first argument is passed (missing `org`), the module logs a console error but doesn't throw — the bad call still flows through and produces a malformed URL that the server will reject. The console message is the only signal from the client side; rely on the server's response status for handling. +**`console.error` on bad args:** when an invalid first argument is passed (missing `org`), the module logs a console error but doesn't throw — the bad call still flows through and produces a malformed URL that the server will reject. The console message is the only signal from the client side; rely on the server's response status for handling. --- @@ -468,8 +505,9 @@ These are not exported, but understanding them helps when reading the source. - `**getDaApiPath(api, org, site, path)`** — URL builder for endpoints DA proxies (`source`, `list`, `config`, `versions`). Branches on `isHlx6` to choose `DA_ADMIN` or `AEM_API`. - `**getAemApiPath(api, org, site, path)**` — URL builder for AEM-only endpoints (`status`, `preview`, `live`, `snapshots`, `jobs`). Branches on `isHlx6` to choose `HLX_ADMIN` (with hardcoded `ref=main`) or `AEM_API`. -- `**withArgs(fn)**` — HOF that resolves the first arg (object or path string) and forwards a normalized `{ org, site, path, ...extras }` object to `fn`. Handles the bad-arg `console.error` for missing org. -- `**callPath({ api, org, site, path, method, … })**` — Dispatcher used by `aem.*` methods. Handles the string-vs-array branching for bulk preview/publish operations, folds `forceUpdate`/`forceSync` into the bulk JSON body, and on single-path calls parses JSON when `returnJson` is true (default). +- `**withArgs(fn)**` — HOF that resolves the first arg (object or path string) and forwards a normalized `{ org, site, path, ...extras }` object to `fn`. Handles the bad-arg `console.error` for missing org. Also prepends a leading slash to `path` if missing. +- `**normalizePath(path)**` — Standalone leading-slash normalizer. Accepts a string or string-array (and passes non-strings through). Used by `snapshot.addPath` / `snapshot.removePath`, which don't go through `withArgs`. +- `**callPath({ api, org, site, path, method, … })**` — Dispatcher used by `aem.*` methods. Handles the string-vs-array branching for bulk preview/publish operations and folds `forceUpdate`/`forceSync` into the bulk JSON body. Returns a `Response`. - `**jsonOpts(method, payload)**` — small helper that builds `{ method, headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(payload) }`. --- @@ -505,7 +543,7 @@ Defined locally: These are tracked but not yet resolved. They don't block typical usage; flagged here for completeness. -- `**config.put` wire shape**: currently sends `multipart/form-data` with field `config`. The H5/H6 admin endpoints actually expect raw JSON body. DA's exact requirement is undocumented; existing da-live tests assert PUT instead of POST. Needs verification against running servers. +- `**config.save` wire shape**: currently sends `multipart/form-data` with field `config`. The H5/H6 admin endpoints actually expect raw JSON body. DA's exact requirement is undocumented; existing da-live tests assert PUT instead of POST. Needs verification against running servers. - `**forceSync` field name**: the H6 server source reads `forceAsync` (with inverse meaning), not `forceSync`. Currently sending `forceSync: true` is silently ignored by the server. This affects the `aem.preview`/`aem.publish` bulk paths and `start/index.js` in da-live. --- diff --git a/test/nx2/utils/api.test.js b/test/nx2/utils/api.test.js index 0ba7d0322..8e3eecbbb 100644 --- a/test/nx2/utils/api.test.js +++ b/test/nx2/utils/api.test.js @@ -153,18 +153,18 @@ describe('api.js', () => { }); describe('source', () => { - it('source.load hits DA on legacy', async () => { + it('source.get hits DA on legacy', async () => { const { org: o, site: s } = makeOrgSite(); // Trigger a ping fetch by querying — it returns no upgrade header → legacy - await source.load({ org: o, site: s, path: '/index.html' }); + await source.get({ org: o, site: s, path: '/index.html' }); const last = lastCall(); expect(last.url).to.equal(`${DA_ADMIN}/source/${o}/${s}/index.html`); expect(last.method).to.equal('GET'); }); - it('source.load hits AEM_API on hlx6', async () => { + it('source.get hits AEM_API on hlx6', async () => { const { org: o, site: s } = makeOrgSite({ hlx6: true }); - await source.load({ org: o, site: s, path: '/index.html' }); + await source.get({ org: o, site: s, path: '/index.html' }); expect(lastCall().url).to.equal(`${AEM_API}/${o}/sites/${s}/source/index.html`); }); @@ -268,7 +268,7 @@ describe('api.js', () => { it('source.save DA wraps data in FormData', async () => { const { org: o, site: s } = makeOrgSite(); const data = new Blob([''], { type: 'text/html' }); - await source.save({ org: o, site: s, path: '/page.html', data }); + await source.save({ org: o, site: s, path: '/page.html', body: data }); const last = lastCall(); expect(last.method).to.equal('POST'); expect(last.body).to.be.instanceof(FormData); @@ -279,7 +279,7 @@ describe('api.js', () => { it('source.save hlx6 sets Content-Type for known text exts', async () => { const { org: o, site: s } = makeOrgSite({ hlx6: true }); - await source.save({ org: o, site: s, path: '/page.html', data: '
' }); + await source.save({ org: o, site: s, path: '/page.html', body: '
' }); const last = lastCall(); expect(last.method).to.equal('POST'); expect(last.headers['Content-Type']).to.equal('text/html'); @@ -289,7 +289,7 @@ describe('api.js', () => { it('source.save hlx6 sets image Content-Type and preserves binary Blob body', async () => { const { org: o, site: s } = makeOrgSite({ hlx6: true }); const blob = new Blob(['binary'], { type: 'image/png' }); - await source.save({ org: o, site: s, path: '/img.png', data: blob }); + await source.save({ org: o, site: s, path: '/img.png', body: blob }); const last = lastCall(); expect(last.headers['Content-Type']).to.equal('image/png'); // Blob passes through untouched — no UTF-8 decoding that would corrupt binary. @@ -298,14 +298,14 @@ describe('api.js', () => { it('source.save hlx6 maps .link to application/json', async () => { const { org: o, site: s } = makeOrgSite({ hlx6: true }); - await source.save({ org: o, site: s, path: '/foo.link', data: '{"externalUrl":"https://x"}' }); + await source.save({ org: o, site: s, path: '/foo.link', body: '{"externalUrl":"https://x"}' }); expect(lastCall().headers['Content-Type']).to.equal('application/json'); }); it('source.save hlx6 omits Content-Type for unknown extensions', async () => { const { org: o, site: s } = makeOrgSite({ hlx6: true }); const blob = new Blob(['payload']); - await source.save({ org: o, site: s, path: '/file.xyz', data: blob }); + await source.save({ org: o, site: s, path: '/file.xyz', body: blob }); const last = lastCall(); expect(last.headers['Content-Type']).to.be.undefined; expect(last.body).to.equal(blob); @@ -331,14 +331,15 @@ describe('api.js', () => { expect(result.status).to.equal(404); }); - it('source.delete sends DELETE and returns { ok, status } on 204', async () => { + it('source.delete sends DELETE and returns Response with status 204', async () => { restoreFetch(); // 204 is a null-body status; Response constructor rejects a non-null body. installFetch({ status: 204, body: null }); const { org: o, site: s } = makeOrgSite(); const result = await source.delete({ org: o, site: s, path: '/x.html' }); expect(lastCall().method).to.equal('DELETE'); - expect(result).to.deep.equal({ ok: true, status: 204 }); + expect(result.ok).to.equal(true); + expect(result.status).to.equal(204); }); it('source.copy hlx6 PUTs with source/collision query params', async () => { @@ -381,15 +382,15 @@ describe('api.js', () => { expect(last.body.get('destination')).to.equal('/dest.html'); }); - it('source.load accepts a full /org/site/path string', async () => { + it('source.get accepts a full /org/site/path string', async () => { const { org: o, site: s } = makeOrgSite({ hlx6: true }); - await source.load(`/${o}/${s}/index.html`); + await source.get(`/${o}/${s}/index.html`); expect(lastCall().url).to.equal(`${AEM_API}/${o}/sites/${s}/source/index.html`); }); it('source.save accepts a path string with extras', async () => { const { org: o, site: s } = makeOrgSite({ hlx6: true }); - await source.save(`/${o}/${s}/page.html`, { data: '
' }); + await source.save(`/${o}/${s}/page.html`, { body: '
' }); const last = lastCall(); expect(last.url).to.equal(`${AEM_API}/${o}/sites/${s}/source/page.html`); expect(last.body).to.equal('
'); @@ -407,12 +408,12 @@ describe('api.js', () => { expect(u.searchParams.get('collision')).to.equal('overwrite'); }); - it('source.load logs an error when org is missing', async () => { + it('source.get logs an error when org is missing', async () => { const origErr = console.error; let errored = false; console.error = () => { errored = true; }; try { - await source.load(''); + await source.get(''); } finally { console.error = origErr; } @@ -512,11 +513,11 @@ describe('api.js', () => { expect(lastCall().url).to.equal(`${DA_ADMIN}/config/${o}/`); }); - it('config.put uses FormData with config field', async () => { + it('config.save uses FormData with config field', async () => { const { org: o, site: s } = makeOrgSite(); - await config.put({ org: o, site: s, body: '{"foo":"bar"}' }); + await config.save({ org: o, site: s, body: '{"foo":"bar"}' }); const last = lastCall(); - expect(last.method).to.equal('POST'); + expect(last.method).to.equal('PUT'); expect(last.body).to.be.instanceof(FormData); expect(last.body.get('config')).to.equal('{"foo":"bar"}'); }); @@ -656,33 +657,28 @@ describe('api.js', () => { expect(lastCall().method).to.equal('GET'); }); - it('aem.getPreview returns parsed JSON by default', async () => { + it('aem.getPreview returns Response (caller parses with asJson)', async () => { restoreFetch(); installFetch({ body: '{"state":"complete"}' }); const { org: o, site: s } = makeOrgSite({ hlx6: true }); const result = await aem.getPreview({ org: o, site: s, path: '/x' }); - expect(result).to.deep.equal({ state: 'complete' }); + expect(result.ok).to.equal(true); + expect(await result.json()).to.deep.equal({ state: 'complete' }); }); - it('aem.getPreview returns undefined when response is not ok', async () => { + it('aem.getPreview returns Response with ok:false when response is not ok', async () => { restoreFetch(); installFetch({ status: 404 }); const { org: o, site: s } = makeOrgSite({ hlx6: true }); const result = await aem.getPreview({ org: o, site: s, path: '/x' }); - expect(result).to.equal(undefined); - }); - - it('aem.getPreview returns Response when returnJson is false', async () => { - const { org: o, site: s } = makeOrgSite({ hlx6: true }); - const result = await aem.getPreview({ org: o, site: s, path: '/x', returnJson: false }); - expect(result).to.have.property('ok', true); - expect(result.permissions).to.deep.equal(['read', 'write']); + expect(result.ok).to.equal(false); + expect(result.status).to.equal(404); }); - it('aem.preview bulk returns Response even with returnJson default', async () => { + it('aem.preview bulk returns Response with permissions', async () => { const { org: o, site: s } = makeOrgSite({ hlx6: true }); const result = await aem.preview({ org: o, site: s, path: ['/a', '/b'] }); - expect(result).to.have.property('ok', true); + expect(result.ok).to.equal(true); expect(result.permissions).to.deep.equal(['read', 'write']); }); }); @@ -706,9 +702,9 @@ describe('api.js', () => { expect(lastCall().url).to.equal(`${AEM_API}/${o}/sites/${s}/snapshots/snap1`); }); - it('snapshot.update POSTs body', async () => { + it('snapshot.save POSTs body', async () => { const { org: o, site: s } = makeOrgSite({ hlx6: true }); - await snapshot.update({ org: o, site: s, snapshotId: 'snap1', body: { title: 'hi' } }); + await snapshot.save({ org: o, site: s, snapshotId: 'snap1', body: { title: 'hi' } }); const last = lastCall(); expect(last.method).to.equal('POST'); expect(JSON.parse(last.body)).to.deep.equal({ title: 'hi' });