All files / src/storybook segment-mdx.ts

100% Statements 35/35
88.46% Branches 23/26
100% Functions 9/9
100% Lines 33/33

Press n or j to go to the next uncovered block, b, p or k for the previous block.

1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198          113x       112x                                                                   144x 144x       230x       190x 15x   190x 190x       113x             113x 144x                                                                                                                                                                         113x         124x 124x   113x   32x   32x   32x       32x 176x 176x   64x 64x 112x 112x 112x 112x 16x 16x         32x     96x   24x          
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),
      ])
    )
  );
};