Skip to content

Commit

Permalink
fix: prototype pollution by opal runtime
Browse files Browse the repository at this point in the history
fixes #3
  • Loading branch information
shishkin committed Jul 28, 2023
1 parent cf753f5 commit 0336d8a
Show file tree
Hide file tree
Showing 9 changed files with 305 additions and 108 deletions.
16 changes: 13 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -9,15 +9,16 @@ Attention: this package hasn't reached v1 yet and breaking changes may be introd

## Features

- Convert AsciiDoc pages with [AsciiDoctor.js](https://docs.asciidoctor.org/asciidoctor.js/latest/)
- Convert AsciiDoc pages with [Asciidoctor.js](https://docs.asciidoctor.org/asciidoctor.js/latest/)
- Support of AsciiDoc `include`s
- Hot reloading of pages and includes
- Access AsciiDoc page attributes in frontmatter props
- Support of Astro layouts
- Render pages in standalone mode if no layout provided
- Page outline/TOC is available in props as Astro `MarkdownHeadings`
- Provide AsciiDoctor converter options
- Register AsciiDoctor extensions and syntax highlighters
- Provide Asciidoctor converter options
- Register Asciidoctor extensions and syntax highlighters
- Runs Asciidoctor in a worker thread to prevent prototype pollution from Opal/Ruby runtime

## Usage

Expand Down Expand Up @@ -96,3 +97,12 @@ const { title = "Astro", headings = [] } = Astro.props;
```

See [example](./packages/example/) project for more details.

## Caveats

NOTE: This integration runs Asciidoctor in a worker thread to prevent [prototype pollution](https://github.com/shishkin/astro-asciidoc/issues/3) from Opal/Ruby runtime.
That means that all options that need to be passed to the Asciidoctor converter need to be serializable according to [worker threads message passing limitations](https://developer.mozilla.org/en-US/docs/Web/API/Web_Workers_API/Structured_clone_algorithm).
In particular it is currently not possible to pass extensions and syntax highlighters as functions.
They need to be in separate Javascript modules and passed through via module file path.
Writing extensions and syntax highlighters in TypeScript is also currently not possible.
See [example](./packages/example/) project for a sample syntax highlighter integration.
9 changes: 9 additions & 0 deletions packages/astro-asciidoc/build.config.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
import { defineBuildConfig } from "unbuild";

export default defineBuildConfig({
entries: ["./src/index", "./src/worker"],
rollup: {
emitCJS: true,
},
declaration: true,
});
33 changes: 33 additions & 0 deletions packages/astro-asciidoc/src/asciidoctor.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
import EventEmitter from "node:events";
import { Worker } from "node:worker_threads";
import type { InitOptions, InputMessage, OutputMessage } from "./worker.js";

export default class AsciidocConverter extends EventEmitter {
private worker;

constructor(opts?: InitOptions) {
super({ captureRejections: true });
const url = new URL("./worker.cjs", import.meta.url);
this.worker = new Worker(url, {
workerData: opts,
});
this.worker.on("exit", (code) => {
this.emit("exit", { code });
});
}

async convert(input: InputMessage): Promise<OutputMessage> {
return new Promise((resolve, reject) => {
this.worker
.removeAllListeners("message")
.removeAllListeners("error")
.on("message", resolve)
.on("error", reject)
.postMessage(input);
});
}

async terminate() {
return this.worker.terminate();
}
}
135 changes: 43 additions & 92 deletions packages/astro-asciidoc/src/index.ts
Original file line number Diff line number Diff line change
@@ -1,88 +1,40 @@
import type { AstroIntegration, MarkdownHeading } from "astro";
import type { Plugin as VitePlugin, ViteDevServer } from "vite";
import asciidoctor, {
type ProcessorOptions,
type SyntaxHighlighterFunctions,
type Extensions,
type Document,
type Section,
} from "@asciidoctor/core";
import { pathToFileURL, fileURLToPath } from "node:url";
import type { ProcessorOptions } from "@asciidoctor/core";
import type { AstroIntegration } from "astro";
import type { ViteDevServer, Plugin as VitePlugin } from "vite";
import AsciidocConverter from "./asciidoctor.js";
import type { InitOptions } from "./worker.js";

type InternalHookParams = Parameters<
NonNullable<AstroIntegration["hooks"]["astro:config:setup"]>
>[0] & {
addPageExtension(ext: string): void;
};

type Catalog = {
[key: string]: any;
refs: {
[key: string]: any;
$$smap: {
[key: string]: {
[key: string]: any;
id: string;
title: string;
level: number;
};
};
};
includes: {
[key: string]: any;
$$keys: string[];
};
};

export interface Options {
/**
* Options for AsciiDoc conversion.
*/
export interface Options extends InitOptions {
/**
* Options passed to Asciidoctor document load and document convert.
*/
options?: ProcessorOptions;
extensions?: ((this: Extensions.Registry) => void)[];
highlighters?: Record<string, SyntaxHighlighterFunctions>;
}

export default function asciidoc(opts?: Options): AstroIntegration {
const fileExt = ".adoc";
const asciidocOptions: ProcessorOptions = opts?.options ?? {};
const converter = asciidoctor();
const asciidocFileExt = ".adoc";
const { options: documentOptions, highlighters } = opts ?? {};
const converter = new AsciidocConverter({
highlighters,
});
let server: ViteDevServer;

opts?.extensions?.length && opts.extensions.forEach((f) => converter.Extensions.register(f));

opts?.highlighters &&
Object.entries(opts.highlighters).forEach(([name, f]) =>
converter.SyntaxHighlighter.register(name, f),
);

function watchIncludes(file: string, catalog: Catalog) {
for (let include of catalog.includes.$$keys) {
if (!include.endsWith(fileExt)) {
include = `${include}${fileExt}`;
}
const fileUrl = new URL(include, pathToFileURL(file));
const filePath = fileURLToPath(fileUrl);
server.watcher.on("change", async (f) => {
if (f !== filePath) return;
const m = server.moduleGraph.getModuleById(file);
m && (await server.reloadModule(m));
});
server.watcher.add(filePath);
}
}

function getHeadings(doc: Document): MarkdownHeading[] {
const tocLevels = doc.getAttribute("toclevels", 2) as number;
return doc
.findBy({ context: "section" }, (b) => b.getLevel() > 0 && b.getLevel() <= tocLevels)
.map((b) => {
const section = b as Section;
return {
text: section.isNumbered()
? `${section.getSectionNumber()} ${section.getName()}`
: section.getName(),
slug: section.getId(),
depth: section.getLevel(),
};
});
function watchIncludes(file: string, includes: string[]) {
server.watcher.on("change", async (f) => {
if (!includes.includes(f)) return;
const m = server.moduleGraph.getModuleById(file);
m && (await server.reloadModule(m));
});
server.watcher.add(includes);
}

return {
Expand All @@ -91,7 +43,7 @@ export default function asciidoc(opts?: Options): AstroIntegration {
"astro:config:setup": (params) => {
const { addPageExtension, updateConfig, addWatchFile } = params as InternalHookParams;

addPageExtension(fileExt);
addPageExtension(asciidocFileExt);

updateConfig({
vite: {
Expand All @@ -101,35 +53,28 @@ export default function asciidoc(opts?: Options): AstroIntegration {
configureServer(s) {
server = s;
},
transform(_code, id) {
if (!id.endsWith(fileExt)) return;
async transform(_code, id) {
if (!id.endsWith(asciidocFileExt)) return;

const doc = converter.loadFile(id, asciidocOptions);
const layout = doc.getAttribute("layout") as string | undefined;
const html = doc.convert(<ProcessorOptions>{
standalone: !layout,
...asciidocOptions,
const doc = await converter.convert({
file: id,
options: documentOptions,
});
const headings = getHeadings(doc);
const frontmatter = {
title: doc.getTitle(),
asciidoc: doc.getAttributes(),
};

watchIncludes(id, doc.getCatalog() as Catalog);
watchIncludes(id, doc.includes);

return {
code: `import { Fragment, jsx as h } from "astro/jsx-runtime";
${layout ? `import Layout from ${JSON.stringify(layout)};` : ""}
${doc.layout ? `import Layout from ${JSON.stringify(doc.layout)};` : ""}
export const file = ${JSON.stringify(id)};
export const title = ${JSON.stringify(frontmatter.title)};
export const frontmatter = ${JSON.stringify(frontmatter)};
export const headings = ${JSON.stringify(headings)};
export const title = ${JSON.stringify(doc.frontmatter.title)};
export const frontmatter = ${JSON.stringify(doc.frontmatter)};
export const headings = ${JSON.stringify(doc.headings)};
export async function getHeadings() { return headings; }
export async function Content() {
const content = h(Fragment, { "set:html": ${JSON.stringify(html)} });
const content = h(Fragment, { "set:html": ${JSON.stringify(doc.html)} });
${
layout
doc.layout
? `return h(Layout, { title, headings, frontmatter, children: content });`
: `return content;`
}
Expand All @@ -150,6 +95,12 @@ export default Content;`,

addWatchFile(new URL(import.meta.url));
},
"astro:server:done": async () => {
await converter.terminate();
},
"astro:build:done": async () => {
await converter.terminate();
},
},
};
}
Loading

0 comments on commit 0336d8a

Please sign in to comment.