diff --git a/src/generators/ast/generate.mjs b/src/generators/ast/generate.mjs index e0c5a6c5..eb3869b5 100644 --- a/src/generators/ast/generate.mjs +++ b/src/generators/ast/generate.mjs @@ -41,6 +41,7 @@ export async function processChunk(inputSlice, itemIndices) { tree: remark().parse(value), // The path is the relative path minus the extension path: relativePath, + fullPath: path, }); } diff --git a/src/generators/jsx-ast/utils/__tests__/buildContent.test.mjs b/src/generators/jsx-ast/utils/__tests__/buildContent.test.mjs index afb86405..8a0f5dd2 100644 --- a/src/generators/jsx-ast/utils/__tests__/buildContent.test.mjs +++ b/src/generators/jsx-ast/utils/__tests__/buildContent.test.mjs @@ -1,8 +1,9 @@ import assert from 'node:assert/strict'; +import { resolve } from 'node:path'; import { describe, it } from 'node:test'; import { setConfig } from '../../../../utils/configuration/index.mjs'; -import { transformHeadingNode } from '../buildContent.mjs'; +import buildContent, { transformHeadingNode } from '../buildContent.mjs'; const heading = { type: 'heading', @@ -66,3 +67,22 @@ describe('transformHeadingNode (deprecation Type -> AlertBox level)', () => { assert.equal(levelAttr.value, 'danger'); }); }); + +describe('Asset Extraction and URL Rewriting', () => { + it('should rewrite local image URLs and populate the assetsMap', async () => { + const mockEntries = [ + { + fullPath: '/node/doc/api/fs.md', + content: { + type: 'root', + children: [{ type: 'image', url: './logo.png' }], + }, + }, + ]; + + const result = await buildContent(mockEntries, { api: 'test' }); + + const expectedSource = resolve('/node/doc/api', './logo.png'); + assert.strictEqual(result.assetsMap[expectedSource], 'logo.png'); + }); +}); diff --git a/src/generators/jsx-ast/utils/buildContent.mjs b/src/generators/jsx-ast/utils/buildContent.mjs index 12f54825..22e4a3c5 100644 --- a/src/generators/jsx-ast/utils/buildContent.mjs +++ b/src/generators/jsx-ast/utils/buildContent.mjs @@ -1,5 +1,7 @@ 'use strict'; +import { resolve, dirname, basename } from 'node:path'; + import { h as createElement } from 'hastscript'; import { slice } from 'mdast-util-slice-markdown'; import readingTime from 'reading-time'; @@ -287,6 +289,59 @@ export const createDocumentLayout = (entries, metadata) => }), ]); +/** + * Checks if a given URL is a local asset path. + * + * @param {string} url - The URL or path to check. + * @returns {boolean} True if the asset is local, false otherwise. + */ +function isLocalAsset(url) { + if (!url || url.startsWith('//')) { + return false; + } + + try { + new URL(url); + return false; + } catch { + return true; + } +} + +/** + * Traverses the AST of markdown files to find local image references, + * rewrites their URLs to a relative assets folder, and collects their paths. + * + * @param {Array} metadataEntries - API documentation metadata entries + * @returns {Map} A Map containing source paths as keys and destination paths as values. + */ +function extractAssetsFromAST(metadataEntries) { + const assetsMap = new Map(); + for (const entry of metadataEntries) { + if (!entry.content) { + continue; + } + visit(entry.content, 'image', imageNode => { + const originalUrl = imageNode.url; + + if (isLocalAsset(originalUrl)) { + const sourceDir = entry.fullPath + ? dirname(entry.fullPath) + : process.cwd(); + const sourcePath = resolve(sourceDir, originalUrl); + const fileName = basename(originalUrl); + + assetsMap.set(sourcePath, fileName); + + // Rewrite AST URL + imageNode.url = `/assets/${fileName}`; + } + }); + } + + return assetsMap; +} + /** * @typedef {import('estree').Node & { data: import('../../metadata/types').MetadataEntry }} JSXContent * @@ -296,6 +351,9 @@ export const createDocumentLayout = (entries, metadata) => * @returns {Promise} */ const buildContent = async (metadataEntries, head) => { + // First extract assets and rewrite URLs in the AST + const assetsMap = Object.fromEntries(extractAssetsFromAST(metadataEntries)); + // The metadata is the heading without the node children const metadata = omitKeys(head, [ 'content', @@ -311,7 +369,7 @@ const buildContent = async (metadataEntries, head) => { const ast = await remark().run(root); // The final MDX content is the expression in the Program's first body node - return { ...ast.body[0].expression, data: head }; + return { ...ast.body[0].expression, data: head, assetsMap }; }; export default buildContent; diff --git a/src/generators/metadata/utils/parse.mjs b/src/generators/metadata/utils/parse.mjs index 29192653..ad7b622f 100644 --- a/src/generators/metadata/utils/parse.mjs +++ b/src/generators/metadata/utils/parse.mjs @@ -31,7 +31,7 @@ import { IGNORE_STABILITY_STEMS } from '../constants.mjs'; * @param {Record} typeMap * @returns {Promise>} */ -export const parseApiDoc = ({ path, tree }, typeMap) => { +export const parseApiDoc = ({ path, tree, fullPath }, typeMap) => { /** * Collection of metadata entries for the file * @type {Array} @@ -85,6 +85,7 @@ export const parseApiDoc = ({ path, tree }, typeMap) => { path, basename: basename(path), heading: headingNode, + fullPath, }); // Generate slug and update heading data diff --git a/src/generators/web/generate.mjs b/src/generators/web/generate.mjs index fab595e0..d66050b5 100644 --- a/src/generators/web/generate.mjs +++ b/src/generators/web/generate.mjs @@ -1,12 +1,46 @@ 'use strict'; -import { readFile } from 'node:fs/promises'; +import { readFile, cp, mkdir } from 'node:fs/promises'; import { join } from 'node:path'; import { processJSXEntries } from './utils/processing.mjs'; import getConfig from '../../utils/configuration/index.mjs'; import { writeFile } from '../../utils/file.mjs'; +/** + * Aggregates and copies local assets referenced in the markdown ASTs to the output directory. + * + * @param {Array} input - The processed entries containing asset maps + * @param {string} outputDir - The absolute path to the generation output directory + * @returns {Promise} + */ +async function copyProjectAssets(input, outputDir) { + const allAssets = new Map(); + + for (const entry of input) { + if (entry.assetsMap) { + for (const [source, name] of Object.entries(entry.assetsMap)) { + allAssets.set(source, join(outputDir, 'assets', name)); + } + } + } + + if (allAssets.size === 0) { + return; + } + + const assetsOutputDir = join(outputDir, 'assets'); + await mkdir(assetsOutputDir, { recursive: true }); + + for (const [source, dest] of allAssets.entries()) { + try { + await cp(source, dest, { force: true }); + } catch (err) { + console.error(`[doc-kit] Error copying asset: ${source}`, err); + } + } +} + /** * Main generation function that processes JSX AST entries into web bundles. * @@ -34,6 +68,8 @@ export async function generate(input) { // Write CSS bundle await writeFile(join(config.output, 'styles.css'), css, 'utf-8'); + + await copyProjectAssets(input, config.output); } return results.map(({ html }) => ({ html: html.toString(), css }));