@adobe/aem-cli 16.15.13 → 16.16.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 CHANGED
@@ -1,3 +1,10 @@
1
+ # [16.16.0](https://github.com/adobe/helix-cli/compare/v16.15.13...v16.16.0) (2025-11-13)
2
+
3
+
4
+ ### Features
5
+
6
+ * 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))
7
+
1
8
  ## [16.15.13](https://github.com/adobe/helix-cli/compare/v16.15.12...v16.15.13) (2025-11-11)
2
9
 
3
10
 
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.0",
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
@@ -177,41 +261,12 @@ export class HelixServer extends BaseServer {
177
261
  // Extract the path within the HTML folder
178
262
  const relativePath = pathname.slice(folderPrefix.length);
179
263
 
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('.')) {
264
+ // Resolve which file to serve (.html or .plain.html)
265
+ const resolvedFile = await this.resolveHtmlFolderFile(relativePath);
266
+ if (!resolvedFile) {
187
267
  return next();
188
268
  }
189
269
 
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);
197
-
198
- // Only check for path traversal - remove the incorrect isAbsolute check
199
- if (relPath.startsWith('..')) {
200
- return next();
201
- }
202
-
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
211
- return next();
212
- }
213
-
214
- const sendFile = promisify(res.sendFile).bind(res);
215
270
  const { log } = this;
216
271
  const liveReload = this._liveReload;
217
272
 
@@ -220,24 +275,33 @@ export class HelixServer extends BaseServer {
220
275
  liveReload.startRequest(req.id, req.url);
221
276
  }
222
277
 
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
- },
278
+ // Load content (handle .plain.html transformation)
279
+ let htmlContent;
280
+ if (resolvedFile.isPlain) {
281
+ htmlContent = await this.transformPlainHtml(resolvedFile.file);
282
+ } else {
283
+ htmlContent = await readFile(resolvedFile.file, 'utf-8');
284
+ }
285
+
286
+ // Inject live reload script
287
+ if (liveReload) {
288
+ htmlContent = utils.injectLiveReloadScript(htmlContent, this);
289
+ }
290
+
291
+ // Send response
292
+ res.set({
293
+ 'content-type': 'text/html; charset=utf-8',
294
+ 'access-control-allow-origin': '*',
233
295
  });
296
+ res.send(htmlContent);
234
297
 
298
+ // Register with live reload
235
299
  if (liveReload) {
236
- liveReload.registerFile(req.id, htmlFile);
300
+ liveReload.registerFile(req.id, resolvedFile.file);
237
301
  liveReload.endRequest(req.id);
238
302
  }
239
303
 
240
- log.debug(`served HTML file ${htmlFile} for ${req.url}`);
304
+ log.debug(`served HTML file ${resolvedFile.file} for ${req.url}`);
241
305
  return undefined;
242
306
  }
243
307
 
@@ -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