Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
26 commits
Select commit Hold shift + click to select a range
c56ee6c
fix: remove MultiJson, ArraySerializer legacy, and include_condition …
costalfy Apr 23, 2026
3766159
fix: replace api.container.lookup with api.siteSettings in apiInitial…
costalfy Apr 23, 2026
067e576
fix: add explicit @service injections to NewsController, read sidebar…
costalfy Apr 23, 2026
23cb061
fix: pass sidebarTopics via route model instead of controller.set()
costalfy Apr 23, 2026
a68f219
feat: convert news.hbs to news.gjs — replace curly-syntax, triple-mus…
costalfy Apr 23, 2026
c1f0785
fix: use @controller.bulkSelectHelper and remove redundant fn wrapper…
costalfy Apr 23, 2026
fcf37f6
fix: use @outletArgs.topic in Glimmer outlet connector, remove explic…
costalfy Apr 23, 2026
02df745
chore: add opencode AGENTS.md
costalfy Apr 23, 2026
4246727
chore: add skill to migrate a discourse plugin
costalfy Apr 23, 2026
a477bbc
docs: add plen to migrate to discourse 2026.04
costalfy Apr 23, 2026
65ce3db
refactor: remove unused initializer
costalfy Apr 23, 2026
57ec1d5
chore: minimum discourse compatibility version
costalfy Apr 23, 2026
47d5feb
refactor: use api.headerIcons.add() to display News icon
costalfy Apr 23, 2026
8efa6a4
chore: update AGENTS.md with latest modification
costalfy Apr 23, 2026
a95f74b
fix: api.siteSettings doesn't exist on Discourse PluginApi object
costalfy Apr 23, 2026
d6b854b
chore: update AGENTS.md with last change
costalfy Apr 23, 2026
2306c06
fix: latest endpoint doesn't work when plugin is enabled
costalfy Apr 23, 2026
c570eff
fix: replace fixed 750px width with max-width on news topic-list-cont…
costalfy Apr 24, 2026
4668eac
chore: add explanatory comment on overflow-x hidden
costalfy Apr 24, 2026
5f3305e
feat: add tablet responsive breakpoint (768-1024px) hiding sidebar
costalfy Apr 24, 2026
4641778
chore: consolidate tablet media query blocks and fix comment
costalfy Apr 24, 2026
6565ccf
fix: mobile thumbnail height auto instead of fixed 200px, remove !imp…
costalfy Apr 24, 2026
e840d3b
fix: remove no-op object-fit:cover where no height is defined, add he…
costalfy Apr 24, 2026
2a5bef8
fix: constrain inline images in news-item-body to max-width 100%
costalfy Apr 24, 2026
c6131d8
docs: make the plugin responsive for mobile and tablet
costalfy Apr 24, 2026
23ef2a6
chore: update AGENTS.md
costalfy Apr 24, 2026
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
2 changes: 1 addition & 1 deletion .discourse-compatibility
Original file line number Diff line number Diff line change
@@ -1 +1 @@
3.1.0.beta4: 289c736c65841578ac9022923581828f089fccac
3.4.0: 4abee26a32a1bd38ec19dfd9f28074db17ebb33a
291 changes: 291 additions & 0 deletions .opencode/skills/discourse-migration/SKILL.md
Original file line number Diff line number Diff line change
@@ -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/<name>/db/migrate/` and `plugins/<name>/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/<name>/db/migrate/
bin/rails g plugin_post_migration DropOldPluginCols --plugin-name=my-plugin # plugins/<name>/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 <paths>` on affected model files after schema-altering migrations
99 changes: 99 additions & 0 deletions AGENTS.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,99 @@
# 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

- All components and templates use **Glimmer `.gjs`** (template-colocation) with `@glimmer/component`. There are no legacy `.hbs` files.
- Route template: `templates/news.gjs` — uses `<DiscoveryTopicsList>` + `<List>` (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, `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.
- 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

- `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 `<p>` 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 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

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)
Loading