diff --git a/frontend/src/LangSelector.vue b/frontend/src/LangSelector.vue index a7e5183..60ea552 100644 --- a/frontend/src/LangSelector.vue +++ b/frontend/src/LangSelector.vue @@ -9,23 +9,28 @@ // store and re-renders it). import { computed } from 'vue' import LangSelect from './LangSelect.vue' -import { flagFor, langName } from './langs' +import { flagFor, langName, langSort } from './langs' import { useStore } from './store' const store = useStore() // The "(primary)" marker is admin-panel information; the public selector -// lists plain languages. -const options = computed(() => - store.langAlternates.map((a) => ({ - tag: a.tag, - code: a.tag, - name: langName(a.tag), - flag: flagFor(a.tag), - primary: false, - })), -) +// lists plain languages. Order: the primary language first, then the rest +// in the lang tab's geographic grouping (./langs langSort) — the head's +// hreflang order is just alphabetical. const primaryTag = computed(() => store.langAlternates.find((a) => a.primary)?.tag ?? '') +const options = computed(() => { + const rest = langSort( + store.langAlternates.map((a) => a.tag).filter((t) => t !== primaryTag.value), + ) + return [primaryTag.value, ...rest].filter(Boolean).map((tag) => ({ + tag, + code: tag, + name: langName(tag), + flag: flagFor(tag), + primary: false, + })) +}) // The explicit pick, else the served language (header-autodetected pages // may have neither), else the primary. const model = computed(() => store.lang || store.servedLang || primaryTag.value) diff --git a/frontend/src/PageEditor.vue b/frontend/src/PageEditor.vue index 525bb34..5400b02 100644 --- a/frontend/src/PageEditor.vue +++ b/frontend/src/PageEditor.vue @@ -33,7 +33,7 @@ import { keymap } from '@codemirror/view' import { indentWithTab } from '@codemirror/commands' import { markdown } from '@codemirror/lang-markdown' import { cmHighlight, cmTheme } from './cmtheme' -import { flagFor, langName } from './langs' +import { flagFor, langName, langSort } from './langs' import { editorLang, pagePrimary } from './editorLang' import LangSelect from './LangSelect.vue' import ConnNote from './ConnNote.vue' @@ -121,11 +121,13 @@ function normPath(p) { // localization settings tab). // The picker's options: the primary language first, then the union of the -// page's translations and the site-wide configured targets, sorted. +// page's translations and the site-wide configured targets in the lang +// tab's geographic grouping (./langs langSort). const langOptions = computed(() => { - const others = [...new Set([...siteLangs.value, ...pageLangs.value])] - .filter((l) => l && l !== primaryLang.value) - .sort() + const others = langSort( + [...new Set([...siteLangs.value, ...pageLangs.value])] + .filter((l) => l && l !== primaryLang.value), + ) return [primaryLang.value, ...others].map((code) => ({ tag: code === primaryLang.value ? '' : code, code, diff --git a/frontend/src/StructureEditor.vue b/frontend/src/StructureEditor.vue index fec45eb..b5b21ab 100644 --- a/frontend/src/StructureEditor.vue +++ b/frontend/src/StructureEditor.vue @@ -19,7 +19,7 @@ import { computed, inject, onActivated, onMounted, onUnmounted, provide, ref, wa import StructureTree from './StructureTree.vue' import LangSelect from './LangSelect.vue' import { slugify } from './slugify' -import { flagFor, langName } from './langs' +import { flagFor, langName, langSort } from './langs' import { editorLang, pagePrimary } from './editorLang' import { dropPageCache, loadPlain } from './swapdoc' @@ -40,9 +40,10 @@ const primaryLang = ref('en') const siteLangs = ref([]) // The strip's options: the primary language first, then the configured -// translation targets (the lang tab manages that set). +// translation targets (the lang tab manages that set) in the lang tab's +// geographic grouping (./langs langSort). const langOptions = computed(() => - [primaryLang.value, ...siteLangs.value.filter((l) => l !== primaryLang.value)] + [primaryLang.value, ...langSort(siteLangs.value.filter((l) => l !== primaryLang.value))] .map((code) => ({ tag: code === primaryLang.value ? '' : code, code, @@ -63,7 +64,7 @@ watch(lang, () => refreshPages()) // dropdown lists "inherit" first (naming what it resolves to), then every // site language. Setting it on a section covers its whole subtree. const rowLangChoices = computed(() => - [primaryLang.value, ...siteLangs.value.filter((l) => l !== primaryLang.value)] + [primaryLang.value, ...langSort(siteLangs.value.filter((l) => l !== primaryLang.value))] .map((code) => ({ tag: code, code, name: langName(code), flag: flagFor(code), primary: false })), ) function rowLangOptions(el) { diff --git a/frontend/src/langs.js b/frontend/src/langs.js index 356385a..8fc097a 100644 --- a/frontend/src/langs.js +++ b/frontend/src/langs.js @@ -30,6 +30,20 @@ export const LANG_GROUPS = [ const displayNames = new Intl.DisplayNames(['en'], { type: 'language' }) +// Consistent menu ordering for language selectors: the geographic/cultural +// grouping above (similar languages sit together, and it does not vary with +// the display language the way alphabetical-by-name would). Tags outside +// the groups trail, ordered by tag. The primary language is not special +// here — callers put it first themselves. +const groupOrder = new Map(LANG_GROUPS.flat().map((c, i) => [c, i])) +export function langSort(codes) { + return [...codes].sort( + (a, b) => + (groupOrder.get(a) ?? groupOrder.size) - (groupOrder.get(b) ?? groupOrder.size) + || a.localeCompare(b), + ) +} + // English display name for a language tag ("fi" -> "Finnish"). export function langName(tag) { try { diff --git a/pagerite/views.py b/pagerite/views.py index c74c983..4e8475b 100644 --- a/pagerite/views.py +++ b/pagerite/views.py @@ -263,20 +263,31 @@ def _transition_css_url(transition: str) -> str | None: def _editor_css_url(vite_url: str | None) -> str | None: - """URL for the editor-specific stylesheet (Vue component styles). + """URLs (comma-joined) for the editor-specific stylesheets (Vue + component styles). This is linked by the public-page edit pen so the editor styles are - loaded before the editor JS dynamic-import resolves. + loaded before the editor JS dynamic-import resolves. Component styles + can land on shared chunks rather than the entry's own stylesheet — + LangSelect's ride on the shared store chunk, as it is also used by the + on-demand public language selector — so collect the stylesheets of the + entry and its imported chunks (the same traversal _langselect_assets + does). """ if vite_url: return None manifest = _manifest() - entry = manifest["src/main.js"] base = manifest.get(_BASE_CSS_KEY, {}).get("file") - for css in entry.get("css", []): - if css != base: - return f"/{css}" - return None + stylesheets, seen = [], set() + queue = ["src/main.js"] + for key in queue: # grows with imported chunks + if key in seen: + continue + seen.add(key) + entry = manifest[key] + stylesheets += [f"/{css}" for css in entry.get("css", []) if css != base] + queue += entry.get("imports", []) + return ",".join(stylesheets) or None def _inline_asset(url: str) -> str: @@ -1047,9 +1058,12 @@ def _language_urls( canonical = url if lang == original else f"{url}?lang={lang}" alternates = [] if data.translate_langs: + # Only languages the page actually has AND that are still enabled + # site-wide (a disabled target stops being advertised). + enabled = {original, *data.translate_langs} alternates = [("x-default", url)] + [ (tag, url if tag == original else f"{url}?lang={tag}") - for tag in sorted({original, *node.langs}) + for tag in sorted({original, *node.langs} & enabled) ] return canonical, alternates