diff options
Diffstat (limited to 'browser/components/storybook/.storybook')
14 files changed, 455 insertions, 334 deletions
diff --git a/browser/components/storybook/.storybook/addon-component-status/StatusIndicator.mjs b/browser/components/storybook/.storybook/addon-component-status/StatusIndicator.jsx index 05d72146cb..581d63a6fa 100644 --- a/browser/components/storybook/.storybook/addon-component-status/StatusIndicator.mjs +++ b/browser/components/storybook/.storybook/addon-component-status/StatusIndicator.jsx @@ -4,7 +4,7 @@ // eslint-disable-next-line no-unused-vars import React from "react"; -import { useParameter } from "@storybook/api"; +import { useParameter } from "@storybook/manager-api"; import { // eslint-disable-next-line no-unused-vars Badge, @@ -85,7 +85,7 @@ export const StatusIndicator = () => { style={{ display: "flex", }} - onVisibilityChange={onVisibilityChange} + onVisibleChange={onVisibilityChange} tooltip={() => ( <div id="statusMessage"> <TooltipMessage diff --git a/browser/components/storybook/.storybook/addon-component-status/preset/manager.mjs b/browser/components/storybook/.storybook/addon-component-status/preset/manager.mjs index 4aa611b156..3fe5eb3f1b 100644 --- a/browser/components/storybook/.storybook/addon-component-status/preset/manager.mjs +++ b/browser/components/storybook/.storybook/addon-component-status/preset/manager.mjs @@ -4,11 +4,9 @@ /** This file handles registering the Storybook addon */ -// eslint-disable-next-line no-unused-vars -import React from "react"; -import { addons, types } from "@storybook/addons"; +import { addons, types } from "@storybook/manager-api"; import { ADDON_ID, TOOL_ID } from "../constants.mjs"; -import { StatusIndicator } from "../StatusIndicator.mjs"; +import { StatusIndicator } from "../StatusIndicator.jsx"; addons.register(ADDON_ID, () => { addons.add(TOOL_ID, { diff --git a/browser/components/storybook/.storybook/addon-fluent/FluentPanel.jsx b/browser/components/storybook/.storybook/addon-fluent/FluentPanel.jsx new file mode 100644 index 0000000000..71714d8d25 --- /dev/null +++ b/browser/components/storybook/.storybook/addon-fluent/FluentPanel.jsx @@ -0,0 +1,110 @@ +/* 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-disable-next-line no-unused-vars +import React, { useEffect, useState } from "react"; +import { addons, useGlobals, useStorybookApi } from "@storybook/manager-api"; +// eslint-disable-next-line no-unused-vars +import { AddonPanel, Table, Form } from "@storybook/components"; +import { FLUENT_CHANGED, FLUENT_SET_STRINGS } from "./constants.mjs"; +// eslint-disable-next-line import/no-unassigned-import +import "./fluent-panel.css"; + +export const FluentPanel = ({ active }) => { + const [fileName, setFileName] = useState(null); + const [strings, setStrings] = useState([]); + const [{ fluentStrings }, updateGlobals] = useGlobals(); + const channel = addons.getChannel(); + const api = useStorybookApi(); + + useEffect(() => { + channel.on(FLUENT_CHANGED, handleFluentChanged); + return () => { + channel.off(FLUENT_CHANGED, handleFluentChanged); + }; + }, [channel]); + + const handleFluentChanged = (nextStrings, fluentFile) => { + setFileName(fluentFile); + setStrings(nextStrings); + }; + + const onInput = e => { + let nextStrings = []; + for (let [key, value] of strings) { + if (key == e.target.name) { + let stringValue = e.target.value; + if (stringValue.startsWith(".")) { + stringValue = "\n" + stringValue; + } + nextStrings.push([key, stringValue]); + } else { + nextStrings.push([key, value]); + } + } + let stringified = nextStrings + .map(([key, value]) => `${key} = ${value}`) + .join("\n"); + channel.emit(FLUENT_SET_STRINGS, stringified); + updateGlobals({ + fluentStrings: { ...fluentStrings, [fileName]: nextStrings }, + }); + return { fileName, strings }; + }; + + const addonTemplate = () => { + if (strings.length === 0) { + return ( + <AddonPanel active={!!active} api={api}> + <div className="addon-panel-body"> + <div className="addon-panel-message"> + This story is not configured to use Fluent. + </div> + </div> + </AddonPanel> + ); + } + + return ( + <AddonPanel active={!!active} api={api}> + <div className="addon-panel-body"> + <Table aria-hidden="false" className="addon-panel-table"> + <thead className="addon-panel-table-head"> + <tr> + <th> + <span>Identifier</span> + </th> + <th> + <span>String</span> + </th> + </tr> + </thead> + <tbody className="addon-panel-table-body"> + {strings.map(([identifier, value]) => ( + <tr key={identifier}> + <td> + <span>{identifier}</span> + </td> + <td> + <Form.Textarea + name={identifier} + onInput={onInput} + defaultValue={value + .trim() + .split("\n") + .map(s => s.trim()) + .join("\n")} + ></Form.Textarea> + </td> + </tr> + ))} + </tbody> + </Table> + </div> + </AddonPanel> + ); + }; + + return addonTemplate(); +}; diff --git a/browser/components/storybook/.storybook/addon-fluent/FluentPanel.mjs b/browser/components/storybook/.storybook/addon-fluent/FluentPanel.mjs deleted file mode 100644 index 692ff73737..0000000000 --- a/browser/components/storybook/.storybook/addon-fluent/FluentPanel.mjs +++ /dev/null @@ -1,121 +0,0 @@ -/* 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/. */ - -import React from "react"; -import { addons } from "@storybook/addons"; -// eslint-disable-next-line no-unused-vars -import { AddonPanel } from "@storybook/components"; -import { FLUENT_CHANGED, FLUENT_SET_STRINGS } from "./constants.mjs"; -// eslint-disable-next-line import/no-unassigned-import -import "./fluent-panel.css"; - -export class FluentPanel extends React.Component { - constructor(props) { - super(props); - this.channel = addons.getChannel(); - this.state = { - name: null, - strings: [], - }; - } - - componentDidMount() { - const { api } = this.props; - api.on(FLUENT_CHANGED, this.handleFluentChanged); - } - - componentWillUnmount() { - const { api } = this.props; - api.off(FLUENT_CHANGED, this.handleFluentChanged); - } - - handleFluentChanged = strings => { - let storyData = this.props.api.getCurrentStoryData(); - let fileName = `${storyData.component}.ftl`; - this.setState(state => ({ ...state, strings, fileName })); - }; - - onInput = e => { - this.setState(state => { - let strings = []; - for (let [key, value] of state.strings) { - if (key == e.target.name) { - let stringValue = e.target.value; - if (stringValue.startsWith(".")) { - stringValue = "\n" + stringValue; - } - strings.push([key, stringValue]); - } else { - strings.push([key, value]); - } - } - let stringified = strings - .map(([key, value]) => `${key} = ${value}`) - .join("\n"); - this.channel.emit(FLUENT_SET_STRINGS, stringified); - const { fluentStrings } = this.props.api.getGlobals(); - this.props.api.updateGlobals({ - fluentStrings: { ...fluentStrings, [state.fileName]: strings }, - }); - return { ...state, strings }; - }); - }; - - render() { - const { api, active } = this.props; - const { strings } = this.state; - if (strings.length === 0) { - return ( - <AddonPanel active={!!active} api={api}> - <div className="addon-panel-body"> - <div className="addon-panel-message"> - This story is not configured to use Fluent. - </div> - </div> - </AddonPanel> - ); - } - - return ( - <AddonPanel active={!!active} api={api}> - <div className="addon-panel-body"> - <table aria-hidden="false" className="addon-panel-table"> - <thead className="addon-panel-table-head"> - <tr> - <th> - <span>Identifier</span> - </th> - <th> - <span>String</span> - </th> - </tr> - </thead> - <tbody className="addon-panel-table-body"> - {strings.map(([identifier, value]) => ( - <tr key={identifier}> - <td> - <span>{identifier}</span> - </td> - <td> - <label> - <textarea - name={identifier} - onInput={this.onInput} - defaultValue={value - .trim() - .split("\n") - .map(s => s.trim()) - .join("\n")} - ></textarea> - </label> - </td> - </tr> - ))} - </tbody> - </table> - </div> - </AddonPanel> - ); - } -} diff --git a/browser/components/storybook/.storybook/addon-fluent/PseudoLocalizationButton.mjs b/browser/components/storybook/.storybook/addon-fluent/PseudoLocalizationButton.jsx index d60112d224..9af7e5ad2b 100644 --- a/browser/components/storybook/.storybook/addon-fluent/PseudoLocalizationButton.mjs +++ b/browser/components/storybook/.storybook/addon-fluent/PseudoLocalizationButton.jsx @@ -4,7 +4,7 @@ // eslint-disable-next-line no-unused-vars import React from "react"; -import { useGlobals } from "@storybook/api"; +import { useGlobals } from "@storybook/manager-api"; import { // eslint-disable-next-line no-unused-vars Icons, diff --git a/browser/components/storybook/.storybook/addon-fluent/fluent-panel.css b/browser/components/storybook/.storybook/addon-fluent/fluent-panel.css index 75f4562820..cb6dd9b6f2 100644 --- a/browser/components/storybook/.storybook/addon-fluent/fluent-panel.css +++ b/browser/components/storybook/.storybook/addon-fluent/fluent-panel.css @@ -15,69 +15,59 @@ font-size: 13px; } -.addon-panel-table { - border-collapse: collapse; +table.addon-panel-table { border-spacing: 0; - color: #333333; - font-size: 13px; - line-height: 20px; text-align: left; width: 100%; margin: 0; } -.addon-panel-table-head { - color: rgba(51,51,51,0.75); +table.addon-panel-table thead.addon-panel-table-head th { + border: none; + color: rgba(46, 52, 56, 0.75); } -.addon-panel-table-head th { - padding: 10px 15px; - border: none; - vertical-align: top; +@media (prefers-color-scheme: dark) { + table.addon-panel-table thead.addon-panel-table-head th { + color: rgba(201, 205, 207, 0.55) + } +} + +table.addon-panel-table thead.addon-panel-table-head tr { + border-top: none; } -.addon-panel-table-head th:first-of-type, .addon-panel-table-body td:first-of-type { +.addon-panel-table-head th:first-of-type, +.addon-panel-table-body td:first-of-type { width: 25%; padding-left: 20px; + border-inline: none; } -.addon-panel-table-head th:last-of-type, .addon-panel-table-body td:last-of-type { +.addon-panel-table-head th:last-of-type, +.addon-panel-table-body td:last-of-type { padding-right: 20px; + border-inline-start: none; } -.addon-panel-table-body { - border-radius: 4px; -} - -.addon-panel-table-body tr { +.addon-panel-body { overflow: hidden; - border-top: 1px solid #e6e6e6; } .addon-panel-table-body td { - padding: 10px 15px; font-weight: bold; + vertical-align: top; } -.addon-panel-table-body label { - display: flex; +table.addon-panel-table .addon-panel-table-body tr:nth-of-type(2n) { + background-color: unset; +} + +.addon-panel-table-body tr:last-of-type td { + border-bottom: none; } .addon-panel-table-body textarea { height: fit-content; - appearance: none; - border: none; - box-sizing: inherit; - display: block; - margin: 0; - background-color: rgb(255, 255, 255); - padding: 6px 10px; - color: #333333; - box-shadow: rgba(0,0,0,.1) 0 0 0 1px inset; - border-radius: 4px; - line-height: 20px; - flex: 1; - text-align: left; - overflow: visible; - max-height: 400px; + width: 100%; } diff --git a/browser/components/storybook/.storybook/addon-fluent/preset/manager.mjs b/browser/components/storybook/.storybook/addon-fluent/preset/manager.mjs index 0f7ff9299b..29b57812bd 100644 --- a/browser/components/storybook/.storybook/addon-fluent/preset/manager.mjs +++ b/browser/components/storybook/.storybook/addon-fluent/preset/manager.mjs @@ -4,13 +4,10 @@ /** This file handles registering the Storybook addon */ -// eslint-disable-next-line no-unused-vars -import React from "react"; -import { addons, types } from "@storybook/addons"; +import { addons, types } from "@storybook/manager-api"; import { ADDON_ID, PANEL_ID, TOOL_ID } from "../constants.mjs"; -import { PseudoLocalizationButton } from "../PseudoLocalizationButton.mjs"; -// eslint-disable-next-line no-unused-vars -import { FluentPanel } from "../FluentPanel.mjs"; +import { PseudoLocalizationButton } from "../PseudoLocalizationButton.jsx"; +import { FluentPanel } from "../FluentPanel.jsx"; // Register the addon. addons.register(ADDON_ID, api => { @@ -27,8 +24,6 @@ addons.register(ADDON_ID, api => { title: "Fluent", //👇 Sets the type of UI element in Storybook type: types.PANEL, - render: ({ active, key }) => ( - <FluentPanel active={active} api={api} key={key}></FluentPanel> - ), + render: ({ active }) => FluentPanel({ active }), }); }); diff --git a/browser/components/storybook/.storybook/addon-fluent/withPseudoLocalization.mjs b/browser/components/storybook/.storybook/addon-fluent/withPseudoLocalization.mjs index 9d6c62af38..74fd67a6cd 100644 --- a/browser/components/storybook/.storybook/addon-fluent/withPseudoLocalization.mjs +++ b/browser/components/storybook/.storybook/addon-fluent/withPseudoLocalization.mjs @@ -2,7 +2,7 @@ * 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/. */ -import { useEffect, useGlobals, addons } from "@storybook/addons"; +import { useEffect, useGlobals, useChannel } from "@storybook/preview-api"; import { DIRECTIONS, DIRECTION_BY_STRATEGY, @@ -25,11 +25,11 @@ export const withPseudoLocalization = (StoryFn, context) => { const [{ pseudoStrategy }] = useGlobals(); const direction = DIRECTION_BY_STRATEGY[pseudoStrategy] || DIRECTIONS.ltr; const isInDocs = context.viewMode === "docs"; - const channel = addons.getChannel(); + const emit = useChannel({}); useEffect(() => { if (pseudoStrategy) { - channel.emit(UPDATE_STRATEGY_EVENT, pseudoStrategy); + emit(UPDATE_STRATEGY_EVENT, pseudoStrategy); } }, [pseudoStrategy]); @@ -56,8 +56,7 @@ export const withPseudoLocalization = (StoryFn, context) => { */ export const withFluentStrings = (StoryFn, context) => { const [{ fluentStrings }, updateGlobals] = useGlobals(); - const channel = addons.getChannel(); - + const emit = useChannel({}); const fileName = context.component + ".ftl"; let strings = []; @@ -83,7 +82,7 @@ export const withFluentStrings = (StoryFn, context) => { } } - channel.emit(FLUENT_CHANGED, strings); + emit(FLUENT_CHANGED, strings, fileName); return StoryFn(); }; diff --git a/browser/components/storybook/.storybook/main.js b/browser/components/storybook/.storybook/main.js index 3e42f778a4..5791d1e492 100644 --- a/browser/components/storybook/.storybook/main.js +++ b/browser/components/storybook/.storybook/main.js @@ -6,6 +6,7 @@ const path = require("path"); const webpack = require("webpack"); const rewriteChromeUri = require("./chrome-uri-utils.js"); +const mdIndexer = require("./markdown-story-indexer.js"); const projectRoot = path.resolve(__dirname, "../../../../"); @@ -37,34 +38,35 @@ module.exports = { path.resolve(__dirname, "addon-fluent"), path.resolve(__dirname, "addon-component-status"), ], - framework: "@storybook/web-components", + framework: { + name: "@storybook/web-components-webpack5", + options: {}, + }, + + experimental_indexers: async existingIndexers => { + const customIndexer = { + test: /(stories|story)\.md$/, + createIndex: mdIndexer, + }; + return [...existingIndexers, customIndexer]; + }, webpackFinal: async (config, { configType }) => { // `configType` has a value of 'DEVELOPMENT' or 'PRODUCTION' // You can change the configuration based on that. // 'PRODUCTION' is used when building the static version of storybook. // Make whatever fine-grained changes you need - config.resolve.alias.browser = `${projectRoot}/browser`; - config.resolve.alias.toolkit = `${projectRoot}/toolkit`; - config.resolve.alias[ - "toolkit-widgets" - ] = `${projectRoot}/toolkit/content/widgets/`; - config.resolve.alias[ - "lit.all.mjs" - ] = `${projectRoot}/toolkit/content/widgets/vendor/lit.all.mjs`; - // @mdx-js/react@1.x.x versions don't get hoisted to the root node_modules - // folder due to the versions of React it accepts as a peer dependency. That - // means we have to go one level deeper and look in the node_modules of - // @storybook/addon-docs, which depends on @mdx-js/react. - config.resolve.alias["@storybook/addon-docs"] = - "browser/components/storybook/node_modules/@storybook/addon-docs"; - config.resolve.alias["@mdx-js/react"] = - "@storybook/addon-docs/node_modules/@mdx-js/react"; - - // The @storybook/web-components project uses lit-html. Redirect it to our - // bundled version. - config.resolve.alias["lit-html/directive-helpers.js"] = "lit.all.mjs"; - config.resolve.alias["lit-html"] = "lit.all.mjs"; + config.resolve.alias = { + browser: `${projectRoot}/browser`, + toolkit: `${projectRoot}/toolkit`, + "toolkit-widgets": `${projectRoot}/toolkit/content/widgets/`, + "lit.all.mjs": `${projectRoot}/toolkit/content/widgets/vendor/lit.all.mjs`, + react: "browser/components/storybook/node_modules/react", + "react/jsx-runtime": + "browser/components/storybook/node_modules/react/jsx-runtime", + "@storybook/addon-docs": + "browser/components/storybook/node_modules/@storybook/addon-docs", + }; config.plugins.push( // Rewrite chrome:// URI imports to file system paths. @@ -147,7 +149,4 @@ module.exports = { // Return the altered config return config; }, - core: { - builder: "webpack5", - }, }; diff --git a/browser/components/storybook/.storybook/markdown-story-indexer.js b/browser/components/storybook/.storybook/markdown-story-indexer.js new file mode 100644 index 0000000000..7a03f94844 --- /dev/null +++ b/browser/components/storybook/.storybook/markdown-story-indexer.js @@ -0,0 +1,50 @@ +/* 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 */ + +const { loadCsf } = require("@storybook/csf-tools"); +const { compile } = require("@storybook/mdx2-csf"); +const { getStoryTitle, getMDXSource } = require("./markdown-story-utils.js"); +const fs = require("fs"); + +/** + * Function that tells Storybook how to index markdown based stories. This is + * responsible for Storybook knowing how to populate the sidebar in the + * Storybook UI, then retrieve the relevant file when a story is selected. In + * order to get the data Storybook needs, we have to convert the markdown to + * MDX, the convert that to CSF. + * More info on indexers can be found here: storybook.js.org/docs/api/main-config-indexers + * @param {string} fileName - Path to the file being processed. + * @param {Object} opts - Options to configure the indexer. + * @returns Array of IndexInput objects. + */ +module.exports = async (fileName, opts) => { + // eslint-disable-next-line no-unsanitized/method + const content = fs.readFileSync(fileName, "utf8"); + const title = getStoryTitle(fileName); + const code = getMDXSource(content, title); + + // Compile MDX into CSF + const csfCode = await compile(code, opts); + + // Parse CSF component + let csf = loadCsf(csfCode, { fileName, makeTitle: () => title }).parse(); + + // Return an array of story indexes. + // Cribbed from https://github.com/storybookjs/storybook/blob/4169cd5b4ec9111de69f64a5e06edab9a6d2b0b8/code/addons/docs/src/preset.ts#L189 + const { indexInputs, stories } = csf; + return indexInputs.map((input, index) => { + const docsOnly = stories[index].parameters?.docsOnly; + const tags = input.tags ? input.tags : []; + if (docsOnly) { + tags.push("stories-mdx-docsOnly"); + } + // the mdx-csf compiler automatically adds the 'stories-mdx' tag to meta, + // here' we're just making sure it is always there + if (!tags.includes("stories-mdx")) { + tags.push("stories-mdx"); + } + return { ...input, tags }; + }); +}; diff --git a/browser/components/storybook/.storybook/markdown-story-loader.js b/browser/components/storybook/.storybook/markdown-story-loader.js index b11036af74..f1848d0e82 100644 --- a/browser/components/storybook/.storybook/markdown-story-loader.js +++ b/browser/components/storybook/.storybook/markdown-story-loader.js @@ -15,71 +15,7 @@ * 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>\n$<code></with-common-styles></Canvas>" - ); -} +const { getStoryTitle, getMDXSource } = require("./markdown-story-utils.js"); /** * The WebpackLoader export. Takes markdown as its source and returns a docs @@ -89,61 +25,8 @@ function parseStoriesFromMarkdown(source) { * @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, "/"); - let componentName; - - if (relativePath.includes("toolkit/content/widgets")) { - let storyNameRegex = /(?<=\/widgets\/)(?<name>.*?)(?=\/)/g; - 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}`; - - let componentStories; - if (componentName) { - componentStories = this.resourcePath - .replace("README", componentName) - .replace(".md", ".mjs"); - try { - fs.statSync(componentStories); - componentStories = "./" + path.basename(componentStories); - } catch { - componentStories = null; - } - } - - // 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, Story } from "@storybook/addon-docs"; -${componentStories ? `import * as Stories from "${componentStories}";` : ""} - -<Meta - title="${title}" - ${componentStories ? `of={Stories}` : ""} - parameters={{ - previewTabs: { - canvas: { hidden: true }, - }, - viewMode: "docs", - }} -/> - -${parseStoriesFromMarkdown(source)}`; - + let title = getStoryTitle(this.resourcePath); + let mdxSource = getMDXSource(source, title, this.resourcePath); return mdxSource; }; diff --git a/browser/components/storybook/.storybook/markdown-story-utils.js b/browser/components/storybook/.storybook/markdown-story-utils.js new file mode 100644 index 0000000000..1cc78164ad --- /dev/null +++ b/browser/components/storybook/.storybook/markdown-story-utils.js @@ -0,0 +1,197 @@ +/* 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 */ + +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 getTitleFromPath(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>\n$<code></with-common-styles></Canvas>" + ); +} + +/** + * Finds the name of the component for files in toolkit widgets. + * @param {string} resourcePath - Path to the file being processed. + * @returns The component name e.g. "moz-toggle" + */ +function getComponentName(resourcePath) { + let componentName = ""; + if (resourcePath.includes("toolkit/content/widgets")) { + let storyNameRegex = /(?<=\/widgets\/)(?<name>.*?)(?=\/)/g; + componentName = storyNameRegex.exec(resourcePath)?.groups?.name; + } + return componentName; +} + +/** + * Figures out where a markdown based story should live in Storybook i.e. + * whether it belongs under "Docs" or "UI Widgets" as well as what name to + * display in the sidebar. + * @param {string} resourcePath - Path to the file being processed. + * @returns {string} The title of the story. + */ +function getStoryTitle(resourcePath) { + // Currently we sort docs only stories under "Docs" by default. + let storyPath = "Docs"; + + let relativePath = path + .relative(projectRoot, resourcePath) + .replaceAll(path.sep, "/"); + + let componentName = getComponentName(relativePath); + 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 = getTitleFromPath(relativePath); + let title = storyTitle.includes("/") + ? storyTitle + : `${storyPath}/${storyTitle}`; + return title; +} + +/** + * Figures out the path to import a component for cases where we have + * interactive examples in the docs that require the component to have been + * loaded. This wasn't necessary prior to Storybook V7 since everything was + * loaded up front; now stories are loaded on demand. + * @param {string} resourcePath - Path to the file being processed. + * @returns Path used to import a component into a story. + */ +function getImportPath(resourcePath) { + // Limiting this to toolkit widgets for now since we don't have any + // interactive examples in other docs stories. + if (!resourcePath.includes("toolkit/content/widgets")) { + return ""; + } + let componentName = getComponentName(resourcePath); + let fileExtension = ""; + if (componentName) { + let mjsPath = resourcePath.replace( + "README.stories.md", + `${componentName}.mjs` + ); + let jsPath = resourcePath.replace( + "README.stories.md", + `${componentName}.js` + ); + + if (fs.existsSync(mjsPath)) { + fileExtension = "mjs"; + } else if (fs.existsSync(jsPath)) { + fileExtension = "js"; + } else { + return ""; + } + } + return `"toolkit-widgets/${componentName}/${componentName}.${fileExtension}"`; +} + +/** + * Takes markdown and re-writes it to MDX. Conditionally includes a table of + * arguments when we're documenting a component. + * @param {string} source - The markdown source to rewrite to MDX. + * @param {string} title - The title of the story. + * @param {string} resourcePath - Path to the file being processed. + * @returns The markdown source converted to MDX. + */ +function getMDXSource(source, title, resourcePath = "") { + let importPath = getImportPath(resourcePath); + let componentName = getComponentName(resourcePath); + + // 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, Canvas, ArgTypes } from "@storybook/addon-docs"; +${importPath ? `import ${importPath};` : ""} + +<Meta + title="${title}" + parameters={{ + previewTabs: { + canvas: { hidden: true }, + }, + viewMode: "docs", + }} +/> + +${parseStoriesFromMarkdown(source)} + +${ + importPath && + ` +## Args Table + +<ArgTypes of={"${componentName}"} /> +` +}`; + + return mdxSource; +} + +module.exports = { + getMDXSource, + getStoryTitle, +}; diff --git a/browser/components/storybook/.storybook/preview-head.html b/browser/components/storybook/.storybook/preview-head.html index c2f9e8d1a2..206972e714 100644 --- a/browser/components/storybook/.storybook/preview-head.html +++ b/browser/components/storybook/.storybook/preview-head.html @@ -21,6 +21,15 @@ text-decoration: underline !important; } + div:has(p, .toc-wrapper) { + width: unset !important; + min-width: 10rem !important; + } + + .toc-wrapper { + margin-inline-end: 8px; + } + /* Override the default Storybook padding in favour of styles provided by our WithCommonStyles wrapper */ .sb-show-main.sb-main-padded { diff --git a/browser/components/storybook/.storybook/preview.mjs b/browser/components/storybook/.storybook/preview.mjs index acff43b7c0..4e0f3f407d 100644 --- a/browser/components/storybook/.storybook/preview.mjs +++ b/browser/components/storybook/.storybook/preview.mjs @@ -94,15 +94,27 @@ class WithCommonStyles extends MozLitElement { customElements.define("with-common-styles", WithCommonStyles); // Wrap all stories in `with-common-styles`. -export const decorators = [ - (story, context) => - html` - <with-common-styles - .story=${story} - .context=${context} - ></with-common-styles> - `, -]; +export default { + decorators: [ + (story, context) => + html` + <with-common-styles + .story=${story} + .context=${context} + ></with-common-styles> + `, + ], + parameters: { + docs: { + toc: { + disable: false, + headingSelector: "h2, h3", + ignoreSelector: "h2.text-truncated-ellipsis, .toc-ignore", + title: "On this page", + }, + }, + }, +}; // Enable props tables documentation. setCustomElementsManifest(customElementsManifest); |