@adobe/aem-cli 16.15.13 → 16.16.1

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 CHANGED
@@ -1,3 +1,17 @@
1
+ ## [16.16.1](https://github.com/adobe/helix-cli/compare/v16.16.0...v16.16.1) (2025-11-14)
2
+
3
+
4
+ ### Bug Fixes
5
+
6
+ * **html-folder:** support index files for directory requests with trailing slash ([#2632](https://github.com/adobe/helix-cli/issues/2632)) ([55d782d](https://github.com/adobe/helix-cli/commit/55d782db744a43841fb9da08354a8d22d8d14dd7))
7
+
8
+ # [16.16.0](https://github.com/adobe/helix-cli/compare/v16.15.13...v16.16.0) (2025-11-13)
9
+
10
+
11
+ ### Features
12
+
13
+ * add .plain.html support for HTML folder serving ([#2631](https://github.com/adobe/helix-cli/issues/2631)) ([2186d4c](https://github.com/adobe/helix-cli/commit/2186d4c330f97db86849b9388e119b7d599c9fc1))
14
+
1
15
  ## [16.15.13](https://github.com/adobe/helix-cli/compare/v16.15.12...v16.15.13) (2025-11-11)
2
16
 
3
17
 
package/README.md CHANGED
@@ -74,6 +74,26 @@ This feature is especially helpful when:
74
74
  - Monitoring client-side behavior during development
75
75
  - Working with AI coding assistants that need visibility into both server and client logs
76
76
 
77
+ ### html folder for content preview
78
+
79
+ The `--html-folder` option enables serving HTML files without extensions, useful for previewing content changes when you don't have access to the authoring system.
80
+
81
+ ```
82
+ $ aem up --html-folder content
83
+ ```
84
+
85
+ This enables two features:
86
+
87
+ 1. **Extension-less URLs**: Access `/content/page` to serve `content/page.html`
88
+ 2. **Plain HTML with metadata**: Create `content/page.plain.html` files that are automatically wrapped with proper HTML structure and metadata processing
89
+
90
+ #### Plain HTML files (.plain.html)
91
+
92
+ Plain HTML files contain only the main content and an optional metadata block. The CLI automatically:
93
+ - Wraps content in `<html><head><body><header><main><footer>` structure
94
+ - Merges in `head.html` content
95
+ - The metadata block is removed from the rendered content and converted to meta tags in the `<head>`.
96
+
77
97
  ### setting up a self-signed cert for using https
78
98
 
79
99
  1. create the certificate
@@ -161,6 +181,7 @@ If present, `ALL_PROXY` is used as fallback if there is no other match.
161
181
  | `--livereload` | `AEM_LIVERELOAD` | `true` | Enable automatic reloading of modified sources in browser. |
162
182
  | `--no-livereload` | `AEM_NO_LIVERELOAD` | `false` | Disable live-reload. |
163
183
  | `--forward-browser-logs` | `AEM_FORWARD_BROWSER_LOGS` | `false` | Forward browser console logs to terminal. |
184
+ | `--html-folder` | `AEM_HTML_FOLDER` | undefined | Serve HTML files from folder without extensions. Supports .html and .plain.html files. |
164
185
  | `--open` | `AEM_OPEN` | `/` | Open a browser window at specified path after server start. |
165
186
  | `--no-open` | `AEM_NO_OPEN` | `false` | Disable automatic opening of browser window. |
166
187
  | `--tls-key` | `AEM_TLS_KEY` | undefined | Path to .key file (for enabling TLS) |
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@adobe/aem-cli",
3
- "version": "16.15.13",
3
+ "version": "16.16.1",
4
4
  "description": "AEM CLI",
5
5
  "main": "index.js",
6
6
  "type": "module",
@@ -154,6 +154,90 @@ export class HelixServer extends BaseServer {
154
154
  .send('');
155
155
  }
156
156
 
157
+ /**
158
+ * Resolves which HTML file to serve from the HTML folder
159
+ * @param {string} relativePath path relative to HTML folder
160
+ * @returns {Promise<{file: string, isPlain: boolean}|null>} resolved file info or null
161
+ */
162
+ async resolveHtmlFolderFile(relativePath) {
163
+ // Security check: prevent path traversal with /../ anywhere in the path
164
+ if (relativePath.includes('/../')) {
165
+ return null;
166
+ }
167
+
168
+ // Don't process if it already has an extension
169
+ if (relativePath.includes('.')) {
170
+ return null;
171
+ }
172
+
173
+ // Try .html first
174
+ const htmlFile = path.resolve(
175
+ this._project.directory,
176
+ this._htmlFolder,
177
+ `${relativePath}.html`,
178
+ );
179
+
180
+ if (!utils.validatePathSecurity(htmlFile, this._project.directory)) {
181
+ return null;
182
+ }
183
+
184
+ try {
185
+ const stats = await lstat(htmlFile);
186
+ if (stats.isFile()) {
187
+ return { file: htmlFile, isPlain: false };
188
+ }
189
+ } catch (e) {
190
+ // .html not found, try .plain.html
191
+ }
192
+
193
+ // Try .plain.html
194
+ const plainHtmlFile = path.resolve(
195
+ this._project.directory,
196
+ this._htmlFolder,
197
+ `${relativePath}.plain.html`,
198
+ );
199
+
200
+ if (!utils.validatePathSecurity(plainHtmlFile, this._project.directory)) {
201
+ return null;
202
+ }
203
+
204
+ try {
205
+ const stats = await lstat(plainHtmlFile);
206
+ if (stats.isFile()) {
207
+ return { file: plainHtmlFile, isPlain: true };
208
+ }
209
+ } catch (e) {
210
+ // Neither exists
211
+ }
212
+
213
+ return null;
214
+ }
215
+
216
+ /**
217
+ * Transforms .plain.html content into complete HTML with head.html
218
+ * @param {string} plainHtmlFile path to .plain.html file
219
+ * @returns {Promise<string>} complete HTML document
220
+ */
221
+ async transformPlainHtml(plainHtmlFile) {
222
+ const plainContent = await readFile(plainHtmlFile, 'utf-8');
223
+
224
+ // Extract metadata and clean content
225
+ const { content, metadata } = utils.extractMetadataBlock(plainContent);
226
+
227
+ // Extract default metadata values from content
228
+ const defaults = utils.extractDefaultMetadata(content);
229
+
230
+ // Get head HTML using existing HeadHtmlSupport
231
+ await this._project.headHtml.update();
232
+ const headHtml = this._project.headHtml.localHtml || '';
233
+
234
+ // Generate meta tags from metadata with defaults
235
+ const metaTags = utils.generateMetaTags(metadata, defaults);
236
+
237
+ // Wrap in complete HTML structure
238
+ return utils.wrapPlainHtml(content, headHtml, metaTags);
239
+ }
240
+
157
241
  /**
158
242
  * HTML Folder handler - serves HTML files without extensions
159
243
  * @param {Express.Request} req request
@@ -175,43 +259,19 @@ export class HelixServer extends BaseServer {
175
259
  }
176
260
 
177
261
  // Extract the path within the HTML folder
178
- const relativePath = pathname.slice(folderPrefix.length);
179
-
180
- // Security check: prevent path traversal with /../ anywhere in the path
181
- if (relativePath.includes('/../')) {
182
- return next();
183
- }
184
-
185
- // Don't process if it already has an extension
186
- if (relativePath.includes('.')) {
187
- return next();
188
- }
189
-
190
- // Build the HTML file path - only support .html extension
191
- const htmlFile = path.resolve(this._project.directory, this._htmlFolder, `${relativePath}.html`);
192
-
193
- // Security check: ensure the file is within the project directory
194
- // Use resolve to normalize both paths before comparing
195
- const resolvedProjectDir = path.resolve(this._project.directory);
196
- const relPath = path.relative(resolvedProjectDir, htmlFile);
262
+ let relativePath = pathname.slice(folderPrefix.length);
197
263
 
198
- // Only check for path traversal - remove the incorrect isAbsolute check
199
- if (relPath.startsWith('..')) {
200
- return next();
264
+ // Handle directory requests (trailing slash) by appending 'index'
265
+ if (relativePath === '' || relativePath.endsWith('/')) {
266
+ relativePath = `${relativePath}index`.replace(/\/+/g, '/');
201
267
  }
202
268
 
203
- // Check if the HTML file exists and is a file
204
- try {
205
- const stats = await lstat(htmlFile);
206
- if (!stats.isFile()) {
207
- return next();
208
- }
209
- } catch (e) {
210
- // File doesn't exist, continue to next handler
269
+ // Resolve which file to serve (.html or .plain.html)
270
+ const resolvedFile = await this.resolveHtmlFolderFile(relativePath);
271
+ if (!resolvedFile) {
211
272
  return next();
212
273
  }
213
274
 
214
- const sendFile = promisify(res.sendFile).bind(res);
215
275
  const { log } = this;
216
276
  const liveReload = this._liveReload;
217
277
 
@@ -220,24 +280,33 @@ export class HelixServer extends BaseServer {
220
280
  liveReload.startRequest(req.id, req.url);
221
281
  }
222
282
 
223
- // Serve the file
224
- // Use the root option to specify the base directory for sendFile
225
- // This prevents issues with worktrees in subdirectories
226
- await sendFile(path.basename(htmlFile), {
227
- root: path.dirname(htmlFile),
228
- dotfiles: 'deny',
229
- headers: {
230
- 'access-control-allow-origin': '*',
231
- 'content-type': 'text/html; charset=utf-8',
232
- },
283
+ // Load content (handle .plain.html transformation)
284
+ let htmlContent;
285
+ if (resolvedFile.isPlain) {
286
+ htmlContent = await this.transformPlainHtml(resolvedFile.file);
287
+ } else {
288
+ htmlContent = await readFile(resolvedFile.file, 'utf-8');
289
+ }
290
+
291
+ // Inject live reload script
292
+ if (liveReload) {
293
+ htmlContent = utils.injectLiveReloadScript(htmlContent, this);
294
+ }
295
+
296
+ // Send response
297
+ res.set({
298
+ 'content-type': 'text/html; charset=utf-8',
299
+ 'access-control-allow-origin': '*',
233
300
  });
301
+ res.send(htmlContent);
234
302
 
303
+ // Register with live reload
235
304
  if (liveReload) {
236
- liveReload.registerFile(req.id, htmlFile);
305
+ liveReload.registerFile(req.id, resolvedFile.file);
237
306
  liveReload.endRequest(req.id);
238
307
  }
239
308
 
240
- log.debug(`served HTML file ${htmlFile} for ${req.url}`);
309
+ log.debug(`served HTML file ${resolvedFile.file} for ${req.url}`);
241
310
  return undefined;
242
311
  }
243
312
 
@@ -17,6 +17,8 @@ import { PassThrough } from 'stream';
17
17
  import { readFileSync } from 'fs';
18
18
  import { fileURLToPath } from 'url';
19
19
  import cookie from 'cookie';
20
+ import { unified } from 'unified';
21
+ import rehypeParse from 'rehype-parse';
20
22
  import { getFetch } from '../fetch-utils.js';
21
23
 
22
24
  // Load console interceptor script at startup
@@ -525,6 +527,254 @@ window.LiveReloadOptions = {
525
527
  text = text.replaceAll(re, (match, arg, q1, value, q2) => (`${arg}=${q1}${value || '/'}${q2}`));
526
528
  return Buffer.from(text, 'utf-8');
527
529
  },
530
+
531
+ /**
532
+ * Escapes HTML entities for use in attributes
533
+ * @param {string} text text to escape
534
+ * @returns {string} escaped text
535
+ */
536
+ escapeHtml(text) {
537
+ return text
538
+ .replace(/&/g, '&amp;')
539
+ .replace(/</g, '&lt;')
540
+ .replace(/>/g, '&gt;')
541
+ .replace(/"/g, '&quot;')
542
+ .replace(/'/g, '&#039;');
543
+ },
544
+
545
+ /**
546
+ * Extracts text content from HTML using proper HTML parsing
547
+ * @param {string} html HTML content to extract text from
548
+ * @returns {string} plain text content
549
+ */
550
+ extractTextFromHtml(html) {
551
+ // Helper to recursively extract text from AST nodes
552
+ function extractText(node) {
553
+ if (node.type === 'text') {
554
+ return node.value;
555
+ }
556
+ if (node.children) {
557
+ return node.children.map(extractText).join('');
558
+ }
559
+ return '';
560
+ }
561
+
562
+ try {
563
+ const ast = unified()
564
+ .use(rehypeParse, { fragment: true })
565
+ .parse(html);
566
+ return extractText(ast).trim();
567
+ } catch (e) {
568
+ // If parsing fails, return empty string to avoid XSS vulnerabilities
569
+ return '';
570
+ }
571
+ },
572
+
573
+ /**
574
+ * Validates that a file path is within a base directory (security check)
575
+ * @param {string} filePath absolute file path to validate
576
+ * @param {string} baseDirectory base directory path
577
+ * @returns {boolean} true if path is safe, false if it tries to escape base directory
578
+ */
579
+ validatePathSecurity(filePath, baseDirectory) {
580
+ const resolvedBaseDir = path.resolve(baseDirectory);
581
+ const relPath = path.relative(resolvedBaseDir, filePath);
582
+ return !relPath.startsWith('..');
583
+ },
584
+
585
+ /**
586
+ * Extracts metadata block from plain HTML content
587
+ * @param {string} html HTML content with potential metadata block
588
+ * @returns {{content: string, metadata: object}} cleaned content and metadata object
589
+ */
590
+ extractMetadataBlock(html) {
591
+ // The metadata block structure is:
592
+ // <div>
593
+ // <div class="metadata">
594
+ // <div><div>key</div><div>value</div></div>
595
+ // </div>
596
+ // </div>
597
+
598
+ // Find the outer div containing the metadata div
599
+ const outerDivRegex = /<div>\s*<div class="metadata">/;
600
+ const outerMatch = html.match(outerDivRegex);
601
+
602
+ if (!outerMatch) {
603
+ return { content: html, metadata: {} };
604
+ }
605
+
606
+ // Find the matching closing </div></div> by counting nested divs
607
+ // Start after <div><div class="metadata">
608
+ const startIndex = outerMatch.index + outerMatch[0].length;
609
+ let depth = 2; // Already inside two divs
610
+ let endIndex = startIndex;
611
+ let closingDivCount = 0;
612
+
613
+ for (let i = startIndex; i < html.length && closingDivCount < 2; i += 1) {
614
+ if (html.substr(i, 5) === '<div>') {
615
+ depth += 1;
616
+ } else if (html.substr(i, 6) === '</div>') {
617
+ depth -= 1;
618
+ if (depth < 2) {
619
+ closingDivCount += 1;
620
+ if (closingDivCount === 2) {
621
+ endIndex = i + 6; // Include the last </div>
622
+ }
623
+ }
624
+ }
625
+ }
626
+
627
+ if (closingDivCount !== 2) {
628
+ // Malformed HTML, return as-is
629
+ return { content: html, metadata: {} };
630
+ }
631
+
632
+ // Extract the metadata block content and remove entire outer div from HTML
633
+ const fullMetadataBlock = html.substring(outerMatch.index, endIndex);
634
+ const metadataBlock = html.substring(startIndex, endIndex - 12); // -12 for </div></div>
635
+ const cleanedContent = html.replace(fullMetadataBlock, '').trim();
636
+
637
+ // Parse metadata block to extract key-value pairs
638
+ const pairRegex = /<div>\s*<div>([^<]+)<\/div>\s*<div>([\s\S]*?)<\/div>\s*<\/div>/g;
639
+ const metadata = {};
640
+ let pairMatch;
641
+
642
+ // eslint-disable-next-line no-cond-assign
643
+ while ((pairMatch = pairRegex.exec(metadataBlock)) !== null) {
644
+ const key = pairMatch[1].trim();
645
+ let value = pairMatch[2].trim();
646
+
647
+ // Handle <img> tags - extract src
648
+ const imgMatch = value.match(/<img[^>]+src="([^"]+)"/);
649
+ if (imgMatch) {
650
+ [, value] = imgMatch;
651
+ } else {
652
+ // Use proper HTML parsing to extract text content
653
+ value = utils.extractTextFromHtml(value);
654
+ }
655
+
656
+ metadata[key] = value;
657
+ }
658
+
659
+ return { content: cleanedContent, metadata };
660
+ },
661
+
662
+ /**
663
+ * Extracts default metadata values from HTML content
664
+ * @param {string} content HTML content to extract defaults from
665
+ * @returns {object} default metadata values
666
+ */
667
+ extractDefaultMetadata(content) {
668
+ const defaults = {};
669
+
670
+ // Extract title from first H1
671
+ const h1Match = content.match(/<h1[^>]*>(.*?)<\/h1>/i);
672
+ if (h1Match) {
673
+ // Use proper HTML parsing to extract text
674
+ defaults.title = utils.extractTextFromHtml(h1Match[1]);
675
+ }
676
+
677
+ // Extract description from first paragraph with 10+ words
678
+ const pMatch = content.match(/<p[^>]*>(.*?)<\/p>/i);
679
+ if (pMatch) {
680
+ // Use proper HTML parsing to extract text
681
+ const text = utils.extractTextFromHtml(pMatch[1]);
682
+ const wordCount = text.split(/\s+/).length;
683
+ if (wordCount >= 10) {
684
+ defaults.description = text;
685
+ }
686
+ }
687
+
688
+ // Extract image from first img tag
689
+ const imgMatch = content.match(/<img[^>]+src="([^"]+)"/i);
690
+ if (imgMatch) {
691
+ [, defaults.image] = imgMatch;
692
+ }
693
+
694
+ return defaults;
695
+ },
696
+
697
+ /**
698
+ * Generates meta tags from metadata object with AEM.live special property support
699
+ * @param {object} metadata key-value pairs of metadata
700
+ * @param {object} defaults default values extracted from content
701
+ * @returns {string} HTML string of meta tags
702
+ */
703
+ generateMetaTags(metadata, defaults = {}) {
704
+ // Handle title:suffix special property
705
+ let title = metadata.title || defaults.title || '';
706
+ const titleSuffix = metadata['title:suffix'];
707
+ if (titleSuffix && title) {
708
+ title = `${title} ${titleSuffix}`;
709
+ }
710
+
711
+ // Apply defaults for missing values
712
+ const description = metadata.description || defaults.description || '';
713
+ const image = metadata.image || defaults.image || '/default-meta-image.png';
714
+
715
+ // Build final metadata object with computed values
716
+ const finalMetadata = { ...metadata };
717
+ if (title) finalMetadata.title = title;
718
+ if (description) finalMetadata.description = description;
719
+ if (image) finalMetadata.image = image;
720
+
721
+ // Remove special properties that shouldn't become meta tags
722
+ delete finalMetadata['title:suffix'];
723
+
724
+ // Generate meta tags (both standard and OG format)
725
+ const ogFields = ['title', 'description', 'image', 'url'];
726
+ let metaTags = '';
727
+
728
+ // eslint-disable-next-line no-restricted-syntax
729
+ for (const [key, value] of Object.entries(finalMetadata)) {
730
+ // Skip empty values
731
+ if (!value) {
732
+ // eslint-disable-next-line no-continue
733
+ continue;
734
+ }
735
+
736
+ // Handle 'tags' property specially - create article:tag for each
737
+ if (key === 'tags') {
738
+ // Split by comma or newline
739
+ const tagList = value.split(/[,\n]/).map((t) => t.trim()).filter((t) => t);
740
+ // eslint-disable-next-line no-restricted-syntax
741
+ for (const tag of tagList) {
742
+ metaTags += `<meta property="article:tag" content="${utils.escapeHtml(tag)}">`;
743
+ }
744
+ // Don't create standard name= tag for 'tags'
745
+ } else if (key === 'canonical') {
746
+ // Handle canonical specially
747
+ metaTags += `<link rel="canonical" href="${utils.escapeHtml(value)}">`;
748
+ metaTags += `<meta property="og:url" content="${utils.escapeHtml(value)}">`;
749
+ metaTags += `<meta name="twitter:url" content="${utils.escapeHtml(value)}">`;
750
+ // Don't create standard name= tag
751
+ } else {
752
+ // Standard meta tag
753
+ metaTags += `<meta name="${key}" content="${utils.escapeHtml(value)}">`;
754
+
755
+ // Open Graph meta tag for common fields
756
+ if (ogFields.includes(key.toLowerCase())) {
757
+ metaTags += `<meta property="og:${key}" content="${utils.escapeHtml(value)}">`;
758
+ // Add twitter card tags for common fields
759
+ metaTags += `<meta name="twitter:${key}" content="${utils.escapeHtml(value)}">`;
760
+ }
761
+ }
762
+ }
763
+
764
+ return metaTags;
765
+ },
766
+
767
+ /**
768
+ * Wraps plain HTML content in complete HTML structure
769
+ * @param {string} content plain HTML content
770
+ * @param {string} headHtml head.html content
771
+ * @param {string} metaTags generated meta tags
772
+ * @returns {string} complete HTML document
773
+ */
774
+ wrapPlainHtml(content, headHtml, metaTags) {
775
+ const fullHead = headHtml + metaTags;
776
+ return `<html><head>${fullHead}</head><body><header></header><main>${content}</main><footer></footer></body></html>`;
777
+ },
528
778
  };
529
779
 
530
780
  export default Object.freeze(utils);
package/src/up.js CHANGED
@@ -117,7 +117,7 @@ export default function up() {
117
117
  })
118
118
  .option('html-folder', {
119
119
  alias: 'htmlFolder',
120
- describe: 'Serve HTML files from this folder without extensions (e.g., /folder/file serves folder/file.html) use this to preview content changes if you do not have access to the authoring system',
120
+ describe: 'Serve HTML files from this folder without extensions (e.g., /folder/file serves folder/file.html or folder/file.plain.html) use this to preview content changes if you do not have access to the authoring system',
121
121
  type: 'string',
122
122
  })
123
123