Skip to content

Commit

Permalink
Suggest ViewComponents on reserved class use (#1907)
Browse files Browse the repository at this point in the history
  • Loading branch information
neall authored Mar 30, 2023
1 parent 26dd685 commit c691fbe
Show file tree
Hide file tree
Showing 5 changed files with 691 additions and 350 deletions.
5 changes: 5 additions & 0 deletions .changeset/tall-phones-battle.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'@primer/view-components': patch
---

Fix up reserved CSS class linter
Original file line number Diff line number Diff line change
Expand Up @@ -13,18 +13,57 @@ module ERBLint
module Linters
# Counts the number of times a class reserved for ViewComponents is used
class DisallowComponentCssCounter < BaseLinter
CLASSES_COVERED_BY_OTHER_LINTERS =
BaseLinter.subclasses.reduce([]) do |html_classes, klass|
html_classes.concat(klass.const_get(:CLASSES))
end

CLASSES = (
JSON.parse(
File.read(
File.join(__dir__, "..", "..", "..", "..", "static", "classes.json")
)
) - BaseLinter.subclasses.reduce([]) do |html_classes, klass|
html_classes.concat(klass.const_get(:CLASSES))
).reject do |html_class, _ruby_classes|
CLASSES_COVERED_BY_OTHER_LINTERS.include?(html_class)
end
).freeze

TAGS = nil
MESSAGE = "Primer ViewComponents defines some HTML classes with associated styles that should not be used outside those components. (These classes might have their styles changed or even disappear in the future.) Instead of using this class directly, please use its component if appropriate or define the styles you need some other way."
def run(processed_source)
@total_offenses = 0
@offenses_not_corrected = 0

processed_source
.parser
.nodes_with_type(:tag)
.each do |node|
tag = BetterHtml::Tree::Tag.from_node(node)

tag.attributes["class"]&.value&.split(/\s+/)&.each do |class_name|
if CLASSES.key? class_name
@total_offenses += 1
@offenses_not_corrected += 1
add_offense(
processed_source.to_source_range(tag.loc),
format_message(class_name)
)
end
end
end

counter_correct?(processed_source)

dump_data(processed_source) if ENV["DUMP_LINT_DATA"] == "1"
end

private

def format_message(class_name)
"DisallowComponentCssCounter:HTML class \"#{class_name}\" is reserved for Primer ViewComponents. It might disappear or have different styles in the future. You might want to use #{ruby_classes_sentence_string(class_name)} from Primer ViewComponents instead."
end

def ruby_classes_sentence_string(class_name)
CLASSES[class_name].to_sentence(last_word_connector: ", or ", two_words_connector: " or ")
end
end
end
end
60 changes: 41 additions & 19 deletions script/export-css-selectors
Original file line number Diff line number Diff line change
Expand Up @@ -8,11 +8,16 @@ const CSSwhat = require('css-what')

console.log('Exporting CSS selectors...')

const capitalize = (s) => s.slice(0, 1).toUpperCase() + s.slice(1)
const snakeToCamelCase = (s) => s.split('_').map(capitalize).join('')
const componentNameToRubyClass = (componentName) =>
'Primer::' + componentName.split('/').map(snakeToCamelCase).join('::')

const exportSelectors = (folder) => {
const folder_glob = folder + '/**/*.css'
const folderGlob = `${folder}/**/*.css`
const componentNameRegex = new RegExp(`${folder.replace('/','\\/')}\\/(.*).css`)

return glob(folder_glob).then(files =>
return glob(folderGlob).then(files =>
Promise.all(
files.map(async (file) => {
console.log(`Processing ${file}`)
Expand All @@ -21,7 +26,7 @@ const exportSelectors = (folder) => {
const componentName = componentNameRegex.exec(file)[1]

const selectors = []
const classes = []
const classFiles = []

root.walkRules(rule => {
// @keyframes at-rules have children that look like they have normal
Expand All @@ -34,11 +39,11 @@ const exportSelectors = (folder) => {

rule.selectors.forEach(ruleSelector => {
selectors.push(ruleSelector)
CSSwhat.parse(ruleSelector)[0].forEach(ruleObj => {
if (ruleObj.type === 'attribute' && ruleObj.name === 'class') {
classes.push(ruleObj.value)
}
})
const ruleObj = CSSwhat.parse(ruleSelector)[0][0]
if (ruleObj.type === 'attribute' && ruleObj.name === 'class') {
const baseHtmlClass = ruleObj.value
classFiles.push([baseHtmlClass, componentNameToRubyClass(componentName)])
}
})
})

Expand All @@ -47,37 +52,54 @@ const exportSelectors = (folder) => {
`${file}.json`,
JSON.stringify({
name: componentName,
selectors: [...new Set(selectors)],
classes: [...new Set(classes)]
selectors: [...new Set(selectors)]
}, null, 2)
).catch(error => console.error(`Failed to write ${file}.json`, { error }))

return classes
return classFiles
})
)
)
}

// stylesheets under app/lib/primer/css need their individual
// json files generated
exportSelectors('app/lib/primer/css')

// class names referenced under app/components/primer might need
// to be reserved in addition to getting individual json files
const classShouldBeReserved = className =>
(className[0].toUpperCase() === className[0])

exportSelectors('app/components/primer')
.then(classLists => {
console.log(`Writing static/classes.json`)
const htmlClassToRubyClasses = {}

classLists
.reduce(((a, b) => a.concat(b)), [])
.filter(cf => classShouldBeReserved(cf[0]))
// sort by length so we process e.g. "Label" before "Label--accent"
.sort(([htmlClassA], [htmlClassB]) => htmlClassA.length - htmlClassB.length)
.forEach(([htmlClass, rubyClass]) => {
if (!htmlClassToRubyClasses[htmlClass]) {
htmlClassToRubyClasses[htmlClass] = new Set()
}

htmlClassToRubyClasses[htmlClass].add(rubyClass)
})

console.log('Writing static/classes.json')
return writeFile(
'static/classes.json',
JSON.stringify(
[...new Set(classLists.reduce((a, b) => a.concat(b)))]
.filter(classShouldBeReserved)
.sort(),
Object.fromEntries(
Object
.entries(htmlClassToRubyClasses)
.map(([key, set]) => [key, [...set.values()]])
),
null,
2
)
)
})
.catch(error => console.error("failed to write static/classes.json", { error }))

// stylesheets under app/lib/primer/css need their individual
// json files generated
exportSelectors('app/lib/primer/css')
Loading

0 comments on commit c691fbe

Please sign in to comment.