diff --git a/.changeset/weak-suits-shake.md b/.changeset/weak-suits-shake.md new file mode 100644 index 00000000..61b471f5 --- /dev/null +++ b/.changeset/weak-suits-shake.md @@ -0,0 +1,8 @@ +--- +'@jpmorganchase/mosaic-plugins': patch +'@jpmorganchase/mosaic-site': patch +--- + +Add DocumentAssetPlugin + +The `DocumentAssetsPlugin` is responsible for copying assets from a document sub-directory to the public folder of your site. This is particularly useful for co-locating images within your document structure and referencing them from documents using relative paths. diff --git a/.gitignore b/.gitignore index 7599c55a..cf3946ce 100644 --- a/.gitignore +++ b/.gitignore @@ -16,6 +16,7 @@ tsconfig.tsbuildinfo # Deployment packages/rig +packages/site/public/images # Test Results coverage diff --git a/.prettierignore b/.prettierignore index c4169c7e..425feed8 100644 --- a/.prettierignore +++ b/.prettierignore @@ -10,6 +10,7 @@ patches LICENSE *.png *.hbs +*.jpg **/build **/dist diff --git a/docs/configure/plugins/document-assets-plugin.mdx b/docs/configure/plugins/document-assets-plugin.mdx new file mode 100644 index 00000000..2adc6620 --- /dev/null +++ b/docs/configure/plugins/document-assets-plugin.mdx @@ -0,0 +1,73 @@ +--- +title: DocumentAssetsPlugin +layout: DetailOverview +--- + +# {meta.title} + +The `DocumentAssetsPlugin` is responsible for copying assets from a document sub-directory to the public folder of your site. This is particularly useful for co-locating images within your document structure and referencing them from documents using relative paths. + +## Co-locating Images + +A common use case is to store images within the same directory structure as your documents. This allows you to reference images using relative paths. + +For example, to load an image (`mosaic.jpg`) from a sub-directory called `images`, relative to the document's path, you can use the following Markdown: + +```markdown +![alt text](./images/mosaic.jpg) +``` + +This will render the image as follows: +![alt text](./images/mosaic.jpg) + +## Centralized Image Directory + +Alternatively, if you prefer to store all your images in a common parent directory, you can reference them using a relative path that navigates up the directory structure. + +For example, to load an image from a common parent directory, you can use: + +``` +![alt text](../../images/mosaic.jpg) +``` + +## Handling Absolute Paths and URLs + +The plugin ignores image paths that start with a leading slash (/) or are fully qualified URLs. This ensures that only relative paths are processed and copied to the public folder. + +``` +![alt text](/images/mosaic.jpg) +![alt text](https://www.saltdesignsystem.com/img/hero_image.svg) +``` + +## Priority + +This plugin runs with a priority of -1 so it runs _after_ most other plugins. + +## Options + +| Property | Description | Default | +| ------------ | ---------------------------------------------------------------------------------- | ------------- | +| srcDir | The path where pages reside **after** cloning or when running locally | './docs' | +| outputDir | There path to your site's public images directory where you want to put the images | './public' | +| assetSubDirs | An array of subdirectory globs that could contain assets | ['**/images'] | +| imagesPrefix | The prefix that is added to all new paths | '/images' | + +## Adding to Mosaic + +This plugin is **not** included in the mosaic config shipped by the Mosaic standard generator so it must be added manually to the `plugins` collection: + +```js +plugins: [ + { + modulePath: '@jpmorganchase/mosaic-plugins/DocumentAssetsPlugin', + priority: -1, + options: { + srcDir: `../../docs`, + outputDir: './public/images/mosaic', + assetSubDirs: ['**/images'], + imagesPrefix: '/images' + } + } + // other plugins +]; +``` diff --git a/docs/configure/plugins/images/mosaic.jpg b/docs/configure/plugins/images/mosaic.jpg new file mode 100644 index 00000000..3b8b0bcd Binary files /dev/null and b/docs/configure/plugins/images/mosaic.jpg differ diff --git a/docs/configure/plugins/public-assets-plugin.mdx b/docs/configure/plugins/public-assets-plugin.mdx index a00b74ec..165b002f 100644 --- a/docs/configure/plugins/public-assets-plugin.mdx +++ b/docs/configure/plugins/public-assets-plugin.mdx @@ -7,11 +7,11 @@ layout: DetailOverview The `PublicAssetsPlugin` is responsible for finding "assets" in the Mosaic filesystem and copying them to another directory. -Typical usecase is for copying `sitemap.xml` and `search-data.json` to the public directory of a Next.js site as these are considered [static assets](https://nextjs.org/docs/pages/building-your-application/optimizing/static-assets) for Next.js. +Typical use-case is for copying `sitemap.xml` and `search-data.json` to the public directory of a Next.js site as these are considered [static assets](https://nextjs.org/docs/pages/building-your-application/optimizing/static-assets) for Next.js. ## Priority -This plugin runs with no special priority. +This plugin runs with a priority of -1 so it runs _after_ most other plugins. ## Options diff --git a/packages/plugins/package.json b/packages/plugins/package.json index 367ca96b..6af76703 100644 --- a/packages/plugins/package.json +++ b/packages/plugins/package.json @@ -35,7 +35,8 @@ "directory": "packages/plugins" }, "devDependencies": { - "@types/fs-extra": "^9.0.13" + "@types/fs-extra": "^9.0.13", + "mock-fs": "^5.2.0" }, "dependencies": { "@apidevtools/json-schema-ref-parser": "^10.1.0", diff --git a/packages/plugins/src/DocumentAssetsPlugin.ts b/packages/plugins/src/DocumentAssetsPlugin.ts new file mode 100644 index 00000000..aca1f715 --- /dev/null +++ b/packages/plugins/src/DocumentAssetsPlugin.ts @@ -0,0 +1,183 @@ +import type { Page, Plugin as PluginType } from '@jpmorganchase/mosaic-types'; +import fsExtra from 'fs-extra'; +import glob from 'fast-glob'; +import path from 'path'; +import { escapeRegExp } from 'lodash-es'; +import { unified } from 'unified'; +import { visit } from 'unist-util-visit'; +import remarkParse from 'remark-parse'; +import remarkStringify from 'remark-stringify'; +import { VFile } from 'vfile'; + +interface DocumentAssetsPluginOptions { + /** + * An array of subdirectory globs that could contain assets + * @default: ['**\/images'] + */ + assetSubDirs?: string[]; + /** + * The source path, where your docs reside, when the site runs + */ + srcDir?: string; + /** + * The directory to copy matched assets to, typically the site's public directory + * @default './public' + */ + outputDir?: string; + /** + * The prefix we add to all images in documents, so that it routes to the public directory + * @default '/images' + */ + imagesPrefix?: string; +} + +function isUrl(assetPath: string): boolean { + try { + new URL(assetPath); + return true; + } catch (_err) {} + return false; +} + +const createPageTest = (ignorePages: string[], pageExtensions: string[]) => { + const extTest = new RegExp(`${pageExtensions.map(ext => escapeRegExp(ext)).join('|')}$`); + const ignoreTest = new RegExp(`${ignorePages.map(ignore => escapeRegExp(ignore)).join('|')}$`); + + return (file: string) => + !ignoreTest.test(file) && extTest.test(file) && !path.basename(file).startsWith('.'); +}; + +function remarkRewriteImagePaths(newPrefix: string) { + return (tree: any) => { + visit(tree, 'image', (node: any) => { + if (node.url) { + if (isUrl(node.url) || /^\//.test(node.url)) { + // Absolute URL or path, do nothing + return; + } else { + const isRelativePath = !isUrl(node.url) && !path.isAbsolute(node.url); + const assetPath = isRelativePath ? node.url : `./${node.url}`; + const resolvedPath = path.resolve(path.dirname(newPrefix), assetPath); + node.url = resolvedPath; + } + } + }); + }; +} + +/** + * Plugin that finds assets within the Mosaic filesystem and copies them to the configured `outputDir`. + * Documents that create relative references to those images, will be re-written to pull the images from the `outputDir`. + */ +const DocumentAssetsPlugin: PluginType = { + async afterUpdate( + _mutableFileSystem, + _helpers, + { + assetSubDirs = [path.join('**', 'images')], + srcDir = path.join(process.cwd(), 'docs'), + outputDir = `${path.sep}public` + } + ) { + const resolvedCwd = path.resolve(process.cwd()); + const resolvedSrcDir = path.resolve(srcDir); + const resolvedOutputDir = path.resolve(outputDir); + if (!resolvedOutputDir.startsWith(resolvedCwd)) { + throw new Error(`outputDir must be within the current working directory: ${outputDir}`); + } + await fsExtra.ensureDir(srcDir); + await fsExtra.ensureDir(outputDir); + + for (const assetSubDir of assetSubDirs) { + const resolvedAssetSubDir = path.resolve(resolvedSrcDir, assetSubDir); + if (!resolvedAssetSubDir.startsWith(resolvedSrcDir)) { + console.log('ERROR 3'); + + throw new Error(`Asset subdirectory must be within srcDir: ${srcDir}`); + } + + let globbedImageDirs; + try { + globbedImageDirs = await glob(assetSubDir, { + cwd: resolvedSrcDir, + onlyDirectories: true + }); + } catch (err) { + console.error(`Error globbing ${assetSubDir} in ${srcDir}:`, err); + continue; + } + + if (globbedImageDirs?.length === 0) { + continue; + } + + for (const globbedImageDir of globbedImageDirs) { + let imageFiles; + let globbedPath; + let rootSrcDir = srcDir; + let rootOutputDir = outputDir; + try { + if (!path.isAbsolute(rootSrcDir)) { + rootSrcDir = path.resolve(path.join(process.cwd(), srcDir)); + } + globbedPath = path.join(rootSrcDir, globbedImageDir); + imageFiles = await fsExtra.promises.readdir(globbedPath); + } catch (err) { + console.error(`Error reading directory ${globbedPath}:`, err); + continue; + } + if (!path.isAbsolute(rootOutputDir)) { + rootOutputDir = path.resolve(path.join(process.cwd(), outputDir)); + } + + for (const imageFile of imageFiles) { + try { + const imageSrcPath = path.join(globbedImageDir, imageFile); + const fullImageSrcPath = path.join(rootSrcDir, imageSrcPath); + const fullImageDestPath = path.join(rootOutputDir, imageSrcPath); + + await fsExtra.mkdir(path.dirname(fullImageDestPath), { recursive: true }); + const symlinkAlreadyExists = await fsExtra.pathExists(fullImageDestPath); + if (!symlinkAlreadyExists) { + await fsExtra.symlink(fullImageSrcPath, fullImageDestPath); + console.log(`Symlink created: ${fullImageSrcPath} -> ${fullImageDestPath}`); + } + } catch (error) { + console.error(`Error processing ${imageFile}:`, error); + } + } + } + } + }, + async $afterSource( + pages, + { ignorePages, pageExtensions }, + { imagesPrefix = `${path.sep}images` } + ) { + if (!pageExtensions.includes('.mdx')) { + return pages; + } + for (const page of pages) { + const isNonHiddenPage = createPageTest(ignorePages, ['.mdx']); + if (!isNonHiddenPage(page.fullPath)) { + continue; + } + + const processor = unified() + .use(remarkParse) + .use(remarkRewriteImagePaths, path.join(imagesPrefix, page.route)) + .use(remarkStringify); + await processor + .process(page.content) + .then((file: VFile) => { + page.content = String(file); + }) + .catch((err: Error) => { + console.error('Error processing Markdown:', err); + }); + } + return pages; + } +}; + +export default DocumentAssetsPlugin; diff --git a/packages/plugins/src/__tests__/DocumentAssetsPlugin.test.ts b/packages/plugins/src/__tests__/DocumentAssetsPlugin.test.ts new file mode 100644 index 00000000..53743dc6 --- /dev/null +++ b/packages/plugins/src/__tests__/DocumentAssetsPlugin.test.ts @@ -0,0 +1,168 @@ +import { expect, describe, test, afterEach, vi, beforeEach } from 'vitest'; + +import fsExtra from 'fs-extra'; +import mockFs from 'mock-fs'; +import path from 'path'; +import DocumentAssetsPlugin from '../DocumentAssetsPlugin'; + +describe('GIVEN the LocalImagePlugin', () => { + describe('afterUpdate', () => { + const srcDir = './src'; + const outputDir = './public'; + const assetSubDirs = ['**/images']; + + beforeEach(() => { + vi.spyOn(process, 'cwd').mockReturnValue('/mocked/cwd'); + mockFs({ + '/mocked/cwd/src': { + images: { + 'image1.png': 'file content', + 'image2.jpg': 'file content' + } + }, + './public': {} + }); + }); + + afterEach(() => { + vi.clearAllMocks(); + mockFs.restore(); + }); + + test('should process image directories and call symlink with correct arguments', async () => { + const symlinkMock = vi.spyOn(fsExtra, 'symlink').mockResolvedValue(undefined); + + await DocumentAssetsPlugin.afterUpdate(null, null, { assetSubDirs, srcDir, outputDir }); + + const outputPathImage1 = path.join(process.cwd(), outputDir, 'images', 'image1.png'); + const outputPathImage2 = path.join(process.cwd(), outputDir, 'images', 'image2.jpg'); + const srcPathImage1 = path.join(process.cwd(), srcDir, 'images', 'image1.png'); + const srcPathImage2 = path.join(process.cwd(), srcDir, 'images', 'image2.jpg'); + + // assert that symlink was called with correct arguments + expect(symlinkMock).toHaveBeenCalledWith(srcPathImage1, outputPathImage1); + expect(symlinkMock).toHaveBeenCalledWith(srcPathImage2, outputPathImage2); + }); + + test('should handle errors gracefully and continue processing other files', async () => { + const symlinkMock = vi + .spyOn(fsExtra, 'symlink') + .mockImplementationOnce((src: fsExtra.PathLike, _dest) => { + if (`${src}`.includes('image1.png')) { + throw new Error('Symlink error'); + } + return Promise.resolve(); + }); + + console.error = vi.fn(); + + await DocumentAssetsPlugin.afterUpdate(null, null, { assetSubDirs, srcDir, outputDir }); + + const outputPathImage2 = path.join(process.cwd(), outputDir, 'images', 'image2.jpg'); + const srcPathImage2 = path.join(process.cwd(), srcDir, 'images', 'image2.jpg'); + + // assert that a single error does not break the plugin + expect(console.error).toHaveBeenCalledWith( + expect.stringContaining('Error processing image1.png'), + expect.any(Error) + ); + expect(symlinkMock).toHaveBeenCalledWith(srcPathImage2, outputPathImage2); + }); + + test('should throw an error if outputDir is not below process.cwd()', async () => { + const options = { + srcDir: './docs', + outputDir: '/outside/cwd/public' + }; + + await expect(DocumentAssetsPlugin.afterUpdate({}, {}, options)).rejects.toThrow( + 'outputDir must be within the current working directory' + ); + }); + }); + + describe('$afterSource', () => { + const srcDir = '/src'; + const outputDir = './public'; + const pages = [ + // relative path `.` - should re-map URL to public images + { + fullPath: '/path/to/page1.mdx', + route: '/namespace/subdir1/page1', + content: '![alt text](./images/image1.png)' + }, + // relative path `..` - should re-map URL to public images + { + fullPath: '/path/to/page2.mdx', + route: '/namespace/subdir1/subdir2/page2', + content: '![alt text](../images/image2.png)' + }, + // No leading slash - should treat as relative path + { + fullPath: '/path/to/page3.mdx', + route: '/namespace/page3', + content: '![alt text](images/image3.jpg)' + }, + // No leading slash with extra level - should treat as relative path + { + fullPath: '/path/to/page4.mdx', + route: '/namespace/subdir1/page4', + content: '![alt text](images/image4.png)' + }, + // Ignored .txt - should remain un-changed + { + fullPath: '/path/to/page5.txt', + route: '/namespace/page5', + content: '![alt text](images/image5.png)' + }, + // ignored path - should remain un-changed + { + fullPath: '/path/to/ignore.mdx', + route: '/namespace/ignore.mdx', + content: '![alt text](images/image6.png)' + }, + // http address - should remain un-changed + { + fullPath: '/path/to/page7.mdx', + route: '/namespace/page7', + content: '![alt text](https://www.saltdesignsystem.com/img/hero_image.svg)' + } + ]; + const ignorePages = ['/path/to/ignore.mdx']; + const pageExtensions = ['.mdx']; + + beforeEach(() => { + vi.clearAllMocks(); + }); + + test('should return pages if .mdx is not included in pageExtensions', async () => { + const result = await DocumentAssetsPlugin.$afterSource( + pages, + { + ignorePages, + pageExtensions: ['.txt'] + }, + { srcDir, outputDir } + ); + expect(result).toEqual(pages); + }); + + test('should process .mdx pages and update their content', async () => { + const result = await DocumentAssetsPlugin.$afterSource( + pages, + { ignorePages, pageExtensions }, + { srcDir, outputDir } + ); + expect(result.length).toEqual(7); + expect(result[0].content).toBe('![alt text](/images/namespace/subdir1/images/image1.png)\n'); + expect(result[1].content).toBe('![alt text](/images/namespace/subdir1/images/image2.png)\n'); + expect(result[2].content).toBe('![alt text](/images/namespace/images/image3.jpg)\n'); + expect(result[3].content).toBe('![alt text](/images/namespace/subdir1/images/image4.png)\n'); + expect(result[4].content).toBe('![alt text](images/image5.png)'); + expect(result[5].content).toBe('![alt text](images/image6.png)'); + expect(result[6].content).toBe( + '![alt text](https://www.saltdesignsystem.com/img/hero_image.svg)\n' + ); + }); + }); +}); diff --git a/packages/site/mosaic.config.mjs b/packages/site/mosaic.config.mjs index 043fd125..359e6dec 100644 --- a/packages/site/mosaic.config.mjs +++ b/packages/site/mosaic.config.mjs @@ -23,6 +23,16 @@ const siteConfig = { outputDir: './public', assets: ['sitemap.xml', 'search-data.json'] } + }, + { + modulePath: '@jpmorganchase/mosaic-plugins/DocumentAssetsPlugin', + priority: -1, + options: { + srcDir: `../../docs`, + outputDir: './public/images/mosaic', + assetSubDirs: ['**/images'], + imagesPrefix: '/images' + } } ] }; diff --git a/yarn.lock b/yarn.lock index 22883282..6e38e607 100644 --- a/yarn.lock +++ b/yarn.lock @@ -9608,7 +9608,7 @@ mkdirp@^1.0.4: resolved "https://registry.npmjs.org/mkdirp/-/mkdirp-1.0.4.tgz#3eb5ed62622756d79a5f0e2a221dfebad75c2f7e" integrity sha512-vVqVZQyf3WLx2Shd0qJ9xuvqgAyKPLAiqITEtqW0oIUjzo3PePDd6fW9iFz30ef7Ysp/oiWqbhszeGWW2T6Gzw== -mock-fs@^5.0.0: +mock-fs@^5.0.0, mock-fs@^5.2.0: version "5.2.0" resolved "https://registry.yarnpkg.com/mock-fs/-/mock-fs-5.2.0.tgz#3502a9499c84c0a1218ee4bf92ae5bf2ea9b2b5e" integrity sha512-2dF2R6YMSZbpip1V1WHKGLNjr/k48uQClqMVb5H3MOvwc9qhYis3/IWbj02qIg/Y8MDXKFF4c5v0rxx2o6xTZw==