Skip to content

Commit 8b80ade

Browse files
authored
Merge pull request #437 from half-ogre/half-ogre/render-html
Add raw HTML passthrough and fix MDX attrs
2 parents de68bd9 + 92e18e3 commit 8b80ade

File tree

5 files changed

+220
-5
lines changed

5 files changed

+220
-5
lines changed

package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -70,6 +70,7 @@
7070
"react": "^19.2.4",
7171
"rehype-highlight": "^7.0.2",
7272
"rehype-katex": "^7.0.1",
73+
"rehype-raw": "^7.0.0",
7374
"rehype-slug": "^6.0.0",
7475
"rehype-stringify": "^10.0.1",
7576
"remark-emoji": "^5.0.2",

pnpm-lock.yaml

Lines changed: 49 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

src/types.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,7 @@ export type WritrOptions = {
2222
* @property {boolean} [gfm] - Github flavor markdown (default: true)
2323
* @property {boolean} [math] - Math support (default: true)
2424
* @property {boolean} [mdx] - MDX support (default: false)
25+
* @property {boolean} [rawHtml] - Raw HTML passthrough (default: false)
2526
* @property {boolean} [caching] - Caching (default: true)
2627
*/
2728
export type RenderOptions = {
@@ -32,6 +33,7 @@ export type RenderOptions = {
3233
gfm?: boolean; // Github flavor markdown (default: true)
3334
math?: boolean; // Math support (default: true)
3435
mdx?: boolean; // MDX support (default: false)
36+
rawHtml?: boolean; // Raw HTML passthrough (default: false)
3537
caching?: boolean; // Caching (default: true)
3638
};
3739

src/writr.ts

Lines changed: 50 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ import * as yaml from "js-yaml";
66
import type React from "react";
77
import rehypeHighlight from "rehype-highlight";
88
import rehypeKatex from "rehype-katex";
9+
import rehypeRaw from "rehype-raw";
910
import rehypeSlug from "rehype-slug";
1011
import rehypeStringify from "rehype-stringify";
1112
import remarkEmoji from "remark-emoji";
@@ -68,6 +69,7 @@ export class Writr extends Hookified {
6869
gfm: true,
6970
math: true,
7071
mdx: false,
72+
rawHtml: false,
7173
caching: true,
7274
},
7375
};
@@ -656,7 +658,29 @@ export class Writr extends Hookified {
656658
processor.use(remarkEmoji);
657659
}
658660

659-
processor.use(remarkRehype);
661+
if (options.mdx) {
662+
processor.use(remarkMDX);
663+
}
664+
665+
// biome-ignore lint/suspicious/noExplicitAny: remarkRehype handler types
666+
const rehypeOptions: Record<string, any> = {};
667+
668+
if (options.rawHtml) {
669+
rehypeOptions.allowDangerousHtml = true;
670+
}
671+
672+
if (options.mdx) {
673+
rehypeOptions.handlers = {
674+
mdxJsxFlowElement: mdxJsxHandler,
675+
mdxJsxTextElement: mdxJsxHandler,
676+
};
677+
}
678+
679+
processor.use(remarkRehype, rehypeOptions);
680+
681+
if (options.rawHtml) {
682+
processor.use(rehypeRaw);
683+
}
660684

661685
if (options.slug) {
662686
processor.use(rehypeSlug);
@@ -670,10 +694,6 @@ export class Writr extends Hookified {
670694
processor.use(remarkMath).use(rehypeKatex);
671695
}
672696

673-
if (options.mdx) {
674-
processor.use(remarkMDX);
675-
}
676-
677697
processor.use(rehypeStringify);
678698

679699
return processor;
@@ -711,10 +731,35 @@ export class Writr extends Hookified {
711731
current.mdx = options.mdx;
712732
}
713733

734+
if (options.rawHtml !== undefined) {
735+
current.rawHtml = options.rawHtml;
736+
}
737+
714738
if (options.caching !== undefined) {
715739
current.caching = options.caching;
716740
}
717741

718742
return current;
719743
}
720744
}
745+
746+
// biome-ignore lint/suspicious/noExplicitAny: mdx AST node types are not exported
747+
function mdxJsxHandler(state: any, node: any) {
748+
const properties: Record<string, string | boolean> = {};
749+
for (const attr of node.attributes) {
750+
if (attr.type === "mdxJsxAttribute") {
751+
if (attr.value === null) {
752+
properties[attr.name] = true;
753+
} else if (typeof attr.value === "string") {
754+
properties[attr.name] = attr.value;
755+
}
756+
}
757+
}
758+
759+
return {
760+
type: "element",
761+
tagName: node.name ?? "div",
762+
properties,
763+
children: state.all(node),
764+
};
765+
}

test/writr.test.ts

Lines changed: 118 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -288,6 +288,124 @@ describe("writr", () => {
288288
});
289289
});
290290

