@adobe/aem-cli 16.17.1 → 16.18.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,181 @@
1
+ /*
2
+ * Copyright 2026 Adobe. All rights reserved.
3
+ * This file is licensed to you under the Apache License, Version 2.0 (the "License");
4
+ * you may not use this file except in compliance with the License. You may obtain a copy
5
+ * of the License at http://www.apache.org/licenses/LICENSE-2.0
6
+ *
7
+ * Unless required by applicable law or agreed to in writing, software distributed under
8
+ * the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR REPRESENTATIONS
9
+ * OF ANY KIND, either express or implied. See the License for the specific language
10
+ * governing permissions and limitations under the License.
11
+ */
12
+ import path from 'path';
13
+ import fse from 'fs-extra';
14
+ import git from 'isomorphic-git';
15
+
16
+ /** Ref pointing at the last commit whose tree was fully synced to da.live. */
17
+ export const DA_SYNCED_REF = 'refs/da/synced';
18
+
19
+ /**
20
+ * OID of the last da.live sync. Uses refs/da/synced when present; otherwise infers
21
+ * from commit history (newest `push:` then `clone:`) so repos created before the ref
22
+ * existed still compare against the correct baseline.
23
+ * @param {import('isomorphic-git').FsClient} fs
24
+ * @param {string} dir
25
+ * @returns {Promise<string>}
26
+ */
27
+ export async function resolveSyncedOid(fs, dir) {
28
+ try {
29
+ return await git.resolveRef({ fs, dir, ref: DA_SYNCED_REF });
30
+ } catch (err) {
31
+ if (err && (err.code === 'NotFoundError' || err.name === 'NotFoundError')) {
32
+ const commits = await git.log({ fs, dir, depth: 500 });
33
+ for (const c of commits) {
34
+ if (String(c.commit.message).startsWith('push:')) {
35
+ return c.oid;
36
+ }
37
+ }
38
+ for (const c of commits) {
39
+ if (String(c.commit.message).startsWith('clone:')) {
40
+ return c.oid;
41
+ }
42
+ }
43
+ return git.resolveRef({ fs, dir, ref: 'HEAD' });
44
+ }
45
+ throw err;
46
+ }
47
+ }
48
+
49
+ /**
50
+ * @param {import('isomorphic-git').FsClient} fs
51
+ * @param {string} dir
52
+ * @param {string} oid
53
+ * @returns {Promise<void>}
54
+ */
55
+ export async function writeSyncedRef(fs, dir, oid) {
56
+ await git.writeRef({
57
+ fs,
58
+ dir,
59
+ ref: DA_SYNCED_REF,
60
+ value: oid,
61
+ force: true,
62
+ });
63
+ }
64
+
65
+ /**
66
+ * True when the index or working tree differs from HEAD (uncommitted work).
67
+ * @param {Array<[string, number, number, number]>} matrix
68
+ * @returns {boolean}
69
+ */
70
+ export function statusMatrixHasUncommitted(matrix) {
71
+ return matrix.some(([, h, w, s]) => !(h === 1 && w === 1 && s === 1));
72
+ }
73
+
74
+ /**
75
+ * Diff trees at two commits: paths as da.live paths (`/file`).
76
+ * @param {import('isomorphic-git').FsClient} fs
77
+ * @param {string} dir
78
+ * @param {string} baseOid
79
+ * @param {string} headOid
80
+ * @returns {Promise<{ added: string[], modified: string[], deleted: string[] }>}
81
+ */
82
+ export async function diffCommitTrees(fs, dir, baseOid, headOid) {
83
+ if (baseOid === headOid) {
84
+ return { added: [], modified: [], deleted: [] };
85
+ }
86
+
87
+ const baseFiles = new Set(await git.listFiles({ fs, dir, ref: baseOid }));
88
+ const headFiles = new Set(await git.listFiles({ fs, dir, ref: headOid }));
89
+
90
+ const added = [];
91
+ const modified = [];
92
+ const deleted = [];
93
+
94
+ for (const f of headFiles) {
95
+ if (!baseFiles.has(f)) {
96
+ added.push(`/${f}`);
97
+ } else {
98
+ // eslint-disable-next-line no-await-in-loop
99
+ const b1 = await git.readBlob({
100
+ fs,
101
+ dir,
102
+ oid: baseOid,
103
+ filepath: f,
104
+ });
105
+ // eslint-disable-next-line no-await-in-loop
106
+ const b2 = await git.readBlob({
107
+ fs,
108
+ dir,
109
+ oid: headOid,
110
+ filepath: f,
111
+ });
112
+ if (b1.oid !== b2.oid) {
113
+ modified.push(`/${f}`);
114
+ }
115
+ }
116
+ }
117
+
118
+ for (const f of baseFiles) {
119
+ if (!headFiles.has(f)) {
120
+ deleted.push(`/${f}`);
121
+ }
122
+ }
123
+
124
+ return { added, modified, deleted };
125
+ }
126
+
127
+ /**
128
+ * Number of commits reachable from `tipOid` before hitting `ancestorOid` (exclusive).
129
+ * @param {import('isomorphic-git').FsClient} fs
130
+ * @param {string} dir
131
+ * @param {string} tipOid
132
+ * @param {string} ancestorOid
133
+ * @returns {Promise<number>}
134
+ */
135
+ export async function countCommitsAhead(fs, dir, tipOid, ancestorOid) {
136
+ if (tipOid === ancestorOid) {
137
+ return 0;
138
+ }
139
+ const commits = await git.log({
140
+ fs,
141
+ dir,
142
+ ref: tipOid,
143
+ depth: 5000,
144
+ });
145
+ let n = 0;
146
+ for (const c of commits) {
147
+ if (c.oid === ancestorOid) {
148
+ return n;
149
+ }
150
+ n += 1;
151
+ }
152
+ return n;
153
+ }
154
+
155
+ /**
156
+ * Committer time in ms for conflict checks (da.live lastModified).
157
+ * @param {import('isomorphic-git').FsClient} fs
158
+ * @param {string} dir
159
+ * @param {string} commitOid
160
+ * @returns {Promise<number>}
161
+ */
162
+ export async function getCommitCommitterTimeMs(fs, dir, commitOid) {
163
+ const { commit } = await git.readCommit({ fs, dir, oid: commitOid });
164
+ return commit.committer.timestamp * 1000;
165
+ }
166
+
167
+ /**
168
+ * Ensures an entry is present in the project .gitignore, creating the file if needed.
169
+ * @param {string} projectDir
170
+ * @param {string} entry
171
+ */
172
+ export async function ensureGitIgnored(projectDir, entry) {
173
+ const gitIgnorePath = path.resolve(projectDir, '.gitignore');
174
+ let content = '';
175
+ if (await fse.pathExists(gitIgnorePath)) {
176
+ content = await fse.readFile(gitIgnorePath, 'utf-8');
177
+ }
178
+ if (!content.split('\n').map((l) => l.trim()).includes(entry)) {
179
+ await fse.appendFile(gitIgnorePath, `\n${entry}\n`);
180
+ }
181
+ }
@@ -0,0 +1,321 @@
1
+ /*
2
+ * Copyright 2026 Adobe. All rights reserved.
3
+ * This file is licensed to you under the Apache License, Version 2.0 (the "License");
4
+ * you may not use this file except in compliance with the License. You may obtain a copy
5
+ * of the License at http://www.apache.org/licenses/LICENSE-2.0
6
+ *
7
+ * Unless required by applicable law or agreed to in writing, software distributed under
8
+ * the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR REPRESENTATIONS
9
+ * OF ANY KIND, either express or implied. See the License for the specific language
10
+ * governing permissions and limitations under the License.
11
+ */
12
+
13
+ /**
14
+ * Transforms da.live-style `<div class="metadata">` blocks in content HTML into
15
+ * `<meta>` tags for local dev (`aem up`), strips the block from the body, and
16
+ * adds description / Open Graph / Twitter tags where data is available.
17
+ */
18
+
19
+ import { unified } from 'unified';
20
+ import rehypeParse from 'rehype-parse';
21
+ import { select } from 'hast-util-select';
22
+ import { toHtml } from 'hast-util-to-html';
23
+
24
+ /** @typedef {import('hast').Root} HastRoot */
25
+ /** @typedef {import('hast').Element} HastElement */
26
+
27
+ const REHYPE_PARSE = { fragment: true };
28
+
29
+ /**
30
+ * @param {string} s
31
+ * @returns {string}
32
+ */
33
+ export function escapeHtmlAttr(s) {
34
+ return String(s)
35
+ .replace(/&/g, '&amp;')
36
+ .replace(/"/g, '&quot;')
37
+ .replace(/</g, '&lt;');
38
+ }
39
+
40
+ /**
41
+ * @param {string} label
42
+ * @returns {string}
43
+ */
44
+ export function slugifyMetadataLabel(label) {
45
+ return label
46
+ .normalize('NFKD')
47
+ .replace(/[\u0300-\u036f]/g, '')
48
+ .trim()
49
+ .toLowerCase()
50
+ .replace(/[^a-z0-9]+/g, '-')
51
+ .replace(/^-|-$/g, '');
52
+ }
53
+
54
+ /**
55
+ * @param {import('hast').Node | null | undefined} node
56
+ * @returns {string}
57
+ */
58
+ function textContent(node) {
59
+ if (!node) {
60
+ return '';
61
+ }
62
+ if (node.type === 'text') {
63
+ return node.value;
64
+ }
65
+ if (Array.isArray(node.children)) {
66
+ return node.children.map((c) => textContent(c)).join('');
67
+ }
68
+ return '';
69
+ }
70
+
71
+ /**
72
+ * @param {HastElement} metadataRoot
73
+ * @returns {Array<[string, string]>}
74
+ */
75
+ export function extractMetadataPairs(metadataRoot) {
76
+ /** @type {Array<[string, string]>} */
77
+ const pairs = [];
78
+ if (!metadataRoot.children) {
79
+ return pairs;
80
+ }
81
+ for (const row of metadataRoot.children) {
82
+ if (row.type !== 'element' || row.tagName !== 'div') {
83
+ // eslint-disable-next-line no-continue
84
+ continue;
85
+ }
86
+ const cells = (row.children || []).filter(
87
+ (c) => c.type === 'element' && c.tagName === 'div',
88
+ );
89
+ if (cells.length < 2) {
90
+ // eslint-disable-next-line no-continue
91
+ continue;
92
+ }
93
+ const label = textContent(cells[0]).trim();
94
+ const value = textContent(cells[1]).trim();
95
+ if (label) {
96
+ pairs.push([label, value]);
97
+ }
98
+ }
99
+ return pairs;
100
+ }
101
+
102
+ /**
103
+ * @param {HastElement} node
104
+ * @param {string} className
105
+ * @returns {boolean}
106
+ */
107
+ function hasClass(node, className) {
108
+ const cn = node.properties?.className;
109
+ if (Array.isArray(cn)) {
110
+ return cn.includes(className);
111
+ }
112
+ if (typeof cn === 'string') {
113
+ return cn.split(/\s+/).includes(className);
114
+ }
115
+ return false;
116
+ }
117
+
118
+ /**
119
+ * @param {import('hast').Node} tree
120
+ * @param {import('hast').Element} target
121
+ * @returns {boolean}
122
+ */
123
+ function removeNode(tree, target) {
124
+ if (tree === target) {
125
+ return true;
126
+ }
127
+ if ('children' in tree && Array.isArray(tree.children)) {
128
+ const { children } = tree;
129
+ for (let i = 0; i < children.length; i += 1) {
130
+ const c = children[i];
131
+ if (c === target) {
132
+ children.splice(i, 1);
133
+ return true;
134
+ }
135
+ if (removeNode(c, target)) {
136
+ return true;
137
+ }
138
+ }
139
+ }
140
+ return false;
141
+ }
142
+
143
+ /**
144
+ * @param {HastRoot} tree
145
+ * @returns {string}
146
+ */
147
+ function firstImgSrc(tree) {
148
+ const img = select('img[src]', tree);
149
+ if (!img || img.type !== 'element') {
150
+ return '';
151
+ }
152
+ const s = img.properties?.src;
153
+ return typeof s === 'string' ? s : '';
154
+ }
155
+
156
+ /**
157
+ * @param {HastRoot} tree
158
+ * @returns {string}
159
+ */
160
+ function firstParagraphText(tree) {
161
+ const p = select('p', tree);
162
+ return p && p.type === 'element' ? textContent(p).trim() : '';
163
+ }
164
+
165
+ /**
166
+ * Truncates a string to `max` characters, trimming and appending an ellipsis when needed.
167
+ * @param {string} s
168
+ * @param {number} max
169
+ * @returns {string}
170
+ */
171
+ function truncateWithEllipsis(s, max) {
172
+ if (s.length <= max) {
173
+ return s;
174
+ }
175
+ return `${s.slice(0, max - 1).trim()}…`;
176
+ }
177
+
178
+ const SEO_LABEL_SKIP = new Set(['title', 'description', 'image']);
179
+
180
+ /**
181
+ * @param {object | null | undefined} sheetRow row from /metadata.json matched for this URL
182
+ * @param {Set<string> | null | undefined} excludeMetaNames lowercase meta `name` values to skip
183
+ * (local page wins)
184
+ * @returns {string[]}
185
+ */
186
+ export function buildSheetMetaLines(sheetRow, excludeMetaNames) {
187
+ if (!sheetRow || typeof sheetRow !== 'object') {
188
+ return [];
189
+ }
190
+ /** @type {string[]} */
191
+ const lines = [];
192
+ for (const [k, v] of Object.entries(sheetRow)) {
193
+ if (k === 'URL' || k.startsWith(':')) {
194
+ // eslint-disable-next-line no-continue
195
+ continue;
196
+ }
197
+ if (excludeMetaNames && excludeMetaNames.has(k.toLowerCase())) {
198
+ // eslint-disable-next-line no-continue
199
+ continue;
200
+ }
201
+ if (v === undefined || v === null) {
202
+ // eslint-disable-next-line no-continue
203
+ continue;
204
+ }
205
+ const s = String(v).trim();
206
+ if (!s) {
207
+ // eslint-disable-next-line no-continue
208
+ continue;
209
+ }
210
+ lines.push(`<meta name="${escapeHtmlAttr(k)}" content="${escapeHtmlAttr(s)}">`);
211
+ }
212
+ return lines;
213
+ }
214
+
215
+ /**
216
+ * @param {string[]} lines
217
+ * @returns {string}
218
+ */
219
+ function joinLines(lines) {
220
+ return lines.length > 0 ? `\n${lines.join('\n')}\n` : '';
221
+ }
222
+
223
+ /**
224
+ * @param {string} htmlFragment body-only or partial HTML from content/
225
+ * @param {{ absolutePageUrl?: string, sheetRow?: object | null }} [options]
226
+ * @returns {{ htmlFragment: string, metaTagsHtml: string }}
227
+ */
228
+ export function transformContentMetadataHtml(htmlFragment, options = {}) {
229
+ const { absolutePageUrl = '', sheetRow = null } = options;
230
+
231
+ let tree;
232
+ try {
233
+ tree = unified().use(rehypeParse, REHYPE_PARSE).parse(htmlFragment);
234
+ } catch {
235
+ return { htmlFragment, metaTagsHtml: joinLines(buildSheetMetaLines(sheetRow)) };
236
+ }
237
+
238
+ const metadataEl = select('div.metadata', tree);
239
+ if (!metadataEl || metadataEl.type !== 'element' || !hasClass(metadataEl, 'metadata')) {
240
+ return { htmlFragment, metaTagsHtml: joinLines(buildSheetMetaLines(sheetRow)) };
241
+ }
242
+
243
+ const pairs = extractMetadataPairs(metadataEl);
244
+
245
+ /** Names from page metadata; sheet entries with the same meta name are skipped. */
246
+ const localPairMetaNames = new Set();
247
+ for (const [label, value] of pairs) {
248
+ const slug = slugifyMetadataLabel(label);
249
+ if (!slug || value === undefined) {
250
+ // eslint-disable-next-line no-continue
251
+ continue;
252
+ }
253
+ localPairMetaNames.add(slug);
254
+ }
255
+
256
+ const sheetLines = buildSheetMetaLines(sheetRow, localPairMetaNames);
257
+
258
+ const lowerMap = new Map(pairs.map(([k, v]) => [k.toLowerCase().trim(), v]));
259
+
260
+ removeNode(tree, metadataEl);
261
+
262
+ const title = (lowerMap.get('title') || textContent(select('h1', tree)).trim() || '').trim();
263
+ let description = (lowerMap.get('description') || firstParagraphText(tree) || '').trim();
264
+ description = truncateWithEllipsis(description, 200);
265
+ const image = (lowerMap.get('image') || lowerMap.get('og image') || firstImgSrc(tree) || '').trim();
266
+
267
+ /** @type {string[]} */
268
+ const seoLines = [];
269
+
270
+ if (description) {
271
+ const e = escapeHtmlAttr(description);
272
+ seoLines.push(`<meta name="description" content="${e}">`);
273
+ seoLines.push(`<meta property="og:description" content="${e}">`);
274
+ seoLines.push(`<meta name="twitter:description" content="${e}">`);
275
+ }
276
+
277
+ if (title) {
278
+ const e = escapeHtmlAttr(title);
279
+ seoLines.push(`<meta property="og:title" content="${e}">`);
280
+ seoLines.push(`<meta name="twitter:title" content="${e}">`);
281
+ }
282
+
283
+ if (absolutePageUrl) {
284
+ seoLines.push(`<meta property="og:url" content="${escapeHtmlAttr(absolutePageUrl)}">`);
285
+ }
286
+
287
+ if (image) {
288
+ const e = escapeHtmlAttr(image);
289
+ const alt = escapeHtmlAttr(title || 'image');
290
+ seoLines.push(`<meta property="og:image" content="${e}">`);
291
+ seoLines.push(`<meta property="og:image:secure_url" content="${e}">`);
292
+ seoLines.push(`<meta property="og:image:alt" content="${alt}">`);
293
+ seoLines.push('<meta name="twitter:card" content="summary_large_image">');
294
+ seoLines.push(`<meta name="twitter:image" content="${e}">`);
295
+ } else {
296
+ seoLines.push('<meta name="twitter:card" content="summary">');
297
+ }
298
+
299
+ /** @type {string[]} */
300
+ const pairLines = [];
301
+ for (const [label, value] of pairs) {
302
+ const key = label.toLowerCase().trim();
303
+ if (SEO_LABEL_SKIP.has(key)) {
304
+ // eslint-disable-next-line no-continue
305
+ continue;
306
+ }
307
+ const slug = slugifyMetadataLabel(label);
308
+ if (!slug || value === undefined) {
309
+ // eslint-disable-next-line no-continue
310
+ continue;
311
+ }
312
+ pairLines.push(
313
+ `<meta name="${escapeHtmlAttr(slug)}" content="${escapeHtmlAttr(value)}">`,
314
+ );
315
+ }
316
+
317
+ // SEO first, then sheet (fields not overridden by local pairs), then local page metadata
318
+ const allLines = [...seoLines, ...sheetLines, ...pairLines];
319
+ const htmlOut = toHtml(tree);
320
+ return { htmlFragment: htmlOut, metaTagsHtml: joinLines(allLines) };
321
+ }
@@ -0,0 +1,44 @@
1
+ /*
2
+ * Copyright 2026 Adobe. All rights reserved.
3
+ * This file is licensed to you under the Apache License, Version 2.0 (the "License");
4
+ * you may not use this file except in compliance with the License. You may obtain a copy
5
+ * of the License at http://www.apache.org/licenses/LICENSE-2.0
6
+ *
7
+ * Unless required by applicable law or agreed to in writing, software distributed under
8
+ * the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR REPRESENTATIONS
9
+ * OF ANY KIND, either express or implied. See the License for the specific language
10
+ * governing permissions and limitations under the License.
11
+ */
12
+
13
+ export const CONTENT_DIR = 'content';
14
+ export const CONFIG_FILE = '.da-config.json';
15
+ export const GIT_AUTHOR = { name: 'aem-cli', email: 'aem-cli@adobe.com' };
16
+
17
+ /** Above this count, clone warns and requires confirmation (or --yes). */
18
+ export const LARGE_CLONE_FILE_THRESHOLD = 10000;
19
+
20
+ /**
21
+ * Parallelism for da.live I/O: recursive list fan-out, clone downloads, push uploads/deletes.
22
+ */
23
+ export const CONTENT_IO_CONCURRENCY = 10;
24
+
25
+ /**
26
+ * Normalizes a da.live path: leading slash, no trailing slash except root.
27
+ * @param {string} input
28
+ * @returns {string}
29
+ * @throws {Error} if input is null, undefined, or empty
30
+ */
31
+ export function normalizeDaPath(input) {
32
+ if (input === undefined || input === null) {
33
+ throw new Error('Content path is required.');
34
+ }
35
+ let s = String(input).trim();
36
+ if (s === '') {
37
+ throw new Error('Content path cannot be empty.');
38
+ }
39
+ if (!s.startsWith('/')) {
40
+ s = `/${s}`;
41
+ }
42
+ s = s.replace(/\/+$/, '') || '/';
43
+ return s;
44
+ }
@@ -0,0 +1,38 @@
1
+ /*
2
+ * Copyright 2026 Adobe. All rights reserved.
3
+ * This file is licensed to you under the Apache License, Version 2.0 (the "License");
4
+ * you may not use this file except in compliance with the License. You may obtain a copy
5
+ * of the License at http://www.apache.org/licenses/LICENSE-2.0
6
+ *
7
+ * Unless required by applicable law or agreed to in writing, software distributed under
8
+ * the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR REPRESENTATIONS
9
+ * OF ANY KIND, either express or implied. See the License for the specific language
10
+ * governing permissions and limitations under the License.
11
+ */
12
+ import clone from './clone.js';
13
+ import add from './add.js';
14
+ import commit from './commit.js';
15
+ import push from './push.js';
16
+ import status from './status.js';
17
+ import diff from './diff.js';
18
+ import mergeCmd from './merge.js';
19
+
20
+ export default function content() {
21
+ return {
22
+ command: 'content',
23
+ description: 'Manage AEM content from da.live',
24
+ builder: (yargs) => {
25
+ yargs
26
+ .command(clone())
27
+ .command(add())
28
+ .command(commit())
29
+ .command(push())
30
+ .command(status())
31
+ .command(diff())
32
+ .command(mergeCmd())
33
+ .demandCommand(1, 'You need at least one content subcommand.')
34
+ .help();
35
+ },
36
+ handler: () => {},
37
+ };
38
+ }