Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 4 additions & 1 deletion .commitlintrc.json
Original file line number Diff line number Diff line change
@@ -1,3 +1,6 @@
{
"extends": ["@commitlint/config-conventional"]
"extends": ["@commitlint/config-conventional"],
"rules": {
"body-max-line-length": [2, "always", "Infinity"]
}
}
8 changes: 5 additions & 3 deletions .github/workflows/d-compat-branch.yml
Original file line number Diff line number Diff line change
@@ -1,10 +1,12 @@
name: Create new d-compat branch
name: Sync d-compat

on:
workflow_dispatch:
schedule:
- cron: "0 0 * * *"

- cron: "0 */6 * * *"
push:
branches:
- main
jobs:
ci:
uses: discourse/.github/.github/workflows/create-d-compat-branch.yml@v1
2 changes: 0 additions & 2 deletions .github/workflows/discourse-theme.yml
Original file line number Diff line number Diff line change
Expand Up @@ -9,5 +9,3 @@ on:
jobs:
ci:
uses: discourse/.github/.github/workflows/discourse-theme.yml@v1
with:
core_ref: stable
3 changes: 3 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
@@ -1,3 +1,6 @@
node_modules
.discourse-site
.eslintcache
.claude
.github/skills
CLAUDE.md
1 change: 0 additions & 1 deletion .template-lintrc.cjs

This file was deleted.

1 change: 1 addition & 0 deletions .vscode/settings.json
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,7 @@
"stylelint.validate": ["css", "postcss", "scss"],

// disable other stylesheet linters
"biome.enabled": false,
"css.validate": false,
"less.validate": false,
"scss.validate": false
Expand Down
48 changes: 20 additions & 28 deletions Gemfile.lock
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
GEM
remote: https://rubygems.org/
specs:
activesupport (8.1.2.1)
activesupport (8.1.3)
base64
bigdecimal
concurrent-ruby (~> 1.0, >= 1.3.1)
Expand All @@ -14,61 +14,52 @@ GEM
securerandom (>= 0.3)
tzinfo (~> 2.0, >= 2.0.5)
uri (>= 0.13.1)
addressable (2.9.0)
public_suffix (>= 2.0.2, < 8.0)
ast (2.4.3)
base64 (0.3.0)
bigdecimal (4.1.2)
concurrent-ruby (1.3.6)
concurrent-ruby (1.3.7)
connection_pool (3.0.2)
drb (2.2.3)
i18n (1.14.8)
i18n (1.15.2)
concurrent-ruby (~> 1.0)
json (2.19.4)
json-schema (6.2.0)
addressable (~> 2.8)
bigdecimal (>= 3.1, < 5)
language_server-protocol (3.17.0.5)
json (2.20.0)
language_server-protocol (3.17.0.6)
lint_roller (1.1.0)
logger (1.7.0)
mcp (0.9.2)
json-schema (>= 4.1)
minitest (6.0.6)
drb (~> 2.0)
prism (~> 1.5)
parallel (1.27.0)
parser (3.3.10.2)
parallel (2.1.0)
parser (3.3.11.1)
ast (~> 2.4.1)
racc
prettier_print (1.2.1)
prism (1.9.0)
public_suffix (7.0.5)
racc (1.8.1)
rack (3.2.6)
rainbow (3.1.1)
regexp_parser (2.11.3)
rubocop (1.85.1)
regexp_parser (2.12.0)
rubocop (1.88.2)
json (~> 2.3)
language_server-protocol (~> 3.17.0.2)
lint_roller (~> 1.1.0)
mcp (~> 0.6)
parallel (~> 1.10)
parallel (>= 1.10)
parser (>= 3.3.0.2)
rainbow (>= 2.2.2, < 4.0)
regexp_parser (>= 2.9.3, < 3.0)
rubocop-ast (>= 1.49.0, < 2.0)
ruby-progressbar (~> 1.7)
unicode-display_width (>= 2.4.0, < 4.0)
rubocop-ast (1.49.1)
rubocop-ast (1.50.0)
parser (>= 3.3.7.2)
prism (~> 1.7)
rubocop-capybara (2.22.1)
rubocop-capybara (3.0.0)
lint_roller (~> 1.1)
rubocop (~> 1.72, >= 1.72.1)
rubocop-discourse (3.14.0)
rubocop (~> 1.81)
rubocop-discourse (3.18.0)
activesupport (>= 6.1)
lint_roller (>= 1.1.0)
rubocop-capybara (>= 2.22.0)
rubocop-capybara (>= 2.23.0)
rubocop-discourse-base (>= 1.0.0)
rubocop-factory_bot (>= 2.27.0)
rubocop-rails (>= 2.30.3)
Expand All @@ -79,15 +70,16 @@ GEM
rubocop-factory_bot (2.28.0)
lint_roller (~> 1.1)
rubocop (~> 1.72, >= 1.72.1)
rubocop-rails (2.34.3)
rubocop-rails (2.35.5)
activesupport (>= 4.2.0)
lint_roller (~> 1.1)
rack (>= 1.1)
rubocop (>= 1.75.0, < 2.0)
rubocop-ast (>= 1.44.0, < 2.0)
rubocop-rspec (3.9.0)
rubocop-rspec (3.10.2)
lint_roller (~> 1.1)
rubocop (~> 1.81)
regexp_parser (>= 2.0)
rubocop (~> 1.86, >= 1.86.2)
rubocop-rspec_rails (2.32.0)
lint_roller (~> 1.1)
rubocop (~> 1.72, >= 1.72.1)
Expand All @@ -111,4 +103,4 @@ DEPENDENCIES
syntax_tree