291+
describe("Writr rawHtml", () => {
292+
it("should strip raw HTML by default", async () => {
293+
const writr = new Writr('# Hello\n\n<div class="custom">content</div>');
294+
const result = await writr.render();
295+
expect(result).not.toContain("<div");
296+
});
297+
298+
it("should preserve div tags with attributes when rawHtml is true", async () => {
299+
const writr = new Writr('# Hello\n\n<div class="custom">content</div>');
300+
const result = await writr.render({ rawHtml: true });
301+
expect(result).toContain('<div class="custom">content</div>');
302+
});
303+
304+
it("should preserve iframe tags with attributes when rawHtml is true", async () => {
305+
const writr = new Writr(
306+
'# Hello\n\n<iframe src="https://example.com" width="560" height="315"></iframe>',
307+
);
308+
const result = await writr.render({ rawHtml: true });
309+
expect(result).toContain("<iframe");
310+
expect(result).toContain('src="https://example.com"');
311+
expect(result).toContain('width="560"');
312+
});
313+
314+
it("should preserve raw HTML with renderSync when rawHtml is true", () => {
315+
const writr = new Writr('# Hello\n\n<div class="custom">content</div>');
316+
const result = writr.renderSync({ rawHtml: true });
317+
expect(result).toContain('<div class="custom">content</div>');
318+
});
319+
320+
it("should strip raw HTML when rawHtml is explicitly false", async () => {
321+
const writr = new Writr('# Hello\n\n<div class="custom">content</div>');
322+
const result = await writr.render({ rawHtml: false });
323+
expect(result).not.toContain("<div");
324+
});
325+
326+
it("should be settable via constructor options", () => {
327+
const writr = new Writr({
328+
renderOptions: {
329+
rawHtml: true,
330+
},
331+
});
332+
expect(writr.options.renderOptions?.rawHtml).toEqual(true);
333+
});
334+
});
335+
336+
describe("Writr mdx HTML passthrough", () => {
337+
it("should preserve iframe tags with attributes when mdx is true", async () => {
338+
const writr = new Writr(
339+
'# Hello\n\n<iframe src="https://example.com" width="560" height="315"></iframe>',
340+
);
341+
const result = await writr.render({ mdx: true });
342+
expect(result).toContain("<iframe");
343+
expect(result).toContain('src="https://example.com"');
344+
expect(result).toContain('width="560"');
345+
expect(result).toContain('height="315"');
346+
});
347+
348+
it("should preserve div tags with attributes when mdx is true", async () => {
349+
const writr = new Writr('# Hello\n\n<div class="custom">content</div>');
350+
const result = await writr.render({ mdx: true });
351+
expect(result).toContain('<div class="custom">');
352+
expect(result).toContain("content");
353+
});
354+
355+
it("should preserve boolean attributes when mdx is true", async () => {
356+
const writr = new Writr('<video src="vid.mp4" controls></video>');
357+
const result = await writr.render({ mdx: true });
358+
expect(result).toContain("<video");
359+
expect(result).toContain('src="vid.mp4"');
360+
expect(result).toContain("controls");
361+
});
362+
363+
it("should preserve inline HTML elements when mdx is true", async () => {
364+
const writr = new Writr('Hello <span style="color:red">world</span> there');
365+
const result = await writr.render({ mdx: true });
366+
expect(result).toContain('<span style="color:red">world</span>');
367+
});
368+
369+
it("should preserve iframe tags with renderSync when mdx is true", () => {
370+
const writr = new Writr(
371+
'<iframe src="https://example.com" width="560"></iframe>',
372+
);
373+
const result = writr.renderSync({ mdx: true });
374+
expect(result).toContain("<iframe");
375+
expect(result).toContain('src="https://example.com"');
376+
});
377+
378+
it("should render a fragment as a div when mdx is true", async () => {
379+
const writr = new Writr("<>fragment content</>");
380+
const result = await writr.render({ mdx: true });
381+
expect(result).toContain("<div>");
382+
expect(result).toContain("fragment content");
383+
});
384+
385+
it("should handle elements with no attributes when mdx is true", async () => {
386+
const writr = new Writr("<section>hello</section>");
387+
const result = await writr.render({ mdx: true });
388+
expect(result).toContain("<section>");
389+
expect(result).toContain("hello");
390+
});
391+
392+
it("should skip spread expression attributes and keep named attributes when mdx is true", async () => {
393+
const writr = new Writr('<Comp {...props} foo="bar" />\n');
394+
const result = await writr.render({ mdx: true });
395+
expect(result).toContain('foo="bar"');
396+
expect(result).not.toContain("props");
397+
});
398+
399+
it("should skip expression attribute values when mdx is true", async () => {
400+
const writr = new Writr("<div data-x={42}>hello</div>");
401+
const result = await writr.render({ mdx: true });
402+
expect(result).toContain("<div>");
403+
expect(result).toContain("hello");
404+
expect(result).not.toContain("object");
405+
expect(result).not.toContain("42");
406+
});
407+
});
408+
291409
describe("WritrFrontMatter", () => {
292410
test("should initialize with content and work from same object", () => {
293411
const writr = new Writr(productPageWithMarkdown);

0 commit comments

Comments
 (0)