>");'],
25 | };
26 |
27 | const { container } = render();
28 |
29 | expect(container).toHaveTextContent(/VARIABLE_SUBSTITUTED/);
30 | fireEvent.click(screen.getByRole('button'));
31 |
32 | expect(copy).toHaveBeenCalledWith(expect.stringMatching(/VARIABLE_SUBSTITUTED/));
33 | });
34 |
35 | it.skip('does not nest the button inside the code block', () => {
36 | render({'console.log("hi");'});
37 | const btn = screen.getByRole('button');
38 |
39 | expect(btn.parentNode?.nodeName.toLowerCase()).not.toBe('code');
40 | });
41 |
42 | it.skip('allows undefined children?!', () => {
43 | const { container } = render();
44 |
45 | expect(container).toHaveTextContent('');
46 | });
47 | });
48 |
--------------------------------------------------------------------------------
/__tests__/compilers.test.ts:
--------------------------------------------------------------------------------
1 | import type { Element } from 'hast';
2 |
3 | import { mdast, mdx, mdxish } from '../index';
4 |
5 | describe('ReadMe Flavored Blocks', () => {
6 | it('Embed', () => {
7 | const txt = '[Embedded meta links.](https://nyti.me/s/gzoa2xb2v3 "@embed")';
8 | const ast = mdast(txt);
9 | const out = mdx(ast);
10 | expect(out).toMatchSnapshot();
11 | });
12 |
13 | it('Emojis', () => {
14 | expect(mdx(mdast(':smiley:'))).toMatchInlineSnapshot(`
15 | ":smiley:
16 | "
17 | `);
18 | });
19 | });
20 |
21 | describe('mdxish ReadMe Flavored Blocks', () => {
22 | it('Embed', () => {
23 | const txt = '[Embedded meta links.](https://nyti.me/s/gzoa2xb2v3 "@embed")';
24 | const hast = mdxish(txt);
25 | const embed = hast.children[0] as Element;
26 |
27 | expect(embed.type).toBe('element');
28 | expect(embed.tagName).toBe('embed');
29 | expect(embed.properties.url).toBe('https://nyti.me/s/gzoa2xb2v3');
30 | expect(embed.properties.title).toBe('Embedded meta links.');
31 | });
32 |
33 | it('Emojis', () => {
34 | const hast = mdxish(':smiley:');
35 | const paragraph = hast.children[0] as Element;
36 |
37 | expect(paragraph.type).toBe('element');
38 | expect(paragraph.tagName).toBe('p');
39 | // gemojiTransformer converts :smiley: to 😃
40 | const textNode = paragraph.children[0];
41 | expect(textNode.type).toBe('text');
42 | expect('value' in textNode && textNode.value).toBe('😃');
43 | });
44 | });
45 |
--------------------------------------------------------------------------------
/__tests__/lib/render-mdxish/Glossary.test.tsx:
--------------------------------------------------------------------------------
1 | import '@testing-library/jest-dom';
2 | import { render, screen } from '@testing-library/react';
3 | import React from 'react';
4 |
5 | import { vi } from 'vitest';
6 |
7 | import { mdxish } from '../../../index';
8 | import renderMdxish from '../../../lib/renderMdxish';
9 |
10 | describe('Glossary', () => {
11 | // Make sure we don't have any console errors when rendering a glossary item
12 | // which has happened before & crashing the app
13 | // It was because of the engine was converting the Glossary item to nested tags
14 | // which React was not happy about
15 | let stderrSpy: ReturnType;
16 | let consoleErrorSpy: ReturnType;
17 |
18 | beforeAll(() => {
19 | stderrSpy = vi.spyOn(process.stderr, 'write').mockImplementation(() => true);
20 | consoleErrorSpy = vi.spyOn(console, 'error').mockImplementation(() => {});
21 | });
22 |
23 | afterEach(() => {
24 | stderrSpy.mockRestore();
25 | consoleErrorSpy.mockRestore();
26 | });
27 |
28 | it('renders a glossary item without console errors', () => {
29 | const md = `The term exogenous should show a tooltip on hover.
30 | `;
31 | const tree = mdxish(md);
32 | const mod = renderMdxish(tree);
33 | render( );
34 | expect(screen.getByText('exogenous')).toBeVisible();
35 |
36 | expect(stderrSpy).not.toHaveBeenCalled();
37 | expect(consoleErrorSpy).not.toHaveBeenCalled();
38 | });
39 | });
--------------------------------------------------------------------------------
/components/Accordion/style.scss:
--------------------------------------------------------------------------------
1 | .Accordion {
2 | background: rgba(var(--color-bg-page-rgb, white), 1);
3 | border: 1px solid var(--color-border-default, rgba(black, 0.1));
4 | border-radius: 5px;
5 |
6 | &-title {
7 | align-items: center;
8 | background: rgba(var(--color-bg-page-rgb, white), 1);
9 | border: 0;
10 | border-radius: 5px;
11 | color: var(--color-text-default, #384248);
12 | cursor: pointer;
13 | display: flex;
14 | font-size: 16px;
15 | font-weight: 500;
16 | padding: 10px;
17 | position: relative;
18 | text-align: left;
19 | width: 100%;
20 |
21 | &:hover {
22 | background: var(--color-bg-hover, rgba(black, 0.05));
23 | }
24 |
25 | .Accordion[open] & {
26 | border-bottom-left-radius: 0;
27 | border-bottom-right-radius: 0;
28 | }
29 |
30 | &::marker {
31 | content: "";
32 | }
33 |
34 | &::-webkit-details-marker {
35 | display: none;
36 | }
37 | }
38 |
39 | &-toggleIcon,
40 | &-toggleIcon_opened {
41 | color: var(--color-text-minimum, #637288);
42 | font-size: 14px;
43 | margin-left: 5px;
44 | margin-right: 10px;
45 | transition: transform 0.1s;
46 |
47 | &_opened {
48 | transform: rotate(90deg);
49 | }
50 | }
51 |
52 | &-icon {
53 | color: var(--project-color-primary, inherit);
54 | margin-right: 10px;
55 | }
56 |
57 | &-content {
58 | color: var(--color-text-muted, #4f5a66);
59 | padding: 5px 15px 0 15px;
60 | }
61 | }
62 |
--------------------------------------------------------------------------------
/lib/renderMdxish.tsx:
--------------------------------------------------------------------------------
1 | import type { CustomComponents, RMDXModule } from '../types';
2 | import type { Root } from 'hast';
3 |
4 | import { extractToc, tocToHast } from '../processor/plugin/toc';
5 |
6 | import { loadComponents } from './utils/mdxish/mdxish-load-components';
7 | import {
8 | buildRMDXModule,
9 | createRehypeReactProcessor,
10 | exportComponentsForRehype,
11 | type RenderOpts,
12 | } from './utils/mdxish/mdxish-render-utils';
13 |
14 | export type { RenderOpts as RenderMdxishOpts };
15 |
16 | /**
17 | * Converts a HAST tree to a React component.
18 | * @param tree - The HAST tree to convert
19 | * @param opts - The options for the render
20 | * @returns The React component
21 | *
22 | * @see {@link https://github.com/readmeio/rmdx/blob/main/docs/mdxish-flow.md}
23 | */
24 | const renderMdxish = (tree: Root, opts: RenderOpts = {}): RMDXModule => {
25 | const { components: userComponents = {}, ...contextOpts } = opts;
26 |
27 | const components: CustomComponents = {
28 | ...loadComponents(),
29 | ...userComponents,
30 | };
31 |
32 | const headings = extractToc(tree, components);
33 | const componentsForRehype = exportComponentsForRehype(components);
34 | const processor = createRehypeReactProcessor(componentsForRehype);
35 | const content = processor.stringify(tree) as React.ReactNode;
36 |
37 | const tocHast = headings.length > 0 ? tocToHast(headings) : null;
38 |
39 | return buildRMDXModule(content, headings, tocHast, contextOpts);
40 | };
41 |
42 | export default renderMdxish;
43 |
--------------------------------------------------------------------------------
/processor/transform/stripComments.ts:
--------------------------------------------------------------------------------
1 | import type { Root } from 'mdast';
2 |
3 | import { visit, SKIP } from 'unist-util-visit';
4 |
5 | const HTML_COMMENT_REGEX = //g;
6 | const MDX_COMMENT_REGEX = /\/\*[\s\S]*?\*\//g;
7 |
8 | /**
9 | * A remark plugin to remove comments from Markdown and MDX.
10 | */
11 | export const stripCommentsTransformer = () => {
12 | return (tree: Root) => {
13 | visit(tree, ['html', 'mdxFlowExpression', 'mdxTextExpression'], (node, index, parent) => {
14 | if (parent && typeof index === 'number') {
15 | // Remove HTML comments
16 | if (node.type === 'html' && HTML_COMMENT_REGEX.test(node.value)) {
17 | const newValue = node.value.replace(HTML_COMMENT_REGEX, '').trim();
18 | if (newValue) {
19 | node.value = newValue;
20 | } else {
21 | parent.children.splice(index, 1);
22 | return [SKIP, index];
23 | }
24 | }
25 |
26 | // Remove MDX comments
27 | if (
28 | (node.type === 'mdxFlowExpression' || node.type === 'mdxTextExpression') &&
29 | MDX_COMMENT_REGEX.test(node.value)
30 | ) {
31 | const newValue = node.value.replace(MDX_COMMENT_REGEX, '').trim();
32 | if (newValue) {
33 | node.value = newValue;
34 | } else {
35 | parent.children.splice(index, 1);
36 | return [SKIP, index];
37 | }
38 | }
39 | }
40 |
41 | return undefined;
42 | });
43 | };
44 | };
45 |
--------------------------------------------------------------------------------
/__tests__/transformers/preprocess-jsx-expressions.test.ts:
--------------------------------------------------------------------------------
1 | import { preprocessJSXExpressions } from '../../processor/transform/mdxish/preprocess-jsx-expressions';
2 |
3 | describe('preprocessJSXExpressions', () => {
4 | describe('Step 3: Evaluate attribute expressions', () => {
5 | it('should evaluate JSX attribute expressions and convert them to string attributes', () => {
6 | const context = {
7 | baseUrl: 'https://example.com',
8 | userId: '123',
9 | isActive: true,
10 | };
11 |
12 | const content = 'Link ';
13 | const result = preprocessJSXExpressions(content, context);
14 |
15 | expect(result).toContain('href="https://example.com"');
16 | expect(result).toContain('id="123"');
17 | expect(result).toContain('active="true"');
18 | expect(result).not.toContain('href={baseUrl}');
19 | expect(result).not.toContain('id={userId}');
20 | expect(result).not.toContain('active={isActive}');
21 | });
22 |
23 | it.each([
24 | [true, '{"b":1}'],
25 | [false, '{"c":2}'],
26 | ])('should handle nested dictionary attributes when a is %s', (a, expectedJson) => {
27 | const context = { a };
28 |
29 | const content = 'Link
';
30 | const result = preprocessJSXExpressions(content, context);
31 |
32 | expect(result).toContain(`foo='${expectedJson}'`);
33 | expect(result).not.toContain('foo={a ? {b: 1} : {c: 2}}');
34 | });
35 | });
36 | });
37 |
--------------------------------------------------------------------------------
/lib/mdx.ts:
--------------------------------------------------------------------------------
1 | import type { Root as HastRoot } from 'hast';
2 | import type { Root as MdastRoot } from 'mdast';
3 | import type { PluggableList } from 'unified';
4 | import type { VFile } from 'vfile';
5 |
6 | import rehypeRemark from 'rehype-remark';
7 | import remarkGfm from 'remark-gfm';
8 | import remarkMdx from 'remark-mdx';
9 | import remarkStringify from 'remark-stringify';
10 | import { unified } from 'unified';
11 |
12 | import compilers from '../processor/compile';
13 | import { compatabilityTransfomer, divTransformer, readmeToMdx, tablesToJsx } from '../processor/transform';
14 | import { escapePipesInTables } from '../processor/transform/escape-pipes-in-tables';
15 |
16 | interface Opts {
17 | file?: VFile | string;
18 | hast?: boolean;
19 | remarkTransformers?: PluggableList;
20 | }
21 |
22 | export const mdx = (
23 | tree: HastRoot | MdastRoot,
24 | { hast = false, remarkTransformers = [], file, ...opts }: Opts = {},
25 | ) => {
26 | const processor = unified()
27 | .use(hast ? rehypeRemark : undefined)
28 | .use(remarkMdx)
29 | .use(remarkGfm)
30 | .use(remarkTransformers)
31 | .use(divTransformer)
32 | .use(readmeToMdx)
33 | .use(tablesToJsx)
34 | .use(compatabilityTransfomer)
35 | .use(escapePipesInTables)
36 | .use(compilers)
37 | .use(remarkStringify, opts);
38 |
39 | // @ts-expect-error - @todo: coerce the processor and tree to the correct
40 | // type depending on the value of hast
41 | return processor.stringify(processor.runSync(tree, file));
42 | };
43 |
44 | export default mdx;
45 |
--------------------------------------------------------------------------------
/processor/transform/div.ts:
--------------------------------------------------------------------------------
1 | import type { Recipe } from '../../types';
2 | import type { Node, Parent } from 'mdast';
3 | import type { Transform } from 'mdast-util-from-markdown';
4 |
5 | import { visit } from 'unist-util-visit';
6 |
7 | import { NodeTypes } from '../../enums';
8 |
9 | // This transformer has been necessary for migrating legacy markdown files
10 | // where tutorial tiles were wrapped in a div. It also provides a fallback for legacy magic blocks that were never fully supported:
11 | // [block:custom-block]
12 | // { ... }
13 | // [/block]
14 | // This transformer runs before the readme-to-mdx transformer which reshapes the tutorial tile node
15 | // to the Recipe component
16 | const divTransformer = (): Transform => tree => {
17 | visit(tree, 'div', (node: Node, index, parent: Parent) => {
18 | const type = node.data?.hName;
19 | switch (type) {
20 | // Check if the div is a tutorial-tile in disguise
21 | case NodeTypes.tutorialTile:
22 | {
23 | // eslint-disable-next-line @typescript-eslint/no-unused-vars
24 | const { hName, hProperties, ...rest } = node.data;
25 | const tile = {
26 | ...rest,
27 | type: NodeTypes.recipe,
28 | } as Recipe;
29 | parent.children.splice(index, 1, tile);
30 | }
31 | break;
32 | // idk what this is and/or just make it a paragraph
33 | default:
34 | node.type = type || 'paragraph';
35 | }
36 | });
37 |
38 | return tree;
39 | };
40 |
41 | export default divTransformer;
42 |
--------------------------------------------------------------------------------
/lib/owlmoji.ts:
--------------------------------------------------------------------------------
1 | import type { Gemoji } from 'gemoji';
2 |
3 | import { gemoji, nameToEmoji } from 'gemoji';
4 |
5 | export const owlmoji = [
6 | {
7 | emoji: '', // This `emoji` property doesn't get consumed, but is required for type consistency
8 | names: ['owlbert'],
9 | tags: ['owlbert'],
10 | description: 'an owlbert for any occasion',
11 | category: 'ReadMe',
12 | },
13 | {
14 | emoji: '',
15 | names: ['owlbert-books'],
16 | tags: ['owlbert'],
17 | description: 'owlbert carrying books',
18 | category: 'ReadMe',
19 | },
20 | {
21 | emoji: '',
22 | names: ['owlbert-mask'],
23 | tags: ['owlbert'],
24 | description: 'owlbert with a respirator',
25 | category: 'ReadMe',
26 | },
27 | {
28 | emoji: '',
29 | names: ['owlbert-reading'],
30 | tags: ['owlbert'],
31 | description: 'owlbert reading',
32 | category: 'ReadMe',
33 | },
34 | {
35 | emoji: '',
36 | names: ['owlbert-thinking'],
37 | tags: ['owlbert'],
38 | description: 'owlbert thinking',
39 | category: 'ReadMe',
40 | },
41 | ] satisfies Gemoji[];
42 |
43 | const owlmojiNames = owlmoji.flatMap(emoji => emoji.names);
44 |
45 | export default class Owlmoji {
46 | static kind = (name: string) => {
47 | if (name in nameToEmoji) return 'gemoji';
48 | else if (name.match(/^fa-/)) return 'fontawesome';
49 | else if (owlmojiNames.includes(name)) return 'owlmoji';
50 | return null;
51 | };
52 |
53 | static nameToEmoji = nameToEmoji;
54 |
55 | static owlmoji = gemoji.concat(owlmoji);
56 | }
57 |
--------------------------------------------------------------------------------
/__tests__/parsers/tables.test.ts:
--------------------------------------------------------------------------------
1 | import { removePosition } from 'unist-util-remove-position';
2 |
3 | import { mdast } from '../../index';
4 |
5 | describe('table parser', () => {
6 | describe('unescaping pipes', () => {
7 | it('parses tables with pipes in inline code', () => {
8 | const doc = `
9 | | | |
10 | | :----------- | :- |
11 | | \`foo \\| bar\` | |
12 | `;
13 | const ast = mdast(doc);
14 | removePosition(ast, { force: true });
15 |
16 | expect(ast).toMatchSnapshot();
17 | });
18 |
19 | it('parses tables with pipes', () => {
20 | const doc = `
21 | | | |
22 | | :--------- | :- |
23 | | foo \\| bar | |
24 | `;
25 | const ast = mdast(doc);
26 | removePosition(ast, { force: true });
27 |
28 | expect(ast).toMatchSnapshot();
29 | });
30 |
31 | it('parses jsx tables with pipes in inline code', () => {
32 | const doc = `
33 |
34 |
35 |
36 |
37 | force
38 | jsx
39 |
40 |
41 |
42 |
43 |
44 |
45 |
46 |
47 |
48 |
49 |
50 | \`foo | bar\`
51 |
52 |
53 |
54 |
55 |
56 |
57 |
58 |
59 | `;
60 |
61 | const ast = mdast(doc);
62 | removePosition(ast, { force: true });
63 |
64 | expect(ast).toMatchSnapshot();
65 | });
66 | });
67 | });
68 |
--------------------------------------------------------------------------------
/docs/syntax-extensions.md:
--------------------------------------------------------------------------------
1 | ---
2 | title: Flavored Syntax
3 | category:
4 | uri: uri-that-does-not-map-to-5fdf7610134322007389a6ec
5 | content:
6 | excerpt: Specs and examples of ReadMe's (restrained) Markdown syntax extensions.
7 | privacy:
8 | view: public
9 | ---
10 | Custom Blocks
11 | ---
12 |
13 | ### Code Tabs
14 |
15 | A tabbed interface for displaying multiple code blocks. These are written nearly identically to a series of vanilla markdown code snippets, except for their distinct *lack* of an additional line break separating each subsequent block. [**Syntax & examples**.](doc:code-blocks)
16 |
17 | ### Callouts
18 |
19 | Callouts are very nearly equivalent to standard Markdown block quotes in their syntax, other than some specific requirements on their content: To be considered a “callout”, the block quote must start with an initial emoji, which is used to determine the callout's theme. [**Syntax & examples**.](doc:callouts)
20 |
21 | ### Embeds
22 |
23 | Embedded content is written as a simple Markdown link, with a title of "@embed". [**Syntax & examples**.](doc:embeds)
24 |
25 | Standard Markdown
26 | ---
27 |
28 | The engine also supports all standard markdown constructs, as well as CommonMark options, and most GitHub syntax extensions.
29 |
30 | - [**Tables**](doc:tables)
31 | - [**Lists**](doc:lists)
32 | - [**Headings**](doc:headings)
33 | - [**Images**](doc:images)
34 | - **Decorations** (link, strong, and emphasis tags, etc.)
35 |
--------------------------------------------------------------------------------
/components/Cards/index.tsx:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 |
3 | import './style.scss';
4 |
5 | interface CardProps
6 | extends React.PropsWithChildren<{
7 | badge?: string;
8 | href?: string;
9 | icon?: string;
10 | iconColor?: string;
11 | kind?: 'card' | 'tile';
12 | target?: string;
13 | title?: string;
14 | }> {}
15 |
16 | export const Card = ({ badge, children, href, kind = 'card', icon, iconColor, target, title }: CardProps) => {
17 | const Tag = href ? 'a' : 'div';
18 |
19 | return (
20 |
21 | {icon && }
22 |
23 | {title && (
24 |
25 | {title}
26 | {badge && {badge} }
27 | {href && }
28 |
29 | )}
30 | {children}
31 |
32 |
33 | );
34 | };
35 |
36 | interface CardsGridProps extends React.PropsWithChildren<{ cardWidth?: string, columns?: number | string }> {}
37 |
38 | const CardsGrid = ({ cardWidth = '200px', columns = 'auto-fit', children }: CardsGridProps) => {
39 |
40 | return (
41 |
42 | {children}
43 |
44 | );
45 | };
46 |
47 | export default CardsGrid;
48 |
--------------------------------------------------------------------------------
/__tests__/lib/__snapshots__/stripComments.test.ts.snap:
--------------------------------------------------------------------------------
1 | // Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html
2 |
3 | exports[`removeComments > preserves magic block indentation 1`] = `
4 | "* foo
5 | * foo
6 | [block:html]
7 | {
8 | "html": "Hoo ha "
9 | }
10 | [/block]"
11 | `;
12 |
13 | exports[`removeComments > removes HTML comments 1`] = `
14 | "Hello
15 |
16 | Beep boop bop
17 | Bop
18 | [block:html]
19 | {
20 | "html": "Magic blocks should not have comments removed.
21 | "
22 | }
23 | [/block]
24 |
25 |
26 |
27 | \`\`\`html
28 |
29 | Code blocks should not have comments removed.
30 |
31 |
32 | \`\`\`
33 | [block:image]{ "images": [{ "image": ["https://owlbertsio-resized.s3.amazonaws.com/This-Is-Fine.jpg.full.png", "", "" ], "align": "center" } ]}[/block]
34 |
35 | \`\`
36 |
37 |
38 |
39 | Beep boop bop
40 |
41 | 
42 |
43 | Beep boop bop
44 |
45 | | A | B | C |
46 | | :- | :------------- | :- |
47 | | 1 | | 3 |"
48 | `;
49 |
50 | exports[`removeComments > removes MDX comments 1`] = `
51 | "# Title
52 |
53 | {foo}
54 |
55 | Some text.
56 |
57 | More text.
58 |
59 | \`{/* Comment in code element should NOT be removed */}\`
60 |
61 | Last text"
62 | `;
63 |
64 | exports[`removeComments > supports a magic block as the first line of the document 1`] = `
65 | "[block:html]
66 | {
67 | "html": "
68 | Hello
69 |
"
70 | }
71 | [/block]
72 |
73 | How are you?"
74 | `;
75 |
--------------------------------------------------------------------------------
/scripts/perf-test.js:
--------------------------------------------------------------------------------
1 | const childProcess = require('child_process');
2 | const { Blob } = require('node:buffer');
3 |
4 | const rdmd = require('..');
5 |
6 | const mdBuffer = childProcess.execSync('cat ./docs/*', { encoding: 'utf8' });
7 |
8 | const createDoc = bytes => {
9 | let doc = '';
10 |
11 | while (new Blob([doc]).size < bytes) {
12 | const start = Math.ceil(Math.random() * mdBuffer.length);
13 | doc += mdBuffer.slice(start, start + bytes);
14 | }
15 |
16 | return doc;
17 | };
18 |
19 | // https://stackoverflow.com/a/14919494
20 | function humanFileSize(bytes, si = false, dp = 1) {
21 | const thresh = si ? 1000 : 1024;
22 |
23 | if (Math.abs(bytes) < thresh) {
24 | return `${bytes} B`;
25 | }
26 |
27 | const units = si
28 | ? ['kB', 'MB', 'GB', 'TB', 'PB', 'EB', 'ZB', 'YB']
29 | : ['KiB', 'MiB', 'GiB', 'TiB', 'PiB', 'EiB', 'ZiB', 'YiB'];
30 | let u = -1;
31 | const r = 10 ** dp;
32 |
33 | do {
34 | // eslint-disable-next-line no-param-reassign
35 | bytes /= thresh;
36 | // eslint-disable-next-line no-plusplus
37 | ++u;
38 | } while (Math.round(Math.abs(bytes) * r) / r >= thresh && u < units.length - 1);
39 |
40 | return `${bytes.toFixed(dp)} ${units[u]}`;
41 | }
42 |
43 | const max = 8;
44 |
45 | console.log('n : string size : duration');
46 |
47 | new Array(max).fill(0).forEach((_, i) => {
48 | const bytes = 10 ** i;
49 | const doc = createDoc(bytes);
50 | const then = Date.now();
51 |
52 | rdmd.mdast(doc);
53 | const duration = Date.now() - then;
54 |
55 | console.log(`${i} : ${humanFileSize(new Blob([doc]).size)} : ${duration / 1000} s`);
56 | });
57 |
--------------------------------------------------------------------------------
/processor/transform/handle-missing-components.ts:
--------------------------------------------------------------------------------
1 | import type { CompileOpts } from '../../lib/compile';
2 | import type { Parents } from 'mdast';
3 | import type { Transform } from 'mdast-util-from-markdown';
4 | import type { MdxJsxFlowElement, MdxJsxTextElement } from 'mdast-util-mdx';
5 |
6 | import { visit } from 'unist-util-visit';
7 |
8 | import * as Components from '../../components';
9 | import mdast from '../../lib/mdast';
10 | import { getExports, isMDXElement } from '../utils';
11 |
12 | type HandleMissingComponentsProps = Pick;
13 |
14 | const handleMissingComponents =
15 | ({ components, missingComponents }: HandleMissingComponentsProps): Transform =>
16 | tree => {
17 | const allComponents = new Set([
18 | ...getExports(tree),
19 | ...Object.keys(Components),
20 | ...Object.keys(components),
21 | ...Object.values(components).flatMap(doc => getExports(mdast(doc))),
22 | 'Variable',
23 | 'TutorialTile',
24 | ]);
25 |
26 | visit(
27 | tree,
28 | isMDXElement,
29 | (node: MdxJsxFlowElement | MdxJsxTextElement, index: number, parent: Parents) => {
30 | if (allComponents.has(node.name) || node.name.match(/^[a-z]/)) return;
31 |
32 | if (missingComponents === 'throw') {
33 | throw new Error(
34 | `Expected component \`${node.name}\` to be defined: you likely forgot to import, pass, or provide it.`,
35 | );
36 | }
37 |
38 | parent.children.splice(index, 1);
39 | },
40 | true,
41 | );
42 | };
43 |
44 | export default handleMissingComponents;
45 |
--------------------------------------------------------------------------------
/docs/headings.md:
--------------------------------------------------------------------------------
1 | ---
2 | title: Headings
3 | category:
4 | uri: uri-that-does-not-map-to-5fdf7610134322007389a6ed
5 | privacy:
6 | view: public
7 | ---
8 |
9 | ## Examples
10 |
11 | > ### Heading Block 3
12 | >
13 | > #### Heading Block 4
14 | >
15 | > ##### Heading Block 5
16 | >
17 | > ###### Heading Block 6
18 |
19 | ## Edge Cases
20 |
21 | ### Heading Styles
22 |
23 | ####Compact Notation
24 |
25 | Headers are denoted using a space-separated `#` prefix. While the space is technically required in most standard Markdown implementations, some processors allow for a compact notation as well. ReadMe supports this style, so writing this
26 |
27 | ```
28 | ###A Valid Heading
29 |
30 | Lorem ipsum dolor etc.
31 | ```
32 |
33 | > 🛑
34 | > Compact headings must be followed by two line breaks before the following block.
35 |
36 | #### ATX-Style Notation ####
37 |
38 | If you prefer, you can "wrap" headers with hashes rather than simply prefixing them:
39 |
40 | ```
41 | ## ATX Headings are Valid ##
42 | ```
43 |
44 | #### Underline Notation
45 |
46 | For top-level headings, the underline notation is valid:
47 |
48 | ```
49 | Heading One
50 | ===========
51 |
52 | Heading Two
53 | ---
54 | ```
55 |
56 | ### Incremented Anchors
57 |
58 | Occasionally, a single doc might contain multiple headings with the same text, which can cause the generated anchor links to conflict. ReadMe's new markdown processor normalizes heading anchors by auto-incrementing similar heading's IDs. Try it out by clicking on this section header _or_ the following sub-section title:
59 |
60 | #### Incremented Heading Anchors
61 |
62 | #### Incremented Heading Anchors
63 |
--------------------------------------------------------------------------------
/processor/transform/index.ts:
--------------------------------------------------------------------------------
1 | import calloutTransformer from './callouts';
2 | import codeTabsTransformer from './code-tabs';
3 | import compatabilityTransfomer from './compatability';
4 | import divTransformer from './div';
5 | import embedTransformer from './embeds';
6 | import gemojiTransformer from './gemoji+';
7 | import handleMissingComponents from './handle-missing-components';
8 | import imageTransformer from './images';
9 | import injectComponents from './inject-components';
10 | import mdxToHast from './mdx-to-hast';
11 | import mdxishTables from './mdxish/mdxish-tables';
12 | import mermaidTransformer from './mermaid';
13 | import readmeComponentsTransformer from './readme-components';
14 | import readmeToMdx from './readme-to-mdx';
15 | import tablesToJsx from './tables-to-jsx';
16 | import tailwindTransformer from './tailwind';
17 | import validateMCPIntro from './validate-mcpintro';
18 | import variablesTransformer from './variables';
19 |
20 | export {
21 | compatabilityTransfomer,
22 | divTransformer,
23 | injectComponents,
24 | mdxToHast,
25 | mdxishTables,
26 | mermaidTransformer,
27 | readmeComponentsTransformer,
28 | readmeToMdx,
29 | tablesToJsx,
30 | tailwindTransformer,
31 | handleMissingComponents,
32 | validateMCPIntro,
33 | variablesTransformer,
34 | };
35 |
36 | export const defaultTransforms = {
37 | calloutTransformer,
38 | codeTabsTransformer,
39 | embedTransformer,
40 | imageTransformer,
41 | gemojiTransformer,
42 | };
43 |
44 | export const mdxishTransformers = [calloutTransformer, codeTabsTransformer, imageTransformer, gemojiTransformer];
45 |
46 | export default Object.values(defaultTransforms);
47 |
--------------------------------------------------------------------------------
/__tests__/lib/mdast/variables/out.json:
--------------------------------------------------------------------------------
1 | {
2 | "children": [
3 | {
4 | "children": [
5 | {
6 | "position": {
7 | "end": {
8 | "column": 8,
9 | "line": 1,
10 | "offset": 7
11 | },
12 | "start": {
13 | "column": 1,
14 | "line": 1,
15 | "offset": 0
16 | }
17 | },
18 | "type": "text",
19 | "value": "Hello, "
20 | },
21 | {
22 | "data": {
23 | "hName": "Variable",
24 | "hProperties": {
25 | "name": "name"
26 | }
27 | },
28 | "position": {
29 | "end": {
30 | "column": 19,
31 | "line": 1,
32 | "offset": 18
33 | },
34 | "start": {
35 | "column": 8,
36 | "line": 1,
37 | "offset": 7
38 | }
39 | },
40 | "type": "readme-variable",
41 | "value": "{user.name}"
42 | }
43 | ],
44 | "position": {
45 | "end": {
46 | "column": 19,
47 | "line": 1,
48 | "offset": 18
49 | },
50 | "start": {
51 | "column": 1,
52 | "line": 1,
53 | "offset": 0
54 | }
55 | },
56 | "type": "paragraph"
57 | }
58 | ],
59 | "position": {
60 | "end": {
61 | "column": 1,
62 | "line": 2,
63 | "offset": 19
64 | },
65 | "start": {
66 | "column": 1,
67 | "line": 1,
68 | "offset": 0
69 | }
70 | },
71 | "type": "root"
72 | }
73 |
--------------------------------------------------------------------------------
/__tests__/migration/html-comments.test.ts:
--------------------------------------------------------------------------------
1 | import { migrate } from '../helpers';
2 |
3 | describe('migrating html comments', () => {
4 | it('migrates escaped html comments', () => {
5 | const md = `
6 |
22 | `;
23 |
24 | const mdx = migrate(md);
25 | expect(mdx).toMatchInlineSnapshot(`
26 | "{/*
27 |
28 |
29 |
30 | ## Walkthrough
31 |
32 | {\`
33 |
34 | \`}
35 |
36 |
37 |
38 |
39 | */}
40 | "
41 | `);
42 | });
43 |
44 | it('converts markdown within html comments', () => {
45 | const md = `
46 |
53 | `;
54 |
55 | const mdx = migrate(md);
56 | expect(mdx).toMatchInlineSnapshot(`
57 | "{/*
58 |
59 | ### Heading inside comment
60 |
61 | * a **list** item
62 |
63 |
64 | */}
65 | "
66 | `);
67 | });
68 | });
69 |
--------------------------------------------------------------------------------
/__tests__/migration/magic-block.test.ts:
--------------------------------------------------------------------------------
1 | import { migrate } from '../helpers';
2 |
3 | describe('migrating magic blocks', () => {
4 | it('compiles magic blocks without enough newlines', () => {
5 | const md = `
6 | [block:api-header]
7 | {
8 | "title": "About cBEYONData"
9 | }
10 | [/block]
11 | [ Overview of cBEYONData ](/docs/about-cbeyondata)
12 | [block:api-header]
13 | {
14 | "title": "About CFO Control Control Tower"
15 | }
16 | [/block]
17 | [Overview of CFO Control Tower](https://docs.cfocontroltower.com/docs/about-cfo-control-tower)
18 | [block:image]
19 | {
20 | "images": [
21 | {
22 | "image": [
23 | "https://files.readme.io/569fe58-Intro_Image02.png",
24 | "Intro Image02.png",
25 | 1280,
26 | 118,
27 | "#eaeaed"
28 | ],
29 | "sizing": "full",
30 | "caption": "cBEYONData.com "
31 | }
32 | ]
33 | }
34 | [/block]
35 |
36 | [block:callout]
37 | {
38 | "type": "danger",
39 | "title": "CONFIDENTIAL",
40 | "body": "*This documentation is confidential and proprietary information of cBEYONData LLC.* "
41 | }
42 | [/block]
43 | `;
44 | const mdx = migrate(md);
45 | expect(mdx).toMatchInlineSnapshot(`
46 | "## About cBEYONData
47 |
48 | [ Overview of cBEYONData ](/docs/about-cbeyondata)
49 |
50 | ## About CFO Control Control Tower
51 |
52 | [Overview of CFO Control Tower](https://docs.cfocontroltower.com/docs/about-cfo-control-tower)
53 |
54 | > ❗️ CONFIDENTIAL
55 | >
56 | > *This documentation is confidential and proprietary information of cBEYONData LLC.*
57 | "
58 | `);
59 | });
60 | });
61 |
--------------------------------------------------------------------------------
/processor/transform/gemoji+.ts:
--------------------------------------------------------------------------------
1 | import type { FaEmoji, Gemoji } from '../../types';
2 | import type { Image, Root } from 'mdast';
3 |
4 | import { findAndReplace } from 'mdast-util-find-and-replace';
5 |
6 | import { NodeTypes } from '../../enums';
7 | import Owlmoji from '../../lib/owlmoji';
8 |
9 | export const regex = /(?<=^|\s):(?\+1|[-\w]+):/g;
10 |
11 | const gemojiReplacer = (_, name: string) => {
12 | switch (Owlmoji.kind(name)) {
13 | case 'gemoji': {
14 | const node: Gemoji = {
15 | type: NodeTypes.emoji,
16 | value: Owlmoji.nameToEmoji[name],
17 | name,
18 | };
19 |
20 | return node;
21 | }
22 | case 'fontawesome': {
23 | const node: FaEmoji = {
24 | type: NodeTypes.i,
25 | value: name,
26 | data: {
27 | hName: 'i',
28 | hProperties: {
29 | className: ['fa-regular', name],
30 | },
31 | },
32 | };
33 |
34 | return node;
35 | }
36 | case 'owlmoji': {
37 | const node: Image = {
38 | type: 'image',
39 | title: `:${name}:`,
40 | alt: `:${name}:`,
41 | url: `/public/img/emojis/${name}.png`,
42 | data: {
43 | hProperties: {
44 | className: 'emoji',
45 | align: 'absmiddle',
46 | height: '20',
47 | width: '20',
48 | },
49 | },
50 | };
51 |
52 | return node;
53 | }
54 | default:
55 | return false;
56 | }
57 | };
58 |
59 | const gemojiTransformer = () => (tree: Root) => {
60 | findAndReplace(tree, [regex, gemojiReplacer]);
61 |
62 | return tree;
63 | };
64 |
65 | export default gemojiTransformer;
66 |
--------------------------------------------------------------------------------
/__tests__/transformers/mdxish-component-blocks.test.ts:
--------------------------------------------------------------------------------
1 | import type { Element } from 'hast';
2 |
3 | import { mdxish } from '../../index';
4 | import { parseAttributes } from '../../processor/transform/mdxish/mdxish-component-blocks';
5 |
6 | describe('mdxish-component-blocks', () => {
7 | describe('parseAttributes', () => {
8 | it('should parse normal key-value attributes', () => {
9 | const attrString = 'theme="info"';
10 | const result = parseAttributes(attrString);
11 |
12 | expect(result).toHaveLength(1);
13 | expect(result[0]).toStrictEqual({
14 | type: 'mdxJsxAttribute',
15 | name: 'theme',
16 | value: 'info',
17 | });
18 | });
19 |
20 | it('should parse boolean attributes without values', () => {
21 | const attrString = 'theme="info" empty';
22 | const result = parseAttributes(attrString);
23 |
24 | expect(result).toHaveLength(2);
25 | expect(result[0]).toStrictEqual({
26 | type: 'mdxJsxAttribute',
27 | name: 'theme',
28 | value: 'info',
29 | });
30 | expect(result[1]).toStrictEqual({
31 | type: 'mdxJsxAttribute',
32 | name: 'empty',
33 | value: null,
34 | });
35 | });
36 | });
37 |
38 | it('should parse Cards tag with columns attribute using mdxish', () => {
39 | const markdown = ' ';
40 |
41 | const hast = mdxish(markdown);
42 | const firstChild = hast.children[0] as Element;
43 | const cards = firstChild.children?.[0] as Element;
44 |
45 | expect(cards.type).toBe('element');
46 | expect(cards.tagName).toBe('Cards');
47 | expect(cards.properties.columns).toBe('3');
48 | });
49 | });
50 |
--------------------------------------------------------------------------------
/__tests__/Glossary.test.tsx:
--------------------------------------------------------------------------------
1 | import { render, fireEvent, screen } from '@testing-library/react';
2 | import React from 'react';
3 |
4 | import { Glossary } from '../components/Glossary';
5 |
6 | test('should output a glossary item if the term exists', () => {
7 | const term = 'acme';
8 | const definition = 'This is a definition';
9 | const { container } = render(acme );
10 |
11 | const trigger = container.querySelector('.GlossaryItem-trigger');
12 | expect(trigger).toHaveTextContent(term);
13 | if (trigger) {
14 | fireEvent.mouseEnter(trigger);
15 | }
16 | const tooltipContent = screen.getByText(definition, { exact: false });
17 | expect(tooltipContent).toHaveTextContent(`${term} - ${definition}`);
18 | });
19 |
20 | test('should be case insensitive', () => {
21 | const term = 'aCme';
22 | const definition = 'This is a definition';
23 | const { container } = render(acme );
24 |
25 | const trigger = container.querySelector('.GlossaryItem-trigger');
26 | expect(trigger).toHaveTextContent('acme');
27 | if (trigger) {
28 | fireEvent.mouseEnter(trigger);
29 | }
30 | const tooltipContent = screen.getByText(definition, { exact: false });
31 | expect(tooltipContent).toHaveTextContent(`${term} - ${definition}`);
32 | });
33 |
34 | test('should output the term if the definition does not exist', () => {
35 | const term = 'something';
36 | const { container } = render({term} );
37 |
38 | expect(container.querySelector('.GlossaryItem-trigger')).not.toBeInTheDocument();
39 | expect(container.querySelector('span')).toHaveTextContent(term);
40 | });
41 |
--------------------------------------------------------------------------------
/__tests__/components/Image.test.tsx:
--------------------------------------------------------------------------------
1 | import { render, screen } from '@testing-library/react';
2 | import React from 'react';
3 |
4 | import Image from '../../components/Image';
5 |
6 | describe('Image', () => {
7 | it('should render', () => {
8 | render( );
9 |
10 | expect(screen.getByRole('img')).toMatchInlineSnapshot(`
11 |
20 | `);
21 | });
22 |
23 | it('should render as a figure/figcaption if it has a caption', () => {
24 | render(
25 |
31 | );
32 |
33 | expect(screen.getByRole('button')).toMatchInlineSnapshot(`
34 |
40 |
43 |
52 |
53 | A pizza bro
54 |
55 |
56 |
57 | `);
58 | });
59 | });
60 |
--------------------------------------------------------------------------------
/__tests__/fixtures/code-block-tests.md:
--------------------------------------------------------------------------------
1 | ---
2 | title: 'Code Block Tests'
3 | category: 5fdf9fc9c2a7ef443e937315
4 | hidden: true
5 | ---
6 |
7 | ## Basics
8 |
9 | ### Simple
10 |
11 | ```php
12 | = "Hello world!" ?> ;
13 | ```
14 |
15 | ```js
16 | console.log('Hello world!');
17 | ```
18 |
19 | ### Tab Meta
20 |
21 | ```Zed
22 | Tab Number Zero
23 | ```
24 |
25 | ```One
26 | Tab Number One
27 | ```
28 |
29 | ### Lang Meta
30 |
31 | ```js English
32 | console.log('Hello world!');
33 | ```
34 |
35 | ```js French
36 | console.log('Bonjour le monde!');
37 | ```
38 |
39 | ```js German
40 | console.log('Hallo welt!');
41 | ```
42 |
43 | ## Breakage
44 |
45 | ### Block Separator 👍
46 |
47 | ##### Section One
48 |
49 | ```Plain
50 | console.log("zed");
51 | ```
52 |
53 | ##### Section Two
54 |
55 | ```js Highlighted
56 | console.log('one');
57 | ```
58 |
59 | `Hello` the `world`?
60 |
61 | ### Inline Separator 👍
62 |
63 | **Section One**
64 |
65 | ```Plain
66 | console.log("zed");
67 | ```
68 |
69 | **Section Two**
70 |
71 | ```js Highlighted
72 | console.log('one');
73 | ```
74 |
75 | ### Plain-Text Separator
76 |
77 | Section One
78 |
79 | ```Plain
80 | console.log("zed");
81 | ```
82 |
83 | Section **Two**
84 |
85 | ```js Highlighted
86 | console.log('one');
87 | ```
88 |
89 | ## Block Wraps
90 |
91 | ### List-Internal
92 |
93 | - ```Name
94 | {{company_name}}
95 | ```
96 | ```Email
97 | {{company_email}}
98 | ```
99 | ```URL
100 | {{company_url}}
101 | ```
102 |
103 | ## Formatting
104 |
105 | ```
106 | This is a long line that will be wrapped to fit within the container.
107 | ```
108 |
--------------------------------------------------------------------------------
/__tests__/lib/mdast/variables-with-spaces/out.json:
--------------------------------------------------------------------------------
1 | {
2 | "children": [
3 | {
4 | "children": [
5 | {
6 | "position": {
7 | "end": {
8 | "column": 8,
9 | "line": 1,
10 | "offset": 7
11 | },
12 | "start": {
13 | "column": 1,
14 | "line": 1,
15 | "offset": 0
16 | }
17 | },
18 | "type": "text",
19 | "value": "Hello, "
20 | },
21 | {
22 | "data": {
23 | "hName": "Variable",
24 | "hProperties": {
25 | "name": "this is cursed"
26 | }
27 | },
28 | "position": {
29 | "end": {
30 | "column": 19,
31 | "line": 1,
32 | "offset": 18
33 | },
34 | "start": {
35 | "column": 8,
36 | "line": 1,
37 | "offset": 7
38 | }
39 | },
40 | "type": "readme-variable",
41 | "value": "{ user['this is cursed'] }"
42 | }
43 | ],
44 | "position": {
45 | "end": {
46 | "column": 19,
47 | "line": 1,
48 | "offset": 18
49 | },
50 | "start": {
51 | "column": 1,
52 | "line": 1,
53 | "offset": 0
54 | }
55 | },
56 | "type": "paragraph"
57 | }
58 | ],
59 | "position": {
60 | "end": {
61 | "column": 1,
62 | "line": 2,
63 | "offset": 19
64 | },
65 | "start": {
66 | "column": 1,
67 | "line": 1,
68 | "offset": 0
69 | }
70 | },
71 | "type": "root"
72 | }
73 |
--------------------------------------------------------------------------------
/lib/utils/mdxish/mdxish-load-components.ts:
--------------------------------------------------------------------------------
1 | import type { CustomComponents, RMDXModule } from '../../../types';
2 | import type { MDXProps } from 'mdx/types';
3 |
4 | import React from 'react';
5 |
6 | import * as Components from '../../../components';
7 |
8 | /**
9 | * Load components from the components directory and wrap them in RMDXModule format
10 | * Similar to prototype.js getAvailableComponents, but for React components instead of MDX files
11 | * This allows mix to use React components directly without MDX compilation
12 | */
13 | export function loadComponents(): CustomComponents {
14 | const components: CustomComponents = {};
15 |
16 | // Iterate through all exported components from components/index.ts
17 | // This mirrors prototype.js approach of getting all available components
18 | Object.entries(Components).forEach(([name, Component]) => {
19 | // Skip non-component exports (React components are functions or objects)
20 | // Also skip falsy values (null, undefined, etc.) since typeof null === 'object'
21 | if (!Component || (typeof Component !== 'function' && typeof Component !== 'object')) {
22 | return;
23 | }
24 |
25 | // Wrap the component in RMDXModule format
26 | // RMDXModule expects: { default: Component, Toc: null, toc: [] }
27 | // getComponent looks for mod.default, so we wrap each component
28 | // MDXContent must be a function (props: MDXProps) => Element
29 | const wrappedModule: RMDXModule = {
30 | default: (props: MDXProps) => React.createElement(Component as React.ComponentType, props),
31 | Toc: null,
32 | toc: [],
33 | } satisfies RMDXModule;
34 |
35 | components[name] = wrappedModule;
36 | });
37 |
38 | return components;
39 | }
40 |
--------------------------------------------------------------------------------
/Makefile:
--------------------------------------------------------------------------------
1 | -include .env
2 |
3 | .DEFAULT_GOAL := help
4 | .PHONY: help
5 | .EXPORT_ALL_VARIABLES:
6 |
7 | DOCKER_WORKSPACE := "/markdown"
8 | MOUNTS = --volume ${PWD}:${DOCKER_WORKSPACE} \
9 | --volume ${DOCKER_WORKSPACE}/node_modules
10 |
11 | build:
12 | docker build -t markdown $(dockerfile) --build-arg REACT_VERSION=${REACT_VERSION} .
13 |
14 | # This lets us call `make run test.browser`. Make expects cmdline args
15 | # to be targets. So this creates noop targets out of args. Copied from
16 | # SO.
17 | ifeq (run,$(firstword $(MAKECMDGOALS)))
18 | RUN_ARGS := $(wordlist 2,$(words $(MAKECMDGOALS)),$(MAKECMDGOALS))
19 | $(eval $(RUN_ARGS):;@:)
20 | endif
21 |
22 | run: build ## Run npm scripts in a docker container. (default: make test.browser)
23 | docker run -it --rm ${MOUNTS} markdown $(RUN_ARGS)
24 |
25 | ci: build ## CI runner for `npm run test.browser -- --ci`
26 | # We don't mount root because CI doesn't care about live changes,
27 | # except for grabbing the snapshot diffs, so we mount __tests__.
28 | # Mounting root would break `make emoji` in the Dockerfile.
29 | docker run -i \
30 | --volume ${PWD}/__tests__:${DOCKER_WORKSPACE}/__tests__ \
31 | --env NODE_VERSION=${NODE_VERSION} \
32 | markdown test.browser -- --ci
33 |
34 | # I would like this to be `updateSnapshots` but I think it's better to
35 | # be consistent with jest.
36 | updateSnapshot: build ## Run `npm run test.browser -- --updateSnapshot`
37 | docker run -i --rm ${MOUNTS} markdown test.browser -- --updateSnapshot
38 |
39 | shell: build ## Docker shell.
40 | docker run -it --rm ${MOUNTS} --entrypoint /bin/bash markdown
41 |
42 | help: ## Show this help.
43 | @grep -E '^[a-zA-Z._-]+:.*?## .*$$' $(MAKEFILE_LIST) | sort | awk 'BEGIN {FS = ":.*?## "}; {printf "\033[36m%-30s\033[0m %s\n", $$1, $$2}'
44 |
--------------------------------------------------------------------------------
/__tests__/lib/mdast/images/inline/out.json:
--------------------------------------------------------------------------------
1 | {
2 | "type": "root",
3 | "children": [
4 | {
5 | "type": "paragraph",
6 | "children": [
7 | {
8 | "type": "text",
9 | "value": "This should work "
10 | },
11 | {
12 | "align": "left",
13 | "border": true,
14 | "src": "https://media.giphy.com/media/3o7TKz9bX9v6hZ8NSA/giphy.gif",
15 | "width": "50%",
16 | "alt": "",
17 | "children": [
18 | {
19 | "type": "text",
20 | "value": "Captioned"
21 | }
22 | ],
23 | "title": null,
24 | "type": "image-block",
25 | "data": {
26 | "hName": "img",
27 | "hProperties": {
28 | "align": "left",
29 | "border": true,
30 | "src": "https://media.giphy.com/media/3o7TKz9bX9v6hZ8NSA/giphy.gif",
31 | "width": "50%",
32 | "alt": "",
33 | "children": [
34 | {
35 | "type": "text",
36 | "value": "Captioned",
37 | "position": {
38 | "start": {
39 | "line": 1,
40 | "column": 129,
41 | "offset": 128
42 | },
43 | "end": {
44 | "line": 1,
45 | "column": 138,
46 | "offset": 137
47 | }
48 | }
49 | }
50 | ],
51 | "title": null
52 | }
53 | }
54 | },
55 | {
56 | "type": "text",
57 | "value": "."
58 | }
59 | ]
60 | }
61 | ]
62 | }
63 |
--------------------------------------------------------------------------------
/__tests__/lib/mdast/__snapshots__/anchor.test.tsx.snap:
--------------------------------------------------------------------------------
1 | // Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html
2 |
3 | exports[`convert anchor tag > converts anchor tag to link node 1`] = `
4 | {
5 | "children": [
6 | {
7 | "children": [
8 | {
9 | "children": [
10 | {
11 | "position": {
12 | "end": {
13 | "column": 41,
14 | "line": 2,
15 | "offset": 41,
16 | },
17 | "start": {
18 | "column": 35,
19 | "line": 2,
20 | "offset": 35,
21 | },
22 | },
23 | "type": "text",
24 | "value": "ReadMe",
25 | },
26 | ],
27 | "position": {
28 | "end": {
29 | "column": 50,
30 | "line": 2,
31 | "offset": 50,
32 | },
33 | "start": {
34 | "column": 1,
35 | "line": 2,
36 | "offset": 1,
37 | },
38 | },
39 | "type": "link",
40 | "url": "https://readme.com",
41 | },
42 | ],
43 | "position": {
44 | "end": {
45 | "column": 50,
46 | "line": 2,
47 | "offset": 50,
48 | },
49 | "start": {
50 | "column": 1,
51 | "line": 2,
52 | "offset": 1,
53 | },
54 | },
55 | "type": "paragraph",
56 | },
57 | ],
58 | "position": {
59 | "end": {
60 | "column": 5,
61 | "line": 3,
62 | "offset": 55,
63 | },
64 | "start": {
65 | "column": 1,
66 | "line": 1,
67 | "offset": 0,
68 | },
69 | },
70 | "type": "root",
71 | }
72 | `;
73 |
--------------------------------------------------------------------------------
/processor/transform/tailwind.tsx:
--------------------------------------------------------------------------------
1 | import type { PhrasingContent, BlockContent, Root } from 'mdast';
2 | import type { MdxJsxFlowElement, MdxJsxTextElement } from 'mdast-util-mdx';
3 | import type { Plugin } from 'unified';
4 | import type { VFile } from 'vfile';
5 |
6 | import { visit, SKIP } from 'unist-util-visit';
7 |
8 | import { isMDXElement, toAttributes, getExports } from '../utils';
9 |
10 | interface TailwindRootOptions {
11 | components: Record;
12 | parseRoot?: boolean;
13 | }
14 |
15 | type Visitor =
16 | | ((node: MdxJsxFlowElement, index: number, parent: BlockContent) => undefined | void)
17 | | ((node: MdxJsxTextElement, index: number, parent: PhrasingContent) => undefined | void);
18 |
19 | const injectTailwindRoot =
20 | ({ components = {} }): Visitor =>
21 | (node, index, parent) => {
22 | if (!('name' in node)) return;
23 | if (!(node.name in components)) return;
24 | if (!('children' in parent)) return;
25 |
26 | const attrs = {
27 | flow: node.type === 'mdxJsxFlowElement',
28 | };
29 |
30 | const wrapper = {
31 | type: node.type,
32 | name: 'TailwindRoot',
33 | attributes: toAttributes(attrs),
34 | children: [node],
35 | };
36 |
37 | parent.children.splice(index, 1, wrapper);
38 |
39 | // eslint-disable-next-line consistent-return
40 | return SKIP;
41 | };
42 |
43 | const tailwind: Plugin<[TailwindRootOptions]> =
44 | ({ components }) =>
45 | (tree: Root, vfile: VFile) => {
46 | const localComponents = getExports(tree).reduce((acc, name) => {
47 | acc[name] = String(vfile);
48 | return acc;
49 | }, {});
50 |
51 | visit(tree, isMDXElement, injectTailwindRoot({ components: { ...components, ...localComponents } }));
52 |
53 | return tree;
54 | };
55 |
56 | export default tailwind;
57 |
--------------------------------------------------------------------------------
/__tests__/__snapshots__/html-block-parser.test.js.snap:
--------------------------------------------------------------------------------
1 | // Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html
2 |
3 | exports[`Parse html block > parses an html block 1`] = `
4 | {
5 | "children": [
6 | {
7 | "children": [
8 | {
9 | "attributes": [],
10 | "children": [
11 | {
12 | "position": {
13 | "end": {
14 | "column": 21,
15 | "line": 2,
16 | "offset": 21,
17 | },
18 | "start": {
19 | "column": 6,
20 | "line": 2,
21 | "offset": 6,
22 | },
23 | },
24 | "type": "text",
25 | "value": "Some block html",
26 | },
27 | ],
28 | "name": "div",
29 | "position": {
30 | "end": {
31 | "column": 27,
32 | "line": 2,
33 | "offset": 27,
34 | },
35 | "start": {
36 | "column": 1,
37 | "line": 2,
38 | "offset": 1,
39 | },
40 | },
41 | "type": "mdxJsxTextElement",
42 | },
43 | ],
44 | "position": {
45 | "end": {
46 | "column": 27,
47 | "line": 2,
48 | "offset": 27,
49 | },
50 | "start": {
51 | "column": 1,
52 | "line": 2,
53 | "offset": 1,
54 | },
55 | },
56 | "type": "paragraph",
57 | },
58 | ],
59 | "position": {
60 | "end": {
61 | "column": 5,
62 | "line": 3,
63 | "offset": 32,
64 | },
65 | "start": {
66 | "column": 1,
67 | "line": 1,
68 | "offset": 0,
69 | },
70 | },
71 | "type": "root",
72 | }
73 | `;
74 |
--------------------------------------------------------------------------------
/__tests__/lib/render-mdxish/CodeTabs.test.tsx:
--------------------------------------------------------------------------------
1 | import '@testing-library/jest-dom';
2 | import { render } from '@testing-library/react';
3 | import React from 'react';
4 |
5 | import { mdxish, renderMdxish } from '../../../lib';
6 |
7 | describe('code tabs renderer', () => {
8 | describe('given 2 consecutive code blocks', () => {
9 | const cppCode = `#include
10 |
11 | int main(void) {
12 | std::cout << "hello world";
13 | return 0;
14 | }`;
15 | const pythonCode = 'print("hello world")';
16 |
17 | const md = `
18 | \`\`\`cplusplus
19 | ${cppCode}
20 | \`\`\`
21 | \`\`\`python
22 | ${pythonCode}
23 | \`\`\`
24 | `;
25 | const mod = renderMdxish(mdxish(md));
26 |
27 | it('should not error when rendering', () => {
28 | expect(() => render( )).not.toThrow();
29 | });
30 |
31 | it('should combine the 2 code blocks into a code-tabs block', () => {
32 | const { container } = render( );
33 |
34 | // Should have a div with class CodeTabs
35 | expect(container.querySelector('div.CodeTabs')).toBeInTheDocument();
36 |
37 | // Verify both codes are in the DOM (C++ is visible, Python tab is hidden but present)
38 | // Using textContent to handle cases where syntax highlighting splits text across nodes
39 | expect(container.textContent).toContain('#include ');
40 | expect(container.textContent).toContain('std::cout << "hello world"');
41 | expect(container.textContent).toContain(pythonCode);
42 | });
43 |
44 | it('should render the buttons with the correct text', () => {
45 | const { container } = render( );
46 | const buttons = container.querySelectorAll('button');
47 | expect(buttons).toHaveLength(2);
48 | expect(buttons[0]).toHaveTextContent('C++');
49 | expect(buttons[1]).toHaveTextContent('Python');
50 | });
51 | });
52 | });
53 |
--------------------------------------------------------------------------------
/docs/features.md:
--------------------------------------------------------------------------------
1 | ---
2 | title: And more...
3 | category:
4 | uri: uri-that-does-not-map-to-5fdf7610134322007389a6ed
5 | content:
6 | excerpt: Additional Markdown features of the ReadMe platform implementation.
7 | privacy:
8 | view: public
9 | ---
10 |
11 | We've also built a lot of great features right in to the ReadMe app, which work on top of our markdown engine to give your developers a best-in-class documentation experience. These features aren't all baked in to the new engine itself, but they're worth mentioning nonetheless!
12 |
13 | ## Data Replacement
14 |
15 | #### User Variables
16 |
17 | If you've set up JWT logins and user variables in your ReadMe project, you can use the included `user` variable. So if you're logged in to and have a `name` variable set, then this...
18 |
19 | ```
20 | Hi, my name is **{user.name}**!
21 | ```
22 |
23 | ...should expand to this: “Hi, my name is **{user.name}**!”
24 |
25 | #### Glossary Terms
26 |
27 | Did you know you can define various technical terms for your ReadMe project? Using our glossary feature, these terms can be used anywhere in your Markdown! Just use the built in Glossary component:
28 |
29 | ```
30 | Both **exogenous ** and **endogenous ** are long words.
31 | ```
32 |
33 | Which expands to: “Both **exogenous ** and **endogenous ** are long words.”
34 |
35 | #### Emoji Shortcodes
36 |
37 | You can use GitHub-style emoji short codes (feat. Owlbert!)
38 |
39 | ```
40 | :sparkles: :owlbert-reading:
41 | ```
42 |
43 | This expands out to: “:sparkles: :owlbert-reading:”
44 |
45 | ## Generative Semantics
46 |
47 | - Markup-based TOC generation.
48 | - Auto-generated [heading anchors](doc:headings#section-incremented-anchors).
49 |
50 | ## Known Issues
51 |
52 | - Variable and glossary term expansions are rendered even when they've been manually escaped by the author.
53 |
--------------------------------------------------------------------------------
/__tests__/migration/html-blocks/index.test.ts:
--------------------------------------------------------------------------------
1 | import fs from 'node:fs';
2 |
3 | import { migrate } from '../../helpers';
4 |
5 | describe('migrating html blocks', () => {
6 | it('correctly escapes back ticks', () => {
7 | const md = `
8 | [block:html]
9 | {
10 | "html": "\`example.com\` "
11 | }
12 | [/block]
13 | `;
14 |
15 | const mdx = migrate(md);
16 | expect(mdx).toMatchInlineSnapshot(`
17 | "{\`
18 | \\\`example.com\\\`
19 | \`}
20 | "
21 | `);
22 | });
23 |
24 | it('does not escape already escaped backticks', () => {
25 | const md = `
26 | [block:html]
27 | {
28 | "html": "${'\\\\`example.com\\\\`'} "
29 | }
30 | [/block]
31 | `;
32 |
33 | const mdx = migrate(md);
34 | expect(mdx).toMatchInlineSnapshot(`
35 | "{\`
36 | \\\\\`example.com\\\\\`
37 | \`}
38 | "
39 | `);
40 | });
41 |
42 | it('does not unescape backslashes', async () => {
43 | const md = fs.readFileSync(`${__dirname}/fixtures/html-block-escapes/in.md`, 'utf-8');
44 |
45 | await expect(migrate(md)).toMatchFileSnapshot(`${__dirname}/fixtures/html-block-escapes/out.mdx`);
46 | });
47 |
48 | it('correctly migrates html with newlines', async () => {
49 | const md = fs.readFileSync(`${__dirname}/fixtures/html-block-escapes-newlines/in.md`, 'utf-8');
50 |
51 | await expect(migrate(md)).toMatchFileSnapshot(`${__dirname}/fixtures/html-block-escapes-newlines/out.mdx`);
52 | });
53 |
54 | it('correctly migrates html with brackets and template literals', async () => {
55 | const md = fs.readFileSync(`${__dirname}/fixtures/html-block-with-brackets/in.md`, 'utf-8');
56 |
57 | await expect(migrate(md)).toMatchFileSnapshot(`${__dirname}/fixtures/html-block-with-brackets/out.mdx`);
58 | });
59 | });
60 |
--------------------------------------------------------------------------------
/example/components.ts:
--------------------------------------------------------------------------------
1 | const components = {
2 | Demo: `
3 | ## This is a Demo Component!
4 |
5 | > 📘 It can render JSX components!
6 | `,
7 | Test: `
8 | export const Test = ({ color = 'thistle' } = {}) => {
9 | return
10 | Hello, World!
11 |
;
12 | };
13 |
14 | export default Test;
15 | `,
16 | MultipleExports: `
17 | export const One = () => "One";
18 |
19 | export const Two = () => "Two";
20 | `,
21 | TailwindRootTest: `
22 | export const StyledComponent = () => {
23 | return
24 | Hello, World!
25 |
;
26 | }
27 | `,
28 | Steps: `
29 | export const Step = ({ children }) => {
30 | return (
31 |
32 |
33 | {children}
34 |
35 |
36 | );
37 | };
38 |
39 |
40 | {props.children}
41 |
42 | `,
43 |
44 | DarkMode: `
45 | import { useState } from 'react';
46 |
47 | export const DarkMode = () => {
48 | const [mode, setMode] = useState('dark');
49 |
50 | return (
51 |
52 |
setMode(mode === 'light' ? 'dark' : 'light')}
55 | style={{ border: 'none' }}
56 | >
57 | Toggle Mode
58 |
59 |
63 | {mode} Mode Component
64 |
65 |
66 | )
67 | }
68 | `,
69 | };
70 |
71 | export default components;
72 |
--------------------------------------------------------------------------------
/.github/workflows/ci.yml:
--------------------------------------------------------------------------------
1 | name: CI
2 | on: [push, pull_request]
3 |
4 | jobs:
5 | Test:
6 | if: "!contains(github.event.head_commit.message, 'SKIP CI')"
7 | runs-on: ubuntu-latest
8 | strategy:
9 | matrix:
10 | node-version:
11 | - lts/-1
12 | - lts/*
13 | - latest
14 | react: [18]
15 | steps:
16 | - uses: actions/checkout@v6
17 |
18 | - name: Use Node.js ${{ matrix.node-version }}
19 | uses: actions/setup-node@v6
20 | with:
21 | node-version: ${{ matrix.node-version }}
22 |
23 | - name: Install dependencies
24 | run: npm ci
25 |
26 | - name: Install React <18 deps
27 | if: matrix.react == '16' || matrix.react == '17'
28 | run: npm i react@${{ matrix.react }} react-dom@${{ matrix.react }} @testing-library/react@12
29 |
30 | - name: Run tests
31 | run: npm test
32 |
33 | visual:
34 | name: 'Visual Tests'
35 | if: "!contains(github.event.head_commit.message, 'SKIP CI')"
36 | runs-on: ubuntu-latest
37 | strategy:
38 | matrix:
39 | node-version: [22.x]
40 | react: [18]
41 |
42 | steps:
43 | - uses: actions/checkout@v6
44 |
45 | - name: Run visual tests (node ${{ matrix.node-version }})
46 | run: make ci
47 |
48 | - name: Upload snapshot diffs
49 | uses: actions/upload-artifact@v5
50 | if: ${{ failure() }}
51 | with:
52 | name: snapshots-diffs
53 | path: __tests__/browser/__image_snapshots__/__diff_output__
54 | overwrite: true
55 |
56 | - name: Update regression test snapshots
57 | if: ${{ failure() }}
58 | run: make updateSnapshot
59 |
60 | - name: Upload snapshots
61 | uses: actions/upload-artifact@v5
62 | if: ${{ failure() }}
63 | with:
64 | name: image-snapshots
65 | path: __tests__/browser/__image_snapshots__/
66 | overwrite: true
67 |
--------------------------------------------------------------------------------
/processor/migration/emphasis.ts:
--------------------------------------------------------------------------------
1 | import type { Emphasis, Node, Parent, Root, Strong, Text } from 'mdast';
2 |
3 | import { visit, EXIT } from 'unist-util-visit';
4 |
5 | const strongTest = (node: Node): node is Emphasis | Strong => ['emphasis', 'strong'].includes(node.type);
6 |
7 | const addSpaceBefore = (index: number, parent: Parent) => {
8 | if (!(index > 0 && parent.children[index - 1])) return;
9 |
10 | const prev = parent.children[index - 1];
11 | if (!('value' in prev) || prev.value.endsWith(' ') || prev.type === 'escape') return;
12 |
13 | parent.children.splice(index, 0, { type: 'text', value: ' ' });
14 | };
15 |
16 | const addSpaceAfter = (index: number, parent: Parent) => {
17 | if (!(index < parent.children.length - 1 && parent.children[index + 1])) return;
18 |
19 | const nextChild = parent.children[index + 1];
20 | if (!('value' in nextChild) || nextChild.value.startsWith(' ')) return;
21 |
22 | parent.children.splice(index + 1, 0, { type: 'text', value: ' ' });
23 | };
24 |
25 | const trimEmphasis = (node: Emphasis | Strong, index: number, parent: Parent) => {
26 | let trimmed = false;
27 |
28 | visit(node, 'text', (child: Text) => {
29 | const newValue = child.value.trimStart();
30 |
31 | if (newValue !== child.value) {
32 | trimmed = true;
33 | child.value = newValue;
34 | }
35 |
36 | return EXIT;
37 | });
38 |
39 | visit(
40 | node,
41 | 'text',
42 | (child: Text) => {
43 | const newValue = child.value.trimEnd();
44 |
45 | if (newValue !== child.value) {
46 | trimmed = true;
47 | child.value = newValue;
48 | }
49 |
50 | return EXIT;
51 | },
52 | true,
53 | );
54 |
55 | if (trimmed) {
56 | addSpaceBefore(index, parent);
57 | addSpaceAfter(index, parent);
58 | }
59 | };
60 |
61 | const emphasisTransfomer = () => (tree: Root) => {
62 | visit(tree, strongTest, trimEmphasis);
63 |
64 | return tree;
65 | };
66 |
67 | export default emphasisTransfomer;
68 |
--------------------------------------------------------------------------------
/docs/tables.md:
--------------------------------------------------------------------------------
1 | ---
2 | title: Tables
3 | category:
4 | uri: uri-that-does-not-map-to-5fdf7610134322007389a6ed
5 | privacy:
6 | view: public
7 | ---
8 |
9 | ## Syntax
10 |
11 | ```markdown
12 | | Left | Center | Right |
13 | |:-----|:--------:|------:|
14 | | L0 | **bold** | $1600 |
15 | | L1 | `code` | $12 |
16 | | L2 | _italic_ | $1 |
17 | ```
18 |
19 | ### Examples
20 |
21 | This example also shows off custom theming!
22 |
23 | | Left | Center | Right |
24 | | :--- | :------: | ----: |
25 | | L0 | **bold** | $1600 |
26 | | L1 | `code` | $12 |
27 | | L2 | _italic_ | $1 |
28 |
29 | ## Custom CSS
30 |
31 | Tables have been simplified to mirror a more standard implementation. We've also set up CSS variable-based theming, which should make it easier to add custom styles.
32 |
33 | ```scss CSS Variables
34 | .markdown-body .rdmd-table {
35 | --table-text: black;
36 | --table-head: #5b1c9f;
37 | --table-head-text: white;
38 | --table-stripe: #f0eaf7;
39 | --table-edges: rgba(34, 5, 64, 0.5);
40 | --table-row: white;
41 | }
42 | ```
43 | ```scss CSS Selectors
44 | /* Table
45 | */
46 | .markdown-body .rdmd-table table {}
47 |
48 | /* Rows
49 | */
50 | .markdown-body .rdmd-table tr {}
51 | .markdown-body .rdmd-table thead tr {}
52 | /* header row's background */
53 | .markdown-body .rdmd-table tr:nth-child(2n) {}
54 | /* striped rows' background */
55 |
56 | /* Cells
57 | */
58 | .markdown-body .rdmd-table th {}
59 | .markdown-body .rdmd-table td {}
60 | ```
61 |
62 | export const stylesheet = `
63 | .markdown-body .rdmd-table {
64 | --table-text: black;
65 | --table-head: #5b1c9f;
66 | --table-head-text: white;
67 | --table-stripe: #f0eaf7;
68 | --table-edges: rgba(34, 5, 64, .5);
69 | --table-row: white;
70 | }
71 |
72 | #rdmd-demo .markdown-body .rdmd-table thead tr {
73 | box-shadow: none;
74 | }
75 |
76 | #rdmd-demo .markdown-body .rdmd-table thead tr th:last-child {
77 | box-shadow: none;
78 | }
79 | `;
80 |
81 |
84 |
--------------------------------------------------------------------------------
/components/Callout/index.tsx:
--------------------------------------------------------------------------------
1 | import emojiRegex from 'emoji-regex';
2 | import * as React from 'react';
3 |
4 | interface Props extends React.PropsWithChildren> {
5 | attributes?: Record;
6 | empty?: boolean;
7 | icon?: string;
8 | theme?: string;
9 | }
10 |
11 | export const themes: Record = {
12 | error: 'error',
13 | default: 'default',
14 | info: 'info',
15 | okay: 'okay',
16 | warn: 'warn',
17 | '\uD83D\uDCD8': 'info',
18 | '\uD83D\uDEA7': 'warn',
19 | '\u26A0\uFE0F': 'warn',
20 | '\uD83D\uDC4D': 'okay',
21 | '\u2705': 'okay',
22 | '\u2757\uFE0F': 'error',
23 | '\u2757': 'error',
24 | '\uD83D\uDED1': 'error',
25 | '\u2049\uFE0F': 'error',
26 | '\u203C\uFE0F': 'error',
27 | '\u2139\uFE0F': 'info',
28 | '\u26A0': 'warn',
29 | };
30 |
31 | export const defaultIcons = {
32 | info: '\uD83D\uDCD8',
33 | warn: '\uD83D\uDEA7',
34 | okay: '\uD83D\uDC4D',
35 | error: '\u2757\uFE0F',
36 | };
37 |
38 | const Callout = (props: Props) => {
39 | const { attributes, empty } = props;
40 | const children = React.Children.toArray(props.children);
41 |
42 | const icon = props.icon;
43 | const isEmoji = icon && emojiRegex().test(icon);
44 | const heading = empty ?
: children[0];
45 | const theme = props.theme || (icon && themes[icon]) || 'default';
46 |
47 | return (
48 | // @ts-expect-error -- theme is not a valid attribute
49 | // eslint-disable-next-line react/jsx-props-no-spreading, react/no-unknown-property
50 |
51 | {icon ? (
52 | isEmoji ? (
53 | {icon}
54 | ) : (
55 |
56 | )
57 | ) : null}
58 | {heading}
59 | {children.slice(1)}
60 |
61 | );
62 | };
63 |
64 | export default Callout;
65 |
--------------------------------------------------------------------------------
/.github/workflows/release.yml:
--------------------------------------------------------------------------------
1 | name: Release
2 | on:
3 | push:
4 | branches:
5 | - main
6 | - beta
7 |
8 | permissions:
9 | id-token: write
10 | contents: write
11 |
12 | jobs:
13 | Release:
14 | if: "!contains(github.event.head_commit.message, 'SKIP CI')"
15 | runs-on: ubuntu-latest
16 | steps:
17 | # Setup the git repo & Node environemnt.
18 | #
19 | - name: Checkout branch (${{ github.ref }})
20 | uses: actions/checkout@v6
21 | with:
22 | persist-credentials: false # install breaks with persistant creds!
23 |
24 | - name: Setup node
25 | uses: actions/setup-node@v6
26 | with:
27 | node-version: 22
28 |
29 | - name: Update npm
30 | run: npm i -g npm@latest
31 |
32 | - name: Install dependencies
33 | run: |
34 | npm ci
35 | npm run build --if-present
36 | env:
37 | PUPPETEER_SKIP_DOWNLOAD: true
38 |
39 | # Build, version, and tag a new release.
40 | #
41 | - name: Publish release
42 | run: npm run release # configured in .releaserc
43 | env:
44 | GH_TOKEN: ${{ secrets.GH_TOKEN }} # auth push to remote repo
45 |
46 | # Push release changes to the remote.
47 | #
48 | - name: Push to remote
49 | uses: ad-m/github-push-action@master
50 | with:
51 | github_token: ${{ secrets.GITHUB_TOKEN }}
52 | branch: ${{ github.ref }}
53 |
54 | # Merge @latest release back to @next.
55 | #
56 | - name: Sync to next
57 | if: "github.ref == 'refs/heads/main'"
58 | uses: ad-m/github-push-action@master
59 | with:
60 | github_token: ${{ secrets.GITHUB_TOKEN }}
61 | branch: ${{ github.ref }}
62 | continue-on-error: true
63 |
64 | # Sync docs to rdmd.readme.io
65 | #
66 | - name: Sync docs to rdmd.readme.io
67 | uses: readmeio/rdme@v10
68 | with:
69 | rdme: docs upload ./docs --key=${{ secrets.RDME_KEY }} --version=2
70 |
--------------------------------------------------------------------------------
/components/PostmanRunButton/index.tsx:
--------------------------------------------------------------------------------
1 | import React, { useEffect } from 'react';
2 |
3 | interface PostmanRunButtonProps {
4 | action: string;
5 | collectionId: string;
6 | collectionUrl: string;
7 | visibility: string;
8 | }
9 |
10 | const PostmanRunButton = ({
11 | collectionId = '', // Add your collection ID here
12 | collectionUrl = '', // Add your collection URL here
13 | visibility = 'public',
14 | action = 'collection/fork',
15 | }: PostmanRunButtonProps) => {
16 | useEffect(() => {
17 | // Only run on client-side
18 | if (typeof window !== 'undefined') {
19 | const scriptFunction = function noIdeaWhatThisShouldBeCalledOrDoesOne(p, o, s, t, m) {
20 | if (!p[s]) {
21 | p[s] = function noIdeaWhatThisShouldBeCalledOrDoesTwo(...args) {
22 | const postmanRunObject = p[t] || (p[t] = []);
23 | postmanRunObject.push(args);
24 | };
25 | }
26 | if (!o.getElementById(s + t)) {
27 | const scriptElement = o.createElement('script');
28 | scriptElement.id = s + t;
29 | scriptElement.async = 1;
30 | scriptElement.src = m;
31 | o.getElementsByTagName('head')[0].appendChild(scriptElement);
32 | }
33 | };
34 |
35 | // Execute the script function directly
36 | scriptFunction(window, document, '_pm', 'PostmanRunObject', 'https://run.pstmn.io/button.js');
37 |
38 | return () => {
39 | // Cleanup if needed
40 | const scriptElement = document.getElementById('_pmPostmanRunObject');
41 | if (scriptElement) document.head.removeChild(scriptElement);
42 | };
43 | }
44 | return undefined;
45 | }, []);
46 |
47 | return (
48 |
57 | );
58 | };
59 |
60 | export default PostmanRunButton;
61 |
--------------------------------------------------------------------------------