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 };