@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.
- package/CHANGELOG.md +7 -0
- package/README.md +1 -1
- package/package.json +6 -1
- package/src/cli.js +3 -0
- package/src/content/add.cmd.js +133 -0
- package/src/content/add.js +47 -0
- package/src/content/clone.cmd.js +214 -0
- package/src/content/clone.js +74 -0
- package/src/content/commit.cmd.js +57 -0
- package/src/content/commit.js +40 -0
- package/src/content/content-git.js +181 -0
- package/src/content/content-metadata-html.js +321 -0
- package/src/content/content-shared.js +44 -0
- package/src/content/content.js +38 -0
- package/src/content/da-api.js +198 -0
- package/src/content/da-auth.js +209 -0
- package/src/content/diff.cmd.js +123 -0
- package/src/content/diff.js +45 -0
- package/src/content/merge.cmd.js +150 -0
- package/src/content/merge.js +45 -0
- package/src/content/push.cmd.js +291 -0
- package/src/content/push.js +58 -0
- package/src/content/status.cmd.js +94 -0
- package/src/content/status.js +33 -0
- package/src/server/BaseServer.js +9 -1
- package/src/server/HelixProject.js +19 -0
- package/src/server/HelixServer.js +82 -0
- package/src/server/MetadataSheetSupport.js +221 -0
|
@@ -15,6 +15,7 @@ import { IgnoreConfig } from '@adobe/helix-shared-config';
|
|
|
15
15
|
import { HelixServer } from './HelixServer.js';
|
|
16
16
|
import { BaseProject } from './BaseProject.js';
|
|
17
17
|
import HeadHtmlSupport from './HeadHtmlSupport.js';
|
|
18
|
+
import MetadataSheetSupport from './MetadataSheetSupport.js';
|
|
18
19
|
import Indexer from './Indexer.js';
|
|
19
20
|
|
|
20
21
|
export class HelixProject extends BaseProject {
|
|
@@ -22,6 +23,7 @@ export class HelixProject extends BaseProject {
|
|
|
22
23
|
super(HelixServer);
|
|
23
24
|
this._proxyUrl = null;
|
|
24
25
|
this._headHtml = null;
|
|
26
|
+
this._metadataSheet = null;
|
|
25
27
|
this._indexer = null;
|
|
26
28
|
this._printIndex = false;
|
|
27
29
|
this._allowInsecure = false;
|
|
@@ -42,6 +44,7 @@ export class HelixProject extends BaseProject {
|
|
|
42
44
|
withSiteToken(value) {
|
|
43
45
|
this.siteToken = value;
|
|
44
46
|
this._server.withSiteToken(value);
|
|
47
|
+
this._metadataSheet?.setSiteToken(value);
|
|
45
48
|
return this;
|
|
46
49
|
}
|
|
47
50
|
|
|
@@ -133,6 +136,10 @@ export class HelixProject extends BaseProject {
|
|
|
133
136
|
return this._headHtml;
|
|
134
137
|
}
|
|
135
138
|
|
|
139
|
+
get metadataSheet() {
|
|
140
|
+
return this._metadataSheet;
|
|
141
|
+
}
|
|
142
|
+
|
|
136
143
|
get htmlFolder() {
|
|
137
144
|
return this._htmlFolder;
|
|
138
145
|
}
|
|
@@ -188,6 +195,17 @@ export class HelixProject extends BaseProject {
|
|
|
188
195
|
}
|
|
189
196
|
}
|
|
190
197
|
|
|
198
|
+
async initMetadataSheet() {
|
|
199
|
+
if (this.proxyUrl) {
|
|
200
|
+
this._metadataSheet = new MetadataSheetSupport({
|
|
201
|
+
proxyUrl: this.proxyUrl,
|
|
202
|
+
log: this.log,
|
|
203
|
+
allowInsecure: this.allowInsecure,
|
|
204
|
+
siteToken: this.siteToken,
|
|
205
|
+
});
|
|
206
|
+
}
|
|
207
|
+
}
|
|
208
|
+
|
|
191
209
|
async init404Html() {
|
|
192
210
|
if (this.proxyUrl) {
|
|
193
211
|
this._file404html = resolve(this.directory, '404.html');
|
|
@@ -249,6 +267,7 @@ export class HelixProject extends BaseProject {
|
|
|
249
267
|
this.log.debug('Launching AEM dev server...');
|
|
250
268
|
await super.start();
|
|
251
269
|
await this.initHeadHtml();
|
|
270
|
+
await this.initMetadataSheet();
|
|
252
271
|
await this.init404Html();
|
|
253
272
|
await this.initHtmlFolder();
|
|
254
273
|
await this.initHlxIgnore();
|
|
@@ -20,10 +20,32 @@ import RequestContext from './RequestContext.js';
|
|
|
20
20
|
import { asyncHandler, BaseServer } from './BaseServer.js';
|
|
21
21
|
import LiveReload from './LiveReload.js';
|
|
22
22
|
import { saveSiteTokenToFile } from '../config/config-utils.js';
|
|
23
|
+
import { CONTENT_DIR } from '../content/content-shared.js';
|
|
24
|
+
import { transformContentMetadataHtml } from '../content/content-metadata-html.js';
|
|
23
25
|
|
|
24
26
|
const LOGIN_ROUTE = '/.aem/cli/login';
|
|
25
27
|
const LOGIN_ACK_ROUTE = '/.aem/cli/login/ack';
|
|
26
28
|
|
|
29
|
+
/**
|
|
30
|
+
* @param {import('express').Request} req
|
|
31
|
+
* @param {import('./RequestContext.js').default} ctx
|
|
32
|
+
* @returns {string}
|
|
33
|
+
*/
|
|
34
|
+
function absolutePageUrlFromRequest(req, ctx) {
|
|
35
|
+
const raw = req.headers['x-forwarded-proto'];
|
|
36
|
+
const proto = (Array.isArray(raw) ? raw[0] : raw) || req.protocol || 'http';
|
|
37
|
+
const host = req.get('host');
|
|
38
|
+
if (!host) {
|
|
39
|
+
return '';
|
|
40
|
+
}
|
|
41
|
+
try {
|
|
42
|
+
const pathWithQuery = req.originalUrl || ctx.url;
|
|
43
|
+
return new URL(pathWithQuery, `${proto}://${host}`).href;
|
|
44
|
+
} catch {
|
|
45
|
+
return '';
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
|
|
27
49
|
export class HelixServer extends BaseServer {
|
|
28
50
|
/**
|
|
29
51
|
* Creates a new HelixServer for the given project.
|
|
@@ -149,6 +171,7 @@ export class HelixServer extends BaseServer {
|
|
|
149
171
|
|
|
150
172
|
this.withSiteToken(siteToken);
|
|
151
173
|
this._project.headHtml.setSiteToken(siteToken);
|
|
174
|
+
this._project.metadataSheet?.setSiteToken(siteToken);
|
|
152
175
|
await saveSiteTokenToFile(siteToken);
|
|
153
176
|
this.log.info('Site token received and saved to file.');
|
|
154
177
|
|
|
@@ -371,6 +394,65 @@ export class HelixServer extends BaseServer {
|
|
|
371
394
|
|
|
372
395
|
// try to serve static
|
|
373
396
|
try {
|
|
397
|
+
// Check content/ first — prefer local content checkout over proxy
|
|
398
|
+
const contentDir = path.join(this._project.directory, CONTENT_DIR);
|
|
399
|
+
const contentFilePath = path.join(contentDir, ctx.path);
|
|
400
|
+
if (!path.relative(contentDir, contentFilePath).startsWith('..')) {
|
|
401
|
+
try {
|
|
402
|
+
if (contentFilePath.endsWith('.html')) {
|
|
403
|
+
// readFile throws EISDIR for directories and ENOENT for missing files
|
|
404
|
+
let htmlContent = await readFile(contentFilePath, 'utf-8');
|
|
405
|
+
const absolutePageUrl = absolutePageUrlFromRequest(req, ctx);
|
|
406
|
+
if (this._project.metadataSheet) {
|
|
407
|
+
this._project.metadataSheet.setCookie(req.headers.cookie || '');
|
|
408
|
+
await this._project.metadataSheet.ensureLoaded();
|
|
409
|
+
}
|
|
410
|
+
const sheetRow = this._project.metadataSheet?.matchPath(ctx.path) ?? null;
|
|
411
|
+
const { htmlFragment, metaTagsHtml } = transformContentMetadataHtml(htmlContent, {
|
|
412
|
+
absolutePageUrl,
|
|
413
|
+
sheetRow,
|
|
414
|
+
});
|
|
415
|
+
htmlContent = htmlFragment;
|
|
416
|
+
// content/ files are plain HTML (body only, no <head>)
|
|
417
|
+
// wrap with a full document and inject local head.html
|
|
418
|
+
if (!htmlContent.includes('<head>')) {
|
|
419
|
+
await this._project.headHtml.update();
|
|
420
|
+
const headHtml = this._project.headHtml.localHtml || '';
|
|
421
|
+
htmlContent = `<html><head>${headHtml}${metaTagsHtml}</head>${htmlContent}</html>`;
|
|
422
|
+
} else {
|
|
423
|
+
await this._project.headHtml.setCookie(req.headers.cookie);
|
|
424
|
+
htmlContent = await this._project.headHtml.replace(htmlContent);
|
|
425
|
+
if (metaTagsHtml) {
|
|
426
|
+
htmlContent = htmlContent.replace(/<\/head>/i, `${metaTagsHtml}</head>`);
|
|
427
|
+
}
|
|
428
|
+
}
|
|
429
|
+
if (liveReload) {
|
|
430
|
+
htmlContent = utils.injectLiveReloadScript(htmlContent, this);
|
|
431
|
+
liveReload.registerFile(ctx.requestId, contentFilePath);
|
|
432
|
+
}
|
|
433
|
+
res.set({
|
|
434
|
+
'content-type': 'text/html; charset=utf-8',
|
|
435
|
+
'access-control-allow-origin': '*',
|
|
436
|
+
});
|
|
437
|
+
res.send(htmlContent);
|
|
438
|
+
log.debug(`${pfx}served from ${CONTENT_DIR}/: ${ctx.path}`);
|
|
439
|
+
return;
|
|
440
|
+
}
|
|
441
|
+
// sendFile throws EISDIR for directories and ENOENT for missing files
|
|
442
|
+
await sendFile(contentFilePath, {
|
|
443
|
+
dotfiles: 'allow',
|
|
444
|
+
headers: { 'access-control-allow-origin': '*' },
|
|
445
|
+
});
|
|
446
|
+
if (liveReload) {
|
|
447
|
+
liveReload.registerFile(ctx.requestId, contentFilePath);
|
|
448
|
+
}
|
|
449
|
+
log.debug(`${pfx}served from ${CONTENT_DIR}/: ${ctx.path}`);
|
|
450
|
+
return;
|
|
451
|
+
} catch (e) {
|
|
452
|
+
log.debug(`${pfx}${CONTENT_DIR}/ miss for ${ctx.path}: ${e.code}`);
|
|
453
|
+
}
|
|
454
|
+
}
|
|
455
|
+
|
|
374
456
|
// Check if it's an HTML file and live reload is enabled
|
|
375
457
|
if (liveReload && filePath.endsWith('.html')) {
|
|
376
458
|
// Read the HTML file and inject the livereload script
|
|
@@ -0,0 +1,221 @@
|
|
|
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
|
+
import globToRegExp from 'glob-to-regexp';
|
|
14
|
+
import { getFetch } from '../fetch-utils.js';
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* Normalizes the request path for matching against metadata.json URL globs.
|
|
18
|
+
* @param {string} pathname
|
|
19
|
+
* @returns {string}
|
|
20
|
+
*/
|
|
21
|
+
export function normalizePathForMetadataMatch(pathname) {
|
|
22
|
+
let p = pathname || '/';
|
|
23
|
+
if (!p.startsWith('/')) {
|
|
24
|
+
p = `/${p}`;
|
|
25
|
+
}
|
|
26
|
+
if (p.length > 5 && p.endsWith('.html')) {
|
|
27
|
+
p = p.slice(0, -5);
|
|
28
|
+
}
|
|
29
|
+
return p || '/';
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* @param {unknown} body
|
|
34
|
+
* @returns {object[]}
|
|
35
|
+
*/
|
|
36
|
+
function extractDataRows(body) {
|
|
37
|
+
if (!body || typeof body !== 'object') {
|
|
38
|
+
return [];
|
|
39
|
+
}
|
|
40
|
+
const { data } = /** @type {{ data?: unknown }} */ (body);
|
|
41
|
+
if (!Array.isArray(data)) {
|
|
42
|
+
return [];
|
|
43
|
+
}
|
|
44
|
+
return /** @type {object[]} */ (data.filter((r) => r && typeof r === 'object'));
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* @param {object[]} rows
|
|
49
|
+
* @returns {Array<{ pattern: string, regex: RegExp, row: object }>}
|
|
50
|
+
*/
|
|
51
|
+
export function compileMetadataSheetPatterns(rows) {
|
|
52
|
+
/** @type {Array<{ pattern: string, regex: RegExp, row: object }>} */
|
|
53
|
+
const out = [];
|
|
54
|
+
for (const row of rows) {
|
|
55
|
+
const pattern = row.URL;
|
|
56
|
+
if (typeof pattern !== 'string' || !pattern.trim()) {
|
|
57
|
+
// eslint-disable-next-line no-continue
|
|
58
|
+
continue;
|
|
59
|
+
}
|
|
60
|
+
try {
|
|
61
|
+
const regex = globToRegExp(pattern, { globstar: true });
|
|
62
|
+
out.push({ pattern, regex, row });
|
|
63
|
+
} catch {
|
|
64
|
+
// eslint-disable-next-line no-continue
|
|
65
|
+
continue;
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
return out;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/**
|
|
72
|
+
* Merges all matching metadata sheet rows for a path: most specific URL pattern first,
|
|
73
|
+
* then each field uses the first non-empty value (empty string falls through to broader rows).
|
|
74
|
+
* @param {string} normalizedPath
|
|
75
|
+
* @param {Array<{ pattern: string, regex: RegExp, row: object }>} compiled
|
|
76
|
+
* @returns {Record<string, string> | null}
|
|
77
|
+
*/
|
|
78
|
+
export function mergeMetadataSheetRows(normalizedPath, compiled) {
|
|
79
|
+
/** @type {Array<{ pattern: string, row: object }>} */
|
|
80
|
+
const hits = [];
|
|
81
|
+
for (const { pattern, regex, row } of compiled) {
|
|
82
|
+
if (regex.test(normalizedPath)) {
|
|
83
|
+
hits.push({ pattern, row });
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
if (hits.length === 0) {
|
|
87
|
+
return null;
|
|
88
|
+
}
|
|
89
|
+
hits.sort((a, b) => b.pattern.length - a.pattern.length);
|
|
90
|
+
|
|
91
|
+
/** @type {Set<string>} */
|
|
92
|
+
const keys = new Set();
|
|
93
|
+
for (const { row } of hits) {
|
|
94
|
+
for (const k of Object.keys(row)) {
|
|
95
|
+
if (k !== 'URL' && !k.startsWith(':')) {
|
|
96
|
+
keys.add(k);
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
/** @type {Record<string, string>} */
|
|
102
|
+
const merged = {};
|
|
103
|
+
for (const key of keys) {
|
|
104
|
+
for (const { row } of hits) {
|
|
105
|
+
const v = row[key];
|
|
106
|
+
if (v === undefined || v === null) {
|
|
107
|
+
// eslint-disable-next-line no-continue
|
|
108
|
+
continue;
|
|
109
|
+
}
|
|
110
|
+
const s = String(v).trim();
|
|
111
|
+
if (s === '') {
|
|
112
|
+
// eslint-disable-next-line no-continue
|
|
113
|
+
continue;
|
|
114
|
+
}
|
|
115
|
+
merged[key] = s;
|
|
116
|
+
break;
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
return Object.keys(merged).length > 0 ? merged : null;
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
export default class MetadataSheetSupport {
|
|
124
|
+
/**
|
|
125
|
+
* @param {{ proxyUrl: string, log: object, allowInsecure: boolean, siteToken?: string }} opts
|
|
126
|
+
*/
|
|
127
|
+
constructor({
|
|
128
|
+
proxyUrl, log, allowInsecure, siteToken,
|
|
129
|
+
}) {
|
|
130
|
+
this.url = new URL(proxyUrl);
|
|
131
|
+
this.url.pathname = '/metadata.json';
|
|
132
|
+
this.log = log;
|
|
133
|
+
this.allowInsecure = allowInsecure;
|
|
134
|
+
this.siteToken = siteToken || '';
|
|
135
|
+
this.cookie = '';
|
|
136
|
+
/** @type {Array<{ pattern: string, regex: RegExp, row: object }> | null} */
|
|
137
|
+
this._compiled = null;
|
|
138
|
+
/** @type {Promise<void> | null} */
|
|
139
|
+
this._loadPromise = null;
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
setCookie(cookie) {
|
|
143
|
+
const next = cookie || '';
|
|
144
|
+
if (this.cookie !== next) {
|
|
145
|
+
this.cookie = next;
|
|
146
|
+
this._compiled = null;
|
|
147
|
+
this._loadPromise = null;
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
setSiteToken(siteToken) {
|
|
152
|
+
const next = siteToken || '';
|
|
153
|
+
if (this.siteToken !== next) {
|
|
154
|
+
this.siteToken = next;
|
|
155
|
+
this._compiled = null;
|
|
156
|
+
this._loadPromise = null;
|
|
157
|
+
}
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
invalidate() {
|
|
161
|
+
this._compiled = null;
|
|
162
|
+
this._loadPromise = null;
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
async ensureLoaded() {
|
|
166
|
+
if (this._compiled !== null) {
|
|
167
|
+
return;
|
|
168
|
+
}
|
|
169
|
+
if (this._loadPromise) {
|
|
170
|
+
await this._loadPromise;
|
|
171
|
+
return;
|
|
172
|
+
}
|
|
173
|
+
this._loadPromise = this._fetchAndCompile();
|
|
174
|
+
try {
|
|
175
|
+
await this._loadPromise;
|
|
176
|
+
} finally {
|
|
177
|
+
this._loadPromise = null;
|
|
178
|
+
}
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
async _fetchAndCompile() {
|
|
182
|
+
const headers = {};
|
|
183
|
+
if (this.cookie) {
|
|
184
|
+
headers.cookie = this.cookie;
|
|
185
|
+
}
|
|
186
|
+
if (this.siteToken) {
|
|
187
|
+
headers.authorization = `token ${this.siteToken}`;
|
|
188
|
+
}
|
|
189
|
+
try {
|
|
190
|
+
const resp = await getFetch(this.allowInsecure)(this.url, {
|
|
191
|
+
cache: 'no-store',
|
|
192
|
+
headers,
|
|
193
|
+
});
|
|
194
|
+
if (!resp.ok) {
|
|
195
|
+
this.log.debug(`metadata.json not loaded (${resp.status}) from ${this.url}`);
|
|
196
|
+
this._compiled = [];
|
|
197
|
+
return;
|
|
198
|
+
}
|
|
199
|
+
const text = await resp.text();
|
|
200
|
+
const body = JSON.parse(text);
|
|
201
|
+
const rows = extractDataRows(body);
|
|
202
|
+
this._compiled = compileMetadataSheetPatterns(rows);
|
|
203
|
+
this.log.debug(`loaded metadata.json (${this._compiled.length} URL pattern(s)) from ${this.url}`);
|
|
204
|
+
} catch (e) {
|
|
205
|
+
this.log.debug(`metadata.json fetch/parse failed: ${e.message || e}`);
|
|
206
|
+
this._compiled = [];
|
|
207
|
+
}
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
/**
|
|
211
|
+
* @param {string} pathname ctx.path or resource path with .html
|
|
212
|
+
* @returns {object | null}
|
|
213
|
+
*/
|
|
214
|
+
matchPath(pathname) {
|
|
215
|
+
if (!this._compiled || this._compiled.length === 0) {
|
|
216
|
+
return null;
|
|
217
|
+
}
|
|
218
|
+
const normalized = normalizePathForMetadataMatch(pathname);
|
|
219
|
+
return mergeMetadataSheetRows(normalized, this._compiled);
|
|
220
|
+
}
|
|
221
|
+
}
|