summaryrefslogtreecommitdiffstats
path: root/browser/components/storybook/.storybook/markdown-story-loader.js
blob: 150db5639ec4db5b5890bc2148a3e4798800d125 (plain)
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
/* This Source Code Form is subject to the terms of the Mozilla Public
 * License, v. 2.0. If a copy of the MPL was not distributed with this
 * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
/* eslint-env node */

/**
 * This file contains a Webpack loader that takes markdown as its source and
 * outputs a docs only MDX Storybook story. This enables us to write docs only
 * pages in plain markdown by specifying a `.stories.md` extension.
 *
 * For more context on docs only stories, see:
 * https://storybook.js.org/docs/web-components/writing-docs/mdx#documentation-only-mdx
 *
 * The MDX generated by the loader will then get run through the same loaders
 * Storybook usually uses to transform MDX files.
 */

const path = require("path");
const fs = require("fs");

const projectRoot = path.resolve(__dirname, "../../../../");

/**
 * Takes a file path and returns a string to use as the story title, capitalized
 * and split into multiple words. The file name gets transformed into the story
 * name, which will be visible in the Storybook sidebar. For example, either:
 *
 * /stories/hello-world.stories.md or /stories/helloWorld.md
 *
 * will result in a story named "Hello World".
 *
 * @param {string} filePath - path of the file being processed.
 * @returns {string} The title of the story.
 */
function getStoryTitle(filePath) {
  let fileName = path.basename(filePath, ".stories.md");
  if (fileName != "README") {
    try {
      let relatedFilePath = path.resolve(
        "../../../",
        filePath.replace(".md", ".mjs")
      );
      let relatedFile = fs.readFileSync(relatedFilePath).toString();
      let relatedTitle = relatedFile.match(/title: "(.*)"/)[1];
      if (relatedTitle) {
        return relatedTitle + "/README";
      }
    } catch {}
  }
  return separateWords(fileName);
}

/**
 * Splits a string into multiple capitalized words e.g. hello-world, helloWorld,
 * and hello.world all become "Hello World."
 * @param {string} str - String in any case.
 * @returns {string} The string split into multiple words.
 */
function separateWords(str) {
  return (
    str
      .match(/[A-Z]?[a-z0-9]+/g)
      ?.map(text => text[0].toUpperCase() + text.substring(1))
      .join(" ") || str
  );
}

/**
 * Enables rendering code in our markdown docs by parsing the source for
 * annotated code blocks and replacing them with Storybook's Canvas component.
 * @param {string} source - Stringified markdown source code.
 * @returns {string} Source with code blocks replaced by Canvas components.
 */
function parseStoriesFromMarkdown(source) {
  let storiesRegex = /```(?:js|html) story\n(?<code>[\s\S]*?)```/g;
  // $code comes from the <code> capture group in the regex above. It consists
  // of any code in between backticks and gets run when used in a Canvas component.
  return source.replace(
    storiesRegex,
    "<Canvas withSource='none'><with-common-styles>$<code></with-common-styles></Canvas>"
  );
}

/**
 * The WebpackLoader export. Takes markdown as its source and returns a docs
 * only MDX story. Falls back to filing stories under "Docs" for everything
 * outside of `toolkit/content/widgets`.
 *
 * @param {string} source - The markdown source to rewrite to MDX.
 */
module.exports = function markdownStoryLoader(source) {
  // Currently we sort docs only stories under "Docs" by default.
  let storyPath = "Docs";

  // `this.resourcePath` is the path of the file being processed.
  let relativePath = path
    .relative(projectRoot, this.resourcePath)
    .replaceAll(path.sep, "/");

  if (relativePath.includes("toolkit/content/widgets")) {
    let storyNameRegex = /(?<=\/widgets\/)(?<name>.*?)(?=\/)/g;
    let componentName = storyNameRegex.exec(relativePath)?.groups?.name;
    if (componentName) {
      // Get the common name for a component e.g. Toggle for moz-toggle
      storyPath =
        "UI Widgets/" + separateWords(componentName).replace(/^Moz/g, "");
    }
  }

  let storyTitle = getStoryTitle(relativePath);
  let title = storyTitle.includes("/")
    ? storyTitle
    : `${storyPath}/${storyTitle}`;

  // Unfortunately the indentation/spacing here seems to be important for the
  // MDX parser to know what to do in the next step of the Webpack process.
  let mdxSource = `
import { Meta, Description, Canvas } from "@storybook/addon-docs";

<Meta
  title="${title}"
  parameters={{
    previewTabs: {
      canvas: { hidden: true },
    },
    viewMode: "docs",
  }}
/>

${parseStoriesFromMarkdown(source)}`;

  return mdxSource;
};