Skip to content

Commit 6eefa29

Browse files
committed
Bug 1854696 Adding a Storybook Component Status Page to /docs r=desktop-theme-reviewers,hjones
Differential Revision: https://phabricator.services.mozilla.com/D264673 UltraBlame original commit: c5817966e60dd877fc0b03ba7adcd32e44437f31
1 parent 1773b32 commit 6eefa29

File tree

7 files changed

+939
-2
lines changed

7 files changed

+939
-2
lines changed

browser/components/storybook/.storybook/main.js

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,8 @@ module.exports = {
1616

1717

1818

19+
`../**/component-status.stories.mjs`,
20+
1921
"../**/README.storybook.stories.md",
2022

2123
"../**/README.*.stories.md",
Lines changed: 221 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,221 @@
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+
5+
import fs from "node:fs";
6+
import path from "node:path";
7+
import { fileURLToPath } from "node:url";
8+
9+
const __filename = fileURLToPath(import.meta.url);
10+
const __dirname = path.dirname(__filename);
11+
12+
/* -------- paths -------- */
13+
14+
// Root of the `component-status` directory
15+
const STATUS_ROOT = path.resolve(__dirname, "..");
16+
// Root of the `firefox` repository
17+
const REPO_ROOT = path.resolve(STATUS_ROOT, "../../..");
18+
19+
const STORIES_DIR = path.join(REPO_ROOT, "toolkit", "content", "widgets");
20+
const BUGS_IDS_JSON = path.join(
21+
STATUS_ROOT,
22+
"component-status",
23+
"data",
24+
"bug-ids.json"
25+
);
26+
const OUT_JSON = path.join(STATUS_ROOT, "component-status", "components.json");
27+
28+
const PROD_STORYBOOK_URL =
29+
globalThis?.process?.env?.PROD_STORYBOOK_URL ||
30+
"https://firefoxux.github.io/firefox-desktop-components/";
31+
32+
/* -------- data bug-ids -------- */
33+
34+
function readJsonIfExists(filePath) {
35+
try {
36+
if (fs.existsSync(filePath)) {
37+
const txt = fs.readFileSync(filePath, "utf8");
38+
return JSON.parse(txt);
39+
}
40+
} catch (e) {
41+
console.error(`Error reading or parsing ${filePath}:`, e);
42+
}
43+
return {};
44+
}
45+
46+
const BUG_IDS = readJsonIfExists(BUGS_IDS_JSON);
47+
48+
/* -------- helpers -------- */
49+
50+
function slugify(str) {
51+
if (!str) {
52+
return "";
53+
}
54+
let s = String(str).trim().toLowerCase();
55+
s = s.replace(/[^a-z0-9]+/g, "-");
56+
s = s.replace(/^-+|-+$/g, "");
57+
s = s.replace(/--+/g, "-");
58+
return s;
59+
}
60+
61+
function getBugzillaUrl(bugId) {
62+
return bugId && bugId > 0
63+
? `https://bugzilla.mozilla.org/show_bug.cgi?id=${bugId}`
64+
: "";
65+
}
66+
67+
function readFileSafe(file) {
68+
try {
69+
return fs.readFileSync(file, "utf8");
70+
} catch (_e) {
71+
return "";
72+
}
73+
}
74+
75+
function findStoriesFiles(dir) {
76+
try {
77+
return fs.readdirSync(dir, { withFileTypes: true }).flatMap(ent => {
78+
const p = path.join(dir, ent.name);
79+
if (ent.isDirectory()) {
80+
return findStoriesFiles(p);
81+
}
82+
return ent.isFile() && /\.stories\.mjs$/i.test(ent.name) ? [p] : [];
83+
});
84+
} catch (e) {
85+
console.error(`Error finding files in ${dir}:`, e);
86+
return [];
87+
}
88+
}
89+
90+
// Parses `export default { title: "...", parameters: { status: "..." } }` from the file content
91+
// Parses `export default { title: "...", parameters: { status: "..." } }`
92+
function parseMeta(src) {
93+
const meta = { title: "", status: "unknown" };
94+
95+
// First, find and capture the story's title
96+
const titleMatch = src.match(
97+
/export\s+default\s*\{\s*[\s\S]*?title\s*:\s*(['"`])([\s\S]*?)\1/
98+
);
99+
if (titleMatch && titleMatch[2]) {
100+
meta.title = titleMatch[2].trim();
101+
}
102+
103+
// Use the final "};" of the export as a definitive anchor to find the correct closing brace.
104+
const paramsBlockMatch = src.match(
105+
/parameters\s*:\s*(\{[\s\S]*?\})\s*,\s*};/
106+
);
107+
108+
if (!paramsBlockMatch) {
109+
return meta;
110+
}
111+
const paramsContent = paramsBlockMatch[1];
112+
113+
// Look for `status: "some-string"`
114+
const stringStatusMatch = paramsContent.match(
115+
/status\s*:\s*(['"`])([\s\S]*?)\1/
116+
);
117+
if (stringStatusMatch && stringStatusMatch[2]) {
118+
meta.status = stringStatusMatch[2].trim().toLowerCase();
119+
return meta;
120+
}
121+
122+
// If a simple string wasn't found, look for `status: { type: "some-string" }`
123+
const objectStatusMatch = paramsContent.match(
124+
/status\s*:\s*\{\s*type\s*:\s*(['"`])([\s\S]*?)\1/
125+
);
126+
if (objectStatusMatch && objectStatusMatch[2]) {
127+
meta.status = objectStatusMatch[2].trim().toLowerCase();
128+
return meta;
129+
}
130+
131+
return meta;
132+
}
133+
134+
// Finds the main story export name (e.g., "Default" or the first export const)
135+
function pickExportName(src) {
136+
const names = [];
137+
const re = /export\s+const\s+([A-Za-z0-9_]+)\s*=/g;
138+
let m;
139+
while ((m = re.exec(src))) {
140+
names.push(m[1]);
141+
}
142+
if (names.length === 0) {
143+
return "default";
144+
}
145+
for (const n of names) {
146+
if (n.toLowerCase() === "default") {
147+
return "default";
148+
}
149+
}
150+
return names[0].toLowerCase();
151+
}
152+
153+
function componentSlug(filePath, title) {
154+
const rel = path.relative(STORIES_DIR, filePath);
155+
const root = rel.split(path.sep)[0] || "";
156+
if (root) {
157+
return root;
158+
}
159+
const parts = title.split("/");
160+
const last = parts[parts.length - 1].trim();
161+
return slugify(last || "unknown");
162+
}
163+
164+
/* -------- build items -------- */
165+
function buildItems() {
166+
const files = findStoriesFiles(STORIES_DIR);
167+
const items = [];
168+
169+
for (const file of files) {
170+
const src = readFileSafe(file);
171+
if (!src) {
172+
continue;
173+
}
174+
175+
const meta = parseMeta(src);
176+
if (!meta.title) {
177+
continue;
178+
}
179+
180+
const exportKey = pickExportName(src);
181+
const titleSlug = slugify(meta.title);
182+
const exportSlug = slugify(exportKey || "default");
183+
if (!titleSlug || !exportSlug) {
184+
continue;
185+
}
186+
187+
const storyId = `${titleSlug}--${exportSlug}`;
188+
const componentName = componentSlug(file, meta.title);
189+
190+
const storyUrl = `${PROD_STORYBOOK_URL}?path=/story/${storyId}`;
191+
const sourceUrl = `https://searchfox.org/firefox-main/source/toolkit/content/widgets/${encodeURIComponent(componentName)}`;
192+
193+
const bugId = BUG_IDS[componentName] || 0;
194+
const bugUrl = getBugzillaUrl(bugId);
195+
196+
items.push({
197+
component: componentName,
198+
title: meta.title,
199+
status: meta.status,
200+
storyId,
201+
storyUrl,
202+
sourceUrl,
203+
bugUrl,
204+
});
205+
}
206+
207+
items.sort((a, b) => a.component.localeCompare(b.component));
208+
return items;
209+
}
210+
211+
/* -------- write JSON -------- */
212+
213+
const items = buildItems();
214+
const data = {
215+
generatedAt: new Date().toISOString(),
216+
count: items.length,
217+
items,
218+
};
219+
220+
fs.writeFileSync(OUT_JSON, JSON.stringify(data, null, 2) + "\n");
221+
console.warn(`wrote ${OUT_JSON} (${items.length} components)`);
Lines changed: 167 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,167 @@
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 https://mozilla.org/MPL/2.0/. */
4+
5+
import {
6+
LitElement,
7+
html,
8+
css,
9+
} from "chrome://global/content/vendor/lit.all.mjs";
10+
import componentsData from "./components.json";
11+
12+
/* DS styles */
13+
import dsTokensTable from "toolkit/themes/shared/design-system/tokens-table.css";
14+
15+
export default {
16+
title: "Docs/Component Statuses",
17+
parameters: {
18+
options: { showPanel: false },
19+
docs: { source: { state: "closed" } },
20+
},
21+
};
22+
23+
class ComponentStatusList extends LitElement {
24+
static properties = {
25+
_components: { state: true },
26+
};
27+
28+
static styles = css`
29+
tr td:first-of-type {
30+
color-scheme: unset;
31+
}
32+
33+
tr td {
34+
border-bottom-color: var(--border-color);
35+
}
36+
37+
/* the button look */
38+
a {
39+
display: inline-flex;
40+
align-items: center;
41+
justify-content: center;
42+
padding: var(--space-xsmall) var(--space-small);
43+
border: var(--border-width) solid var(--border-color);
44+
border-radius: var(--border-radius-small);
45+
background: var(--button-background-color);
46+
color: var(--link-color); /* prevent visited purple */
47+
text-decoration: none;
48+
line-height: 1;
49+
min-inline-size: 0;
50+
cursor: pointer;
51+
}
52+
53+
/* hover/active */
54+
a:hover {
55+
background: var(--button-background-color-hover);
56+
}
57+
58+
/* arrow only on external buttons */
59+
a[target="_blank"]::after {
60+
content: "↗" !important; /* wins over any earlier content:none */
61+
margin-inline-start: var(--space-small);
62+
font-size: var(--font-size-small);
63+
line-height: 1;
64+
opacity: 0.8;
65+
}
66+
`;
67+
68+
constructor() {
69+
super();
70+
71+
this._components = Array.isArray(componentsData?.items)
72+
? componentsData.items
73+
: [];
74+
}
75+
76+
render() {
77+
return html`
78+
<link rel="stylesheet" href=${dsTokensTable} />
79+
<header>
80+
<h1>Component Statuses</h1>
81+
<p>
82+
Tracking
83+
<a
84+
href="https://bugzilla.mozilla.org/show_bug.cgi?id=1795301"
85+
target="_blank"
86+
rel="noreferrer"
87+
>reusable components</a
88+
>
89+
from
90+
<code>toolkit/content/widgets</code>.
91+
</p>
92+
</header>
93+
<div class="table-wrapper">${this._renderTable()}</div>
94+
`;
95+
}
96+
97+
/******** Helpers *********/
98+
// Get story Id href
99+
_storyHrefFromId(storyId) {
100+
return storyId ? `/?path=/story/${storyId}` : "#";
101+
}
102+
103+
_renderLinkGroup(it) {
104+
const storyHref = this._storyHrefFromId(it.storyId);
105+
const links = [["Story", storyHref, { top: true }]];
106+
if (it.sourceUrl) {
107+
links.push(["Source", it.sourceUrl, { top: false }]);
108+
}
109+
const bugUrl = it.bugUrl;
110+
if (bugUrl && /bugzilla\.mozilla\.org/.test(bugUrl)) {
111+
links.push(["Bugzilla", bugUrl, { top: false }]);
112+
}
113+
114+
return html`
115+
${links.map(
116+
([label, href, opts = {}]) => html`
117+
<a
118+
href=${href}
119+
rel="noreferrer"
120+
target=${opts.top ? "_top" : "_blank"}
121+
>
122+
${label}
123+
</a>
124+
`
125+
)}
126+
`;
127+
}
128+
129+
_renderTable() {
130+
return html`
131+
<table class="token-table">
132+
<thead>
133+
<tr>
134+
<th>Component</th>
135+
<th>Status</th>
136+
<th>Links</th>
137+
</tr>
138+
</thead>
139+
<tbody>
140+
${this._components.map(
141+
it => html`
142+
<tr>
143+
<td>
144+
<a
145+
href=${this._storyHrefFromId(it.storyId)}
146+
target="_top"
147+
rel="noreferrer"
148+
>
149+
${it.component}
150+
</a>
151+
</td>
152+
<td>${it.status ?? "unknown"}</td>
153+
<td>${this._renderLinkGroup(it)}</td>
154+
</tr>
155+
`
156+
)}
157+
</tbody>
158+
</table>
159+
`;
160+
}
161+
}
162+
163+
customElements.define("component-status-list", ComponentStatusList);
164+
165+
export const Default = () => {
166+
return html`<component-status-list></component-status-list>`;
167+
};

0 commit comments

Comments
 (0)