a reactive (signals based) hypermedia web framework (wip) stormlightlabs.github.io/volt/
hypermedia frontend signals
at main 11 kB view raw
1import { echo } from "$console/echo.js"; 2import { getDocsPath, getLibSrcPath } from "$utils/paths.js"; 3import { trackVersion } from "$versioning/tracker.js"; 4import { mkdir, readFile, writeFile } from "node:fs/promises"; 5import path from "node:path"; 6 7type CSSComment = { selector: string; comment: string }; 8 9type CSSVariable = { name: string; value: string; category: string }; 10 11type ElementCoverage = { element: string; covered: boolean }; 12 13/** 14 * Extract CSS doc comments from CSS file by parsing block comments and associatint them with selectors 15 */ 16function extractCSSComments(cssContent: string): CSSComment[] { 17 const comments: CSSComment[] = []; 18 const lines = cssContent.split("\n"); 19 20 let currentComment = ""; 21 let inComment = false; 22 let commentLines: string[] = []; 23 24 for (let i = 0; i < lines.length; i++) { 25 const line = lines[i]; 26 const trimmed = line.trim(); 27 28 if (trimmed.startsWith("/**") || trimmed.startsWith("/*")) { 29 inComment = true; 30 commentLines = []; 31 const commentText = trimmed.replace(/^\/\*+\s*/, "").replace(/\*\/\s*$/, ""); 32 if (commentText && !commentText.startsWith("=")) { 33 commentLines.push(commentText); 34 } 35 continue; 36 } 37 38 if (inComment) { 39 if (trimmed.includes("*/")) { 40 const commentText = trimmed.replace(/\*\/.*$/, "").replace(/^\*\s*/, ""); 41 if (commentText && !commentText.startsWith("=")) { 42 commentLines.push(commentText); 43 } 44 currentComment = commentLines.join(" ").trim(); 45 inComment = false; 46 47 for (let j = i + 1; j < lines.length; j++) { 48 const nextLine = lines[j].trim(); 49 if (nextLine === "" || nextLine.startsWith("/*")) { 50 continue; 51 } 52 53 if (nextLine.includes("{") || j + 1 < lines.length && lines[j + 1].includes("{")) { 54 const selector = nextLine.replace("{", "").trim(); 55 if (selector && currentComment) { 56 comments.push({ selector, comment: currentComment }); 57 } 58 break; 59 } 60 break; 61 } 62 currentComment = ""; 63 continue; 64 } 65 66 const commentText = trimmed.replace(/^\*\s*/, ""); 67 if (commentText && !commentText.startsWith("=")) { 68 commentLines.push(commentText); 69 } 70 } 71 } 72 73 return comments; 74} 75 76/** 77 * Extract CSS custom properties (variables) from :root, grouping them by category based on naming conventions 78 */ 79function extractCSSVariables(cssContent: string): CSSVariable[] { 80 const variables: CSSVariable[] = []; 81 const lines = cssContent.split("\n"); 82 let inRoot = false; 83 84 for (const line of lines) { 85 const trimmed = line.trim(); 86 87 if (trimmed.startsWith(":root")) { 88 inRoot = true; 89 continue; 90 } 91 92 if (inRoot && trimmed === "}") { 93 inRoot = false; 94 continue; 95 } 96 97 if (inRoot && trimmed.startsWith("--")) { 98 const match = trimmed.match(/^(--[a-z0-9-]+)\s*:\s*([^;]+);/); 99 if (match) { 100 const [, name, value] = match; 101 const category = categorizeCSSVar(name); 102 variables.push({ name, value: value.trim(), category }); 103 } 104 } 105 } 106 107 return variables; 108} 109 110/** 111 * Categorize CSS variable by name prefix 112 */ 113function categorizeCSSVar(name: string): string { 114 if (name.startsWith("--font")) return "Typography"; 115 if (name.startsWith("--line-height")) return "Typography"; 116 if (name.startsWith("--space")) return "Spacing"; 117 if (name.startsWith("--color")) return "Colors"; 118 if (name.startsWith("--shadow")) return "Effects"; 119 if (name.startsWith("--radius")) return "Effects"; 120 if (name.startsWith("--transition")) return "Effects"; 121 if (name.startsWith("--content")) return "Layout"; 122 if (name.startsWith("--sidenote")) return "Layout"; 123 return "Other"; 124} 125 126function validateElementCoverage(cssContent: string): ElementCoverage[] { 127 const elementsToCheck = [ 128 "html", 129 "body", 130 "h1", 131 "h2", 132 "h3", 133 "h4", 134 "h5", 135 "h6", 136 "p", 137 "a", 138 "em", 139 "strong", 140 "mark", 141 "small", 142 "sub", 143 "sup", 144 "ul", 145 "ol", 146 "li", 147 "dl", 148 "dt", 149 "dd", 150 "blockquote", 151 "cite", 152 "code", 153 "pre", 154 "kbd", 155 "samp", 156 "var", 157 "hr", 158 "table", 159 "thead", 160 "tbody", 161 "th", 162 "td", 163 "tr", 164 "form", 165 "fieldset", 166 "legend", 167 "label", 168 "input", 169 "select", 170 "textarea", 171 "button", 172 "img", 173 "figure", 174 "figcaption", 175 "video", 176 "audio", 177 "canvas", 178 "svg", 179 "iframe", 180 "article", 181 "section", 182 "aside", 183 "header", 184 "footer", 185 "nav", 186 "details", 187 "summary", 188 ]; 189 190 const coverage: ElementCoverage[] = []; 191 192 for (const element of elementsToCheck) { 193 const patterns = [ 194 // element { 195 new RegExp(`^${element}\\s*\\{`, "m"), 196 // element, 197 new RegExp(`^${element},`, "m"), 198 // , element { 199 new RegExp(`,\\s*${element}\\s*\\{`, "m"), 200 // element:pseudo 201 new RegExp(`^${element}:`, "m"), 202 // element[attr] 203 new RegExp(`${element}\\[`, "m"), 204 ]; 205 206 const covered = patterns.some((pattern) => pattern.test(cssContent)); 207 coverage.push({ element, covered }); 208 } 209 210 return coverage; 211} 212 213/** 214 * Generate markdown documentation from extracted data 215 */ 216function generateSemanticsDocs(comments: CSSComment[], variables: CSSVariable[], coverage: ElementCoverage[]): string { 217 const lines: string[] = [ 218 "# Volt CSS Semantics", 219 "", 220 "Auto-generated documentation from base.css", 221 "", 222 "## CSS Custom Properties", 223 "", 224 "All design tokens defined in the stylesheet.", 225 "", 226 ]; 227 228 const categoryMap = new Map<string, CSSVariable[]>(); 229 for (const variable of variables) { 230 if (!categoryMap.has(variable.category)) { 231 categoryMap.set(variable.category, []); 232 } 233 categoryMap.get(variable.category)!.push(variable); 234 } 235 236 for (const [category, vars] of categoryMap) { 237 lines.push(`### ${category}`, ""); 238 for (const v of vars) { 239 lines.push(`- \`${v.name}\`: \`${v.value}\``); 240 } 241 lines.push(""); 242 } 243 244 lines.push("## Element Coverage", "", "HTML elements with defined styling in the stylesheet.", ""); 245 246 const covered = coverage.filter((c) => c.covered); 247 const notCovered = coverage.filter((c) => !c.covered); 248 249 lines.push(`**Coverage**: ${covered.length}/${coverage.length} elements`, "", "### Styled Elements", ""); 250 251 const coveredByCategory = groupElementsByCategory(covered.map((c) => c.element)); 252 for (const [category, elements] of Object.entries(coveredByCategory)) { 253 lines.push(`**${category}**: ${elements.join(", ")}`); 254 } 255 lines.push(""); 256 257 if (notCovered.length > 0) { 258 lines.push("### Unstyled Elements", "", notCovered.map((c) => c.element).join(", "), ""); 259 } 260 261 lines.push("## Documentation Comments", "", "Inline documentation extracted from CSS comments.", ""); 262 263 for (const comment of comments) { 264 if (comment.comment.length > 200) { 265 continue; 266 } 267 268 lines.push(`### \`${comment.selector}\``, "", comment.comment, ""); 269 } 270 271 return lines.join("\n"); 272} 273 274function groupElementsByCategory(elements: string[]): Record<string, string[]> { 275 const categories: Record<string, string[]> = { 276 "Document Structure": [], 277 "Typography": [], 278 "Lists": [], 279 "Semantic": [], 280 "Forms": [], 281 "Tables": [], 282 "Media": [], 283 "Code": [], 284 }; 285 286 const categoryMap: Record<string, string> = { 287 html: "Document Structure", 288 body: "Document Structure", 289 h1: "Typography", 290 h2: "Typography", 291 h3: "Typography", 292 h4: "Typography", 293 h5: "Typography", 294 h6: "Typography", 295 p: "Typography", 296 a: "Typography", 297 em: "Typography", 298 strong: "Typography", 299 mark: "Typography", 300 small: "Typography", 301 sub: "Typography", 302 sup: "Typography", 303 hr: "Typography", 304 ul: "Lists", 305 ol: "Lists", 306 li: "Lists", 307 dl: "Lists", 308 dt: "Lists", 309 dd: "Lists", 310 blockquote: "Semantic", 311 cite: "Semantic", 312 article: "Semantic", 313 section: "Semantic", 314 aside: "Semantic", 315 header: "Semantic", 316 footer: "Semantic", 317 nav: "Semantic", 318 details: "Semantic", 319 summary: "Semantic", 320 form: "Forms", 321 fieldset: "Forms", 322 legend: "Forms", 323 label: "Forms", 324 input: "Forms", 325 select: "Forms", 326 textarea: "Forms", 327 button: "Forms", 328 table: "Tables", 329 thead: "Tables", 330 tbody: "Tables", 331 th: "Tables", 332 td: "Tables", 333 tr: "Tables", 334 img: "Media", 335 figure: "Media", 336 figcaption: "Media", 337 video: "Media", 338 audio: "Media", 339 canvas: "Media", 340 svg: "Media", 341 iframe: "Media", 342 code: "Code", 343 pre: "Code", 344 kbd: "Code", 345 samp: "Code", 346 var: "Code", 347 }; 348 349 for (const element of elements) { 350 const category = categoryMap[element] || "Other"; 351 if (!categories[category]) { 352 categories[category] = []; 353 } 354 categories[category].push(element); 355 } 356 357 return Object.fromEntries(Object.entries(categories).filter(([, els]) => els.length > 0)); 358} 359 360/** 361 * CSS documentation command implementation 362 * 363 * Generates semantics.md from base.css 364 */ 365export async function cssDocsCommand(): Promise<void> { 366 const libSrcPath = await getLibSrcPath(); 367 const docsPath = await getDocsPath(); 368 const cssPath = path.join(libSrcPath, "styles", "base.css"); 369 const outputDir = path.join(docsPath, "css"); 370 const outputPath = path.join(outputDir, "semantics.md"); 371 372 echo.title("\nGenerating CSS Documentation\n"); 373 374 let cssContent: string; 375 try { 376 cssContent = await readFile(cssPath, "utf8"); 377 echo.ok(`Read ${cssPath}`); 378 } catch (error) { 379 echo.err(`Failed to read CSS file: ${cssPath}`); 380 throw error; 381 } 382 383 echo.info("\nExtracting CSS documentation..."); 384 385 const comments = extractCSSComments(cssContent); 386 echo.ok(` Found ${comments.length} documented selectors`); 387 388 const variables = extractCSSVariables(cssContent); 389 echo.ok(` Found ${variables.length} CSS custom properties`); 390 391 const coverage = validateElementCoverage(cssContent); 392 const coveredCount = coverage.filter((c) => c.covered).length; 393 echo.ok(` Element coverage: ${coveredCount}/${coverage.length}`); 394 395 const markdown = generateSemanticsDocs(comments, variables, coverage); 396 397 await mkdir(outputDir, { recursive: true }); 398 399 echo.info("\nTracking version..."); 400 const versionedContent = await trackVersion(outputPath, markdown); 401 await writeFile(outputPath, versionedContent, "utf8"); 402 403 echo.success(`\nCSS documentation generated: docs/css/semantics.md\n`); 404 echo.label("Summary:"); 405 echo.text(` CSS Comments: ${comments.length}`); 406 echo.text(` CSS Variables: ${variables.length}`); 407 echo.text(` Element Coverage: ${coveredCount}/${coverage.length}\n`); 408}