最終的に、これのためにカスタムなTheme Componentを使用することにしました:
// A-Z 並べ替えにはカテゴリ全体が必要です。コアではトピックリストをタイトル順にソートできないため
// (TopicQuery::SORTABLE_MAPPING に "title" が存在しない)、ソートする前に
// ページネーションされたリストを使い切ります。上限は設定されています — ドキュメントカテゴリは小さいため、
// それ以上の場合は読み込まれたものをソートするだけになり、下の MutationObserver が最新状態を維持します。
// ponytail: コアがサーバーサイドのタイトル順ソートをリリースしたら、このループ全体を削除してください。
const MAX_DOC_PAGES = 12; // 1ページ30件で約360件
let docLoadRun = 0;
async function loadAllDocTopics(api) {
const run = ++docLoadRun;
// `controller:discovery/topics` は現在の Discourse では非推奨です
// (非推奨 ID: discourse.discovery-topics-controller)。Discovery は現在、
// フィルタごとに専用コントローラーを使用しています。ドキュメントカテゴリは常に最新トピック
// リストなので、そのコントローラーを直接参照します。ここで失敗しても最初のページをソートするだけなので、
// すべてのステップは optional-chaining で処理しています。
const list = api.container.lookup("controller:discovery/latest")?.model;
if (!list?.loadMore) {
return;
}
for (let page = 0; page < MAX_DOC_PAGES; page++) {
if (!list.more_topics_url) {
return;
}
const before = list.topics?.length ?? 0;
await list.loadMore();
// 読み込み中に移動したか、リストの増加が止まったか — どちらの場合も停止します。
if (run !== docLoadRun || (list.topics?.length ?? 0) === before) {
return;
}
}
}
// Docs トピックリストを A-Z 順にソートし、ページの <html lang> と照合します。
let docSortObservers = new Map();
function docSortLocale() {
const lang = document.documentElement.lang;
if (!lang) {
return undefined;
}
try {
Intl.getCanonicalLocales(lang); // 不正なタグでは RangeError をスローします
return lang;
} catch {
return undefined; // ブラウザのデフォルトロケールにフォールバックします
}
}
function sortDocTopicList(body, locale) {
const rows = [...body.querySelectorAll(":scope > .topic-list-item")];
if (rows.length < 2) {
return;
}
const sorted = [...rows].sort((a, b) => {
const titleA = a.querySelector(".title")?.textContent.trim() ?? "";
const titleB = b.querySelector(".title")?.textContent.trim() ?? "";
return titleA.localeCompare(titleB, locale);
});
if (sorted.every((row, i) => rows[i] === row)) {
return; // 既にソート済み — オブザーバーが自分自身を再帰的に呼び出すのを防ぎます
}
// 行の後に続くものの前に挿入し、load-more センチネルを最後に保ちます。
const anchor = rows[rows.length - 1].nextSibling;
// 最初に見える行をピン留めし、その上への挿入でスクロールが跳ねないようします。
const pinned = rows.find((row) => row.getBoundingClientRect().bottom > 0);
const pinnedTop = pinned?.getBoundingClientRect().top;
// 自分自身の移動でこの関数に再入しないよう、オブザーバーを一時停止します。
const observer = docSortObservers.get(body);
observer?.disconnect();
sorted.forEach((row) => body.insertBefore(row, anchor));
observer?.observe(body, { childList: true });
if (pinned) {
window.scrollBy(0, pinned.getBoundingClientRect().top - pinnedTop);
}
}
function sortDocCategoryTopicLists() {
docSortObservers.forEach((observer) => observer.disconnect());
docSortObservers = new Map();
const locale = docSortLocale();
// flex/grid コンテナではないため、並べ替えには DOM ノードの移動が必要です — CSS の `order` は無視されます。
document.querySelectorAll(".topic-list.doc-simple-mode .topic-list-body").forEach((body) => {
// ソートで例外が発生してもリストが常に表示されるよう try/finally を使用します。
try {
sortDocTopicList(body, locale);
} finally {
body.classList.add("docs-sorted");
}
// 読み込みが落ち着くまでソートしない — 読み込み中の並べ替えでローダーが停止していました。
let settleTimer = null;
const observer = new MutationObserver(() => {
window.clearTimeout(settleTimer);
settleTimer = window.setTimeout(() => {
sortDocTopicList(body, locale);
}, 400);
});
observer.observe(body, { childList: true });
docSortObservers.set(body, observer);
});
}
このコードは改善の余地があると思います。提案や PR を喜んで受け入れます。