orphic-cypress
Version:
Set of utilities and typescript transformers to cover storybook stories with cypress component tests
198 lines (182 loc) • 5.36 kB
text/typescript
import * as React from "react";
/**
* Check if the first prop of a
*/
const isRawMd = (childProps: {
mdxType?: string;
children?: { props?: { className?: string } };
}): boolean =>
childProps.mdxType === "pre" &&
childProps.children?.props?.className === "language-md";
/** Rendered child, with props */
export type RenderedChild = any;
/** mdx component as it exists after importing via `import someMdx from "./some.mdx"` */
export type Mdx = (props: unknown) => RenderedChild;
/** Object gathered for each header */
export type ParsedMdx = {
/** Full content of this segment of markdown including the header */
full: RenderedChild[];
/** Content of this segment of markdown excluding header */
body: RenderedChild[];
/** Raw string extracted from 'md' code blocks */
md: string;
};
/** Function returned for each header, with properties assigned for more specific use cases */
export type MdxSegment = {
/** Function which is useful passed to parameters.docs.page directly */
(): RenderedChild[];
} & ParsedMdx;
/** Header in kebab case as key to object of markdown segments */
export type HeaderKeyedMdxSegment = { [id: string]: MdxSegment };
/**
* quick and dirty fifo
* @private
*/
export class Fifo<T, U> {
limit: number;
_cache: Map<T, U>;
// TODO: babel was getting upset with `private` keyword
constructor(limit = 50, _cache = new Map<T, U>()) {
this.limit = limit;
this._cache = _cache;
}
get(key: T) {
return this._cache.get(key);
}
set(key: T, val: U) {
if (this._cache.size === this.limit) {
this._cache.delete(this._cache.keys().next().value);
}
this._cache.set(key, val);
return val;
}
}
const cache = new Fifo<Mdx, HeaderKeyedMdxSegment>();
/**
* simple kebab-case converter for space separated text,
* returns undefined if str is undefined or null
* @private
*/
export const safeKebabCase = (str?: string | null) =>
typeof str === "string" ? str.toLowerCase().replace(/ /g, "-") : null;
/**
* Split up an MDX files into headers for easy use in multiple parts of documentation
* or in multiple files, with some added perks.
*
* Currently, this breaks on any header such that a file like
* ~~~md
* # First Component
*
* Something
*
* ## Second Component
*
* ```md
* # Second header description
* This second component does stuff
* ```
* ~~~
* becomes essentially
* ```ts
* {
* "first-component": {
* full: [<h1>First Component</h1>,<p>Something</p>],
* body: [<p>Something</p>],
* md: "",
* },
* "second-component": {
* full: [<h1>Second Component</h1>,<p>Other</p>],
* body: [<code>....</code>],
* md: "# Second header description\nThis second component does stuff",
* },
* }
* ```
* Although actually they'll be functions at those locations that also have those properties,
* but is `() => full` at invocation. Note how it picks up md code blocks as raw text, suitable
* for story descriptions.
*
*
* Then you can use it like
* ```ts
* import mdx from "./some.mdx";
* const mdxObject = segmentMdx(mdx);
* // define FirstComponent...
* FirstComponent.parameters = {
* docs: {
* page: mdxObject['first-component'],
* }
* };
* // define SecondComponent...
* SecondComponent.parameters = {
* docs: {
* story: {
* description: mdxObject['second-component'].md,
* }
* }
* };
* ```
*
* And if you needed to combine them you could do something like
* ```ts
* docs: {
* page: () => [
* ...mdxObject["first-component"].full,
* ...mdxObject["second-component"].full,
* ]
* }
* ```
*
* Or, in an mdx file like so (real example):
* ```md
* import { Meta } from "@storybook/addon-docs";
* import readme from "../../README.md";
* import { segmentMdx } from "orphic-cypress";
*
* <Meta title="MockRequests/Overview" />
*
* <>{segmentMdx(readme)["intercepting-api-requests"].full}</>
*
* <-- more markdown -->
* # Further afield
* ```
*
* Uses a dead simple FIFO cache of size 50 just to avoid thinking about memory consumption issues.
*/
export const segmentMdx = (
mdx: Mdx,
/** force skipping the cache */
force?: boolean
): HeaderKeyedMdxSegment => {
const fromCache = !force && cache.get(mdx);
if (fromCache) return fromCache;
if (typeof mdx !== "function") return cache.set(mdx, {});
const rendered = mdx({});
let currentId = "file";
const collection: { [id: string]: ParsedMdx } = {
file: { full: [], body: [], md: "" },
};
React.Children.forEach(rendered.props.children, (child) => {
const childrenOfChild = child.props.children;
if (/^h\d$/.test(child.props.mdxType)) {
// not sure why exactly the id is sometimes already present
currentId = child.props.id || safeKebabCase(childrenOfChild) || "unknown";
collection[currentId] = { full: [child], body: [], md: "" };
} else if (collection[currentId]) {
collection[currentId].full.push(child);
collection[currentId].body.push(child);
if (isRawMd(child.props)) {
const rawMd = childrenOfChild.props.children;
collection[currentId].md += rawMd;
}
}
});
return cache.set(
mdx,
Object.fromEntries(
Object.entries(collection).map(([k, v]): [string, MdxSegment] => [
k,
Object.assign(() => v.full, v),
])
)
);
};