stylesheet-scan.ts 3.4 KB

12345678910111213141516171819202122232425262728293031323334353637383940414243444546474849505152535455565758596061626364656667686970717273747576777879808182838485868788899091
  1. /**
  2. * Shared helpers for stylesheet-contract specs: flatten CSS text on disk into
  3. * rules and enumerate the package stylesheets those contracts range over.
  4. */
  5. import { readdirSync } from 'node:fs'
  6. import { join } from 'node:path'
  7. import { fileURLToPath } from 'node:url'
  8. /** One flattened CSS rule: its comma-separated selector parts and its declarations in source order. */
  9. export interface CssRule {
  10. selectors: string[]
  11. declarations: [property: string, value: string][]
  12. }
  13. /** Root the package-wide stylesheet scans walk. */
  14. const PACKAGES_DIR = fileURLToPath(new URL('../../../', import.meta.url))
  15. /**
  16. * Flatten a stylesheet into rules. Whitespace, declaration order, and trailing
  17. * semicolons are normalized away; nesting is not handled, which no sheet under
  18. * test uses, and at-rule preludes surface as selector-less rule boundaries.
  19. * @param css - stylesheet text.
  20. * @returns one entry per rule, in source order.
  21. */
  22. export function parseRules(css: string): CssRule[] {
  23. const withoutComments = css.replace(/\/\*[\s\S]*?\*\//g, ' ')
  24. const rules: CssRule[] = []
  25. // Destructuring defaults only satisfy noUncheckedIndexedAccess; both groups
  26. // are unconditional in the pattern.
  27. for (const [, selector = '', body = ''] of withoutComments.matchAll(/([^{}]+)\{([^{}]*)\}/g)) {
  28. const declarations = body
  29. .split(';')
  30. .map(part => part.trim())
  31. .filter(part => part.includes(':'))
  32. .map((part): [string, string] => {
  33. const colon = part.indexOf(':')
  34. return [part.slice(0, colon).trim(), part.slice(colon + 1).trim()]
  35. })
  36. rules.push({ selectors: selector.split(',').map(part => part.trim()), declarations })
  37. }
  38. return rules
  39. }
  40. /**
  41. * Half-open source span of one at-rule's block, excluding its prelude.
  42. * @param css - stylesheet text.
  43. * @param prelude - exact at-rule prelude to locate, without the opening brace.
  44. * @returns the block's brace offsets, or undefined when the prelude is absent.
  45. */
  46. export function atRuleBlock(css: string, prelude: string): { start: number; end: number } | undefined {
  47. const opening = css.indexOf(`${prelude} {`)
  48. if (opening === -1) return undefined
  49. const start = css.indexOf('{', opening)
  50. let depth = 0
  51. for (let index = start; index < css.length; index += 1) {
  52. if (css[index] === '{') depth += 1
  53. else if (css[index] === '}') {
  54. depth -= 1
  55. if (depth === 0) return { start, end: index }
  56. }
  57. }
  58. throw new Error(`unbalanced braces after ${prelude}`)
  59. }
  60. /**
  61. * Custom-property names a value reads.
  62. * @param value - declaration value, possibly with nested var() calls.
  63. * @returns every referenced custom-property name, in source order.
  64. */
  65. export function varReferences(value: string): string[] {
  66. return [...value.matchAll(/var\(\s*(--[\w-]+)/g)].map(([, name = '']) => name)
  67. }
  68. /**
  69. * Every CSS file shipped as package source, excluding build output and
  70. * installed dependencies.
  71. * @returns absolute paths of the stylesheets under packages/.
  72. */
  73. export function packageStylesheets(): string[] {
  74. const found: string[] = []
  75. const walk = (dir: string): void => {
  76. for (const entry of readdirSync(dir, { withFileTypes: true })) {
  77. const path = join(dir, entry.name)
  78. if (entry.isDirectory()) {
  79. if (entry.name !== 'node_modules' && entry.name !== 'lib' && entry.name !== 'dist') walk(path)
  80. } else if (entry.name.endsWith('.css')) found.push(path)
  81. }
  82. }
  83. walk(PACKAGES_DIR)
  84. return found
  85. }