From c56ee6c9a1961ae956cea08f2119c771f4c7fbf7 Mon Sep 17 00:00:00 2001 From: acostanza Date: Thu, 23 Apr 2026 10:37:43 +0200 Subject: [PATCH 01/26] fix: remove MultiJson, ArraySerializer legacy, and include_condition keyword from plugin.rb --- plugin.rb | 14 +++++++++++--- 1 file changed, 11 insertions(+), 3 deletions(-) diff --git a/plugin.rb b/plugin.rb index 49a0add..621b3b4 100644 --- a/plugin.rb +++ b/plugin.rb @@ -25,12 +25,16 @@ feed_url = SiteSetting.discourse_news_rss feed = News::Rss.get_feed_items(feed_url) ## TODO implement caching: News::Rss.cached_feed(feed_url) - serialized = ActiveModel::ArraySerializer.new(feed, each_serializer: News::RssSerializer, root: false) + serialized = ActiveModelSerializers::SerializableResource.new( + feed, + each_serializer: News::RssSerializer, + root: false + ) respond_to do |format| format.html do @list = News::RssTopicList.new(feed, nil) - store_preloaded("topic_list_news_rss", MultiJson.dump(serialized)) + store_preloaded("topic_list_news_rss", serialized.to_json) render 'list/list' end format.json do @@ -85,7 +89,11 @@ def reload(options = nil) ::Topic.prepend(TopicNewsExtension) end - add_to_serializer(:topic_list_item, :news_body, include_condition: -> { object.news_item }) do + add_to_serializer(:topic_list_item, :news_body) do object.news_body end + + add_to_serializer(:topic_list_item, :include_news_body?) do + object.news_item + end end From 3766159549a696fab6e217194b7377a595d1a4cd Mon Sep 17 00:00:00 2001 From: acostanza Date: Thu, 23 Apr 2026 10:40:15 +0200 Subject: [PATCH 02/26] fix: replace api.container.lookup with api.siteSettings in apiInitializer --- .../api-initializers/discourse-news.gjs | 24 +++++++++++++++++++ 1 file changed, 24 insertions(+) create mode 100644 assets/javascripts/discourse/api-initializers/discourse-news.gjs diff --git a/assets/javascripts/discourse/api-initializers/discourse-news.gjs b/assets/javascripts/discourse/api-initializers/discourse-news.gjs new file mode 100644 index 0000000..a0b0bd9 --- /dev/null +++ b/assets/javascripts/discourse/api-initializers/discourse-news.gjs @@ -0,0 +1,24 @@ +import { apiInitializer } from "discourse/lib/api"; +import NewsHeaderButton from "../components/news-header-button"; + +export default apiInitializer("1.0", (api) => { + if (!api.siteSettings.discourse_news_enabled) { + return; + } + + api.headerButtons.add("news", NewsHeaderButton, { before: "auth" }); + + api.modifyClass( + "model:topic", + (Superclass) => + class extends Superclass { + get basicCategoryLinkHtml() { + const category = this.category; + if (!category) { + return ""; + } + return `${category.name}`; + } + } + ); +}); From 067e57636c7341b34abb0e754f78d7ffbbce2926 Mon Sep 17 00:00:00 2001 From: acostanza Date: Thu, 23 Apr 2026 10:41:35 +0200 Subject: [PATCH 03/26] fix: add explicit @service injections to NewsController, read sidebarTopics from model --- .../javascripts/discourse/controllers/news.js | 17 ++++++++++++++--- 1 file changed, 14 insertions(+), 3 deletions(-) diff --git a/assets/javascripts/discourse/controllers/news.js b/assets/javascripts/discourse/controllers/news.js index 0c62292..ef0664a 100644 --- a/assets/javascripts/discourse/controllers/news.js +++ b/assets/javascripts/discourse/controllers/news.js @@ -1,11 +1,22 @@ -import DiscoveryListController from 'discourse/controllers/discovery/list'; +import { service } from "@ember/service"; +import DiscoveryListController from "discourse/controllers/discovery/list"; export default class NewsController extends DiscoveryListController { + @service site; + @service siteSettings; + get showSidebar() { return this.showSidebarTopics && !this.site.mobileView; } get showSidebarTopics() { - return this.sidebarTopics && this.siteSettings.discourse_news_sidebar_topic_list; + return ( + this.model?.sidebarTopics && + this.siteSettings.discourse_news_sidebar_topic_list + ); + } + + get sidebarTopics() { + return this.model?.sidebarTopics ?? []; } -} \ No newline at end of file +} From 23cb061bead12a98d0bf6b6f638d686648e3e18a Mon Sep 17 00:00:00 2001 From: acostanza Date: Thu, 23 Apr 2026 10:42:43 +0200 Subject: [PATCH 04/26] fix: pass sidebarTopics via route model instead of controller.set() --- assets/javascripts/discourse/routes/news.js | 63 +++++++++++---------- 1 file changed, 32 insertions(+), 31 deletions(-) diff --git a/assets/javascripts/discourse/routes/news.js b/assets/javascripts/discourse/routes/news.js index d3942ca..c8cb5e7 100644 --- a/assets/javascripts/discourse/routes/news.js +++ b/assets/javascripts/discourse/routes/news.js @@ -8,44 +8,45 @@ export default class NewsRoute extends buildTopicRoute("news") { @service site; @service store; - model(data, transition) { - if (this.siteSettings.discourse_news_source === "rss") { - return ajax("/news") - .then((result) => ({ - filter: "", - topics: result.map((t) => ({ - title: t.title, - description: t.description, - url: t.url, - image_url: t.image_url, - rss: true, - })), - })) - .catch(popupAjaxError); - } else { - return super.model(data, transition); + async #fetchSidebarTopics() { + if ( + !this.siteSettings.discourse_news_sidebar_topic_list || + this.site.mobileView + ) { + return null; } + + const filter = + this.siteSettings.discourse_news_sidebar_topic_list_filter || "latest"; + const list = await this.store.findFiltered("topicList", { filter }); + const limit = + this.siteSettings.discourse_news_sidebar_topic_list_limit || 10; + return list.topics.slice(0, limit); } - afterModel() { - if ( - this.siteSettings.discourse_news_sidebar_topic_list && - !this.site.mobileView - ) { - const filter = - this.siteSettings.discourse_news_sidebar_topic_list_filter || "latest"; - return this.store.findFiltered("topicList", { filter }).then((list) => { - const limit = - this.siteSettings.discourse_news_sidebar_topic_list_limit || 10; - this.sidebarTopics = list.topics.slice(0, limit); - }); + async model(data, transition) { + const sidebarTopics = await this.#fetchSidebarTopics(); + + if (this.siteSettings.discourse_news_source === "rss") { + const result = await ajax("/news").catch(popupAjaxError); + return { + filter: "", + topics: result.map((t) => ({ + title: t.title, + description: t.description, + url: t.url, + image_url: t.image_url, + rss: true, + })), + sidebarTopics, + }; } + + const base = await super.model(data, transition); + return Object.assign(base, { sidebarTopics }); } setupController(controller, model) { super.setupController(controller, model); - if (this.sidebarTopics) { - controller.set("sidebarTopics", this.sidebarTopics); - } } } From a68f219a55d0d010e5b67844cfc41974cd1504df Mon Sep 17 00:00:00 2001 From: acostanza Date: Thu, 23 Apr 2026 10:44:28 +0200 Subject: [PATCH 05/26] =?UTF-8?q?feat:=20convert=20news.hbs=20to=20news.gj?= =?UTF-8?q?s=20=E2=80=94=20replace=20curly-syntax,=20triple-mustache,=20an?= =?UTF-8?q?d=20action=20helpers=20with=20Glimmer=20equivalents?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../javascripts/discourse/templates/news.gjs | 63 +++++++++++++++++++ .../javascripts/discourse/templates/news.hbs | 53 ---------------- 2 files changed, 63 insertions(+), 53 deletions(-) create mode 100644 assets/javascripts/discourse/templates/news.gjs delete mode 100644 assets/javascripts/discourse/templates/news.hbs diff --git a/assets/javascripts/discourse/templates/news.gjs b/assets/javascripts/discourse/templates/news.gjs new file mode 100644 index 0000000..68b5bc5 --- /dev/null +++ b/assets/javascripts/discourse/templates/news.gjs @@ -0,0 +1,63 @@ +import { fn } from "@ember/helper"; +import { htmlSafe } from "@ember/template"; +import DiscoveryTopicsList from "discourse/components/discovery-topics-list"; +import List from "discourse/components/topic-list/list"; +import TopicLink from "discourse/components/topic-list/topic-link"; +import formatDate from "discourse/helpers/format-date"; +import { i18n } from "discourse-i18n"; + + diff --git a/assets/javascripts/discourse/templates/news.hbs b/assets/javascripts/discourse/templates/news.hbs deleted file mode 100644 index a0c20c0..0000000 --- a/assets/javascripts/discourse/templates/news.hbs +++ /dev/null @@ -1,53 +0,0 @@ -
- - {{#if this.hasTopics}} - {{topic-list - top=top - showTopicPostBadges=showTopicPostBadges - showPosters=true - canBulkSelect=canBulkSelect - changeSort=(action "changeSort") - toggleBulkSelect=(action "toggleBulkSelect") - hideCategory=model.hideCategory - order=order - ascending=ascending - bulkSelectEnabled=bulkSelectEnabled - selected=selected - expandGloballyPinned=expandGloballyPinned - expandAllPinned=expandAllPinned - category=category - topics=model.topics - discoveryList=true - scrollOnLoad=true - onScroll=discoveryTopicList.saveScrollPosition}} - {{/if}} - -
- -{{#if showSidebar}} - -{{/if}} \ No newline at end of file From c1f07852f059e0d03c4ce1e2ec81c5e29cc0ed52 Mon Sep 17 00:00:00 2001 From: acostanza Date: Thu, 23 Apr 2026 10:55:11 +0200 Subject: [PATCH 06/26] fix: use @controller.bulkSelectHelper and remove redundant fn wrapper in news.gjs --- assets/javascripts/discourse/templates/news.gjs | 7 +++---- 1 file changed, 3 insertions(+), 4 deletions(-) diff --git a/assets/javascripts/discourse/templates/news.gjs b/assets/javascripts/discourse/templates/news.gjs index 68b5bc5..596b51a 100644 --- a/assets/javascripts/discourse/templates/news.gjs +++ b/assets/javascripts/discourse/templates/news.gjs @@ -1,4 +1,3 @@ -import { fn } from "@ember/helper"; import { htmlSafe } from "@ember/template"; import DiscoveryTopicsList from "discourse/components/discovery-topics-list"; import List from "discourse/components/topic-list/list"; @@ -11,7 +10,7 @@ import { i18n } from "discourse-i18n"; {{#if @controller.hasTopics}} Date: Thu, 23 Apr 2026 10:56:08 +0200 Subject: [PATCH 07/26] fix: use @outletArgs.topic in Glimmer outlet connector, remove explicit yield --- .../connectors/topic-list-item/news-topic-list-item.gjs | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/assets/javascripts/discourse/connectors/topic-list-item/news-topic-list-item.gjs b/assets/javascripts/discourse/connectors/topic-list-item/news-topic-list-item.gjs index a7f10a5..6937d2f 100644 --- a/assets/javascripts/discourse/connectors/topic-list-item/news-topic-list-item.gjs +++ b/assets/javascripts/discourse/connectors/topic-list-item/news-topic-list-item.gjs @@ -21,9 +21,10 @@ export default class NewsTopicListItem extends Component { } From 02df7451cb5f0adb98c9e61d0ece97f56deaf87b Mon Sep 17 00:00:00 2001 From: acostanza Date: Thu, 23 Apr 2026 10:57:54 +0200 Subject: [PATCH 08/26] chore: add opencode AGENTS.md --- AGENTS.md | 77 +++++++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 77 insertions(+) create mode 100644 AGENTS.md diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 0000000..1f20525 --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,77 @@ +# AGENTS.md — discourse-news + +A Discourse plugin that adds a `/news` route displaying topics in a news-article layout, with optional RSS feed source and a sidebar topic list. + +## Plugin structure + +Standard Discourse plugin layout. Entry point is `plugin.rb`. + +``` +plugin.rb # Asset registration, gating on enabled setting, monkey-patches +app/serializers/news/ # ActiveModel serializers (RSS items) +assets/javascripts/discourse/ # Frontend: components, connectors, controllers, routes, templates +assets/stylesheets/ # common/ and mobile/ SCSS +config/routes.rb # GET /news => list#news +config/settings.yml # 11 site settings (see below) +lib/news/ # Ruby backend: RSS fetching, item HTML processing, engine +spec/ # RSpec unit + system (browser) tests +``` + +## Running tests + +This plugin runs inside a full Discourse checkout — it cannot be tested standalone. Tests run through Discourse's CI infrastructure. + +CI is handled entirely by the shared reusable workflow in `.github/workflows/discourse-plugin.yml`, which delegates to `discourse/.github/.github/workflows/discourse-plugin.yml@v1`. That shared workflow runs: +- Ruby linting (RuboCop) +- JavaScript linting (ESLint) +- RSpec unit tests +- System (browser/Capybara) tests + +There is no local `Makefile`, `Rakefile`, or task runner script. + +## Linting + +Ruby: `rubocop` via `rubocop-discourse` (inherits `stree-compat.yml`). + +```sh +bundle exec rubocop +``` + +## Frontend conventions + +- Components use **Glimmer `.gjs`** (template-colocation) with `@glimmer/component`. +- The outlet connector at `assets/javascripts/discourse/connectors/topic-list-item/news-topic-list-item.gjs` replaces the default topic list item when on the news route. +- The main template (`templates/news.hbs`) is still legacy HBS — this is a mixed-pattern file; do not convert it without verifying Discourse version compatibility. +- Header button is registered in the initializer via `api.headerButtons.add()`. +- Do **not** use Ember string prototype extensions (removed in recent commits). + +## Backend conventions + +- `News::Rss` fetches via `Excon`, parses with `RSS::Parser`, caches in `Discourse.cache` with a 5-minute TTL. +- `News::Item` uses `Nokogiri::HTML5` to strip featured images, lightbox wrappers, and empty `

` tags from post bodies. +- `ListController` is monkey-patched to skip `ensure_logged_in` for `/news` (unauthenticated access is intentional). +- `plugin.rb` adds `list_controller#news`, `topic_query#list_news`, and `topic#news_body` as computed attributes; `news_body` is serialized on `topic_list_item` conditionally. + +## Site settings (config/settings.yml) + +| Setting | Type | Default | Notes | +|---|---|---|---| +| `discourse_news_enabled` | bool | `false` | Master switch; plugin is gated on this | +| `discourse_news_source` | enum | `"category"` | `category` or `rss` | +| `discourse_news_category` | category_list | `''` | Categories for news topics | +| `discourse_news_rss` | string | `''` | RSS feed URL (server-side only) | +| `discourse_news_icon` | string | `''` | FA icon or image URL for header nav button | +| `discourse_news_title_below_image` | bool | `false` | Layout option | +| `discourse_news_show_reply_count` | bool | `false` | Category source only | +| `discourse_news_sort` | enum | `"bumped_at"` | `bumped_at` or `created_at` (server-side only) | +| `discourse_news_sidebar_topic_list` | bool | `true` | Show sidebar | +| `discourse_news_sidebar_topic_list_filter` | string | `"latest"` | Sidebar filter | +| `discourse_news_sidebar_topic_list_limit` | int | `10` | Sidebar count | + +## Discourse compatibility + +`.discourse-compatibility` pins commit `289c736c` for Discourse `3.1.0.beta4`. The plugin otherwise tracks modern Discourse (Glimmer components, updated share modal API, CSS custom properties for dark mode). + +## Repo context + +Fork of `paviliondev/discourse-news`, maintained at `darktable-fr/discourse-news`. Branch `main` is the active branch. From 4246727573fd1ed18bb1dc97245d8a883fa6eaef Mon Sep 17 00:00:00 2001 From: acostanza Date: Thu, 23 Apr 2026 10:58:28 +0200 Subject: [PATCH 09/26] chore: add skill to migrate a discourse plugin --- .opencode/skills/discourse-migration/SKILL.md | 291 ++++++++++++++++++ 1 file changed, 291 insertions(+) create mode 100644 .opencode/skills/discourse-migration/SKILL.md diff --git a/.opencode/skills/discourse-migration/SKILL.md b/.opencode/skills/discourse-migration/SKILL.md new file mode 100644 index 0000000..153c06f --- /dev/null +++ b/.opencode/skills/discourse-migration/SKILL.md @@ -0,0 +1,291 @@ +--- +name: discourse-migration +description: MUST load before writing or reviewing any database migration (db/migrate, db/post_migrate, plugin migrations) +--- + +# Discourse Migration Skill + +Discourse runs zero-downtime deployments. Migrations are split across two directories: + +- **`db/migrate/`** — runs pre-deploy. SafeMigrate (`lib/migration/safe_migrate.rb`) **blocks** dropping/renaming tables or columns, and creating concurrent indexes without first dropping them. Raises `Discourse::InvalidMigration` on violation. +- **`db/post_migrate/`** — runs post-deploy (skipped when `SKIP_POST_DEPLOYMENT_MIGRATIONS=1`). No safety restrictions — destructive ops go here. + +Plugins mirror this: `plugins//db/migrate/` and `plugins//db/post_migrate/`. + +SafeMigrate is a dev/test guard only (disabled in production). + +## Generating migrations + +Always use generators — never hand-write timestamps. Manual timestamps like `120000`/`120001` cause collisions. + +```bash +bin/rails g migration CreateWidgets # db/migrate/ +bin/rails g post_migration DropOldColumns # db/post_migrate/ +bin/rails g plugin_migration CreatePluginTable --plugin-name=my-plugin # plugins//db/migrate/ +bin/rails g plugin_post_migration DropOldPluginCols --plugin-name=my-plugin # plugins//db/post_migrate/ +bin/rails g site_setting_rename_migration old_name new_name # site setting rename +``` + +Use `change` for reversible ops, `up`/`down` for irreversible. Use `raise ActiveRecord::IrreversibleMigration` in `down`. Use `up_only { ... }` for data ops inside an otherwise reversible `change`. + +## Avoiding application code in migrations + +Never call application code (models, `SiteSetting`, etc.) in migrations. These references break when code changes months or years later — settings get removed, methods get renamed, or semantics shift silently. + +```ruby +# BAD — relies on application code that may not exist when migration runs later +if SiteSetting.some_setting + add_column ... +end + +# GOOD — query the database directly +result = DB.query_single("SELECT value FROM site_settings WHERE name = 'some_setting'") +if result.first == "t" + add_column ... +end +``` + +`execute` is the default for all migration SQL. Only use `DB.exec`/`DB.query` when you need parameterized queries (`:param` syntax) or return values. + +## Safely removing columns + +Multi-step process across deployments. Helpers: `lib/migration/column_dropper.rb`, `lib/migration/base_dropper.rb`. + +**Step 1 — Mark readonly (regular migration):** + +```ruby +class MarkOldColumnReadonly < ActiveRecord::Migration[8.0] + def up + change_column_default :my_table, :old_column, nil # MUST drop default first + Migration::ColumnDropper.mark_readonly(:my_table, :old_column) + end + + def down + Migration::ColumnDropper.drop_readonly(:my_table, :old_column) + end +end +``` + +Creates a PG trigger rejecting non-null writes. Old code can still read. + +**Step 2 — Ignore in model (code change, same PR):** + +```ruby +self.ignored_columns += %i[old_column] +# TODO(MM-YYYY): Remove this line (calculate 6 months from today) +``` + +Use `+=` to append. Without this, dropping the column causes `StatementInvalid`. + +**Step 3 — Drop column (post-deploy migration):** + +```ruby +class DropOldColumn < ActiveRecord::Migration[8.0] + DROPPED_COLUMNS = { my_table: %i[old_column] } + + def up + DROPPED_COLUMNS.each { |table, columns| Migration::ColumnDropper.execute_drop(table, columns) } + end + + def down + raise ActiveRecord::IrreversibleMigration + end +end +``` + +**Step 4** — Remove `ignored_columns` entry after post-deploy migration is promoted. + +For deprecation warnings before removal: `include HasDeprecatedColumns` then `deprecate_column :col, drop_from: "3.5"` (see `app/models/concerns/has_deprecated_columns.rb`). + +## Safely renaming columns + +Renaming is a multi-step process similar to column removal: + +1. **Pre-deploy migration:** Mark the old column readonly with `Migration::ColumnDropper.mark_readonly`, add the new column, create a trigger to mirror writes from old to new on inserts/updates, and backfill existing data from old column to new. +2. **Code change (same PR):** Update all application code to read/write the new column. Add `self.ignored_columns += %i[old_column]` to the model. +3. **Post-deploy migration:** Drop the old column using `Migration::ColumnDropper.execute_drop`. In most cases, delay this until the rename has been confirmed safe with no data loss. + +## Safely removing tables + +Same pattern via `lib/migration/table_dropper.rb`: + +1. Regular migration: `Migration::TableDropper.read_only_table(:old_table)` +2. Post-deploy migration: `Migration::TableDropper.execute_drop(:old_table)` + +If table is already fully unused, just `drop_table` directly in a post-deploy migration. + +## Removing site settings + +The most common migration type. Use `execute` with `DELETE` or `UPDATE`: + +```ruby +# Removal +execute "DELETE FROM site_settings WHERE name = 'old_setting_name'" + +# Rename +execute "UPDATE site_settings SET name = 'new_name' WHERE name = 'old_name'" +``` + +Always `up`/`down` with `raise ActiveRecord::IrreversibleMigration`. + +## Indexing + +### Concurrent indexes + +Large or busy existing tables require concurrent indexing. Always pair with `disable_ddl_transaction!`. SafeMigrate requires dropping the old index first: + +```ruby +class AddIndexToWidgets < ActiveRecord::Migration[8.0] + disable_ddl_transaction! + + def change + remove_index :widgets, :user_id, algorithm: :concurrently, if_exists: true + add_index :widgets, :user_id, algorithm: :concurrently + end +end +``` + +New tables and small/low-traffic existing tables can use regular (non-concurrent) indexes. + +### Partial indexes + +Use `where:` to reduce index size — common for soft-deletes, nullable uniques, type scoping: + +```ruby +add_index :topic_timers, [:topic_id], where: "deleted_at IS NULL" +add_index :email_logs, [:bounce_key], unique: true, where: "bounce_key IS NOT NULL" +add_index :users, [:id], name: "idx_users_admin", where: "admin" +``` + +### Composite indexes + +Order: equality conditions first, then range/sort. Add both directions for join tables: + +```ruby +add_index :topic_allowed_users, %i[topic_id user_id], unique: true +add_index :topic_allowed_users, %i[user_id topic_id], unique: true +``` + +### GiST indexes + +For trigram search: `using: "gist", opclass: :gist_trgm_ops`. + +### Naming + +Default Rails naming works unless the name exceeds 63 chars (PG limit) — then use a custom `name:`. + +## Foreign keys + +**Discourse mostly does NOT use foreign keys.** Referential integrity is enforced by application logic and `EnsureDbConsistency` (`app/jobs/scheduled/ensure_db_consistency.rb`), which runs every 12 hours calling `ensure_consistency!` on 18 core models. + +**Why:** avoids lock contention, simplifies soft-deletes and bulk ops, prevents unexpected cascading deletes. + +**Exceptions:** FKs are used selectively for critical relationships (uploads, security keys). Cascade deletes are rare (2 instances in codebase). Default: don't add FKs. + +## Data backfills + +Lightweight updates (e.g., nulling `baked_version` for rebake) on large tables are fine unbatched if the WHERE clause limits scope. For heavy data writes on large tables, batch with `disable_ddl_transaction!`: + +```ruby +class BackfillData < ActiveRecord::Migration[8.0] + disable_ddl_transaction! + BATCH_SIZE = 30_000 + + def up + loop do + count = DB.exec(<<~SQL, batch_size: BATCH_SIZE) + WITH cte AS ( + SELECT id, other_col FROM my_table WHERE new_col IS NULL LIMIT :batch_size + ) + UPDATE my_table SET new_col = cte.other_col FROM cte WHERE my_table.id = cte.id + SQL + break if count == 0 + end + end + + def down + raise ActiveRecord::IrreversibleMigration + end +end +``` + +Use `ON CONFLICT` for idempotent inserts. Use parameterized queries (`:param` syntax via `DB.exec`) — not string interpolation. + +## Conditional logic + +Use `Migration::Helpers` (`lib/migration/helpers.rb`) for install-vs-upgrade behavior: + +```ruby +if Migration::Helpers.existing_site? # site created > 1 hour ago + # e.g., insert a site setting to disable new feature for existing sites +end +``` + +Use `column_exists?`, `table_exists?`, `index_exists?` for idempotency guards. + +## NOT NULL constraints and NULLable columns + +Avoid NULLable columns whenever possible — every NULL field is a potential `nil` error. Prefer adding a default (e.g., `false` for booleans, `""` for strings, `0` for counts). Limit NULLs to truly optional fields (optional description, optional URL). + +When adding a NOT NULL constraint to an existing column, always clean data first: `DELETE` invalid rows or `UPDATE` nulls to a default, then `change_column_null`. + +## Bigint conversions + +For large tables (e.g. `notifications.id`), use four migrations: +1. Add shadow bigint column + insert-mirroring trigger +2. Batch-copy existing rows (`disable_ddl_transaction!`, ~10k batches) +3. Swap columns (rename old/new, fix PK/sequences, mark old readonly) +4. Post-deploy: `execute_drop` the old column + +## Testing migrations + +When a migration includes data changes with potential for data loss or inaccuracy, write an RSpec test. Migration files aren't auto-loaded, so require them explicitly: + +```ruby +# frozen_string_literal: true + +require Rails.root.join("db/migrate/20240101000000_backfill_widget_status.rb") + +RSpec.describe BackfillWidgetStatus do + before do + @original_verbose = ActiveRecord::Migration.verbose + ActiveRecord::Migration.verbose = false + end + + after { ActiveRecord::Migration.verbose = @original_verbose } + + it "backfills status from legacy column" do + # Set up test data with fabricators or DB.exec + + described_class.new.up + + # Assert expected state + end +end +``` + +The same pattern works for plugin migrations — just adjust the `require` path (e.g., `plugins/chat/db/migrate/...`). + +## Running annotations + +After a schema-altering migration (columns, tables, indexes), `bin/rake db:migrate` then annotate the affected models by path — core or plugin, same command: + +```bash +bin/annotaterb models app/models/widget.rb plugins/my-plugin/app/models/gadget.rb +``` + +## Review checklist + +1. Destructive ops in `db/post_migrate/`, everything else in `db/migrate/` +2. Timestamp from generator, not hand-written +3. `# frozen_string_literal: true` and `ActiveRecord::Migration[8.0]` +4. Concurrent indexes on large/busy tables: `disable_ddl_transaction!` + `remove_index ... if_exists: true` before `add_index` +5. Column drops: full lifecycle (mark_readonly -> ignored_columns -> execute_drop) +6. Default dropped before `mark_readonly` +7. Heavy data writes on large tables batched with `disable_ddl_transaction!` +8. Idempotent: `IF EXISTS`, `ON CONFLICT`, `column_exists?`, etc. +9. Rollback: `down` method or `raise ActiveRecord::IrreversibleMigration` +10. No foreign keys unless strong justification +11. No application code (models, `SiteSetting`) — query DB directly +12. `execute` for SQL; `DB.exec`/`DB.query` only when param binding or return values needed +13. Run `bin/annotaterb models ` on affected model files after schema-altering migrations From a477bbc154d442b9143c1f493e684e9e4e8db3c1 Mon Sep 17 00:00:00 2001 From: acostanza Date: Thu, 23 Apr 2026 10:59:05 +0200 Subject: [PATCH 10/26] docs: add plen to migrate to discourse 2026.04 --- ...6-04-23-discourse-2026-04-compatibility.md | 515 ++++++++++++++++++ 1 file changed, 515 insertions(+) create mode 100644 docs/superpowers/plans/2026-04-23-discourse-2026-04-compatibility.md diff --git a/docs/superpowers/plans/2026-04-23-discourse-2026-04-compatibility.md b/docs/superpowers/plans/2026-04-23-discourse-2026-04-compatibility.md new file mode 100644 index 0000000..5930869 --- /dev/null +++ b/docs/superpowers/plans/2026-04-23-discourse-2026-04-compatibility.md @@ -0,0 +1,515 @@ +# Discourse 2026.04 Compatibility Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Rendre le plugin discourse-news compatible avec Discourse 2026.04 en corrigeant 6 fichiers qui contiennent des APIs supprimées ou des patterns Ember/Rails obsolètes. + +**Architecture:** Les problèmes sont répartis en deux domaines indépendants — Ruby (plugin.rb) et JavaScript (templates + initializers + connector). Les tâches Ruby ne dépendent pas des tâches JS et peuvent être faites dans n'importe quel ordre. La tâche de conversion `news.hbs → news.gjs` dépend de la tâche route car le template lit `@model.sidebarTopics`. + +**Tech Stack:** Glimmer components (.gjs), `apiInitializer`, `discourse/components/topic-list/list`, ActiveModelSerializers 0.10, Discourse plugin API + +--- + +## Fichiers modifiés + +| Tâche | Fichier | Action | +|---|---|---| +| 1 | `plugin.rb` | 3 corrections Ruby : MultiJson, ArraySerializer, include_condition | +| 2 | `assets/javascripts/discourse/api-initializers/discourse-news.gjs` | Remplacer `api.container.lookup` par `api.siteSettings` | +| 3 | `assets/javascripts/discourse/controllers/news.js` | Ajouter `@service site` et `@service siteSettings` | +| 4 | `assets/javascripts/discourse/routes/news.js` | Passer sidebarTopics via le modèle, supprimer controller.set | +| 5 | `assets/javascripts/discourse/templates/news.hbs` → `news.gjs` | Conversion complète HBS → GJS | +| 6 | `assets/javascripts/discourse/connectors/topic-list-item/news-topic-list-item.gjs` | `@topic` → `@outletArgs.topic`, supprimer `{{yield}}` | + +--- + +## Task 1 : Corriger plugin.rb — 3 bugs Ruby critiques + +**Files:** +- Modify: `plugin.rb:28,33,88` + +### Contexte + +Trois patterns supprimés/brisés dans Discourse 2026.04 : +1. `MultiJson` (gem supprimée) — L.33 +2. `ActiveModel::ArraySerializer` (AMS 0.8 legacy) — L.28 +3. `include_condition:` keyword de `add_to_serializer` — L.88 + +### Steps + +- [ ] **Step 1 : Corriger MultiJson et ArraySerializer (L.28 et L.33)** + +Dans `plugin.rb`, remplacer le bloc `add_to_class(:list_controller, :news)` en entier : + +```ruby +# Avant (L.28-38) : +serialized = ActiveModel::ArraySerializer.new(feed, each_serializer: News::RssSerializer, root: false) + +respond_to do |format| + format.html do + @list = News::RssTopicList.new(feed, nil) + store_preloaded("topic_list_news_rss", MultiJson.dump(serialized)) + render 'list/list' + end + format.json do + render json: serialized + end +end + +# Après : +serialized = ActiveModelSerializers::SerializableResource.new( + feed, + each_serializer: News::RssSerializer, + root: false +) + +respond_to do |format| + format.html do + @list = News::RssTopicList.new(feed, nil) + store_preloaded("topic_list_news_rss", serialized.to_json) + render 'list/list' + end + format.json do + render json: serialized + end +end +``` + +- [ ] **Step 2 : Corriger include_condition (L.88-90)** + +Remplacer : + +```ruby +# Avant : +add_to_serializer(:topic_list_item, :news_body, include_condition: -> { object.news_item }) do + object.news_body +end + +# Après : +add_to_serializer(:topic_list_item, :news_body) do + object.news_body +end + +add_to_serializer(:topic_list_item, :include_news_body?) do + object.news_item +end +``` + +- [ ] **Step 3 : Vérifier la syntaxe Ruby** + +```bash +cd /mnt/media1.intra/Dev/Dev/Andy/github/discourse-news +ruby -c plugin.rb +``` + +Résultat attendu : `Syntax OK` + +- [ ] **Step 4 : Commit** + +```bash +git add plugin.rb +git commit -m "fix: remove MultiJson, ArraySerializer legacy, and include_condition keyword from plugin.rb" +``` + +--- + +## Task 2 : Corriger api-initializers/discourse-news.gjs — api.container.lookup déprécié + +**Files:** +- Modify: `assets/javascripts/discourse/api-initializers/discourse-news.gjs` + +### Contexte + +`api.container.lookup("service:site-settings")` est une API de bas niveau dépréciée. L'objet `api` expose directement `api.siteSettings`. + +### Steps + +- [ ] **Step 1 : Remplacer le lookup container** + +Contenu complet du fichier après modification : + +```javascript +import { apiInitializer } from "discourse/lib/api"; +import NewsHeaderButton from "../components/news-header-button"; + +export default apiInitializer("1.0", (api) => { + if (!api.siteSettings.discourse_news_enabled) { + return; + } + + api.headerButtons.add("news", NewsHeaderButton, { before: "auth" }); + + api.modifyClass( + "model:topic", + (Superclass) => + class extends Superclass { + get basicCategoryLinkHtml() { + const category = this.category; + if (!category) { + return ""; + } + return `${category.name}`; + } + } + ); +}); +``` + +- [ ] **Step 2 : Commit** + +```bash +git add assets/javascripts/discourse/api-initializers/discourse-news.gjs +git commit -m "fix: replace api.container.lookup with api.siteSettings in apiInitializer" +``` + +--- + +## Task 3 : Corriger controllers/news.js — services non injectés + +**Files:** +- Modify: `assets/javascripts/discourse/controllers/news.js` + +### Contexte + +`DiscoveryListController` parent peut injecter `site` et `siteSettings` via héritage classique Ember, mais dans une classe ES6 explicite ce n'est pas garanti en Glimmer. Les déclarer explicitement avec `@service` est requis. + +### Steps + +- [ ] **Step 1 : Ajouter les injections de service** + +Contenu complet du fichier après modification : + +```javascript +import { service } from "@ember/service"; +import DiscoveryListController from "discourse/controllers/discovery/list"; + +export default class NewsController extends DiscoveryListController { + @service site; + @service siteSettings; + + get showSidebar() { + return this.showSidebarTopics && !this.site.mobileView; + } + + get showSidebarTopics() { + return ( + this.model?.sidebarTopics && + this.siteSettings.discourse_news_sidebar_topic_list + ); + } + + get sidebarTopics() { + return this.model?.sidebarTopics ?? []; + } +} +``` + +Note : `this.sidebarTopics` et `this.showSidebarTopics` lisent maintenant depuis `this.model.sidebarTopics` (injecté via la route — voir Task 4). Plus de `controller.set()`. + +- [ ] **Step 2 : Commit** + +```bash +git add assets/javascripts/discourse/controllers/news.js +git commit -m "fix: add explicit @service injections to NewsController, read sidebarTopics from model" +``` + +--- + +## Task 4 : Corriger routes/news.js — sidebarTopics via modèle + +**Files:** +- Modify: `assets/javascripts/discourse/routes/news.js` + +### Contexte + +`controller.set("sidebarTopics", ...)` est fragile sur un contrôleur Glimmer (l'état n'est pas réactif). La solution propre est de faire passer `sidebarTopics` dans l'objet modèle retourné par `model()`, ainsi le contrôleur y accède via `this.model.sidebarTopics`. + +### Steps + +- [ ] **Step 1 : Refactorer la route pour inclure sidebarTopics dans le modèle** + +Contenu complet du fichier après modification : + +```javascript +import { service } from "@ember/service"; +import { ajax } from "discourse/lib/ajax"; +import { popupAjaxError } from "discourse/lib/ajax-error"; +import buildTopicRoute from "discourse/routes/build-topic-route"; + +export default class NewsRoute extends buildTopicRoute("news") { + @service siteSettings; + @service site; + @service store; + + async #fetchSidebarTopics() { + if ( + !this.siteSettings.discourse_news_sidebar_topic_list || + this.site.mobileView + ) { + return null; + } + + const filter = + this.siteSettings.discourse_news_sidebar_topic_list_filter || "latest"; + const list = await this.store.findFiltered("topicList", { filter }); + const limit = + this.siteSettings.discourse_news_sidebar_topic_list_limit || 10; + return list.topics.slice(0, limit); + } + + async model(data, transition) { + const sidebarTopics = await this.#fetchSidebarTopics(); + + if (this.siteSettings.discourse_news_source === "rss") { + const result = await ajax("/news").catch(popupAjaxError); + return { + filter: "", + topics: result.map((t) => ({ + title: t.title, + description: t.description, + url: t.url, + image_url: t.image_url, + rss: true, + })), + sidebarTopics, + }; + } + + const base = await super.model(data, transition); + return Object.assign(base, { sidebarTopics }); + } + + setupController(controller, model) { + super.setupController(controller, model); + } +} +``` + +Note : `afterModel()` est supprimé — la logique est fusionnée dans `model()`. `setupController()` n'a plus besoin de faire `controller.set(...)`. + +- [ ] **Step 2 : Commit** + +```bash +git add assets/javascripts/discourse/routes/news.js +git commit -m "fix: pass sidebarTopics via route model instead of controller.set()" +``` + +--- + +## Task 5 : Convertir templates/news.hbs → templates/news.gjs + +**Files:** +- Delete: `assets/javascripts/discourse/templates/news.hbs` +- Create: `assets/javascripts/discourse/templates/news.gjs` + +### Contexte + +`news.hbs` contient 5 problèmes critiques interdépendants : +- `{{topic-list ...}}` curly-brace invocation supprimée → `` (import `discourse/components/topic-list/list`) +- `(action "changeSort")` / `(action "toggleBulkSelect")` supprimés → `{{fn this.changeSort}}` +- `{{{i18n ...}}}` triple-mustache supprimé → `{{htmlSafe (i18n ...)}}` +- `{{topic-link topic}}` → `` +- `{{format-date ...}}` → `{{formatDate ...}}` +- `model.hideCategory` / `model.topics` → `@model.hideCategory` / `@model.topics` +- `this.sidebarTopics` → `@model.sidebarTopics` (provient de la route — Task 4) + +Le template de route Discourse reçoit `@model` comme argument nommé. + +Les getters `showSidebar` et `showSidebarTopics` restent dans le contrôleur (Task 3) et sont accédés via `this.` dans le template. + +### Steps + +- [ ] **Step 1 : Supprimer l'ancien fichier .hbs** + +```bash +rm /mnt/media1.intra/Dev/Dev/Andy/github/discourse-news/assets/javascripts/discourse/templates/news.hbs +``` + +- [ ] **Step 2 : Créer le nouveau fichier news.gjs** + +Créer `assets/javascripts/discourse/templates/news.gjs` avec ce contenu exact : + +```javascript +import { fn } from "@ember/helper"; +import { htmlSafe } from "@ember/template"; +import DiscoveryTopicsList from "discourse/components/discovery-topics-list"; +import List from "discourse/components/topic-list/list"; +import TopicLink from "discourse/components/topic-list/topic-link"; +import formatDate from "discourse/helpers/format-date"; +import { i18n } from "discourse-i18n"; + + +``` + +**Note sur `@controller`** : Dans un template de route Discourse (`.gjs` placé dans `templates/`), le contrôleur est accessible via l'arg `@controller` (pattern Ember pour les route templates en Glimmer). Les propriétés comme `hasTopics`, `changeSort`, `canBulkSelect`, etc. viennent de `DiscoveryListController` (héritage du contrôleur — Task 3). + +**Note sur `fn @controller.changeSort`** : Le `fn` helper crée un callback partiel depuis la méthode du contrôleur. Aucun argument partiel n'est nécessaire ici — `List` appellera le callback avec les arguments de tri. + +- [ ] **Step 3 : Commit** + +```bash +git add assets/javascripts/discourse/templates/news.gjs +git commit -m "feat: convert news.hbs to news.gjs — replace curly-syntax, triple-mustache, and action helpers with Glimmer equivalents" +``` + +--- + +## Task 6 : Corriger le connector topic-list-item — @topic → @outletArgs.topic + +**Files:** +- Modify: `assets/javascripts/discourse/connectors/topic-list-item/news-topic-list-item.gjs` + +### Contexte + +Dans les outlets Glimmer modernes de Discourse, les arguments passés à l'outlet ne sont **pas** disponibles directement comme `@topic` — ils sont dans l'objet `@outletArgs`. Accéder à `@topic` directement retourne `undefined`. + +Le `{{yield}}` dans le `{{else}}` est également problématique : l'outlet wrapper Glimmer gère automatiquement le rendu du contenu par défaut quand le composant connecteur ne rend rien. Il faut supprimer la branche `{{else}}{{yield}}{{/if}}` et ne rendre que quand on est sur la route news. + +### Steps + +- [ ] **Step 1 : Corriger le connector** + +Contenu complet du fichier après modification : + +```javascript +import Component from "@glimmer/component"; +import { service } from "@ember/service"; +import bodyClass from "discourse/helpers/body-class"; +import NewsItem from "../../components/news-item"; + +export default class NewsTopicListItem extends Component { + @service router; + @service siteSettings; + + get isNewsRoute() { + return this.router.currentRouteName === "news"; + } + + get showReplies() { + return ( + this.siteSettings.discourse_news_source === "category" && + this.siteSettings.discourse_news_show_reply_count + ); + } + + +} +``` + +**Pourquoi supprimer `{{else}}{{yield}}`** : L'outlet `topic-list-item` est un outlet "wrapper" — quand le connecteur ne rend rien (pas sur la route news), l'outlet affiche automatiquement le contenu par défaut (le topic list item standard). Le `{{yield}}` explicite dans un connecteur Glimmer peut provoquer un double-rendu. + +- [ ] **Step 2 : Commit** + +```bash +git add assets/javascripts/discourse/connectors/topic-list-item/news-topic-list-item.gjs +git commit -m "fix: use @outletArgs.topic in Glimmer outlet connector, remove explicit yield" +``` + +--- + +## Vérification finale + +- [ ] **Lancer RuboCop** + +```bash +bundle exec rubocop plugins/discourse-news/plugin.rb +``` + +Résultat attendu : pas d'erreurs critiques (warnings éventuels sur le SQL inline sont acceptables). + +- [ ] **Vérifier que le fichier .hbs est bien supprimé** + +```bash +ls assets/javascripts/discourse/templates/ +``` + +Résultat attendu : seul `news.gjs` présent, pas de `news.hbs`. + +- [ ] **Vérifier la structure finale des fichiers JS** + +```bash +find assets/javascripts -name "*.js" -o -name "*.gjs" | sort +``` + +Résultat attendu : +``` +assets/javascripts/discourse/api-initializers/discourse-news.gjs +assets/javascripts/discourse/components/news-header-button.gjs +assets/javascripts/discourse/components/news-item-title.gjs +assets/javascripts/discourse/components/news-item.gjs +assets/javascripts/discourse/connectors/topic-list-item/news-topic-list-item.gjs +assets/javascripts/discourse/controllers/news.js +assets/javascripts/discourse/news-route-map.js +assets/javascripts/discourse/routes/news.js +assets/javascripts/discourse/templates/news.gjs +``` + +- [ ] **Vérifier qu'aucune référence ember-this-fallback ou MultiJson ne subsiste** + +```bash +grep -r "ember-this-fallback\|MultiJson\|include_condition\|ArraySerializer\|api\.container\.lookup" assets/ plugin.rb +``` + +Résultat attendu : aucune ligne. From 65ce3dbd8ebbf24cb6dd3c0d03234661b4dd13d1 Mon Sep 17 00:00:00 2001 From: acostanza Date: Thu, 23 Apr 2026 10:59:37 +0200 Subject: [PATCH 11/26] refactor: remove unused initializer --- .../initializers/news-initializer.js | 31 ------------------- 1 file changed, 31 deletions(-) delete mode 100644 assets/javascripts/discourse/initializers/news-initializer.js diff --git a/assets/javascripts/discourse/initializers/news-initializer.js b/assets/javascripts/discourse/initializers/news-initializer.js deleted file mode 100644 index 1dd0d9f..0000000 --- a/assets/javascripts/discourse/initializers/news-initializer.js +++ /dev/null @@ -1,31 +0,0 @@ -import { withPluginApi } from "discourse/lib/plugin-api"; -import NewsHeaderButton from "../components/news-header-button"; - -export default { - name: "news-edits", - initialize(container) { - const siteSettings = container.lookup("service:site-settings"); - - if (!siteSettings.discourse_news_enabled) { - return; - } - - withPluginApi("1.6.0", (api) => { - api.headerButtons.add("news", NewsHeaderButton, { before: "auth" }); - - api.modifyClass( - "model:topic", - (Superclass) => - class extends Superclass { - get basicCategoryLinkHtml() { - const category = this.category; - if (!category) { - return ""; - } - return `${category.name}`; - } - } - ); - }); - }, -}; From 57ec1d57cfb55b2abe419f4c23e3a8c632462e3f Mon Sep 17 00:00:00 2001 From: acostanza Date: Thu, 23 Apr 2026 11:24:46 +0200 Subject: [PATCH 12/26] chore: minimum discourse compatibility version --- .discourse-compatibility | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.discourse-compatibility b/.discourse-compatibility index c110806..5992b48 100644 --- a/.discourse-compatibility +++ b/.discourse-compatibility @@ -1 +1 @@ -3.1.0.beta4: 289c736c65841578ac9022923581828f089fccac +3.4.0: 4abee26a32a1bd38ec19dfd9f28074db17ebb33a From 47d5feb00f20d27b898097c3c10d4430277793f5 Mon Sep 17 00:00:00 2001 From: acostanza Date: Thu, 23 Apr 2026 11:30:12 +0200 Subject: [PATCH 13/26] refactor: use api.headerIcons.add() to display News icon --- .../javascripts/discourse/api-initializers/discourse-news.gjs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/assets/javascripts/discourse/api-initializers/discourse-news.gjs b/assets/javascripts/discourse/api-initializers/discourse-news.gjs index a0b0bd9..6c98957 100644 --- a/assets/javascripts/discourse/api-initializers/discourse-news.gjs +++ b/assets/javascripts/discourse/api-initializers/discourse-news.gjs @@ -6,7 +6,7 @@ export default apiInitializer("1.0", (api) => { return; } - api.headerButtons.add("news", NewsHeaderButton, { before: "auth" }); + api.headerIcons.add("news", NewsHeaderButton, { before: "search" }); api.modifyClass( "model:topic", From 8efa6a4cf96836730db0dd9ea44c237346a26305 Mon Sep 17 00:00:00 2001 From: acostanza Date: Thu, 23 Apr 2026 11:42:14 +0200 Subject: [PATCH 14/26] chore: update AGENTS.md with latest modification --- AGENTS.md | 15 ++++++++++----- 1 file changed, 10 insertions(+), 5 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index 1f20525..592a637 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -39,11 +39,16 @@ bundle exec rubocop ## Frontend conventions -- Components use **Glimmer `.gjs`** (template-colocation) with `@glimmer/component`. -- The outlet connector at `assets/javascripts/discourse/connectors/topic-list-item/news-topic-list-item.gjs` replaces the default topic list item when on the news route. -- The main template (`templates/news.hbs`) is still legacy HBS — this is a mixed-pattern file; do not convert it without verifying Discourse version compatibility. -- Header button is registered in the initializer via `api.headerButtons.add()`. +- All components and templates use **Glimmer `.gjs`** (template-colocation) with `@glimmer/component`. There are no legacy `.hbs` files. +- Route template: `templates/news.gjs` — uses `` + `` (from `discourse/components/topic-list/list`, not the deprecated `discourse/components/topic-list` shim). +- The outlet connector at `assets/javascripts/discourse/connectors/topic-list-item/news-topic-list-item.gjs` replaces the default topic list item when on the news route; uses `@outletArgs.topic` (not `@topic`). +- Header icon is registered in the api-initializer via `api.headerIcons.add()` (not `api.headerButtons.add()`). +- In api-initializers, use `api.siteSettings` directly — do **not** use `api.container.lookup("service:site-settings")`. - Do **not** use Ember string prototype extensions (removed in recent commits). +- Route model returns sidebar topics via `Object.assign(base, { sidebarTopics })` — do not use `controller.set()`. +- `bulkSelectHelper` belongs to `DiscoveryListController`, referenced as `@controller.bulkSelectHelper` in templates. +- Triple-mustache `{{{...}}}` is gone — use `{{htmlSafe (i18n ...)}}` for HTML-safe i18n strings. +- `(action "changeSort")` is gone — use `@changeSort={{@controller.changeSort}}` (the action is already bound via `@action`). ## Backend conventions @@ -70,7 +75,7 @@ bundle exec rubocop ## Discourse compatibility -`.discourse-compatibility` pins commit `289c736c` for Discourse `3.1.0.beta4`. The plugin otherwise tracks modern Discourse (Glimmer components, updated share modal API, CSS custom properties for dark mode). +`.discourse-compatibility` pins commit `289c736c` for Discourse `3.1.0.beta4`. The plugin targets modern Discourse (Glimmer components, updated share modal API, CSS custom properties for dark mode). It has been migrated to be compatible with Discourse 2026.04, removing deprecated APIs (`MultiJson`, `ActiveModel::ArraySerializer`, `api.headerButtons`, curly-syntax templates, `controller.set()`). ## Repo context From a95f74b628b141280b5ce525817598b107c17f0c Mon Sep 17 00:00:00 2001 From: acostanza Date: Thu, 23 Apr 2026 12:27:06 +0200 Subject: [PATCH 15/26] fix: api.siteSettings doesn't exist on Discourse PluginApi object --- .../javascripts/discourse/api-initializers/discourse-news.gjs | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/assets/javascripts/discourse/api-initializers/discourse-news.gjs b/assets/javascripts/discourse/api-initializers/discourse-news.gjs index 6c98957..3154642 100644 --- a/assets/javascripts/discourse/api-initializers/discourse-news.gjs +++ b/assets/javascripts/discourse/api-initializers/discourse-news.gjs @@ -2,7 +2,8 @@ import { apiInitializer } from "discourse/lib/api"; import NewsHeaderButton from "../components/news-header-button"; export default apiInitializer("1.0", (api) => { - if (!api.siteSettings.discourse_news_enabled) { + const siteSettings = api.container.lookup("service:site-settings"); + if (!siteSettings.discourse_news_enabled) { return; } From d6b854bde1c11552a913284eab15496b86f22aed Mon Sep 17 00:00:00 2001 From: acostanza Date: Thu, 23 Apr 2026 12:27:44 +0200 Subject: [PATCH 16/26] chore: update AGENTS.md with last change --- AGENTS.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/AGENTS.md b/AGENTS.md index 592a637..b72e684 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -43,7 +43,7 @@ bundle exec rubocop - Route template: `templates/news.gjs` — uses `` + `` (from `discourse/components/topic-list/list`, not the deprecated `discourse/components/topic-list` shim). - The outlet connector at `assets/javascripts/discourse/connectors/topic-list-item/news-topic-list-item.gjs` replaces the default topic list item when on the news route; uses `@outletArgs.topic` (not `@topic`). - Header icon is registered in the api-initializer via `api.headerIcons.add()` (not `api.headerButtons.add()`). -- In api-initializers, use `api.siteSettings` directly — do **not** use `api.container.lookup("service:site-settings")`. +- In api-initializers, `api.siteSettings` does **not** exist — use `api.container.lookup("service:site-settings")` to access site settings. In Glimmer components and services, use `@service siteSettings` instead. - Do **not** use Ember string prototype extensions (removed in recent commits). - Route model returns sidebar topics via `Object.assign(base, { sidebarTopics })` — do not use `controller.set()`. - `bulkSelectHelper` belongs to `DiscoveryListController`, referenced as `@controller.bulkSelectHelper` in templates. From 2306c0683e6100f943fffec1f2ab56a3f1204a3d Mon Sep 17 00:00:00 2001 From: acostanza Date: Thu, 23 Apr 2026 15:17:49 +0200 Subject: [PATCH 17/26] fix: latest endpoint doesn't work when plugin is enabled --- .../connectors/topic-list-item/news-topic-list-item.gjs | 2 ++ 1 file changed, 2 insertions(+) diff --git a/assets/javascripts/discourse/connectors/topic-list-item/news-topic-list-item.gjs b/assets/javascripts/discourse/connectors/topic-list-item/news-topic-list-item.gjs index 6937d2f..0dd9c8a 100644 --- a/assets/javascripts/discourse/connectors/topic-list-item/news-topic-list-item.gjs +++ b/assets/javascripts/discourse/connectors/topic-list-item/news-topic-list-item.gjs @@ -25,6 +25,8 @@ export default class NewsTopicListItem extends Component { @topic={{@outletArgs.topic}} @showReplies={{this.showReplies}} /> + {{else}} + {{yield}} {{/if}} } From c570effa71293a0f16d7d5dcc2dbeb35f343e178 Mon Sep 17 00:00:00 2001 From: acostanza Date: Fri, 24 Apr 2026 11:28:46 +0200 Subject: [PATCH 18/26] fix: replace fixed 750px width with max-width on news topic-list-contents --- assets/stylesheets/common/discourse-news.scss | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/assets/stylesheets/common/discourse-news.scss b/assets/stylesheets/common/discourse-news.scss index f8bee87..9ef4393 100644 --- a/assets/stylesheets/common/discourse-news.scss +++ b/assets/stylesheets/common/discourse-news.scss @@ -36,6 +36,8 @@ body.news #main-outlet { body.news #list-area { display: flex; + min-width: 0; + overflow-x: hidden; > span { display: none; @@ -43,6 +45,8 @@ body.news #list-area { .topic-list-contents { width: 750px; + max-width: 100%; + box-sizing: border-box; } .contents { From 4668eac232c39cfaeab5fe6766658a13f10ce8a1 Mon Sep 17 00:00:00 2001 From: acostanza Date: Fri, 24 Apr 2026 11:30:24 +0200 Subject: [PATCH 19/26] chore: add explanatory comment on overflow-x hidden --- assets/stylesheets/common/discourse-news.scss | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/assets/stylesheets/common/discourse-news.scss b/assets/stylesheets/common/discourse-news.scss index 9ef4393..4869e05 100644 --- a/assets/stylesheets/common/discourse-news.scss +++ b/assets/stylesheets/common/discourse-news.scss @@ -37,7 +37,7 @@ body.news #main-outlet { body.news #list-area { display: flex; min-width: 0; - overflow-x: hidden; + overflow-x: hidden; // belt-and-suspenders: prevent horizontal scroll if child content overflows > span { display: none; From 5f3305ec6bc2b6db845e0cd6f6d93f0d19dfd889 Mon Sep 17 00:00:00 2001 From: acostanza Date: Fri, 24 Apr 2026 11:31:04 +0200 Subject: [PATCH 20/26] feat: add tablet responsive breakpoint (768-1024px) hiding sidebar --- assets/stylesheets/common/discourse-news.scss | 23 +++++++++++++++++++ 1 file changed, 23 insertions(+) diff --git a/assets/stylesheets/common/discourse-news.scss b/assets/stylesheets/common/discourse-news.scss index 4869e05..6af7112 100644 --- a/assets/stylesheets/common/discourse-news.scss +++ b/assets/stylesheets/common/discourse-news.scss @@ -185,3 +185,26 @@ body.news #list-area { } } } + +// === Tablet (768px – 1024px) === +@media screen and (max-width: 1024px) { + body.news #list-area { + flex-direction: column; + + .topic-list-contents { + width: 100%; + max-width: 100%; + } + + .sidebar { + display: none; + } + } + + body.news #list-area .contents .topic-list tbody { + .news-item .news-item-title a { + font-size: 28px; + line-height: 36px; + } + } +} From 4641778f3a232d08a709295e3be5a7761fa001a2 Mon Sep 17 00:00:00 2001 From: acostanza Date: Fri, 24 Apr 2026 11:32:50 +0200 Subject: [PATCH 21/26] chore: consolidate tablet media query blocks and fix comment --- assets/stylesheets/common/discourse-news.scss | 15 ++++++++------- 1 file changed, 8 insertions(+), 7 deletions(-) diff --git a/assets/stylesheets/common/discourse-news.scss b/assets/stylesheets/common/discourse-news.scss index 6af7112..843df95 100644 --- a/assets/stylesheets/common/discourse-news.scss +++ b/assets/stylesheets/common/discourse-news.scss @@ -186,25 +186,26 @@ body.news #list-area { } } -// === Tablet (768px – 1024px) === +// === Tablet and below (≤ 1024px) === +// Discourse loads a separate mobile stylesheet by user-agent for phones; +// this breakpoint handles tablets and browsers resized below 1024px. @media screen and (max-width: 1024px) { body.news #list-area { flex-direction: column; .topic-list-contents { width: 100%; - max-width: 100%; } .sidebar { display: none; } - } - body.news #list-area .contents .topic-list tbody { - .news-item .news-item-title a { - font-size: 28px; - line-height: 36px; + .contents .topic-list tbody { + .news-item .news-item-title a { + font-size: 28px; + line-height: 36px; + } } } } From 6565ccf63b4ef76d0f1a70d221a965feda167acf Mon Sep 17 00:00:00 2001 From: acostanza Date: Fri, 24 Apr 2026 11:33:31 +0200 Subject: [PATCH 22/26] fix: mobile thumbnail height auto instead of fixed 200px, remove !important --- assets/stylesheets/mobile/discourse-news.scss | 11 ++++++----- 1 file changed, 6 insertions(+), 5 deletions(-) diff --git a/assets/stylesheets/mobile/discourse-news.scss b/assets/stylesheets/mobile/discourse-news.scss index dc68330..d40f231 100644 --- a/assets/stylesheets/mobile/discourse-news.scss +++ b/assets/stylesheets/mobile/discourse-news.scss @@ -13,11 +13,12 @@ .news-item-thumbnail { float: initial; - margin-bottom: 10px; + margin: 10px 0; img { - height: 200px !important; - width: 100% !important; + width: 100%; + height: auto; // ratio préservé (était height: 200px !important) + object-fit: cover; } } @@ -25,8 +26,8 @@ margin-bottom: 5px; a { - font-size: 25px; - line-height: 34px; + font-size: 22px; + line-height: 30px; } } From e840d3b380f7c9f86188ef689591fe4cc9ed388a Mon Sep 17 00:00:00 2001 From: acostanza Date: Fri, 24 Apr 2026 11:35:34 +0200 Subject: [PATCH 23/26] fix: remove no-op object-fit:cover where no height is defined, add height:auto --- assets/stylesheets/common/discourse-news.scss | 2 +- assets/stylesheets/mobile/discourse-news.scss | 3 +-- 2 files changed, 2 insertions(+), 3 deletions(-) diff --git a/assets/stylesheets/common/discourse-news.scss b/assets/stylesheets/common/discourse-news.scss index 843df95..cdc7202 100644 --- a/assets/stylesheets/common/discourse-news.scss +++ b/assets/stylesheets/common/discourse-news.scss @@ -97,7 +97,7 @@ body.news #list-area { img { width: 100%; - object-fit: cover; + height: auto; } } diff --git a/assets/stylesheets/mobile/discourse-news.scss b/assets/stylesheets/mobile/discourse-news.scss index d40f231..c913ba3 100644 --- a/assets/stylesheets/mobile/discourse-news.scss +++ b/assets/stylesheets/mobile/discourse-news.scss @@ -17,8 +17,7 @@ img { width: 100%; - height: auto; // ratio préservé (était height: 200px !important) - object-fit: cover; + height: auto; } } From 2a5bef8749327fdb6cd7732ff685e14ca32944a5 Mon Sep 17 00:00:00 2001 From: acostanza Date: Fri, 24 Apr 2026 11:36:13 +0200 Subject: [PATCH 24/26] fix: constrain inline images in news-item-body to max-width 100% --- assets/stylesheets/common/discourse-news.scss | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/assets/stylesheets/common/discourse-news.scss b/assets/stylesheets/common/discourse-news.scss index cdc7202..f118759 100644 --- a/assets/stylesheets/common/discourse-news.scss +++ b/assets/stylesheets/common/discourse-news.scss @@ -126,6 +126,12 @@ body.news #list-area { .news-item-body { max-height: initial; color: var(--primary); + + // Prevent inline images in post body from overflowing on narrow screens + img { + max-width: 100%; + height: auto; + } } .news-item-gutter { From c6131d8294912a221cb138a0e761231f219afa6d Mon Sep 17 00:00:00 2001 From: acostanza Date: Fri, 24 Apr 2026 11:40:07 +0200 Subject: [PATCH 25/26] docs: make the plugin responsive for mobile and tablet --- .../2026-04-24-responsive-news-layout.md | 275 ++++++++++++++++++ 1 file changed, 275 insertions(+) create mode 100644 docs/superpowers/plans/2026-04-24-responsive-news-layout.md diff --git a/docs/superpowers/plans/2026-04-24-responsive-news-layout.md b/docs/superpowers/plans/2026-04-24-responsive-news-layout.md new file mode 100644 index 0000000..780d5a2 --- /dev/null +++ b/docs/superpowers/plans/2026-04-24-responsive-news-layout.md @@ -0,0 +1,275 @@ +# Responsive News Layout Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Rendre le plugin discourse-news entièrement responsive — desktop (≥ 1025px), tablette (768px–1024px) et smartphone (≤ 767px) — sans scrolling horizontal ni images débordantes. + +**Architecture:** On adopte l'Approche A : `@media` queries dans `common/discourse-news.scss` pour gérer les trois breakpoints (le fichier mobile Discourse reste pour les overrides user-agent). Les images inline du corps d'article sont contraintes via `.news-item-body img`. Aucun changement JavaScript ni de template. + +**Tech Stack:** SCSS (plugin Discourse), pas de dépendance externe. + +--- + +## Fichiers modifiés + +| Fichier | Action | Responsabilité | +|---|---|---| +| `assets/stylesheets/common/discourse-news.scss` | Modifier | Layout principal + breakpoints responsive | +| `assets/stylesheets/mobile/discourse-news.scss` | Modifier | Nettoyage `!important`, thumbnail hauteur auto | + +--- + +## Task 1 : Corriger la largeur fixe et le layout flex desktop + +**Fichiers :** +- Modifier : `assets/stylesheets/common/discourse-news.scss:44-46` + +Le problème central est `width: 750px` sans `max-width`. On le remplace pour que la colonne puisse se réduire sur les écrans étroits. + +- [ ] **Step 1.1 : Ouvrir `common/discourse-news.scss` et remplacer le bloc `.topic-list-contents`** + +Remplacer aux lignes 44–46 : +```scss +// AVANT +.topic-list-contents { + width: 750px; +} +``` +par : +```scss +// APRÈS +.topic-list-contents { + width: 750px; + max-width: 100%; + box-sizing: border-box; +} +``` + +- [ ] **Step 1.2 : Ajouter `min-width: 0` sur le flex container `#list-area`** + +Dans le sélecteur `body.news #list-area` (ligne 37), ajouter après `display: flex;` : +```scss +body.news #list-area { + display: flex; + min-width: 0; // empêche le flex item de déborder + overflow-x: hidden; // filet de sécurité global + // ... reste inchangé +} +``` + +- [ ] **Step 1.3 : Committer** +```bash +git add assets/stylesheets/common/discourse-news.scss +git commit -m "fix: replace fixed 750px width with max-width on news topic-list-contents" +``` + +--- + +## Task 2 : Ajouter les `@media` queries pour tablette (768px–1024px) + +**Fichiers :** +- Modifier : `assets/stylesheets/common/discourse-news.scss` (ajouter à la fin) + +Sur tablette, la sidebar est cachée et la colonne articles passe en pleine largeur. Le titre est réduit légèrement. + +- [ ] **Step 2.1 : Ajouter le bloc `@media` tablette à la fin du fichier** + +Ajouter ces règles **après la dernière accolade fermante** du fichier `common/discourse-news.scss` : + +```scss +// === Tablet (768px – 1024px) === +@media screen and (max-width: 1024px) { + body.news #list-area { + flex-direction: column; + + .topic-list-contents { + width: 100%; + max-width: 100%; + } + + .sidebar { + display: none; + } + } + + body.news #list-area .contents .topic-list tbody { + .news-item .news-item-title a { + font-size: 28px; + line-height: 36px; + } + } +} +``` + +- [ ] **Step 2.2 : Vérifier visuellement dans un navigateur à 900px de large** + +Ouvrir `/news` dans un navigateur, redimensionner à 900px de large. Vérifier : +- La sidebar n'est plus visible +- La colonne d'articles occupe 100% de la largeur +- Pas de scrollbar horizontale + +- [ ] **Step 2.3 : Committer** +```bash +git add assets/stylesheets/common/discourse-news.scss +git commit -m "feat: add tablet responsive breakpoint (768-1024px) hiding sidebar" +``` + +--- + +## Task 3 : Corriger le fichier mobile — thumbnail hauteur auto + suppression des `!important` + +**Fichiers :** +- Modifier : `assets/stylesheets/mobile/discourse-news.scss` + +Le fichier actuel force `height: 200px !important` sur les thumbnails. On passe à `height: auto` pour préserver le ratio. On supprime les `!important` devenus inutiles car le fichier mobile est chargé après le commun. + +- [ ] **Step 3.1 : Réécrire `mobile/discourse-news.scss`** + +Remplacer le contenu intégral par : + +```scss +#list-area.news { + .contents { + .topic-list { + width: 100%; + + tbody { + width: 100%; + + .news-item { + max-width: 100%; + width: 100%; + box-sizing: border-box; + + .news-item-thumbnail { + float: initial; + margin: 10px 0; + + img { + width: 100%; + height: auto; // ratio préservé (était height: 200px !important) + object-fit: cover; + } + } + + .news-item-title { + margin-bottom: 5px; + + a { + font-size: 22px; + line-height: 30px; + } + } + + .news-item-body { + margin-top: 0; + } + } + } + } + } +} +``` + +- [ ] **Step 3.2 : Committer** +```bash +git add assets/stylesheets/mobile/discourse-news.scss +git commit -m "fix: mobile thumbnail height auto instead of fixed 200px, remove !important" +``` + +--- + +## Task 4 : Contraindre les images inline dans le corps de l'article + +**Fichiers :** +- Modifier : `assets/stylesheets/common/discourse-news.scss` + +Les posts Discourse peuvent contenir des `` avec des attributs `width` inline (ex: `width="800"`). Sans override CSS, ces images cassent le layout sur les petits écrans. + +- [ ] **Step 4.1 : Ajouter la règle sur `.news-item-body img` dans `common/discourse-news.scss`** + +Dans le bloc `.news-item-body` (ligne 122–125), ajouter les règles image : + +```scss +// AVANT +.news-item-body { + max-height: initial; + color: var(--primary); +} +``` + +```scss +// APRÈS +.news-item-body { + max-height: initial; + color: var(--primary); + + // Empêche les images inline du post de déborder sur les petits écrans + img { + max-width: 100%; + height: auto; + } +} +``` + +- [ ] **Step 4.2 : Vérifier sur un article avec une image large dans le corps** + +Ouvrir `/news`, cliquer sur un article qui contient des images dans son corps. Redimensionner à 375px. Vérifier que les images ne débordent pas. + +- [ ] **Step 4.3 : Committer** +```bash +git add assets/stylesheets/common/discourse-news.scss +git commit -m "fix: constrain inline images in news-item-body to max-width 100%" +``` + +--- + +## Task 5 : Vérification complète multi-viewport + +- [ ] **Step 5.1 : Tester desktop (≥ 1025px)** + +Ouvrir `/news` à pleine largeur. Vérifier : +- Layout flex horizontal (articles à gauche, sidebar à droite) +- Sidebar visible avec bordure +- Images thumbnail en pleine largeur de la colonne, ratio correct +- Pas de scrollbar horizontale + +- [ ] **Step 5.2 : Tester tablette (768px–1024px)** + +Redimensionner à 900px. Vérifier : +- Sidebar cachée +- Colonne articles pleine largeur +- Titre légèrement réduit (28px) +- Pas de scrollbar horizontale + +- [ ] **Step 5.3 : Tester smartphone (≤ 767px)** + +Utiliser les DevTools (mode mobile, ex: iPhone 14 Pro = 390px). Vérifier : +- Tout en colonne +- Thumbnail pleine largeur, hauteur auto +- Titre 22px lisible +- Pas de scrollbar horizontale +- Images du corps de l'article ne débordent pas + +- [ ] **Step 5.4 : Lancer RuboCop pour s'assurer que rien de Ruby n'a été touché** +```bash +bundle exec rubocop +``` +Attendu : pas d'erreur (aucun fichier Ruby modifié). + +- [ ] **Step 5.5 : Committer si des ajustements ont été faits lors des tests** +```bash +git add assets/stylesheets/ +git commit -m "fix: responsive news layout adjustments from cross-viewport testing" +``` + +--- + +## Résumé des changements + +| Problème | Cause | Fix | +|---|---|---| +| Scrollbar horizontale sur tablette/mobile | `width: 750px` fixe sur `.topic-list-contents` | → `max-width: 100%` + `overflow-x: hidden` | +| Sidebar écrase la colonne sur écrans étroits | Flex row sans breakpoint | → `flex-direction: column` + `sidebar { display: none }` à ≤ 1024px | +| Thumbnail tronquée (ratio cassé) sur mobile | `height: 200px !important` | → `height: auto; object-fit: cover` | +| Images du corps de l'article débordent | Attributs `width` inline Discourse | → `.news-item-body img { max-width: 100%; height: auto }` | From 23ef2a6f1dce10cf8c46702238d67efb38a721aa Mon Sep 17 00:00:00 2001 From: acostanza Date: Fri, 24 Apr 2026 12:19:13 +0200 Subject: [PATCH 26/26] chore: update AGENTS.md --- AGENTS.md | 17 +++++++++++++++++ 1 file changed, 17 insertions(+) diff --git a/AGENTS.md b/AGENTS.md index b72e684..3190faf 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -80,3 +80,20 @@ bundle exec rubocop ## Repo context Fork of `paviliondev/discourse-news`, maintained at `darktable-fr/discourse-news`. Branch `main` is the active branch. + +## Work performed + +### Responsive layout fixes (2026-04-24) + +Fixed horizontal scroll and layout issues across viewports in `/news` route. + +- Desktop (≥1025px): flex layout, article column 750px max, sidebar visible +- Tablet (≤1024px): sidebar hidden, column 100%, title 28px +- Mobile: full column, title 22px, thumbnails full width ratio-preserved + +**CSS changes:** +- Removed fixed `width: 750px`, use `max-width: 100%` + `min-width: 0` + `overflow-x: hidden` +- Added `@media (max-width: 1024px)` hiding sidebar and forcing column layout +- Rewrote mobile SCSS: removed `!important`, set `height: auto` for thumbnails +- Added `.news-item-body img { max-width: 100%; height: auto }` to constrain inline images +- Removed `object-fit: cover` from thumbnail img rules (no-op without explicit height)