build-components-status.mjs (5852B)
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)`);