@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 +7 -0
- package/README.md +21 -0
- package/package.json +1 -1
- package/src/server/HelixServer.js +108 -44
- package/src/server/utils.js +250 -0
- package/src/up.js +1 -1
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
|
@@ -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
|
-
//
|
|
181
|
-
|
|
182
|
-
|
|
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
|
-
//
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
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,
|
|
300
|
+
liveReload.registerFile(req.id, resolvedFile.file);
|
|
237
301
|
liveReload.endRequest(req.id);
|
|
238
302
|
}
|
|
239
303
|
|
|
240
|
-
log.debug(`served HTML file ${
|
|
304
|
+
log.debug(`served HTML file ${resolvedFile.file} for ${req.url}`);
|
|
241
305
|
return undefined;
|
|
242
306
|
}
|
|
243
307
|
|
package/src/server/utils.js
CHANGED
|
@@ -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, '&')
|
|
539
|
+
.replace(/</g, '<')
|
|
540
|
+
.replace(/>/g, '>')
|
|
541
|
+
.replace(/"/g, '"')
|
|
542
|
+
.replace(/'/g, ''');
|
|
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
|
|