@redocly/realm-plugin-asciidoc 0.1.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.
@@ -0,0 +1,193 @@
1
+ import type { SearchFacet } from '@redocly/theme';
2
+ import type { PageRouteDetails } from '@redocly/realm/dist/server/types';
3
+ import type { AsciidocSection, AsciidocFrontmatter } from './types.js';
4
+
5
+ import { extractDocumentSearchFacets } from './search-facets.js';
6
+
7
+ type AiDocumentsStore = {
8
+ getLLMsTxts: () => Promise<
9
+ {
10
+ content: string;
11
+ title: string;
12
+ description?: string;
13
+ slug: string;
14
+ fsPath: string;
15
+ includeInLLMsTxt: boolean;
16
+ }[]
17
+ >;
18
+ getSearchDocuments: () => Promise<
19
+ {
20
+ url: string;
21
+ fsPath: string;
22
+ content: string;
23
+ title: string;
24
+ locale: string;
25
+ product?: string;
26
+ facets?: Record<string, string>;
27
+ }[]
28
+ >;
29
+ };
30
+
31
+ const AI_SEARCH_CHUNK_SIZE = 50;
32
+ const AI_SEARCH_DOCUMENT_CHUNK_SIZE = 250;
33
+
34
+ export function getAiDocumentsStore(
35
+ frontmatter: AsciidocFrontmatter,
36
+ sections: AsciidocSection[],
37
+ getSearchFacets: () => Map<string, SearchFacet>,
38
+ ): (
39
+ route: PageRouteDetails,
40
+ staticData: unknown,
41
+ context: unknown,
42
+ actions: unknown,
43
+ ) => Promise<AiDocumentsStore | undefined> {
44
+ return async (
45
+ route: PageRouteDetails,
46
+ _staticData: unknown,
47
+ _context: unknown,
48
+ _actions: unknown,
49
+ ): Promise<AiDocumentsStore | undefined> => {
50
+ if (frontmatter.excludeFromSearch) return;
51
+
52
+ const pageTitle = frontmatter.seo?.title || frontmatter.title || route.slug;
53
+ const description = frontmatter.seo?.description || frontmatter.description;
54
+
55
+ return {
56
+ async getLLMsTxts() {
57
+ const body = sectionsToMarkdown(sections);
58
+ const content = `# ${pageTitle}\n\n${body}`;
59
+
60
+ return [
61
+ {
62
+ title: pageTitle,
63
+ description,
64
+ slug: route.slug,
65
+ fsPath: route.fsPath,
66
+ content,
67
+ includeInLLMsTxt: true,
68
+ },
69
+ ];
70
+ },
71
+ async getSearchDocuments() {
72
+ const metadata = route.metadata || {};
73
+ const facets = extractDocumentSearchFacets(metadata, getSearchFacets);
74
+ const chunks = createContentChunks(sections, pageTitle, route.slug);
75
+ const mergedChunks = mergeChunks(chunks);
76
+
77
+ return mergedChunks
78
+ .filter((chunk) => chunk.content)
79
+ .map((chunk) => ({
80
+ title: chunk.title,
81
+ url: chunk.url,
82
+ content: chunk.toc
83
+ ? `${chunk.toc}\n${chunk.content}`
84
+ : `# ${pageTitle}\n${chunk.content}`,
85
+ fsPath: route.fsPath,
86
+ locale: detectLocaleFromFsPath(route.fsPath),
87
+ product: route.product?.name,
88
+ facets,
89
+ }));
90
+ },
91
+ };
92
+ };
93
+ }
94
+
95
+ type ContentChunk = {
96
+ title: string;
97
+ content: string;
98
+ url: string;
99
+ level: number;
100
+ toc: string;
101
+ };
102
+
103
+ function sectionsToMarkdown(sections: AsciidocSection[], headingLevel = 1): string {
104
+ const parts: string[] = [];
105
+
106
+ for (const section of sections) {
107
+ const prefix = '#'.repeat(headingLevel + 1);
108
+ parts.push(`${prefix} ${section.title}`);
109
+
110
+ if (section.textContent) {
111
+ parts.push(section.textContent);
112
+ }
113
+
114
+ if (section.children.length > 0) {
115
+ parts.push(sectionsToMarkdown(section.children, headingLevel + 1));
116
+ }
117
+ }
118
+
119
+ return parts.join('\n\n');
120
+ }
121
+
122
+ function createContentChunks(
123
+ sections: AsciidocSection[],
124
+ pageTitle: string,
125
+ pageUrl: string,
126
+ ): ContentChunk[] {
127
+ const chunks: ContentChunk[] = [];
128
+ const headingStack: { title: string; level: number }[] = [];
129
+
130
+ function processSection(section: AsciidocSection) {
131
+ // Update heading stack
132
+ headingStack.splice(section.level - 1);
133
+ headingStack.push({ title: section.title, level: section.level });
134
+
135
+ const sectionUrl = `${pageUrl}#${section.id}`;
136
+ const toc = headingStack.map((h) => `${'#'.repeat(h.level + 1)} ${h.title}`).join('\n');
137
+ const chunkTitle =
138
+ section.level > 1 ? `${pageTitle} → ${section.title}` : section.title || pageTitle;
139
+
140
+ chunks.push({
141
+ title: chunkTitle,
142
+ content: section.textContent,
143
+ url: sectionUrl,
144
+ level: section.level,
145
+ toc,
146
+ });
147
+
148
+ for (const child of section.children) {
149
+ processSection(child);
150
+ }
151
+ }
152
+
153
+ for (const section of sections) {
154
+ processSection(section);
155
+ }
156
+
157
+ return chunks;
158
+ }
159
+
160
+ function estimateChunkSize(content: string): number {
161
+ return content.length / 4;
162
+ }
163
+
164
+ function mergeChunks(chunks: ContentChunk[]): ContentChunk[] {
165
+ if (chunks.length === 0) return [];
166
+ const mergedChunks: ContentChunk[] = [];
167
+ let currentChunk = chunks[0];
168
+
169
+ for (const chunk of chunks.slice(1)) {
170
+ const currentChunkSize = estimateChunkSize(currentChunk.content);
171
+ const chunkSize = estimateChunkSize(chunk.content);
172
+ const isLastChunk = chunks.indexOf(chunk) === chunks.length - 1;
173
+
174
+ if (
175
+ chunkSize < AI_SEARCH_CHUNK_SIZE &&
176
+ (currentChunkSize < AI_SEARCH_DOCUMENT_CHUNK_SIZE || isLastChunk) &&
177
+ chunk.level > currentChunk.level
178
+ ) {
179
+ currentChunk.content += '\n' + chunk.content;
180
+ } else {
181
+ mergedChunks.push(currentChunk);
182
+ currentChunk = chunk;
183
+ }
184
+ }
185
+
186
+ mergedChunks.push(currentChunk);
187
+ return mergedChunks;
188
+ }
189
+
190
+ function detectLocaleFromFsPath(relativePath: string): string {
191
+ const localeCandidate = relativePath.split('/')[0];
192
+ return /^[a-z]{2}-[A-Z]{2}$/.test(localeCandidate) ? localeCandidate : '';
193
+ }
package/src/plugin.ts ADDED
@@ -0,0 +1,483 @@
1
+ import path from 'node:path';
2
+ import { createHash } from 'node:crypto';
3
+ import { existsSync, mkdirSync, copyFileSync, readFileSync } from 'node:fs';
4
+ import { fileURLToPath } from 'url';
5
+
6
+ import type { ExternalPlugin, GetStaticDataContext } from '@redocly/realm/dist/server/types';
7
+ import type { ParsedAsciidoc } from './asciidoc-parser.js';
8
+ import type { ContentNode } from './types.js';
9
+
10
+ import { getPathPrefix, withPathPrefix } from '@redocly/theme/core/utils';
11
+
12
+ import { searchResolver } from './search-resolver.js';
13
+ import { getAiDocumentsStore } from './get-ai-search-documents.js';
14
+ import { parseAsciidoc } from './asciidoc-parser.js';
15
+
16
+ const ASCIIDOC_TEMPLATE_ID = 'asciidoc-docs';
17
+
18
+ export default function asciidocPlugin(): ExternalPlugin {
19
+ return {
20
+ id: 'asciidoc',
21
+ loaders: {
22
+ asciidoc: async (relativePath, context) => {
23
+ const content = await context.fs.read(relativePath);
24
+ return parseAsciidoc(content, relativePath, context.fs.cwd);
25
+ },
26
+ },
27
+ processContent: async (actions, context) => {
28
+ const asciidocTemplateId = actions.createTemplate(
29
+ ASCIIDOC_TEMPLATE_ID,
30
+ fromCurrentDir(import.meta.url, './template.js'),
31
+ );
32
+
33
+ for (const record of context.fs.scan(/\.adoc$/)) {
34
+ if (await context.isPathIgnored(record.relativePath)) {
35
+ continue;
36
+ }
37
+
38
+ const { relativePath } = record;
39
+
40
+ try {
41
+ const { data: parsed } = await context.cache.load<ParsedAsciidoc>(
42
+ relativePath,
43
+ 'asciidoc',
44
+ );
45
+
46
+ if (!parsed) continue;
47
+
48
+ const { title, attributes, sections, contentNodes, links } = parsed;
49
+
50
+ // Resolve image src attributes in content nodes to hashed asset URLs
51
+ const resolvedContentNodes = resolveContentNodeImages(
52
+ contentNodes,
53
+ actions.contentDir,
54
+ actions.outdir,
55
+ );
56
+
57
+ const normalizedAttributes = normalizeAsciidocAttributes(attributes);
58
+ const fallbackTitle = path.basename(relativePath, '.adoc');
59
+ const pageTitle = title || fallbackTitle;
60
+ const metadata = {
61
+ type: 'asciidoc',
62
+ title: pageTitle,
63
+ ...normalizedAttributes.metadata,
64
+ };
65
+
66
+ const frontmatter = {
67
+ title: pageTitle,
68
+ description: normalizedAttributes.description,
69
+ keywords: normalizedAttributes.keywords,
70
+ excludeFromSearch: normalizedAttributes.excludeFromSearch,
71
+ seo: {
72
+ title: normalizedAttributes.doctitle || pageTitle,
73
+ description: normalizedAttributes.description || '',
74
+ },
75
+ };
76
+
77
+ actions.addRoute({
78
+ excludeFromSearch: normalizedAttributes.excludeFromSearch,
79
+ fsPath: relativePath,
80
+ templateId: asciidocTemplateId,
81
+ metadata,
82
+ getNavText: async () => pageTitle,
83
+ getStaticData: async (_route, dataContext) => {
84
+ // Resolve cross-page links using Realm's route registry
85
+ const linkedContentNodes = resolveContentNodeLinks(
86
+ resolvedContentNodes,
87
+ relativePath,
88
+ dataContext,
89
+ );
90
+
91
+ return {
92
+ props: {
93
+ title: pageTitle,
94
+ contentNodes: linkedContentNodes,
95
+ sections,
96
+ links,
97
+ frontmatter,
98
+ },
99
+ };
100
+ },
101
+ getSearchDocuments: searchResolver(
102
+ frontmatter,
103
+ relativePath,
104
+ sections,
105
+ actions.getSearchFacets,
106
+ actions.setSearchFacets,
107
+ ),
108
+ getAiDocumentsStore: getAiDocumentsStore(
109
+ frontmatter,
110
+ sections,
111
+ actions.getSearchFacets,
112
+ ),
113
+ });
114
+ } catch (e) {
115
+ console.error(`Failed to process AsciiDoc file: ${relativePath}`, e);
116
+ }
117
+ }
118
+ },
119
+ };
120
+ }
121
+
122
+ type NormalizedAsciidocAttributes = {
123
+ doctitle?: string;
124
+ description?: string;
125
+ keywords?: string[];
126
+ excludeFromSearch: boolean;
127
+ metadata: Record<string, string>;
128
+ };
129
+
130
+ function normalizeAsciidocAttributes(
131
+ attributes: ParsedAsciidoc['attributes'],
132
+ ): NormalizedAsciidocAttributes {
133
+ const doctitle = toStringValue(attributes.doctitle);
134
+ const description = toStringValue(attributes.description);
135
+ const rawKeywords = toStringValue(attributes.keywords);
136
+ const keywords = rawKeywords
137
+ ? rawKeywords
138
+ .split(',')
139
+ .map((keyword) => keyword.trim())
140
+ .filter(Boolean)
141
+ : undefined;
142
+
143
+ const metadata = Object.entries(attributes).reduce<Record<string, string>>(
144
+ (acc, [key, value]) => {
145
+ if (!key.startsWith('metadata-')) return acc;
146
+ const metadataKey = key.slice('metadata-'.length);
147
+ if (!metadataKey) return acc;
148
+
149
+ acc[metadataKey] = String(value);
150
+ return acc;
151
+ },
152
+ {},
153
+ );
154
+
155
+ const excludeFromSearch = toBooleanValue(attributes['exclude-from-search'], false);
156
+
157
+ return {
158
+ doctitle,
159
+ description,
160
+ keywords,
161
+ excludeFromSearch,
162
+ metadata,
163
+ };
164
+ }
165
+
166
+ function toStringValue(value: string | number | boolean | undefined): string | undefined {
167
+ if (typeof value === 'string') return value;
168
+ if (typeof value === 'number' || typeof value === 'boolean') return String(value);
169
+ return undefined;
170
+ }
171
+
172
+ function toBooleanValue(
173
+ value: string | number | boolean | undefined,
174
+ defaultValue: boolean,
175
+ ): boolean {
176
+ if (typeof value === 'boolean') return value;
177
+ if (typeof value === 'number') return value !== 0;
178
+ if (typeof value === 'string') return ['1', 'true', 'yes', 'on'].includes(value.toLowerCase());
179
+ return defaultValue;
180
+ }
181
+
182
+ function __dirname(url: string): string {
183
+ const __filename = fileURLToPath(url);
184
+ return path.dirname(__filename);
185
+ }
186
+
187
+ function fromCurrentDir(moduleUrl: string, filePath: string): string {
188
+ return path.resolve(__dirname(moduleUrl), filePath);
189
+ }
190
+
191
+ // ─── Image asset resolution ─────────────────────────────────────────────────
192
+
193
+ const ASSETS_URL_PREFIX = '/assets';
194
+ const ASSETS_DIRNAME = 'assets';
195
+
196
+ /**
197
+ * Recursively walks the content node tree and resolves image `src` values
198
+ * to hashed asset URLs, copying each file to the output assets directory.
199
+ */
200
+ function resolveContentNodeImages(
201
+ nodes: ContentNode[],
202
+ contentDir: string,
203
+ outdir: string,
204
+ ): ContentNode[] {
205
+ return nodes.map((node) => resolveNodeImages(node, contentDir, outdir));
206
+ }
207
+
208
+ function resolveNodeImages(node: ContentNode, contentDir: string, outdir: string): ContentNode {
209
+ switch (node.type) {
210
+ case 'image': {
211
+ const src = node.src;
212
+ if (src.startsWith('/') && !src.startsWith('//') && !src.startsWith('/assets/')) {
213
+ try {
214
+ const assetUrl = copyImageToAssets(src.slice(1), contentDir, outdir);
215
+ return { ...node, src: assetUrl };
216
+ } catch {
217
+ return node;
218
+ }
219
+ }
220
+ return node;
221
+ }
222
+ case 'section':
223
+ case 'admonition':
224
+ case 'quote':
225
+ case 'sidebar':
226
+ case 'example':
227
+ case 'collapsible':
228
+ return { ...node, children: resolveContentNodeImages(node.children, contentDir, outdir) };
229
+ case 'list':
230
+ return {
231
+ ...node,
232
+ items: node.items.map((item) => ({
233
+ ...item,
234
+ children: resolveContentNodeImages(item.children, contentDir, outdir),
235
+ })),
236
+ };
237
+ case 'descriptionList':
238
+ return {
239
+ ...node,
240
+ items: node.items.map((item) => ({
241
+ ...item,
242
+ children: resolveContentNodeImages(item.children, contentDir, outdir),
243
+ })),
244
+ };
245
+ default:
246
+ return node;
247
+ }
248
+ }
249
+
250
+ function copyImageToAssets(fileRelativePath: string, contentDir: string, outdir: string): string {
251
+ const absolutePath = path.resolve(contentDir, fileRelativePath);
252
+ if (!existsSync(absolutePath)) {
253
+ throw new Error(`Image not found: ${absolutePath}`);
254
+ }
255
+
256
+ const assetsDir = path.join(outdir, ASSETS_DIRNAME);
257
+ if (!existsSync(assetsDir)) {
258
+ mkdirSync(assetsDir, { recursive: true });
259
+ }
260
+
261
+ const ext = path.extname(absolutePath);
262
+ const baseName = path.basename(absolutePath, ext);
263
+ const fileContent = readFileSync(absolutePath);
264
+ const hash = createHash('sha256').update(fileContent).digest('hex').slice(0, 16);
265
+ const hashedName = `${slugifyFileName(baseName)}.${hash}${ext}`;
266
+
267
+ const destPath = path.join(assetsDir, hashedName);
268
+ copyFileSync(absolutePath, destPath);
269
+
270
+ return `${ASSETS_URL_PREFIX}/${hashedName}`;
271
+ }
272
+
273
+ function slugifyFileName(name: string): string {
274
+ return name
275
+ .toLowerCase()
276
+ .replace(/[^a-z0-9.-]/g, '-')
277
+ .replace(/-+/g, '-')
278
+ .replace(/^-|-$/g, '');
279
+ }
280
+
281
+ // Link resolution logic adapted from packages/portal/src/server/plugins/markdown/attribute-resolvers/resolve-link.ts
282
+
283
+ const DOC_EXTENSIONS = new Set(['.adoc', '.asciidoc', '.html', '.md']);
284
+
285
+ function isLocalLink(href: string): boolean {
286
+ return (
287
+ href != null &&
288
+ !href.match(/^[a-z]+:\/\//) &&
289
+ !href.startsWith('//') &&
290
+ !href.startsWith('mailto:')
291
+ );
292
+ }
293
+
294
+ function stripKnownExtensions(filePath: string): string {
295
+ for (const ext of DOC_EXTENSIONS) {
296
+ if (filePath.endsWith(ext)) return filePath.slice(0, -ext.length);
297
+ }
298
+ return filePath;
299
+ }
300
+
301
+ function resolveAsciidocLink(
302
+ href: string,
303
+ fromPage: string,
304
+ dataContext: GetStaticDataContext,
305
+ ): string {
306
+ if (!href || href.startsWith('#') || !isLocalLink(href)) {
307
+ return href;
308
+ }
309
+
310
+ const hashIdx = href.indexOf('#');
311
+ const pathAndQuery = hashIdx >= 0 ? href.slice(0, hashIdx) : href;
312
+ const hashPart = hashIdx >= 0 ? href.slice(hashIdx) : '';
313
+
314
+ const qIdx = pathAndQuery.indexOf('?');
315
+ const rawPath = qIdx >= 0 ? pathAndQuery.slice(0, qIdx) : pathAndQuery;
316
+ const queryPart = qIdx >= 0 ? pathAndQuery.slice(qIdx) : '';
317
+
318
+ if (!rawPath) return href;
319
+
320
+ const ext = path.posix.extname(rawPath);
321
+
322
+ if (DOC_EXTENSIONS.has(ext)) {
323
+ const fsRelativePath = rawPath.startsWith('/')
324
+ ? rawPath.slice(1)
325
+ : path.posix.normalize(path.posix.join(path.posix.dirname(fromPage), rawPath));
326
+
327
+ const route = dataContext.getRouteByFsPath(fsRelativePath);
328
+ if (route) {
329
+ return maybeAddPathPrefix(route.slug + queryPart + hashPart);
330
+ }
331
+
332
+ const slug = '/' + stripKnownExtensions(fsRelativePath).replace(/\/index$/, '');
333
+ const routeBySlug = dataContext.getRouteBySlug(slug, { followRedirect: true });
334
+ if (routeBySlug) {
335
+ return maybeAddPathPrefix(routeBySlug.slug + queryPart + hashPart);
336
+ }
337
+
338
+ return maybeAddPathPrefix(slug + queryPart + hashPart);
339
+ }
340
+
341
+ if (ext === '') {
342
+ if (rawPath.startsWith('/')) {
343
+ const route = dataContext.getRouteBySlug(rawPath, { followRedirect: true });
344
+ if (route) {
345
+ return maybeAddPathPrefix(route.slug + queryPart + hashPart);
346
+ }
347
+ return maybeAddPathPrefix(rawPath + queryPart + hashPart);
348
+ }
349
+
350
+ const currentRoute = dataContext.getRouteByFsPath(fromPage);
351
+ if (currentRoute) {
352
+ const isIndexFile =
353
+ fromPage.endsWith('/index.adoc') ||
354
+ fromPage.endsWith('/index.md') ||
355
+ fromPage.endsWith('/index.html');
356
+ const base = isIndexFile ? currentRoute.slug : path.posix.dirname(currentRoute.slug);
357
+ const resolved = path.posix.normalize(path.posix.join(base, rawPath));
358
+ const normalizedSlug = '/' + resolved.replace(/^\/+/, '');
359
+
360
+ const route = dataContext.getRouteBySlug(normalizedSlug, { followRedirect: true });
361
+ if (route) {
362
+ return maybeAddPathPrefix(route.slug + queryPart + hashPart);
363
+ }
364
+ return maybeAddPathPrefix(normalizedSlug + queryPart + hashPart);
365
+ }
366
+
367
+ const baseDir = path.posix.dirname(fromPage);
368
+ const joined = path.posix.normalize(path.posix.join(baseDir, rawPath));
369
+ const slug = '/' + joined.replace(/^\/+/, '');
370
+ return maybeAddPathPrefix(slug + queryPart + hashPart);
371
+ }
372
+
373
+ return href;
374
+ }
375
+
376
+ function maybeAddPathPrefix(url: string): string {
377
+ const prefix = getPathPrefix();
378
+ if (!prefix || url.startsWith(prefix)) return url;
379
+ return withPathPrefix(url);
380
+ }
381
+
382
+ function resolveHtmlLinks(
383
+ html: string,
384
+ fromPage: string,
385
+ dataContext: GetStaticDataContext,
386
+ ): string {
387
+ return html.replace(
388
+ /(<a\s[^>]*?href=")([^"]*?)("[^>]*?>)/gi,
389
+ (_match, before: string, href: string, after: string) => {
390
+ return before + resolveAsciidocLink(href, fromPage, dataContext) + after;
391
+ },
392
+ );
393
+ }
394
+
395
+ function resolveContentNodeLinks(
396
+ nodes: ContentNode[],
397
+ fromPage: string,
398
+ dataContext: GetStaticDataContext,
399
+ ): ContentNode[] {
400
+ return nodes.map((node) => resolveNodeLinks(node, fromPage, dataContext));
401
+ }
402
+
403
+ function resolveNodeLinks(
404
+ node: ContentNode,
405
+ fromPage: string,
406
+ ctx: GetStaticDataContext,
407
+ ): ContentNode {
408
+ switch (node.type) {
409
+ case 'paragraph':
410
+ return {
411
+ ...node,
412
+ contentHtml: resolveHtmlLinks(node.contentHtml, fromPage, ctx),
413
+ };
414
+ case 'heading':
415
+ return {
416
+ ...node,
417
+ contentHtml: resolveHtmlLinks(node.contentHtml, fromPage, ctx),
418
+ };
419
+ case 'section':
420
+ return {
421
+ ...node,
422
+ titleHtml: resolveHtmlLinks(node.titleHtml, fromPage, ctx),
423
+ children: resolveContentNodeLinks(node.children, fromPage, ctx),
424
+ };
425
+ case 'admonition':
426
+ case 'quote':
427
+ case 'sidebar':
428
+ case 'example':
429
+ case 'collapsible':
430
+ return {
431
+ ...node,
432
+ children: resolveContentNodeLinks(node.children, fromPage, ctx),
433
+ };
434
+ case 'list':
435
+ return {
436
+ ...node,
437
+ items: node.items.map((item) => ({
438
+ ...item,
439
+ contentHtml: resolveHtmlLinks(item.contentHtml, fromPage, ctx),
440
+ children: resolveContentNodeLinks(item.children, fromPage, ctx),
441
+ })),
442
+ };
443
+ case 'descriptionList':
444
+ return {
445
+ ...node,
446
+ items: node.items.map((item) => ({
447
+ ...item,
448
+ termsHtml: item.termsHtml.map((h) => resolveHtmlLinks(h, fromPage, ctx)),
449
+ descriptionHtml: resolveHtmlLinks(item.descriptionHtml, fromPage, ctx),
450
+ children: resolveContentNodeLinks(item.children, fromPage, ctx),
451
+ })),
452
+ };
453
+ case 'table':
454
+ return {
455
+ ...node,
456
+ head: node.head.map((row) =>
457
+ row.map((cell) => ({
458
+ ...cell,
459
+ contentHtml: resolveHtmlLinks(cell.contentHtml, fromPage, ctx),
460
+ })),
461
+ ),
462
+ body: node.body.map((row) =>
463
+ row.map((cell) => ({
464
+ ...cell,
465
+ contentHtml: resolveHtmlLinks(cell.contentHtml, fromPage, ctx),
466
+ })),
467
+ ),
468
+ foot: node.foot.map((row) =>
469
+ row.map((cell) => ({
470
+ ...cell,
471
+ contentHtml: resolveHtmlLinks(cell.contentHtml, fromPage, ctx),
472
+ })),
473
+ ),
474
+ };
475
+ case 'htmlPassthrough':
476
+ return {
477
+ ...node,
478
+ html: resolveHtmlLinks(node.html, fromPage, ctx),
479
+ };
480
+ default:
481
+ return node;
482
+ }
483
+ }