Skip to content

Commit

Permalink
Support transforming links and images with exclusion of document files (
Browse files Browse the repository at this point in the history
  • Loading branch information
mburumaxwell authored Jul 23, 2024
1 parent f02f3f3 commit c4af058
Show file tree
Hide file tree
Showing 14 changed files with 260 additions and 112 deletions.
5 changes: 5 additions & 0 deletions .changeset/popular-trainers-check.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'markdownlayer': minor
---

Support transforming links and images with exclusion of document files
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
10 changes: 9 additions & 1 deletion examples/starter/src/content/blog-posts/post-2/index.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,4 +5,12 @@ published: 2024-01-31
image: cover.jpg
---

Some content for post 2
## Hello World

> Hello **World**
Continuing from [post 1](../post-1/index.md)

Some content. Download file [here](./sample.txt).

![A linked image](./dice.jpg)
1 change: 1 addition & 0 deletions examples/starter/src/content/blog-posts/post-2/sample.txt
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
This is a dummy file to be downloaded.
18 changes: 17 additions & 1 deletion packages/markdownlayer/src/assets.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -57,7 +57,7 @@ describe('processAsset', () => {

vi.mocked(readFile).mockResolvedValue(buffer);

const metadata = await processAsset({ input, from, format, baseUrl: '/static/' });
const metadata = await processAsset({ input, from, format, baseUrl: '/static/' }, true);
expect(metadata).toEqual({
src: '/static/image.e23151fe.png',
format: 'png',
Expand All @@ -72,7 +72,23 @@ describe('processAsset', () => {
// Check that the asset is added to the assets map
expect(assets['image.e23151fe.png']).toBe(join(from, '..') + '/' + input);
});

it('should handle non-image assets gracefully', async () => {
const buffer = Buffer.from('some non-image data');
const input = 'document.txt';
const from = __dirname;
const format = '[name].[hash:8].[ext]';

vi.mocked(readFile).mockResolvedValue(buffer);

const url = await processAsset({ input, from, format, baseUrl: '/static/' });
expect(url).toBe('/static/document.ed975c52.txt');

// Check that the asset is not added to the assets map
expect(assets['document.ed975c52.txt']).toBe(join(from, '..') + '/' + input);
});
});

describe('isValidImageFormat', () => {
it('should return true for valid image formats', () => {
expect(isValidImageFormat('jpeg')).toBe(true);
Expand Down
29 changes: 17 additions & 12 deletions packages/markdownlayer/src/assets.ts
Original file line number Diff line number Diff line change
Expand Up @@ -27,17 +27,20 @@ export async function getImageMetadata(buffer: Buffer): Promise<Omit<ImageData,
return { format, height, width, blurDataURL, blurWidth, blurHeight, aspectRatio };
}

export async function processAsset({
input,
from,
format,
baseUrl,
}: {
input: string;
from: string;
format: string;
baseUrl: string;
}): Promise<ImageData> {
export async function processAsset<T extends true | undefined = undefined>(
{
input,
from,
format,
baseUrl,
}: {
input: string;
from: string;
format: string;
baseUrl: string;
},
isImage?: T,
): Promise<T extends true ? ImageData : string> {
// e.g. input = '../assets/image.png?foo=bar#hash'
const queryIdx = input.indexOf('?');
const hashIdx = input.indexOf('#');
Expand All @@ -63,9 +66,11 @@ export async function processAsset({
const src = baseUrl + name + suffix;
assets[name] = path; // track asset for copying later

if (!isImage) return src as T extends true ? ImageData : string;

const metadata = await getImageMetadata(buffer);
if (metadata == null) throw new Error(`invalid image: ${from}`);
return { src, ...metadata };
return { src, ...metadata } as T extends true ? ImageData : string;
}

export function isValidImageFormat(format: keyof FormatEnum): format is ImageFormat {
Expand Down
167 changes: 74 additions & 93 deletions packages/markdownlayer/src/bundle.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,43 +4,40 @@ import type { Options as CompileOptions } from '@mdx-js/esbuild';
import mdxESBuild from '@mdx-js/esbuild';
import type { BuildOptions, Plugin } from 'esbuild';
import esbuild, { type Message } from 'esbuild';
import { StringDecoder } from 'node:string_decoder';
import rehypeRaw, { type Options as RehypeRawOptions } from 'rehype-raw';
import remarkDirective from 'remark-directive';
import remarkEmoji from 'remark-emoji';
import remarkFrontmatter from 'remark-frontmatter';
import remarkGfm from 'remark-gfm';
import type { Pluggable, PluggableList } from 'unified';

import { remarkAdmonitions, remarkHeadings } from './remark';
import type { DocumentFormat, MarkdownlayerConfigPlugins } from './types';
import { remarkAdmonitions, remarkHeadings, remarkTransformLinks } from './remark';
import type { DocumentFormat, ResolvedConfig } from './types';

export type BundleProps = {
entryPath: string;
config: ResolvedConfig;
path: string;
contents: string;
format: DocumentFormat;
plugins: MarkdownlayerConfigPlugins;
frontmatter: Record<string, unknown>;
};

export type BundleResult = { code: string; errors: Message[] };

export async function bundle({ format, ...options }: BundleProps): Promise<BundleResult> {
export async function bundle({ format, config, ...options }: BundleProps): Promise<BundleResult> {
switch (format) {
case 'md':
case 'mdx':
return await mdx({ format, ...options });
return await mdx({ config, format, ...options });
case 'mdoc':
return await mdoc({ ...options });
return await mdoc({ config, ...options });

default:
throw new Error(`Unsupported format: ${format}`);
}
}

type BundleMdocProps = Omit<BundleProps, 'entryPath' | 'format'>;

async function mdoc({ contents, plugins: { markdoc }, frontmatter }: BundleMdocProps): Promise<BundleResult> {
type BundleMdocProps = Pick<BundleProps, 'config' | 'contents' | 'frontmatter'>;
async function mdoc({ config: { markdoc }, contents, frontmatter }: BundleMdocProps): Promise<BundleResult> {
const { allowComments = true, allowIndentation = true, slots, transformConfig } = markdoc ?? {};
const tokenizer = new Markdoc.Tokenizer({ allowComments, allowIndentation });
const tokens = tokenizer.tokenize(contents);
Expand All @@ -60,16 +57,13 @@ async function mdoc({ contents, plugins: { markdoc }, frontmatter }: BundleMdocP
};
}

type BundleMdxProps = Omit<BundleProps, 'format' | 'frontmatter'> & { format: 'md' | 'mdx' };

const decoder = new StringDecoder('utf8');

async function mdx({ entryPath, contents, format, plugins }: BundleMdxProps): Promise<BundleResult> {
type BundleMdxProps = Pick<BundleProps, 'config' | 'path' | 'contents'> & { format: 'md' | 'mdx' };
async function mdx({ config, path, contents, format }: BundleMdxProps): Promise<BundleResult> {
const inMemoryPlugin: Plugin = {
name: 'in-memory-plugin',
setup(build) {
build.onResolve({ filter: /.*/ }, ({ path: filePath }) => {
if (filePath === entryPath) {
if (filePath === path) {
return {
path: filePath,
pluginData: { inMemory: true, contents: contents },
Expand All @@ -82,17 +76,73 @@ async function mdx({ entryPath, contents, format, plugins }: BundleMdxProps): Pr
},
};

const compileOptions = getCompileOptions({ format, plugins });
const {
admonitions = true,
emoji = true,
gfm = true,
transformLinks = true,
recmaPlugins,
remarkPlugins,
rehypePlugins,
remarkRehypeOptions,
} = config;

const compileOptions: CompileOptions = {
format,
recmaPlugins,
rehypePlugins,
remarkRehypeOptions,

// configure remark plugins
remarkPlugins: [
// standard plugins
remarkFrontmatter,
remarkDirective, // necessary to handle all types of directives including admonitions (containerDirective)
...((admonitions
? [admonitions === true ? remarkAdmonitions : [remarkAdmonitions, admonitions]]
: []) as PluggableList),
remarkHeadings, // must be added before handling of ToC and links
...((emoji ? [emoji === true ? remarkEmoji : [remarkEmoji, emoji]] : []) as PluggableList),
...((gfm ? [gfm === true ? remarkGfm : [remarkGfm, gfm]] : []) as PluggableList),
...((transformLinks
? [[remarkTransformLinks, transformLinks === true ? { config } : { config, ...transformLinks }]]
: []) as PluggableList),

// user-provided plugins
...(remarkPlugins ?? []),
],
};

if (format === 'md') {
// This is what permits to embed HTML elements with format 'md'
// See https://github.com/facebook/docusaurus/pull/8960
// See https://github.com/mdx-js/mdx/pull/2295#issuecomment-1540085960
const rehypeRawPlugin: Pluggable = [
rehypeRaw,
{
passThrough: [
'mdxFlowExpression',
'mdxTextExpression',
// jsx, js
'mdxJsxFlowElement',
'mdxJsxTextElement',
'mdxjsEsm',
],
} satisfies RehypeRawOptions,
];
compileOptions.rehypePlugins!.unshift(rehypeRawPlugin);
}

const buildOptions: BuildOptions = {
entryPoints: [entryPath],
entryPoints: [path],
write: false,
bundle: true,
target: 'es2020',
format: 'iife',
globalName: 'Component',
minify: false, // let the bundling framework handle the minification
splitting: false,
treeShaking: false,
target: 'es2020',
splitting: false,
minify: false, // let the bundling framework handle the minification
keepNames: true,
plugins: [
globalExternals({
Expand All @@ -107,79 +157,10 @@ async function mdx({ entryPath, contents, format, plugins }: BundleMdxProps): Pr
};

const bundled = await esbuild.build(buildOptions);
const code = decoder.write(Buffer.from(bundled.outputFiles![0].contents));
const code = bundled.outputFiles![0].text;

return {
code: `${code};return Component;`,
errors: bundled.errors,
};
}

type GetCompileOptionsProps = { format: 'md' | 'mdx'; plugins: MarkdownlayerConfigPlugins };
type ProcessorCacheEntry = { format: DocumentFormat; options: CompileOptions };

const ProcessorsCache = new Map<GetCompileOptionsProps['format'], ProcessorCacheEntry>();

function getCompileOptions({ format, plugins }: GetCompileOptionsProps): CompileOptions {
let cacheEntry = ProcessorsCache.get(format);
const {
admonitions = true,
emoji = true,
gfm = true,
recmaPlugins,
remarkPlugins,
rehypePlugins,
remarkRehypeOptions,
} = plugins;

if (!cacheEntry) {
const options: CompileOptions = {
format,
recmaPlugins,
rehypePlugins,
remarkRehypeOptions,

// configure remark plugins
remarkPlugins: [
// standard plugins
remarkFrontmatter,
remarkDirective, // necessary to handle all types of directives including admonitions (containerDirective)
...((admonitions
? [admonitions === true ? remarkAdmonitions : [remarkAdmonitions, admonitions]]
: []) as PluggableList),
remarkHeadings, // must be added before handling of ToC and links
...((emoji ? [emoji === true ? remarkEmoji : [remarkEmoji, emoji]] : []) as PluggableList),
// remarkToc,
...((gfm ? [gfm === true ? remarkGfm : [remarkGfm, gfm]] : []) as PluggableList),

// user-provided plugins
...(remarkPlugins ?? []),
],
};

if (format === 'md') {
// This is what permits to embed HTML elements with format 'md'
// See https://github.com/facebook/docusaurus/pull/8960
// See https://github.com/mdx-js/mdx/pull/2295#issuecomment-1540085960
const rehypeRawPlugin: Pluggable = [
rehypeRaw,
{
passThrough: [
'mdxFlowExpression',
'mdxTextExpression',
// jsx, js
'mdxJsxFlowElement',
'mdxJsxTextElement',
'mdxjsEsm',
],
} satisfies RehypeRawOptions,
];
options.rehypePlugins!.unshift(rehypeRawPlugin);
}

cacheEntry = { format, options };
ProcessorsCache.set(format, cacheEntry);
}

return cacheEntry.options;
}
2 changes: 2 additions & 0 deletions packages/markdownlayer/src/remark/index.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,7 @@
export type * from './admonitions';
export type * from './headings';
export type * from './transform-links';

export { default as remarkAdmonitions } from './admonitions';
export { default as remarkHeadings } from './headings';
export { default as remarkTransformLinks } from './transform-links';
Loading

0 comments on commit c4af058

Please sign in to comment.