mirror of
https://github.com/discourse/discourse.git
synced 2026-09-05 04:40:41 -05:00
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:
@@ -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 @@
|
|||||||
|
{}
|
||||||
@@ -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-]+$/);
|
||||||
|
}
|
||||||
|
});
|
||||||
@@ -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,
|
||||||
}),
|
}),
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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|
|
||||||
|
|||||||
@@ -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
|
||||||
|
|
||||||
|
|||||||
@@ -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"
|
||||||
},
|
},
|
||||||
|
|||||||
Generated
+6
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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);
|
||||||
@@ -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: [
|
||||||
|
|||||||
Reference in New Issue
Block a user