DEV: Introduce system for renaming CSS variables (#42733)

From time-to-time, variables may need to be renamed in core. To avoid
breaking existing themes and plugins, this commit introduces a system to
automatically rewrite old variable names to new ones. A list of renames
is maintained in `stylesheets/variable-renames.json`, and is applied
when CSS is compiled. This list also powers a stylelint rule which will
automatically rewrite old names to new names in source code.

Transformations apply to all core/theme/plugin code. For now, the
stylelint rule/autofix applies to core only, but this will be extracted
to `@discourse/lint-configs` in the near future.

When a transformation is applied, it adds a trailing `/* automatically
renamed --old to --new */` comment so that the behavior is
understandable from the browser developer tools.
This commit is contained in:
David Taylor
2026-09-01 11:34:46 +01:00
committed by GitHub
parent aa627483b9
commit 439efc7419
14 changed files with 245 additions and 2 deletions
@@ -101,3 +101,10 @@ these to restyle every button of a variant without touching `.btn` selectors.
`--d-sidebar-animation-time`/`-ease`, etc. The admin layout's sidebar adds `--d-sidebar-admin-*` `--d-sidebar-animation-time`/`-ease`, etc. The admin layout's sidebar adds `--d-sidebar-admin-*`
(e.g. `--d-sidebar-admin-background`) in `admin/sidebar.scss`. Override these to retheme the (e.g. `--d-sidebar-admin-background`) in `admin/sidebar.scss`. Override these to retheme the
sidebar in either context without overriding its internal selectors. sidebar in either context without overriding its internal selectors.
## Renaming variables
From time-to-time, variables may need to be renamed in core. To avoid breaking existing themes and plugins,
Discourse will automatically rewrite old variable names to new ones. A list of renames is maintained in
`stylesheets/variable-renames.json`, and is applied when CSS is compiled. This list also powers
a stylelint rule which will automatically rewrite old names to new names in source code.
@@ -0,0 +1 @@
{}
+1
View File
@@ -32,6 +32,7 @@
"polyfill-crypto.getrandomvalues": "^1.0.0", "polyfill-crypto.getrandomvalues": "^1.0.0",
"postcss": "^8.5.15", "postcss": "^8.5.15",
"postcss-nesting": "^14.0.0", "postcss-nesting": "^14.0.0",
"postcss-value-parser": "^4.2.0",
"rolldown": "1.1.2", "rolldown": "1.1.2",
"source-map-js": "^1.2.1", "source-map-js": "^1.2.1",
"strip-test-selectors": "^0.1.0", "strip-test-selectors": "^0.1.0",
@@ -0,0 +1,56 @@
import valueParser from "postcss-value-parser";
import renames from "../../app/assets/stylesheets/variable-renames.json";
/**
* Rewrites renamed CSS custom properties to their current names, so
* stylesheets which set or read an old name keep working. Each rewritten
* declaration gets a trailing `automatically renamed` comment as a clue in
* dev-tools.
*/
export default function postcssVariableRenamer(renameMap = renames) {
const oldNames = Object.keys(renameMap);
return {
postcssPlugin: "postcss-variable-renamer",
Declaration(declaration, { Comment }) {
const applied = new Map();
const newProp = renameMap[declaration.prop];
if (newProp) {
applied.set(declaration.prop, newProp);
declaration.prop = newProp;
}
if (oldNames.some((oldName) => declaration.value.includes(oldName))) {
let valueChanged = false;
const parsed = valueParser(declaration.value);
parsed.walk((node) => {
const newName = node.type === "word" && renameMap[node.value];
if (newName) {
applied.set(node.value, newName);
node.value = newName;
valueChanged = true;
}
});
if (valueChanged) {
declaration.value = parsed.toString();
}
}
if (applied.size > 0) {
const details = [...applied]
.map(([oldName, newName]) => `${oldName} to ${newName}`)
.join(", ");
declaration.after(
new Comment({ text: `automatically renamed ${details}` })
);
}
},
};
}
postcssVariableRenamer.postcss = true;
@@ -0,0 +1,77 @@
/* eslint-disable qunit/require-expect */
import postcss from "postcss";
import { expect, test } from "vitest";
import renames from "../../app/assets/stylesheets/variable-renames.json";
import postcssVariableRenamer from "./postcss-variable-renamer.js";
const testMap = { "--old-name": "--new-name" };
function process(css) {
return postcss([postcssVariableRenamer(testMap)]).process(css, {
from: undefined,
}).css;
}
test("rewrites declarations of a renamed variable", () => {
expect(process(":root { --old-name: red; }")).toBe(
":root { --new-name: red; /* automatically renamed --old-name to --new-name */ }"
);
});
test("rewrites var() references", () => {
expect(process("a { color: var(--old-name); }")).toBe(
"a { color: var(--new-name); /* automatically renamed --old-name to --new-name */ }"
);
});
test("rewrites references inside fallbacks", () => {
expect(process("a { color: var(--brand, var(--old-name)); }")).toBe(
"a { color: var(--brand, var(--new-name)); /* automatically renamed --old-name to --new-name */ }"
);
});
test("preserves fallback of a renamed reference", () => {
expect(process("a { color: var(--old-name, blue); }")).toBe(
"a { color: var(--new-name, blue); /* automatically renamed --old-name to --new-name */ }"
);
});
test("leaves longer names alone", () => {
const css = "a { color: var(--old-name-hover); }";
expect(process(css)).toBe(css);
});
test("leaves names with a prefix alone", () => {
const css = "a { color: var(--theme--old-name); }";
expect(process(css)).toBe(css);
});
test("adds one comment when a declaration sets and reads the same old name", () => {
expect(process(":root { --old-name: var(--old-name, red); }")).toBe(
":root { --new-name: var(--new-name, red); /* automatically renamed --old-name to --new-name */ }"
);
});
test("leaves untouched declarations alone", () => {
const css = "a { color: var(--primary); }";
expect(process(css)).toBe(css);
});
test("leaves string contents alone", () => {
const css = 'a::before { content: "--old-name"; }';
expect(process(css)).toBe(css);
});
test("map has no chains", () => {
const oldNames = new Set(Object.keys(renames));
for (const newName of Object.values(renames)) {
expect(oldNames).not.toContain(newName);
}
});
test("map only contains custom property names", () => {
for (const [oldName, newName] of Object.entries(renames)) {
expect(oldName).toMatch(/^--[\w-]+$/);
expect(newName).toMatch(/^--[\w-]+$/);
}
});
+2
View File
@@ -5,8 +5,10 @@ import postcss from "postcss";
import postcssNesting from "postcss-nesting"; import postcssNesting from "postcss-nesting";
import { browsers } from "../discourse/config/targets"; import { browsers } from "../discourse/config/targets";
import postcssVariablePrefixer from "./postcss-variable-prefixer"; import postcssVariablePrefixer from "./postcss-variable-prefixer";
import postcssVariableRenamer from "./postcss-variable-renamer";
const postCssProcessor = postcss([ const postCssProcessor = postcss([
postcssVariableRenamer(),
autoprefixer({ autoprefixer({
overrideBrowserslist: browsers, overrideBrowserslist: browsers,
}), }),
+1
View File
@@ -10,6 +10,7 @@ class AssetProcessor
dependency_globs: %w[ dependency_globs: %w[
node_modules/.pnpm/lock.yaml node_modules/.pnpm/lock.yaml
frontend/asset-processor/**/*.{js,mjs} frontend/asset-processor/**/*.{js,mjs}
app/assets/stylesheets/variable-renames.json
frontend/discourse/lib/babel-transform-module-renames.js frontend/discourse/lib/babel-transform-module-renames.js
frontend/discourse/lib/discourse-source-imports.mjs frontend/discourse/lib/discourse-source-imports.mjs
frontend/discourse/config/targets.js frontend/discourse/config/targets.js
+2
View File
@@ -17,6 +17,7 @@ class Stylesheet::Manager
private_constant :CACHE_PATH private_constant :CACHE_PATH
MANIFEST_DIR = "#{Rails.root.join("tmp/cache/assets/#{Rails.env}")}" MANIFEST_DIR = "#{Rails.root.join("tmp/cache/assets/#{Rails.env}")}"
VARIABLE_RENAMES_PATH = Rails.root.join("app/assets/stylesheets/variable-renames.json")
THEME_REGEX = /_theme(_rtl)?\z/ THEME_REGEX = /_theme(_rtl)?\z/
COLOR_SCHEME_STYLESHEET = "color_definitions" COLOR_SCHEME_STYLESHEET = "color_definitions"
@@ -189,6 +190,7 @@ class Stylesheet::Manager
"#{Rails.root.join("app/assets/stylesheets/**/*.*css")}", "#{Rails.root.join("app/assets/stylesheets/**/*.*css")}",
"#{Rails.root.join("app/assets/images/**/*.*")}", "#{Rails.root.join("app/assets/images/**/*.*")}",
"#{Rails.root.join("lib/stylesheet/*.rb")}", "#{Rails.root.join("lib/stylesheet/*.rb")}",
VARIABLE_RENAMES_PATH.to_s,
] ]
Discourse.plugins.each do |plugin| Discourse.plugins.each do |plugin|
+1 -1
View File
@@ -193,7 +193,7 @@ class Stylesheet::Manager::Builder
def theme_digest def theme_digest
Digest::SHA1.hexdigest( Digest::SHA1.hexdigest(
scss_digest.to_s + color_scheme_digest.to_s + settings_digest + uploads_digest + scss_digest.to_s + color_scheme_digest.to_s + settings_digest + uploads_digest +
current_hostname, current_hostname + Stylesheet::Manager.fs_asset_cachebuster,
) )
end end
+1
View File
@@ -21,6 +21,7 @@
"playwright": "1.59.1", "playwright": "1.59.1",
"postcss-selector-parser": "^7.1.4", "postcss-selector-parser": "^7.1.4",
"prettier": "3.8.1", "prettier": "3.8.1",
"postcss-value-parser": "^4.2.0",
"stylelint": "17.5.0", "stylelint": "17.5.0",
"typescript": "^5.9.3" "typescript": "^5.9.3"
}, },
+6
View File
@@ -90,6 +90,9 @@ importers:
postcss-selector-parser: postcss-selector-parser:
specifier: ^7.1.4 specifier: ^7.1.4
version: 7.1.4 version: 7.1.4
postcss-value-parser:
specifier: ^4.2.0
version: 4.2.0
prettier: prettier:
specifier: 3.8.1 specifier: 3.8.1
version: 3.8.1 version: 3.8.1
@@ -177,6 +180,9 @@ importers:
postcss-nesting: postcss-nesting:
specifier: ^14.0.0 specifier: ^14.0.0
version: 14.0.0(postcss@8.5.15) version: 14.0.0(postcss@8.5.15)
postcss-value-parser:
specifier: ^4.2.0
version: 4.2.0
rolldown: rolldown:
specifier: 1.1.2 specifier: 1.1.2
version: 1.1.2 version: 1.1.2
+22
View File
@@ -317,6 +317,22 @@ RSpec.describe Stylesheet::Manager do
expect(digest1).not_to eq(digest2) expect(digest1).not_to eq(digest2)
end end
it "accounts for the asset cachebuster in theme digests" do
theme = Fabricate(:theme)
builder =
Stylesheet::Manager::Builder.new(target: :desktop_theme, theme: theme, manager: manager)
digest1 = builder.digest
Stylesheet::Manager.stubs(:fs_asset_cachebuster).returns("changed")
builder =
Stylesheet::Manager::Builder.new(target: :desktop_theme, theme: theme, manager: manager)
digest2 = builder.digest
expect(digest1).not_to eq(digest2)
end
it "can correctly account for settings in theme's components" do it "can correctly account for settings in theme's components" do
theme = Fabricate(:theme) theme = Fabricate(:theme)
child = Fabricate(:theme, component: true) child = Fabricate(:theme, component: true)
@@ -1130,5 +1146,11 @@ RSpec.describe Stylesheet::Manager do
expect(new_cachebuster).not_to eq(initial_cachebuster) expect(new_cachebuster).not_to eq(initial_cachebuster)
end end
end end
it "includes the variable rename map in its inputs" do
expect(Stylesheet::Manager.send(:list_files)).to include(
Stylesheet::Manager::VARIABLE_RENAMES_PATH.to_s,
)
end
end end
end end
+60
View File
@@ -0,0 +1,60 @@
import { readFileSync } from "node:fs";
import valueParser from "postcss-value-parser";
import stylelint from "stylelint";
const renames = JSON.parse(
readFileSync(
new URL("../app/assets/stylesheets/variable-renames.json", import.meta.url)
)
);
const ruleName = "discourse/no-renamed-variables";
const messages = stylelint.utils.ruleMessages(ruleName, {
renamed: (oldName, newName) => `"${oldName}" was renamed to "${newName}"`,
});
const ruleFunction = (primaryOption) => {
return (root, result) => {
if (!primaryOption) {
return;
}
root.walkDecls((decl) => {
const newProp = renames[decl.prop];
if (newProp) {
stylelint.utils.report({
message: messages.renamed(decl.prop, newProp),
node: decl,
result,
ruleName,
word: decl.prop,
fix: () => (decl.prop = newProp),
});
}
const parsed = valueParser(decl.value);
parsed.walk((node) => {
const newName = node.type === "word" && renames[node.value];
if (newName) {
stylelint.utils.report({
message: messages.renamed(node.value, newName),
node: decl,
result,
ruleName,
word: node.value,
fix: () => {
node.value = newName;
decl.value = parsed.toString();
},
});
}
});
});
};
};
ruleFunction.ruleName = ruleName;
ruleFunction.messages = messages;
ruleFunction.meta = { fixable: true };
export default stylelint.createPlugin(ruleName, ruleFunction);
+8 -1
View File
@@ -1,12 +1,19 @@
import noCoreVariables from "./stylelint-rules/no-core-variables.mjs"; import noCoreVariables from "./stylelint-rules/no-core-variables.mjs";
import noRenamedVariables from "./stylelint-rules/no-renamed-variables.mjs";
import requireDesignTokens from "./stylelint-rules/require-design-tokens.mjs"; import requireDesignTokens from "./stylelint-rules/require-design-tokens.mjs";
import ucClassesInWhere from "./stylelint-rules/uc-classes-in-where.mjs"; import ucClassesInWhere from "./stylelint-rules/uc-classes-in-where.mjs";
export default { export default {
extends: ["@discourse/lint-configs/stylelint"], extends: ["@discourse/lint-configs/stylelint"],
plugins: [noCoreVariables, requireDesignTokens, ucClassesInWhere], plugins: [
noCoreVariables,
noRenamedVariables,
requireDesignTokens,
ucClassesInWhere,
],
rules: { rules: {
"media-feature-range-notation": "context", "media-feature-range-notation": "context",
"discourse/no-renamed-variables": true,
"discourse/uc-classes-in-where": true, "discourse/uc-classes-in-where": true,
}, },
overrides: [ overrides: [