Skip to content

Commit

Permalink
Add document assets plugin (#656)
Browse files Browse the repository at this point in the history
* 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.

* - switch `assetSubDirs` to an array of globs
- remove `isRelativePath` and replace with `path.isAbsolute`

* add checks that we do not traverse outside the working directory
  • Loading branch information
mark-tate authored Sep 23, 2024
1 parent 7379927 commit 89245d8
Show file tree
Hide file tree
Showing 11 changed files with 449 additions and 4 deletions.
8 changes: 8 additions & 0 deletions .changeset/weak-suits-shake.md
Original file line number Diff line number Diff line change
@@ -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.
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ tsconfig.tsbuildinfo

# Deployment
packages/rig
packages/site/public/images

# Test Results
coverage
Expand Down
1 change: 1 addition & 0 deletions .prettierignore
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ patches
LICENSE
*.png
*.hbs
*.jpg

**/build
**/dist
Expand Down
73 changes: 73 additions & 0 deletions docs/configure/plugins/document-assets-plugin.mdx
Original file line number Diff line number Diff line change
@@ -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
];
```
Binary file added docs/configure/plugins/images/mosaic.jpg
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
4 changes: 2 additions & 2 deletions docs/configure/plugins/public-assets-plugin.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
3 changes: 2 additions & 1 deletion packages/plugins/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
183 changes: 183 additions & 0 deletions packages/plugins/src/DocumentAssetsPlugin.ts
Original file line number Diff line number Diff line change
@@ -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<Page, DocumentAssetsPluginOptions> = {
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;
Loading

0 comments on commit 89245d8

Please sign in to comment.