@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.20.13",
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 { transformContentMetadataHtml } from '../content/content-metadata-html.js';
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 fragment = utils.extractMainContent(htmlContent);
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
- const absolutePageUrl = absolutePageUrlFromRequest(req, ctx);
534
- if (this._project.metadataSheet) {
535
- this._project.metadataSheet.setCookie(req.headers.cookie || '');
536
- await this._project.metadataSheet.ensureLoaded();
537
- }
538
- const sheetRow = this._project.metadataSheet?.matchPath(ctx.path) ?? null;
539
- const { htmlFragment, metaTagsHtml } = transformContentMetadataHtml(htmlContent, {
540
- absolutePageUrl,
541
- sheetRow,
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
- htmlContent = htmlFragment;
544
- // content/ files are plain HTML (body only, no <head>)
545
- // wrap with a full document and inject local head.html
546
- if (!htmlContent.includes('<head>')) {
547
- await this._project.headHtml.update();
548
- const headHtml = this._project.headHtml.localHtml || '';
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
- await this._project.headHtml.setCookie(req.headers.cookie);
552
- htmlContent = await this._project.headHtml.replace(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 {Array<{ pattern: string, regex: RegExp, row: object }> | null} */
137
- this._compiled = null;
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._compiled = null;
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._compiled = null;
63
+ this._rows = null;
156
64
  this._loadPromise = null;
157
65
  }
158
66
  }
159
67
 
160
68
  invalidate() {
161
- this._compiled = null;
69
+ this._rows = null;
162
70
  this._loadPromise = null;
163
71
  }
164
72
 
165
73
  async ensureLoaded() {
166
- if (this._compiled !== null) {
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._compiled = [];
104
+ this._rows = [];
197
105
  return;
198
106
  }
199
107
  const text = await resp.text();
200
108
  const body = JSON.parse(text);
201
- const rows = extractDataRows(body);
202
- this._compiled = compileMetadataSheetPatterns(rows);
203
- this.log.debug(`loaded metadata.json (${this._compiled.length} URL pattern(s)) from ${this.url}`);
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._compiled = [];
113
+ this._rows = [];
207
114
  }
208
115
  }
209
116
 
210
117
  /**
211
- * @param {string} pathname ctx.path or resource path with .html
212
- * @returns {object | null}
118
+ * @returns {object[]} raw metadata.json sheet rows, or an empty array if none were loaded
213
119
  */
214
- matchPath(pathname) {
215
- if (!this._compiled || this._compiled.length === 0) {
216
- return null;
217
- }
218
- const normalized = normalizePathForMetadataMatch(pathname);
219
- return mergeMetadataSheetRows(normalized, this._compiled);
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, '&amp;')
36
- .replace(/"/g, '&quot;')
37
- .replace(/</g, '&lt;');
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
- }