tor-browser

The Tor Browser
git clone https://git.dasho.dev/tor-browser.git
Log | Files | Refs | README | LICENSE

markdown-story-utils.js (6388B)


      1 /* This Source Code Form is subject to the terms of the Mozilla Public
      2 * License, v. 2.0. If a copy of the MPL was not distributed with this
      3 * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
      4 /* eslint-env node */
      5 
      6 const path = require("path");
      7 const fs = require("fs");
      8 
      9 const projectRoot = path.resolve(__dirname, "../../../../");
     10 
     11 /**
     12 * Takes a file path and returns a string to use as the story title, capitalized
     13 * and split into multiple words. The file name gets transformed into the story
     14 * name, which will be visible in the Storybook sidebar. For example, either:
     15 *
     16 * /stories/hello-world.stories.md or /stories/helloWorld.md
     17 *
     18 * will result in a story named "Hello World".
     19 *
     20 * @param {string} filePath - path of the file being processed.
     21 * @returns {string} The title of the story.
     22 */
     23 function getTitleFromPath(filePath) {
     24  let fileName = path.basename(filePath, ".stories.md");
     25  if (fileName != "README") {
     26    try {
     27      let relatedFilePath = path.resolve(
     28        "../../../",
     29        filePath.replace(".md", ".mjs")
     30      );
     31      let relatedFile = fs.readFileSync(relatedFilePath).toString();
     32      let relatedTitle = relatedFile.match(/title: "(.*)"/)[1];
     33      if (relatedTitle) {
     34        return relatedTitle + "/README";
     35      }
     36    } catch {}
     37  }
     38  return separateWords(fileName);
     39 }
     40 
     41 /**
     42 * Splits a string into multiple capitalized words e.g. hello-world, helloWorld,
     43 * and hello.world all become "Hello World."
     44 *
     45 * @param {string} str - String in any case.
     46 * @returns {string} The string split into multiple words.
     47 */
     48 function separateWords(str) {
     49  return (
     50    str
     51      .match(/[A-Z]?[a-z0-9]+/g)
     52      ?.map(text => text[0].toUpperCase() + text.substring(1))
     53      .join(" ") || str
     54  );
     55 }
     56 
     57 /**
     58 * Enables rendering code in our markdown docs by parsing the source for
     59 * annotated code blocks and replacing them with Storybook's Canvas component.
     60 *
     61 * @param {string} source - Stringified markdown source code.
     62 * @returns {string} Source with code blocks replaced by Canvas components.
     63 */
     64 function parseStoriesFromMarkdown(source) {
     65  let storiesRegex = /```(?:js|html) story\n(?<code>[\s\S]*?)```/g;
     66  // $code comes from the <code> capture group in the regex above. It consists
     67  // of any code in between backticks and gets run when used in a Canvas component.
     68  return source.replace(
     69    storiesRegex,
     70    "<Canvas withSource='none'><with-common-styles>\n$<code></with-common-styles></Canvas>"
     71  );
     72 }
     73 
     74 /**
     75 * Finds the name of the component for files in toolkit widgets.
     76 *
     77 * @param {string} resourcePath - Path to the file being processed.
     78 * @returns The component name e.g. "moz-toggle"
     79 */
     80 function getComponentName(resourcePath) {
     81  let componentName = "";
     82  if (resourcePath.includes("toolkit/content/widgets")) {
     83    let storyNameRegex = /(?<=\/widgets\/)(?<name>.*?)(?=\/)/g;
     84    componentName = storyNameRegex.exec(resourcePath)?.groups?.name;
     85  }
     86  return componentName;
     87 }
     88 
     89 /**
     90 * Figures out where a markdown based story should live in Storybook i.e.
     91 * whether it belongs under "Docs" or "UI Widgets" as well as what name to
     92 * display in the sidebar.
     93 *
     94 * @param {string} resourcePath - Path to the file being processed.
     95 * @returns {string} The title of the story.
     96 */
     97 function getStoryTitle(resourcePath) {
     98  // Currently we sort docs only stories under "Docs" by default.
     99  let storyPath = "Docs";
    100 
    101  let relativePath = path
    102    .relative(projectRoot, resourcePath)
    103    .replaceAll(path.sep, "/");
    104 
    105  let componentName = getComponentName(relativePath);
    106  if (componentName) {
    107    // Get the common name for a component e.g. Toggle for moz-toggle
    108    storyPath =
    109      "UI Widgets/" + separateWords(componentName).replace(/^Moz/g, "");
    110  }
    111 
    112  let storyTitle = getTitleFromPath(relativePath);
    113  let title = storyTitle.includes("/")
    114    ? storyTitle
    115    : `${storyPath}/${storyTitle}`;
    116  return title;
    117 }
    118 
    119 /**
    120 * Figures out the path to import a component for cases where we have
    121 * interactive examples in the docs that require the component to have been
    122 * loaded. This wasn't necessary prior to Storybook V7 since everything was
    123 * loaded up front; now stories are loaded on demand.
    124 *
    125 * @param {string} resourcePath - Path to the file being processed.
    126 * @returns Path used to import a component into a story.
    127 */
    128 function getImportPath(resourcePath) {
    129  // We need to normalize the path for this logic to work cross-platform.
    130  let normalizedPath = resourcePath.split(path.sep).join("/");
    131  // Limiting this to toolkit widgets for now since we don't have any
    132  // interactive examples in other docs stories.
    133  if (!normalizedPath.includes("toolkit/content/widgets")) {
    134    return "";
    135  }
    136  let componentName = getComponentName(normalizedPath);
    137  let fileExtension = "";
    138  if (componentName) {
    139    let mjsPath = normalizedPath.replace(
    140      "README.stories.md",
    141      `${componentName}.mjs`
    142    );
    143    let jsPath = normalizedPath.replace(
    144      "README.stories.md",
    145      `${componentName}.js`
    146    );
    147 
    148    if (fs.existsSync(mjsPath)) {
    149      fileExtension = "mjs";
    150    } else if (fs.existsSync(jsPath)) {
    151      fileExtension = "js";
    152    } else {
    153      return "";
    154    }
    155  }
    156  return `"toolkit-widgets/${componentName}/${componentName}.${fileExtension}"`;
    157 }
    158 
    159 /**
    160 * Takes markdown and re-writes it to MDX. Conditionally includes a table of
    161 * arguments when we're documenting a component.
    162 *
    163 * @param {string} source - The markdown source to rewrite to MDX.
    164 * @param {string} title - The title of the story.
    165 * @param {string} resourcePath - Path to the file being processed.
    166 * @returns The markdown source converted to MDX.
    167 */
    168 function getMDXSource(source, title, resourcePath = "") {
    169  let importPath = getImportPath(resourcePath);
    170  let componentName = getComponentName(resourcePath);
    171 
    172  // Unfortunately the indentation/spacing here seems to be important for the
    173  // MDX parser to know what to do in the next step of the Webpack process.
    174  let mdxSource = `
    175 import { Meta, Canvas, ArgTypes } from "@storybook/addon-docs";
    176 ${importPath ? `import ${importPath};` : ""}
    177 
    178 <Meta
    179  title="${title}"
    180  parameters={{
    181    previewTabs: {
    182      canvas: { hidden: true },
    183    },
    184    viewMode: "docs",
    185  }}
    186 />
    187 
    188 ${parseStoriesFromMarkdown(source)}
    189 
    190 ${
    191  importPath &&
    192  `
    193 ## Args Table
    194 
    195 <ArgTypes of={"${componentName}"} />
    196 `
    197 }`;
    198 
    199  return mdxSource;
    200 }
    201 
    202 module.exports = {
    203  getMDXSource,
    204  getStoryTitle,
    205 };