BUNDLED WITH
4.0.8
4.0.15
166 changes: 103 additions & 63 deletions javascripts/discourse/api-initializers/notification-banners.gjs
Original file line number Diff line number Diff line change
@@ -1,38 +1,43 @@
import { trustHTML } from "@ember/template";
import { apiInitializer } from "discourse/lib/api";
import loadScript from "discourse/lib/load-script";
import { AUTO_GROUPS } from "discourse/lib/constants";
import NotificationBanners from "../components/notification-banners";

// Cache for color calculations to avoid redundant computations
const colorStyleCache = new Map();

function loadSplideCSS() {
if (document.getElementById("splide-css")) {
return;
// Maximum entries in the color cache to prevent unbounded growth
const MAX_COLOR_CACHE_SIZE = 50;
const HEX_COLOR_REGEX = /^[0-9A-Fa-f]{6}$/;

const normalizeHexColor = (backgroundColor) => {
if (typeof backgroundColor !== "string") {
return null;
}

const link = document.createElement("link");
Object.assign(link, {
rel: "stylesheet",
type: "text/css",
id: "splide-css",
href: settings.theme_uploads.splide_css,
});
document.head.appendChild(link);
}
const normalized = backgroundColor.trim();
if (!HEX_COLOR_REGEX.test(normalized)) {
return null;
}

return normalized.toUpperCase();
};

const bannerStyles = (background_color) => {
const safeBackgroundColor = normalizeHexColor(background_color);

function bannerStyles(background_color) {
// Check cache first
if (colorStyleCache.has(background_color)) {
return colorStyleCache.get(background_color);
if (colorStyleCache.has(safeBackgroundColor)) {
return colorStyleCache.get(safeBackgroundColor);
}

let foregroundColor = "var(--primary)";
let backgroundColor = "var(--tertiary-low)";

if (background_color) {
const r = parseInt(background_color.substring(0, 2), 16);
const g = parseInt(background_color.substring(2, 4), 16);
const b = parseInt(background_color.substring(4, 6), 16);
if (safeBackgroundColor) {
const r = parseInt(safeBackgroundColor.substring(0, 2), 16);
const g = parseInt(safeBackgroundColor.substring(2, 4), 16);
const b = parseInt(safeBackgroundColor.substring(4, 6), 16);

const srgb = [r, g, b].map((i) => {
const normalized = i / 255;
Expand All @@ -43,66 +48,111 @@ function bannerStyles(background_color) {

const L = 0.2126 * srgb[0] + 0.7152 * srgb[1] + 0.0722 * srgb[2];
foregroundColor = L > 0.179 ? "#000000" : "#FFFFFF";
backgroundColor = `#${background_color}`;
backgroundColor = `#${safeBackgroundColor}`;
}

const result = `background-color: ${backgroundColor}; color: ${foregroundColor};`;
const result = trustHTML(
`background-color: ${backgroundColor}; color: ${foregroundColor};`
);

// Evict oldest entry if cache is full
if (colorStyleCache.size >= MAX_COLOR_CACHE_SIZE) {
const oldestKey = colorStyleCache.keys().next().value;
colorStyleCache.delete(oldestKey);
}

// Cache the result
colorStyleCache.set(background_color, result);
colorStyleCache.set(safeBackgroundColor, result);

return result;
}
};

// Utility function to transform outlet name for settings lookup
function normalizeName(outlet) {
const normalizeName = (outlet) => {
return outlet.replaceAll("-", "_");
}
};

function slugify(str) {
const slugify = (str) => {
str = str
.trim() // trim leading/trailing white space
.replace(/[^a-zA-Z0-9 -]/g, "") // remove any non-alphanumeric characters
.replace(/\s+/g, "-") // replace spaces with hyphens
.replace(/-+/g, "-") // remove consecutive hyphens
.padEnd(6, "0");
return str;
}
};

export default apiInitializer((api) => {
loadSplideCSS();
// Validate that a splide options string is valid JSON
const parseSplideOptions = (rawOptions) => {
if (!rawOptions) {
return "{}";
}
try {
JSON.parse(rawOptions);
return rawOptions;
} catch {
return "{}";
}
};

const bannerConfigVersion = settings.banner_config_version;
const currentUserGroups = (currentUser) => {
if (!currentUser) {
return [AUTO_GROUPS.everyone.id, AUTO_GROUPS.anonymous_users.id];
}

const banners = [...settings.banners].reduce((acc, banner) => {
const outlet = banner.plugin_outlet;
const type = banner.carousel ? "carousel" : "solo";
const userGroups = (currentUser.groups ?? [])
.filter((g) => !g.name.startsWith("trust_level_"))
.map((g) => g.id);

// Create new object instead of mutating
const processedBanner = {
...banner,
id: `notification-banner--${slugify(banner.id)}--${bannerConfigVersion}`,
styles: bannerStyles(banner.background_color),
};
userGroups.push(AUTO_GROUPS[`trust_level_${currentUser.trust_level}`].id);
userGroups.push(AUTO_GROUPS.everyone.id);
userGroups.push(AUTO_GROUPS.logged_in_users.id);

return userGroups;
};

const matchedAudience = (banner, currentUser) => {
const audience = banner.enabled_groups ?? [AUTO_GROUPS.everyone.id];

// Initialize outlet if it doesn't exist
if (!acc[outlet]) {
acc[outlet] = {
const userGroups = new Set(currentUserGroups(currentUser));
for (const groupId of audience) {
if (userGroups.has(groupId)) {
return true;
}
}
return false;
};

export default apiInitializer((api) => {
const currentUser = api.getCurrentUser();
const matchedAudienceForBanner = (banner) =>
matchedAudience(banner, currentUser);
const userBanners = settings.banners.filter((banner) =>
matchedAudienceForBanner(banner)
);

const userProcessedBanners = {};
userBanners.forEach((banner) => {
if (!userProcessedBanners[banner.plugin_outlet]) {
userProcessedBanners[banner.plugin_outlet] = {
carousel: [],
solo: [],
};
}
userProcessedBanners[banner.plugin_outlet][
banner.carousel ? "carousel" : "solo"
].push({
...banner,
id: `notification-banner--${slugify(banner.id)}--${settings.banner_config_version}`,
styles: bannerStyles(banner.background_color),
});
});

// Add banner to appropriate array
acc[outlet][type].push(processedBanner);

return acc;
}, {});

Object.keys(banners).forEach((outlet) => {
const carouselBanners = banners[outlet].carousel;
const soloBanners = banners[outlet].solo;
const splideOptions = settings[`splide_options__${normalizeName(outlet)}`];
Object.keys(userProcessedBanners).forEach((outlet) => {
const carouselBanners = userProcessedBanners[outlet].carousel;
const soloBanners = userProcessedBanners[outlet].solo;
const rawOptions = settings[`splide_options__${normalizeName(outlet)}`];
const splideOptions = parseSplideOptions(rawOptions);

api.renderInOutlet(
outlet,
Expand All @@ -116,14 +166,4 @@ export default apiInitializer((api) => {
</template>
);
});

loadScript(settings.theme_uploads.splide_js).then(() => {
const el = document.querySelectorAll(
".splide.notification-banners--above-site-header, .splide.notification-banners--below-site-header, .splide.notification-banners--top-notices"
);
el.forEach((carousel) => {
// eslint-disable-next-line no-undef
new Splide(carousel).mount();
});
});
});
Loading