@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.
@@ -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
+ }