@adobe/aem-cli 16.20.13 → 16.21.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.21.0](https://github.com/adobe/helix-cli/compare/v16.20.13...v16.21.0) (2026-07-15)
|
|
2
|
+
|
|
3
|
+
|
|
4
|
+
### Features
|
|
5
|
+
|
|
6
|
+
* **content:** render DA content via the real html2md -> html-pipeline chain ([d40bf69](https://github.com/adobe/helix-cli/commit/d40bf69e11d308317e63b73890fd087b13a3844a)), closes [#2759](https://github.com/adobe/helix-cli/issues/2759) [#2756](https://github.com/adobe/helix-cli/issues/2756)
|
|
7
|
+
|
|
1
8
|
## [16.20.13](https://github.com/adobe/helix-cli/compare/v16.20.12...v16.20.13) (2026-07-15)
|
|
2
9
|
|
|
3
10
|
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@adobe/aem-cli",
|
|
3
|
-
"version": "16.
|
|
3
|
+
"version": "16.21.0",
|
|
4
4
|
"description": "AEM CLI",
|
|
5
5
|
"main": "index.js",
|
|
6
6
|
"type": "module",
|
|
@@ -44,6 +44,8 @@
|
|
|
44
44
|
},
|
|
45
45
|
"dependencies": {
|
|
46
46
|
"@adobe/fetch": "4.3.0",
|
|
47
|
+
"@adobe/helix-html-pipeline": "6.29.6",
|
|
48
|
+
"@adobe/helix-html2md": "2.2.2",
|
|
47
49
|
"@adobe/helix-log": "7.0.0",
|
|
48
50
|
"@adobe/helix-shared-config": "11.1.29",
|
|
49
51
|
"@adobe/helix-shared-git": "3.0.25",
|
|
@@ -62,7 +64,6 @@
|
|
|
62
64
|
"faye-websocket": "0.11.4",
|
|
63
65
|
"fs-extra": "11.3.5",
|
|
64
66
|
"glob": "13.0.6",
|
|
65
|
-
"glob-to-regexp": "0.4.1",
|
|
66
67
|
"hast-util-select": "6.0.4",
|
|
67
68
|
"hast-util-to-html": "9.0.5",
|
|
68
69
|
"http-proxy-agent": "9.1.0",
|
|
@@ -71,8 +72,8 @@
|
|
|
71
72
|
"ini": "7.0.0",
|
|
72
73
|
"isomorphic-git": "1.38.5",
|
|
73
74
|
"jose": "6.2.3",
|
|
74
|
-
"mime": "4.1.0",
|
|
75
75
|
"livereload-js": "4.0.2",
|
|
76
|
+
"mime": "4.1.0",
|
|
76
77
|
"node-diff3": "3.2.1",
|
|
77
78
|
"node-fetch": "3.3.2",
|
|
78
79
|
"open": "11.0.0",
|
|
@@ -0,0 +1,135 @@
|
|
|
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
|
+
/**
|
|
14
|
+
* Renders da.live-authored content HTML from `content/` through the
|
|
15
|
+
* `html2md` -> `helix-html-pipeline` markdown-rendering chain, run locally by calling
|
|
16
|
+
* `htmlPipe()` (the same entry point production uses) against a hand-built minimal pipeline
|
|
17
|
+
* state and a fake in-memory content-bus loader.
|
|
18
|
+
*/
|
|
19
|
+
|
|
20
|
+
import { html2md } from '@adobe/helix-html2md';
|
|
21
|
+
import {
|
|
22
|
+
PipelineState, PipelineRequest, PipelineResponse, htmlPipe,
|
|
23
|
+
} from '@adobe/helix-html-pipeline';
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* `htmlPipe` treats a literal `.html` path (no selector) as a code-bus (statically deployed)
|
|
27
|
+
* resource and skips markdown rendering entirely -- production only ever requests pages at
|
|
28
|
+
* extension-less paths (`/foo`), reserving `.html` for real static files. `.plain.html` is
|
|
29
|
+
* exempt: its `plain` selector already routes through the content-bus/markdown branch.
|
|
30
|
+
* @param {string} path
|
|
31
|
+
* @returns {string}
|
|
32
|
+
*/
|
|
33
|
+
function toContentPath(path) {
|
|
34
|
+
if (path.endsWith('.plain.html')) {
|
|
35
|
+
return path;
|
|
36
|
+
}
|
|
37
|
+
const clean = path.endsWith('.html') ? path.slice(0, -'.html'.length) : path;
|
|
38
|
+
// a literal "index" path segment is a reserved internal artifact -- fetchContent rejects it
|
|
39
|
+
// outright, so an index document maps back to its containing directory, same as "/".
|
|
40
|
+
if (clean.endsWith('/index')) {
|
|
41
|
+
return clean.slice(0, -'index'.length) || '/';
|
|
42
|
+
}
|
|
43
|
+
return clean;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* A minimal `s3Loader` stand-in. With no folder mapping configured, `htmlPipe` only ever
|
|
48
|
+
* makes two content-bus lookups: the page's own markdown, and (if configured) the
|
|
49
|
+
* metadata.json sheet for the `fetchSourcedMetadata` step.
|
|
50
|
+
* @param {string} md
|
|
51
|
+
* @param {object[]} metadataSheetRows
|
|
52
|
+
* @returns {object}
|
|
53
|
+
*/
|
|
54
|
+
function createLocalLoader(md, metadataSheetRows) {
|
|
55
|
+
return {
|
|
56
|
+
async getObject(bucketId, key) {
|
|
57
|
+
if (bucketId === 'helix-content-bus' && key.endsWith('/metadata.json')) {
|
|
58
|
+
if (metadataSheetRows.length > 0) {
|
|
59
|
+
return new PipelineResponse(JSON.stringify({ data: metadataSheetRows }));
|
|
60
|
+
}
|
|
61
|
+
return new PipelineResponse('', { status: 404 });
|
|
62
|
+
}
|
|
63
|
+
if (bucketId === 'helix-content-bus') {
|
|
64
|
+
return new PipelineResponse(md);
|
|
65
|
+
}
|
|
66
|
+
return new PipelineResponse('', { status: 404 });
|
|
67
|
+
},
|
|
68
|
+
async headObject() {
|
|
69
|
+
return new PipelineResponse('', { status: 404 });
|
|
70
|
+
},
|
|
71
|
+
};
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/**
|
|
75
|
+
* @param {string} rawHtml body-only or partial HTML from content/, as stored by da.live
|
|
76
|
+
* @param {object} [options]
|
|
77
|
+
* @param {string} [options.path] request path, e.g. `/foo.html` or `/foo.plain.html`
|
|
78
|
+
* @param {Console} [options.log]
|
|
79
|
+
* @param {string} [options.headHtml] local head.html content, injected into <head>
|
|
80
|
+
* @param {object[]} [options.metadataSheetRows] raw rows from the site's /metadata.json, fed
|
|
81
|
+
* through the same `fetchSourcedMetadata` step production uses to build sheet-based
|
|
82
|
+
* metadata overrides
|
|
83
|
+
* @param {object} [options.headers] incoming request headers (e.g. `req.headers`), used to
|
|
84
|
+
* resolve a real host for canonical/og:url instead of a placeholder
|
|
85
|
+
* @param {string} [options.org] the AEM org this content belongs to, if known
|
|
86
|
+
* @param {string} [options.site] the AEM site this content belongs to, if known
|
|
87
|
+
* @returns {Promise<string | null>} the rendered HTML (full document, or a bare fragment for
|
|
88
|
+
* `.plain.html` paths), or `null` if rendering failed and the caller should fall back to
|
|
89
|
+
* serving the raw file
|
|
90
|
+
*/
|
|
91
|
+
export async function renderContentHtml(rawHtml, {
|
|
92
|
+
path = '/index.html',
|
|
93
|
+
log = console,
|
|
94
|
+
headHtml = '',
|
|
95
|
+
metadataSheetRows = [],
|
|
96
|
+
headers = {},
|
|
97
|
+
org = 'local',
|
|
98
|
+
site = 'local',
|
|
99
|
+
} = {}) {
|
|
100
|
+
try {
|
|
101
|
+
const md = await html2md(rawHtml, { log, url: new URL(path, 'http://localhost').href });
|
|
102
|
+
|
|
103
|
+
const contentPath = toContentPath(path);
|
|
104
|
+
const state = new PipelineState({
|
|
105
|
+
path: contentPath,
|
|
106
|
+
log,
|
|
107
|
+
org: org || 'local',
|
|
108
|
+
site: site || 'local',
|
|
109
|
+
ref: 'local',
|
|
110
|
+
partition: 'preview',
|
|
111
|
+
s3Loader: createLocalLoader(md, metadataSheetRows),
|
|
112
|
+
config: {
|
|
113
|
+
contentBusId: 'local',
|
|
114
|
+
owner: 'local',
|
|
115
|
+
repo: 'local',
|
|
116
|
+
cdn: {},
|
|
117
|
+
metadata: { source: ['metadata.json'] },
|
|
118
|
+
headers: {},
|
|
119
|
+
features: { rendering: { version: 2 } },
|
|
120
|
+
head: { html: headHtml },
|
|
121
|
+
},
|
|
122
|
+
});
|
|
123
|
+
|
|
124
|
+
const req = new PipelineRequest(new URL(contentPath, 'http://localhost'), { headers });
|
|
125
|
+
const res = await htmlPipe(state, req);
|
|
126
|
+
if (res.error) {
|
|
127
|
+
log.warn?.(`content-html-pipeline: failed to render ${path}: ${res.error}`);
|
|
128
|
+
return null;
|
|
129
|
+
}
|
|
130
|
+
return res.body;
|
|
131
|
+
} catch (e) {
|
|
132
|
+
log.warn?.(`content-html-pipeline: failed to render ${path}: ${e.message}`);
|
|
133
|
+
return null;
|
|
134
|
+
}
|
|
135
|
+
}
|
|
@@ -22,7 +22,7 @@ import { asyncHandler, BaseServer } from './BaseServer.js';
|
|
|
22
22
|
import LiveReload from './LiveReload.js';
|
|
23
23
|
import { saveSiteTokenToFile } from '../config/config-utils.js';
|
|
24
24
|
import { CONTENT_DIR } from '../content/content-shared.js';
|
|
25
|
-
import {
|
|
25
|
+
import { renderContentHtml } from '../content/content-html-pipeline.js';
|
|
26
26
|
import { DA_IMS_CLIENT_ID, DA_IMS_SCOPE, startDaLoginRedirect } from '../content/da-auth.js';
|
|
27
27
|
|
|
28
28
|
const LOGIN_ROUTE = '/.aem/cli/login';
|
|
@@ -43,26 +43,6 @@ const daContentAuthRateLimit = rateLimit({
|
|
|
43
43
|
const HTML_FOLDER_EXTENSIONS = ['.html', '.plain.html'];
|
|
44
44
|
const HTML_FOLDER_EXTENSIONS_PREFER_PLAIN = [...HTML_FOLDER_EXTENSIONS].reverse();
|
|
45
45
|
|
|
46
|
-
/**
|
|
47
|
-
* @param {import('express').Request} req
|
|
48
|
-
* @param {import('./RequestContext.js').default} ctx
|
|
49
|
-
* @returns {string}
|
|
50
|
-
*/
|
|
51
|
-
function absolutePageUrlFromRequest(req, ctx) {
|
|
52
|
-
const raw = req.headers['x-forwarded-proto'];
|
|
53
|
-
const proto = (Array.isArray(raw) ? raw[0] : raw) || req.protocol || 'http';
|
|
54
|
-
const host = req.get('host');
|
|
55
|
-
if (!host) {
|
|
56
|
-
return '';
|
|
57
|
-
}
|
|
58
|
-
try {
|
|
59
|
-
const pathWithQuery = req.originalUrl || ctx.url;
|
|
60
|
-
return new URL(pathWithQuery, `${proto}://${host}`).href;
|
|
61
|
-
} catch {
|
|
62
|
-
return '';
|
|
63
|
-
}
|
|
64
|
-
}
|
|
65
|
-
|
|
66
46
|
export class HelixServer extends BaseServer {
|
|
67
47
|
/**
|
|
68
48
|
* Creates a new HelixServer for the given project.
|
|
@@ -517,11 +497,24 @@ export class HelixServer extends BaseServer {
|
|
|
517
497
|
// Content may already reference the preview host directly (not just via
|
|
518
498
|
// the content.da.live rewrite above), so gate on presence, not on rewrite.
|
|
519
499
|
const needsDaContentAuth = !!previewOrigin && htmlContent.includes(previewOrigin);
|
|
500
|
+
if (this._project.metadataSheet) {
|
|
501
|
+
this._project.metadataSheet.setCookie(req.headers.cookie || '');
|
|
502
|
+
await this._project.metadataSheet.ensureLoaded();
|
|
503
|
+
}
|
|
504
|
+
const metadataSheetRows = this._project.metadataSheet?.getRows();
|
|
520
505
|
if (isPlainFallback) {
|
|
521
506
|
if (liveReload) {
|
|
522
507
|
liveReload.registerFile(ctx.requestId, servedFilePath);
|
|
523
508
|
}
|
|
524
|
-
const
|
|
509
|
+
const rendered = await renderContentHtml(htmlContent, {
|
|
510
|
+
path: ctx.path,
|
|
511
|
+
log,
|
|
512
|
+
metadataSheetRows,
|
|
513
|
+
headers: req.headers,
|
|
514
|
+
org: this._project.org,
|
|
515
|
+
site: this._project.site,
|
|
516
|
+
});
|
|
517
|
+
const fragment = rendered ?? utils.extractMainContent(htmlContent);
|
|
525
518
|
res.set({
|
|
526
519
|
'content-type': 'text/html; charset=utf-8',
|
|
527
520
|
'access-control-allow-origin': '*',
|
|
@@ -530,29 +523,26 @@ export class HelixServer extends BaseServer {
|
|
|
530
523
|
log.debug(`${pfx}served from ${CONTENT_DIR}/: ${ctx.path}`);
|
|
531
524
|
return;
|
|
532
525
|
}
|
|
533
|
-
|
|
534
|
-
|
|
535
|
-
|
|
536
|
-
|
|
537
|
-
|
|
538
|
-
|
|
539
|
-
|
|
540
|
-
|
|
541
|
-
|
|
526
|
+
await this._project.headHtml.update();
|
|
527
|
+
const headHtml = this._project.headHtml.localHtml || '';
|
|
528
|
+
const rendered = await renderContentHtml(htmlContent, {
|
|
529
|
+
path: ctx.path,
|
|
530
|
+
log,
|
|
531
|
+
headHtml,
|
|
532
|
+
metadataSheetRows,
|
|
533
|
+
headers: req.headers,
|
|
534
|
+
org: this._project.org,
|
|
535
|
+
site: this._project.site,
|
|
542
536
|
});
|
|
543
|
-
|
|
544
|
-
|
|
545
|
-
|
|
546
|
-
|
|
547
|
-
|
|
548
|
-
|
|
549
|
-
htmlContent = `<html><head>${headHtml}${metaTagsHtml}</head>${htmlContent}</html>`;
|
|
537
|
+
if (rendered !== null) {
|
|
538
|
+
htmlContent = rendered;
|
|
539
|
+
} else if (!htmlContent.includes('<head>')) {
|
|
540
|
+
// pipeline failed to render -- fall back to a minimal wrap so local dev never
|
|
541
|
+
// breaks outright on a rendering bug.
|
|
542
|
+
htmlContent = `<html><head>${headHtml}</head>${htmlContent}</html>`;
|
|
550
543
|
} else {
|
|
551
|
-
|
|
552
|
-
htmlContent =
|
|
553
|
-
if (metaTagsHtml) {
|
|
554
|
-
htmlContent = htmlContent.replace(/<\/head>/i, `${metaTagsHtml}</head>`);
|
|
555
|
-
}
|
|
544
|
+
// content already had its own <head> -- still merge in the local head.html
|
|
545
|
+
htmlContent = htmlContent.replace(/<\/head>/i, `${headHtml}</head>`);
|
|
556
546
|
}
|
|
557
547
|
const proxyPageUrl = new URL(ctx.url, proxyUrl);
|
|
558
548
|
for (const [key, value] of proxyUrl.searchParams.entries()) {
|
|
@@ -10,25 +10,8 @@
|
|
|
10
10
|
* governing permissions and limitations under the License.
|
|
11
11
|
*/
|
|
12
12
|
|
|
13
|
-
import globToRegExp from 'glob-to-regexp';
|
|
14
13
|
import { getFetch } from '../fetch-utils.js';
|
|
15
14
|
|
|
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
15
|
/**
|
|
33
16
|
* @param {unknown} body
|
|
34
17
|
* @returns {object[]}
|
|
@@ -44,82 +27,6 @@ function extractDataRows(body) {
|
|
|
44
27
|
return /** @type {object[]} */ (data.filter((r) => r && typeof r === 'object'));
|
|
45
28
|
}
|
|
46
29
|
|
|
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
30
|
export default class MetadataSheetSupport {
|
|
124
31
|
/**
|
|
125
32
|
* @param {{ proxyUrl: string, log: object, allowInsecure: boolean, siteToken?: string }} opts
|
|
@@ -133,8 +40,9 @@ export default class MetadataSheetSupport {
|
|
|
133
40
|
this.allowInsecure = allowInsecure;
|
|
134
41
|
this.siteToken = siteToken || '';
|
|
135
42
|
this.cookie = '';
|
|
136
|
-
/** @type {
|
|
137
|
-
|
|
43
|
+
/** @type {object[] | null} raw metadata.json sheet rows, fed to helix-html-pipeline's own
|
|
44
|
+
* `fetchSourcedMetadata` step so it builds the sheet-based overrides itself */
|
|
45
|
+
this._rows = null;
|
|
138
46
|
/** @type {Promise<void> | null} */
|
|
139
47
|
this._loadPromise = null;
|
|
140
48
|
}
|
|
@@ -143,7 +51,7 @@ export default class MetadataSheetSupport {
|
|
|
143
51
|
const next = cookie || '';
|
|
144
52
|
if (this.cookie !== next) {
|
|
145
53
|
this.cookie = next;
|
|
146
|
-
this.
|
|
54
|
+
this._rows = null;
|
|
147
55
|
this._loadPromise = null;
|
|
148
56
|
}
|
|
149
57
|
}
|
|
@@ -152,18 +60,18 @@ export default class MetadataSheetSupport {
|
|
|
152
60
|
const next = siteToken || '';
|
|
153
61
|
if (this.siteToken !== next) {
|
|
154
62
|
this.siteToken = next;
|
|
155
|
-
this.
|
|
63
|
+
this._rows = null;
|
|
156
64
|
this._loadPromise = null;
|
|
157
65
|
}
|
|
158
66
|
}
|
|
159
67
|
|
|
160
68
|
invalidate() {
|
|
161
|
-
this.
|
|
69
|
+
this._rows = null;
|
|
162
70
|
this._loadPromise = null;
|
|
163
71
|
}
|
|
164
72
|
|
|
165
73
|
async ensureLoaded() {
|
|
166
|
-
if (this.
|
|
74
|
+
if (this._rows !== null) {
|
|
167
75
|
return;
|
|
168
76
|
}
|
|
169
77
|
if (this._loadPromise) {
|
|
@@ -193,29 +101,23 @@ export default class MetadataSheetSupport {
|
|
|
193
101
|
});
|
|
194
102
|
if (!resp.ok) {
|
|
195
103
|
this.log.debug(`metadata.json not loaded (${resp.status}) from ${this.url}`);
|
|
196
|
-
this.
|
|
104
|
+
this._rows = [];
|
|
197
105
|
return;
|
|
198
106
|
}
|
|
199
107
|
const text = await resp.text();
|
|
200
108
|
const body = JSON.parse(text);
|
|
201
|
-
|
|
202
|
-
this.
|
|
203
|
-
this.log.debug(`loaded metadata.json (${this._compiled.length} URL pattern(s)) from ${this.url}`);
|
|
109
|
+
this._rows = extractDataRows(body);
|
|
110
|
+
this.log.debug(`loaded metadata.json (${this._rows.length} row(s)) from ${this.url}`);
|
|
204
111
|
} catch (e) {
|
|
205
112
|
this.log.debug(`metadata.json fetch/parse failed: ${e.message || e}`);
|
|
206
|
-
this.
|
|
113
|
+
this._rows = [];
|
|
207
114
|
}
|
|
208
115
|
}
|
|
209
116
|
|
|
210
117
|
/**
|
|
211
|
-
* @
|
|
212
|
-
* @returns {object | null}
|
|
118
|
+
* @returns {object[]} raw metadata.json sheet rows, or an empty array if none were loaded
|
|
213
119
|
*/
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
return null;
|
|
217
|
-
}
|
|
218
|
-
const normalized = normalizePathForMetadataMatch(pathname);
|
|
219
|
-
return mergeMetadataSheetRows(normalized, this._compiled);
|
|
120
|
+
getRows() {
|
|
121
|
+
return this._rows ?? [];
|
|
220
122
|
}
|
|
221
123
|
}
|
|
@@ -1,321 +0,0 @@
|
|
|
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
|
-
/**
|
|
14
|
-
* Transforms da.live-style `<div class="metadata">` blocks in content HTML into
|
|
15
|
-
* `<meta>` tags for local dev (`aem up`), strips the block from the body, and
|
|
16
|
-
* adds description / Open Graph / Twitter tags where data is available.
|
|
17
|
-
*/
|
|
18
|
-
|
|
19
|
-
import { unified } from 'unified';
|
|
20
|
-
import rehypeParse from 'rehype-parse';
|
|
21
|
-
import { select } from 'hast-util-select';
|
|
22
|
-
import { toHtml } from 'hast-util-to-html';
|
|
23
|
-
|
|
24
|
-
/** @typedef {import('hast').Root} HastRoot */
|
|
25
|
-
/** @typedef {import('hast').Element} HastElement */
|
|
26
|
-
|
|
27
|
-
const REHYPE_PARSE = { fragment: true };
|
|
28
|
-
|
|
29
|
-
/**
|
|
30
|
-
* @param {string} s
|
|
31
|
-
* @returns {string}
|
|
32
|
-
*/
|
|
33
|
-
export function escapeHtmlAttr(s) {
|
|
34
|
-
return String(s)
|
|
35
|
-
.replace(/&/g, '&')
|
|
36
|
-
.replace(/"/g, '"')
|
|
37
|
-
.replace(/</g, '<');
|
|
38
|
-
}
|
|
39
|
-
|
|
40
|
-
/**
|
|
41
|
-
* @param {string} label
|
|
42
|
-
* @returns {string}
|
|
43
|
-
*/
|
|
44
|
-
export function slugifyMetadataLabel(label) {
|
|
45
|
-
return label
|
|
46
|
-
.normalize('NFKD')
|
|
47
|
-
.replace(/[\u0300-\u036f]/g, '')
|
|
48
|
-
.trim()
|
|
49
|
-
.toLowerCase()
|
|
50
|
-
.replace(/[^a-z0-9]+/g, '-')
|
|
51
|
-
.replace(/^-|-$/g, '');
|
|
52
|
-
}
|
|
53
|
-
|
|
54
|
-
/**
|
|
55
|
-
* @param {import('hast').Node | null | undefined} node
|
|
56
|
-
* @returns {string}
|
|
57
|
-
*/
|
|
58
|
-
function textContent(node) {
|
|
59
|
-
if (!node) {
|
|
60
|
-
return '';
|
|
61
|
-
}
|
|
62
|
-
if (node.type === 'text') {
|
|
63
|
-
return node.value;
|
|
64
|
-
}
|
|
65
|
-
if (Array.isArray(node.children)) {
|
|
66
|
-
return node.children.map((c) => textContent(c)).join('');
|
|
67
|
-
}
|
|
68
|
-
return '';
|
|
69
|
-
}
|
|
70
|
-
|
|
71
|
-
/**
|
|
72
|
-
* @param {HastElement} metadataRoot
|
|
73
|
-
* @returns {Array<[string, string]>}
|
|
74
|
-
*/
|
|
75
|
-
export function extractMetadataPairs(metadataRoot) {
|
|
76
|
-
/** @type {Array<[string, string]>} */
|
|
77
|
-
const pairs = [];
|
|
78
|
-
if (!metadataRoot.children) {
|
|
79
|
-
return pairs;
|
|
80
|
-
}
|
|
81
|
-
for (const row of metadataRoot.children) {
|
|
82
|
-
if (row.type !== 'element' || row.tagName !== 'div') {
|
|
83
|
-
// eslint-disable-next-line no-continue
|
|
84
|
-
continue;
|
|
85
|
-
}
|
|
86
|
-
const cells = (row.children || []).filter(
|
|
87
|
-
(c) => c.type === 'element' && c.tagName === 'div',
|
|
88
|
-
);
|
|
89
|
-
if (cells.length < 2) {
|
|
90
|
-
// eslint-disable-next-line no-continue
|
|
91
|
-
continue;
|
|
92
|
-
}
|
|
93
|
-
const label = textContent(cells[0]).trim();
|
|
94
|
-
const value = textContent(cells[1]).trim();
|
|
95
|
-
if (label) {
|
|
96
|
-
pairs.push([label, value]);
|
|
97
|
-
}
|
|
98
|
-
}
|
|
99
|
-
return pairs;
|
|
100
|
-
}
|
|
101
|
-
|
|
102
|
-
/**
|
|
103
|
-
* @param {HastElement} node
|
|
104
|
-
* @param {string} className
|
|
105
|
-
* @returns {boolean}
|
|
106
|
-
*/
|
|
107
|
-
function hasClass(node, className) {
|
|
108
|
-
const cn = node.properties?.className;
|
|
109
|
-
if (Array.isArray(cn)) {
|
|
110
|
-
return cn.includes(className);
|
|
111
|
-
}
|
|
112
|
-
if (typeof cn === 'string') {
|
|
113
|
-
return cn.split(/\s+/).includes(className);
|
|
114
|
-
}
|
|
115
|
-
return false;
|
|
116
|
-
}
|
|
117
|
-
|
|
118
|
-
/**
|
|
119
|
-
* @param {import('hast').Node} tree
|
|
120
|
-
* @param {import('hast').Element} target
|
|
121
|
-
* @returns {boolean}
|
|
122
|
-
*/
|
|
123
|
-
function removeNode(tree, target) {
|
|
124
|
-
if (tree === target) {
|
|
125
|
-
return true;
|
|
126
|
-
}
|
|
127
|
-
if ('children' in tree && Array.isArray(tree.children)) {
|
|
128
|
-
const { children } = tree;
|
|
129
|
-
for (let i = 0; i < children.length; i += 1) {
|
|
130
|
-
const c = children[i];
|
|
131
|
-
if (c === target) {
|
|
132
|
-
children.splice(i, 1);
|
|
133
|
-
return true;
|
|
134
|
-
}
|
|
135
|
-
if (removeNode(c, target)) {
|
|
136
|
-
return true;
|
|
137
|
-
}
|
|
138
|
-
}
|
|
139
|
-
}
|
|
140
|
-
return false;
|
|
141
|
-
}
|
|
142
|
-
|
|
143
|
-
/**
|
|
144
|
-
* @param {HastRoot} tree
|
|
145
|
-
* @returns {string}
|
|
146
|
-
*/
|
|
147
|
-
function firstImgSrc(tree) {
|
|
148
|
-
const img = select('img[src]', tree);
|
|
149
|
-
if (!img || img.type !== 'element') {
|
|
150
|
-
return '';
|
|
151
|
-
}
|
|
152
|
-
const s = img.properties?.src;
|
|
153
|
-
return typeof s === 'string' ? s : '';
|
|
154
|
-
}
|
|
155
|
-
|
|
156
|
-
/**
|
|
157
|
-
* @param {HastRoot} tree
|
|
158
|
-
* @returns {string}
|
|
159
|
-
*/
|
|
160
|
-
function firstParagraphText(tree) {
|
|
161
|
-
const p = select('p', tree);
|
|
162
|
-
return p && p.type === 'element' ? textContent(p).trim() : '';
|
|
163
|
-
}
|
|
164
|
-
|
|
165
|
-
/**
|
|
166
|
-
* Truncates a string to `max` characters, trimming and appending an ellipsis when needed.
|
|
167
|
-
* @param {string} s
|
|
168
|
-
* @param {number} max
|
|
169
|
-
* @returns {string}
|
|
170
|
-
*/
|
|
171
|
-
function truncateWithEllipsis(s, max) {
|
|
172
|
-
if (s.length <= max) {
|
|
173
|
-
return s;
|
|
174
|
-
}
|
|
175
|
-
return `${s.slice(0, max - 1).trim()}…`;
|
|
176
|
-
}
|
|
177
|
-
|
|
178
|
-
const SEO_LABEL_SKIP = new Set(['title', 'description', 'image']);
|
|
179
|
-
|
|
180
|
-
/**
|
|
181
|
-
* @param {object | null | undefined} sheetRow row from /metadata.json matched for this URL
|
|
182
|
-
* @param {Set<string> | null | undefined} excludeMetaNames lowercase meta `name` values to skip
|
|
183
|
-
* (local page wins)
|
|
184
|
-
* @returns {string[]}
|
|
185
|
-
*/
|
|
186
|
-
export function buildSheetMetaLines(sheetRow, excludeMetaNames) {
|
|
187
|
-
if (!sheetRow || typeof sheetRow !== 'object') {
|
|
188
|
-
return [];
|
|
189
|
-
}
|
|
190
|
-
/** @type {string[]} */
|
|
191
|
-
const lines = [];
|
|
192
|
-
for (const [k, v] of Object.entries(sheetRow)) {
|
|
193
|
-
if (k === 'URL' || k.startsWith(':')) {
|
|
194
|
-
// eslint-disable-next-line no-continue
|
|
195
|
-
continue;
|
|
196
|
-
}
|
|
197
|
-
if (excludeMetaNames && excludeMetaNames.has(k.toLowerCase())) {
|
|
198
|
-
// eslint-disable-next-line no-continue
|
|
199
|
-
continue;
|
|
200
|
-
}
|
|
201
|
-
if (v === undefined || v === null) {
|
|
202
|
-
// eslint-disable-next-line no-continue
|
|
203
|
-
continue;
|
|
204
|
-
}
|
|
205
|
-
const s = String(v).trim();
|
|
206
|
-
if (!s) {
|
|
207
|
-
// eslint-disable-next-line no-continue
|
|
208
|
-
continue;
|
|
209
|
-
}
|
|
210
|
-
lines.push(`<meta name="${escapeHtmlAttr(k)}" content="${escapeHtmlAttr(s)}">`);
|
|
211
|
-
}
|
|
212
|
-
return lines;
|
|
213
|
-
}
|
|
214
|
-
|
|
215
|
-
/**
|
|
216
|
-
* @param {string[]} lines
|
|
217
|
-
* @returns {string}
|
|
218
|
-
*/
|
|
219
|
-
function joinLines(lines) {
|
|
220
|
-
return lines.length > 0 ? `\n${lines.join('\n')}\n` : '';
|
|
221
|
-
}
|
|
222
|
-
|
|
223
|
-
/**
|
|
224
|
-
* @param {string} htmlFragment body-only or partial HTML from content/
|
|
225
|
-
* @param {{ absolutePageUrl?: string, sheetRow?: object | null }} [options]
|
|
226
|
-
* @returns {{ htmlFragment: string, metaTagsHtml: string }}
|
|
227
|
-
*/
|
|
228
|
-
export function transformContentMetadataHtml(htmlFragment, options = {}) {
|
|
229
|
-
const { absolutePageUrl = '', sheetRow = null } = options;
|
|
230
|
-
|
|
231
|
-
let tree;
|
|
232
|
-
try {
|
|
233
|
-
tree = unified().use(rehypeParse, REHYPE_PARSE).parse(htmlFragment);
|
|
234
|
-
} catch {
|
|
235
|
-
return { htmlFragment, metaTagsHtml: joinLines(buildSheetMetaLines(sheetRow)) };
|
|
236
|
-
}
|
|
237
|
-
|
|
238
|
-
const metadataEl = select('div.metadata', tree);
|
|
239
|
-
if (!metadataEl || metadataEl.type !== 'element' || !hasClass(metadataEl, 'metadata')) {
|
|
240
|
-
return { htmlFragment, metaTagsHtml: joinLines(buildSheetMetaLines(sheetRow)) };
|
|
241
|
-
}
|
|
242
|
-
|
|
243
|
-
const pairs = extractMetadataPairs(metadataEl);
|
|
244
|
-
|
|
245
|
-
/** Names from page metadata; sheet entries with the same meta name are skipped. */
|
|
246
|
-
const localPairMetaNames = new Set();
|
|
247
|
-
for (const [label, value] of pairs) {
|
|
248
|
-
const slug = slugifyMetadataLabel(label);
|
|
249
|
-
if (!slug || value === undefined) {
|
|
250
|
-
// eslint-disable-next-line no-continue
|
|
251
|
-
continue;
|
|
252
|
-
}
|
|
253
|
-
localPairMetaNames.add(slug);
|
|
254
|
-
}
|
|
255
|
-
|
|
256
|
-
const sheetLines = buildSheetMetaLines(sheetRow, localPairMetaNames);
|
|
257
|
-
|
|
258
|
-
const lowerMap = new Map(pairs.map(([k, v]) => [k.toLowerCase().trim(), v]));
|
|
259
|
-
|
|
260
|
-
removeNode(tree, metadataEl);
|
|
261
|
-
|
|
262
|
-
const title = (lowerMap.get('title') || textContent(select('h1', tree)).trim() || '').trim();
|
|
263
|
-
let description = (lowerMap.get('description') || firstParagraphText(tree) || '').trim();
|
|
264
|
-
description = truncateWithEllipsis(description, 200);
|
|
265
|
-
const image = (lowerMap.get('image') || lowerMap.get('og image') || firstImgSrc(tree) || '').trim();
|
|
266
|
-
|
|
267
|
-
/** @type {string[]} */
|
|
268
|
-
const seoLines = [];
|
|
269
|
-
|
|
270
|
-
if (description) {
|
|
271
|
-
const e = escapeHtmlAttr(description);
|
|
272
|
-
seoLines.push(`<meta name="description" content="${e}">`);
|
|
273
|
-
seoLines.push(`<meta property="og:description" content="${e}">`);
|
|
274
|
-
seoLines.push(`<meta name="twitter:description" content="${e}">`);
|
|
275
|
-
}
|
|
276
|
-
|
|
277
|
-
if (title) {
|
|
278
|
-
const e = escapeHtmlAttr(title);
|
|
279
|
-
seoLines.push(`<meta property="og:title" content="${e}">`);
|
|
280
|
-
seoLines.push(`<meta name="twitter:title" content="${e}">`);
|
|
281
|
-
}
|
|
282
|
-
|
|
283
|
-
if (absolutePageUrl) {
|
|
284
|
-
seoLines.push(`<meta property="og:url" content="${escapeHtmlAttr(absolutePageUrl)}">`);
|
|
285
|
-
}
|
|
286
|
-
|
|
287
|
-
if (image) {
|
|
288
|
-
const e = escapeHtmlAttr(image);
|
|
289
|
-
const alt = escapeHtmlAttr(title || 'image');
|
|
290
|
-
seoLines.push(`<meta property="og:image" content="${e}">`);
|
|
291
|
-
seoLines.push(`<meta property="og:image:secure_url" content="${e}">`);
|
|
292
|
-
seoLines.push(`<meta property="og:image:alt" content="${alt}">`);
|
|
293
|
-
seoLines.push('<meta name="twitter:card" content="summary_large_image">');
|
|
294
|
-
seoLines.push(`<meta name="twitter:image" content="${e}">`);
|
|
295
|
-
} else {
|
|
296
|
-
seoLines.push('<meta name="twitter:card" content="summary">');
|
|
297
|
-
}
|
|
298
|
-
|
|
299
|
-
/** @type {string[]} */
|
|
300
|
-
const pairLines = [];
|
|
301
|
-
for (const [label, value] of pairs) {
|
|
302
|
-
const key = label.toLowerCase().trim();
|
|
303
|
-
if (SEO_LABEL_SKIP.has(key)) {
|
|
304
|
-
// eslint-disable-next-line no-continue
|
|
305
|
-
continue;
|
|
306
|
-
}
|
|
307
|
-
const slug = slugifyMetadataLabel(label);
|
|
308
|
-
if (!slug || value === undefined) {
|
|
309
|
-
// eslint-disable-next-line no-continue
|
|
310
|
-
continue;
|
|
311
|
-
}
|
|
312
|
-
pairLines.push(
|
|
313
|
-
`<meta name="${escapeHtmlAttr(slug)}" content="${escapeHtmlAttr(value)}">`,
|
|
314
|
-
);
|
|
315
|
-
}
|
|
316
|
-
|
|
317
|
-
// SEO first, then sheet (fields not overridden by local pairs), then local page metadata
|
|
318
|
-
const allLines = [...seoLines, ...sheetLines, ...pairLines];
|
|
319
|
-
const htmlOut = toHtml(tree);
|
|
320
|
-
return { htmlFragment: htmlOut, metaTagsHtml: joinLines(allLines) };
|
|
321
|
-
}
|