Category pages now localized.
This commit is contained in:
+38
-7
@@ -74,6 +74,14 @@ Region tags normalize to their base subtag (`fi-FI` → `fi`).
|
|||||||
- Navigation/sidebar titles come from the translation's title map, with
|
- Navigation/sidebar titles come from the translation's title map, with
|
||||||
per-node fallback to the original title (a partially translated tree must
|
per-node fallback to the original title (a partially translated tree must
|
||||||
still render).
|
still render).
|
||||||
|
- Category placeholder pages (the 404s for content-less labels) select a
|
||||||
|
language like content pages, but over the **subtree's** combined
|
||||||
|
availability (`subtree_languages`) — they have no chunks of their own;
|
||||||
|
the heading, navigation and card text localize from the title map and
|
||||||
|
the target articles' translations.
|
||||||
|
- Card descriptions and cover picks run on the target article's hybrid
|
||||||
|
Markdown where that page is available in the served language, with
|
||||||
|
per-card fallback to the original.
|
||||||
- Fixed UI strings ("Not Found" etc.) and the editor UI stay English for now.
|
- Fixed UI strings ("Not Found" etc.) and the editor UI stay English for now.
|
||||||
- The markdown typographer (SmartyPants) is English-centric; per-language
|
- The markdown typographer (SmartyPants) is English-centric; per-language
|
||||||
typographer options are a possible follow-up, not blocking.
|
typographer options are a possible follow-up, not blocking.
|
||||||
@@ -211,13 +219,36 @@ def get_translation(path, lang, data) -> Translation | None:
|
|||||||
|
|
||||||
### Editor flow
|
### Editor flow
|
||||||
|
|
||||||
- `GET` of page Markdown for editing with a `lang` parameter returns the
|
The page editor has a language picker (flag + name; the same
|
||||||
hybrid (not the raw original) when the article has that language.
|
country-flag-icons set as the analytics visitor cells) listing the primary
|
||||||
- `PUT`/WS save with `lang` does **not** touch `node.chunks`; it diffs
|
language and the union of the page's translations (`node.langs`) and the
|
||||||
against the hybrid that was served and appends a `Patch`. (Serve a hybrid
|
site-wide `translate_langs`. It opens in the language the page was served
|
||||||
generation token with the editor payload so a save based on a stale hybrid
|
in (`<html lang>`). A note under the toolbar states the blast radius:
|
||||||
can be rebased or rejected — simplest: recompute the diff against the
|
edits to the primary language re-chunk the original (invalidating the
|
||||||
*current* hybrid and accept best-effort, matching the patch philosophy.)
|
affected translation fragments everywhere); edits to a translation stay
|
||||||
|
local to that language.
|
||||||
|
|
||||||
|
- WS `open` with a `lang` returns the effective **hybrid** Markdown and
|
||||||
|
title for that language (ungated by `node.langs` — a language without
|
||||||
|
any fragments yet starts from the original text), plus the language
|
||||||
|
metadata (`lang`, `primary_lang`, `langs`, `translate_langs`).
|
||||||
|
- The editor keeps a **shadow copy** of the Markdown it opened. WS `save`
|
||||||
|
with `lang` sends it as `base`; the server diffs `base` → submitted text
|
||||||
|
(`make_patch`) and appends a `Patch`. Diffing against the shadow (rather
|
||||||
|
than the current hybrid) keeps hunks correct when the original or the
|
||||||
|
machine translation moved under an open editor; application against the
|
||||||
|
then-current hybrid stays best-effort per hunk, as designed.
|
||||||
|
- A changed **title** on a translated save becomes a fragment in
|
||||||
|
`Data.trans` keyed by the original title's chunk hash — the same storage
|
||||||
|
as machine title translations. An untouched title field (holding the
|
||||||
|
served translation) is not sent, so saving never freezes a stale machine
|
||||||
|
title into an override.
|
||||||
|
- Saving never deletes; a translation additionally cannot be emptied (that
|
||||||
|
would render as a blank page in that language).
|
||||||
|
- The live preview renders the version being edited, whichever language
|
||||||
|
the page itself was loaded in (the render is just the edited Markdown +
|
||||||
|
title). A translated save keeps that preview in place — re-fetching the
|
||||||
|
page would come back in the header-selected language.
|
||||||
- Saving the primary-language version re-chunks the submitted Markdown and
|
- Saving the primary-language version re-chunks the submitted Markdown and
|
||||||
updates `Data.chunks` / `node.chunks` — only genuinely new text lands in
|
updates `Data.chunks` / `node.chunks` — only genuinely new text lands in
|
||||||
the kanta change diff (see docs/migrate.md).
|
the kanta change diff (see docs/migrate.md).
|
||||||
|
|||||||
+252
-26
@@ -11,14 +11,27 @@
|
|||||||
// and refreshes the page regions in place — never a reload — so the editor
|
// and refreshes the page regions in place — never a reload — so the editor
|
||||||
// state (unsaved text included) also survives closing the shell. The editor
|
// state (unsaved text included) also survives closing the shell. The editor
|
||||||
// always follows the URL: navigating away retargets it to the new page,
|
// always follows the URL: navigating away retargets it to the new page,
|
||||||
// stashing unsaved text per path (unsavedStash) so returning to the page
|
// stashing unsaved text per path and language (stashes) so returning to the
|
||||||
// restores the working draft; stashes clear on save and on real reload.
|
// page restores the working draft; stashes clear on save and on real reload.
|
||||||
import { onActivated, onMounted, onUnmounted, ref, watch } from 'vue'
|
//
|
||||||
|
// Languages: the editor starts in the language the page was served in and
|
||||||
|
// the toolbar picker (flags, like the analytics visitor cells) switches
|
||||||
|
// between the primary language and its translations. A translation is
|
||||||
|
// edited as its effective (hybrid) Markdown; the hybrid the session
|
||||||
|
// started from is kept as a shadow copy (shadowBase) and sent along at
|
||||||
|
// save time, so the server diffs the user's changes only and stores them
|
||||||
|
// as a patch — edits to a translation never touch the original, while
|
||||||
|
// edits to the primary language re-chunk the original (and thereby
|
||||||
|
// invalidate the affected translation fragments). The live preview always
|
||||||
|
// renders the version being edited, whichever language the page itself
|
||||||
|
// was loaded in.
|
||||||
|
import { computed, onActivated, onMounted, onUnmounted, ref, watch } from 'vue'
|
||||||
import { EditorView, basicSetup } from 'codemirror'
|
import { EditorView, basicSetup } from 'codemirror'
|
||||||
import { EditorState } from '@codemirror/state'
|
import { EditorState } from '@codemirror/state'
|
||||||
import { keymap } from '@codemirror/view'
|
import { keymap } from '@codemirror/view'
|
||||||
import { indentWithTab } from '@codemirror/commands'
|
import { indentWithTab } from '@codemirror/commands'
|
||||||
import { markdown } from '@codemirror/lang-markdown'
|
import { markdown } from '@codemirror/lang-markdown'
|
||||||
|
import * as flagSvgs from 'country-flag-icons/string/3x2'
|
||||||
import { cmHighlight, cmTheme } from './cmtheme'
|
import { cmHighlight, cmTheme } from './cmtheme'
|
||||||
import { dropPageCache, loadPlain } from './swapdoc'
|
import { dropPageCache, loadPlain } from './swapdoc'
|
||||||
|
|
||||||
@@ -34,6 +47,20 @@ const saveError = ref('')
|
|||||||
const editorEl = ref(null)
|
const editorEl = ref(null)
|
||||||
const fileInput = ref(null)
|
const fileInput = ref(null)
|
||||||
|
|
||||||
|
// The language being edited: "" = the primary language. Starts as the
|
||||||
|
// language this page was served in (<html lang>); the server normalizes
|
||||||
|
// the primary to "" in its doc reply.
|
||||||
|
const lang = ref(document.documentElement.lang || '')
|
||||||
|
const primaryLang = ref('en')
|
||||||
|
const pageLangs = ref([]) // translations this page has
|
||||||
|
const siteLangs = ref([]) // site-wide configured target languages
|
||||||
|
const langPickerOpen = ref(false)
|
||||||
|
// The shadow copy: the Markdown this editing session started from, sent as
|
||||||
|
// "base" on translated saves so the server diffs the user's changes only.
|
||||||
|
let shadowBase = ''
|
||||||
|
let titleTouched = false
|
||||||
|
let docLoaded = false
|
||||||
|
|
||||||
let ws = null
|
let ws = null
|
||||||
let view = null
|
let view = null
|
||||||
let savedResolve = null
|
let savedResolve = null
|
||||||
@@ -63,6 +90,55 @@ function normPath(p) {
|
|||||||
return p.trim().replace(/^\/+|\/+$/g, '')
|
return p.trim().replace(/^\/+|\/+$/g, '')
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// --- Language picker -------------------------------------------------------
|
||||||
|
// Flag icons from the same country-flag-icons set the analytics visitor
|
||||||
|
// cells use, resolved from the language tag's most likely region.
|
||||||
|
const displayNames = new Intl.DisplayNames(['en'], { type: 'language' })
|
||||||
|
|
||||||
|
function langName(tag) {
|
||||||
|
try {
|
||||||
|
return displayNames.of(tag) || tag
|
||||||
|
} catch {
|
||||||
|
return tag
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function flagFor(tag) {
|
||||||
|
try {
|
||||||
|
return flagSvgs[new Intl.Locale(tag).maximize().region] || ''
|
||||||
|
} catch {
|
||||||
|
return ''
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// The picker's options: the primary language first, then the union of the
|
||||||
|
// page's translations and the site-wide configured targets, sorted.
|
||||||
|
const langOptions = computed(() => {
|
||||||
|
const others = [...new Set([...siteLangs.value, ...pageLangs.value])]
|
||||||
|
.filter((l) => l && l !== primaryLang.value)
|
||||||
|
.sort()
|
||||||
|
return [primaryLang.value, ...others].map((code) => ({
|
||||||
|
tag: code === primaryLang.value ? '' : code,
|
||||||
|
code,
|
||||||
|
name: langName(code),
|
||||||
|
flag: flagFor(code),
|
||||||
|
primary: code === primaryLang.value,
|
||||||
|
}))
|
||||||
|
})
|
||||||
|
|
||||||
|
const currentLang = computed(
|
||||||
|
() => langOptions.value.find((o) => o.tag === lang.value)
|
||||||
|
?? { tag: '', code: lang.value || primaryLang.value, name: langName(lang.value || primaryLang.value), flag: flagFor(lang.value || primaryLang.value), primary: !lang.value },
|
||||||
|
)
|
||||||
|
|
||||||
|
function switchLang(tag) {
|
||||||
|
tag = tag || ''
|
||||||
|
if (tag === lang.value || !view) return
|
||||||
|
stashCurrent()
|
||||||
|
lang.value = tag
|
||||||
|
send({ type: 'open', path: path.value, lang: tag })
|
||||||
|
}
|
||||||
|
|
||||||
function pageLabel() {
|
function pageLabel() {
|
||||||
return title.value.trim() || ('/' + (path.value || ''))
|
return title.value.trim() || ('/' + (path.value || ''))
|
||||||
}
|
}
|
||||||
@@ -96,11 +172,17 @@ function save() {
|
|||||||
// never moves the page.
|
// never moves the page.
|
||||||
const markdown = view.state.doc.toString()
|
const markdown = view.state.doc.toString()
|
||||||
if (markdown.trim() === '') {
|
if (markdown.trim() === '') {
|
||||||
|
if (lang.value) {
|
||||||
|
// Emptying a translation would render it as a blank page; deleting
|
||||||
|
// pages is a primary-language action.
|
||||||
|
saveError.value = '⚠️ a translation cannot be emptied'
|
||||||
|
return Promise.resolve()
|
||||||
|
}
|
||||||
// Empty text means delete — an explicit choice made here, in the page
|
// Empty text means delete — an explicit choice made here, in the page
|
||||||
// editor; the save APIs (REST PUT / WS save) never delete on empty.
|
// editor; the save APIs (REST PUT / WS save) never delete on empty.
|
||||||
return fetch(`/_api/pages/${path.value}`, { method: 'DELETE' }).then((res) => {
|
return fetch(`/_api/pages/${path.value}`, { method: 'DELETE' }).then((res) => {
|
||||||
saveError.value = res.ok ? '' : '⚠️ changes could not be saved'
|
saveError.value = res.ok ? '' : '⚠️ changes could not be saved'
|
||||||
if (res.ok) unsavedStash.delete(path.value)
|
if (res.ok) stashes.delete(stashKey(path.value, lang.value))
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
const msg = {
|
const msg = {
|
||||||
@@ -110,6 +192,15 @@ function save() {
|
|||||||
markdown,
|
markdown,
|
||||||
published: published.value,
|
published: published.value,
|
||||||
}
|
}
|
||||||
|
if (lang.value) {
|
||||||
|
msg.lang = lang.value
|
||||||
|
// The shadow copy this session started from: the server diffs base →
|
||||||
|
// markdown and stores only the user's changes as a patch.
|
||||||
|
msg.base = shadowBase
|
||||||
|
// An untouched title field is not sent: it holds the served
|
||||||
|
// translation, which a save must not freeze into an override fragment.
|
||||||
|
if (!titleTouched) delete msg.title
|
||||||
|
}
|
||||||
pendingSave = msg
|
pendingSave = msg
|
||||||
send(msg)
|
send(msg)
|
||||||
return new Promise((resolve) => { savedResolve = resolve })
|
return new Promise((resolve) => { savedResolve = resolve })
|
||||||
@@ -120,9 +211,12 @@ async function saveAndRefresh() {
|
|||||||
dirty.value = false
|
dirty.value = false
|
||||||
// Refresh the page regions from the server so nav/sidebar changes apply
|
// Refresh the page regions from the server so nav/sidebar changes apply
|
||||||
// (never a reload: the editor keeps its state). Drop the prefetch cache
|
// (never a reload: the editor keeps its state). Drop the prefetch cache
|
||||||
// first: heading/title changes affect navigation on every page.
|
// first: heading/title changes affect navigation on every page. A
|
||||||
|
// translation save keeps the preview as-is — it already shows the saved
|
||||||
|
// text, and loadPlain would swap the article to the header-selected
|
||||||
|
// language's render.
|
||||||
dropPageCache()
|
dropPageCache()
|
||||||
loadPlain(path.value)
|
if (!lang.value) loadPlain(path.value)
|
||||||
}
|
}
|
||||||
|
|
||||||
function close() {
|
function close() {
|
||||||
@@ -572,18 +666,27 @@ function insertTable(cols, rows) {
|
|||||||
view.focus()
|
view.focus()
|
||||||
}
|
}
|
||||||
|
|
||||||
// Unsaved edits survive navigation within the session: leaving a page
|
// Unsaved edits survive navigation within the session: leaving a page (or
|
||||||
// stashes its working text here, returning restores it (the server doc
|
// switching the language) stashes its working text and shadow base here,
|
||||||
// still arrives, for title/published and as the base underneath).
|
// returning restores them (the server doc still arrives, for
|
||||||
// Entries clear on save and on real reload (the shell is in-memory only).
|
// title/published and language metadata). Entries clear on save and on
|
||||||
const unsavedStash = new Map()
|
// real reload (the shell is in-memory only).
|
||||||
|
const stashes = new Map()
|
||||||
|
const stashKey = (p, l) => `${p}|${l}`
|
||||||
|
|
||||||
|
function stashCurrent() {
|
||||||
|
if (dirty.value && path.value) {
|
||||||
|
stashes.set(stashKey(path.value, lang.value), {
|
||||||
|
text: view.state.doc.toString(),
|
||||||
|
base: shadowBase,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
function openPath(p) {
|
function openPath(p) {
|
||||||
if (dirty.value && path.value && p !== path.value) {
|
if (p !== path.value) stashCurrent()
|
||||||
unsavedStash.set(path.value, view.state.doc.toString())
|
|
||||||
}
|
|
||||||
path.value = p
|
path.value = p
|
||||||
send({ type: 'open', path: p })
|
send({ type: 'open', path: p, lang: lang.value })
|
||||||
}
|
}
|
||||||
|
|
||||||
function setDocument(text, preserveSelection = false) {
|
function setDocument(text, preserveSelection = false) {
|
||||||
@@ -624,13 +727,25 @@ function previewIntoArticle(html, multicol) {
|
|||||||
|
|
||||||
function onMessage(ev) {
|
function onMessage(ev) {
|
||||||
const msg = JSON.parse(ev.data)
|
const msg = JSON.parse(ev.data)
|
||||||
if (msg.type === 'doc' && msg.path === path.value) {
|
// The doc must answer the current path and language; before the first
|
||||||
|
// doc the language is not yet server-normalized (the primary arrives as
|
||||||
|
// "" while the picker may have started from an explicit code), so the
|
||||||
|
// first doc is accepted on path alone and adopts the echoed language.
|
||||||
|
if (msg.type === 'doc' && msg.path === path.value
|
||||||
|
&& (!docLoaded || (msg.lang || '') === lang.value)) {
|
||||||
|
docLoaded = true
|
||||||
title.value = msg.title
|
title.value = msg.title
|
||||||
published.value = msg.published
|
published.value = msg.published
|
||||||
|
primaryLang.value = msg.primary_lang || 'en'
|
||||||
|
pageLangs.value = msg.langs || []
|
||||||
|
siteLangs.value = msg.translate_langs || []
|
||||||
|
lang.value = msg.lang || ''
|
||||||
|
titleTouched = false
|
||||||
// Restore stashed unsaved edits over the server doc when returning
|
// Restore stashed unsaved edits over the server doc when returning
|
||||||
// to a page left dirty.
|
// to a page left dirty.
|
||||||
const stashed = unsavedStash.get(msg.path)
|
const stashed = stashes.get(stashKey(msg.path, lang.value))
|
||||||
setDocument(stashed ?? msg.markdown)
|
setDocument(stashed ? stashed.text : msg.markdown)
|
||||||
|
shadowBase = stashed ? stashed.base : msg.markdown
|
||||||
dirty.value = stashed != null
|
dirty.value = stashed != null
|
||||||
requestRender()
|
requestRender()
|
||||||
// A section pen's target line survives the open/path-switch here.
|
// A section pen's target line survives the open/path-switch here.
|
||||||
@@ -638,12 +753,20 @@ function onMessage(ev) {
|
|||||||
} else if (msg.type === 'html' && msg.path === path.value) {
|
} else if (msg.type === 'html' && msg.path === path.value) {
|
||||||
previewIntoArticle(msg.html, msg.multicol)
|
previewIntoArticle(msg.html, msg.multicol)
|
||||||
} else if (msg.type === 'saved') {
|
} else if (msg.type === 'saved') {
|
||||||
saveError.value = ''
|
// A late "saved" for a save sent before a path/language switch must not
|
||||||
pendingSave = null
|
// clear the current session's state.
|
||||||
dirty.value = false
|
if (pendingSave && pendingSave.path === path.value
|
||||||
unsavedStash.delete(path.value)
|
&& (pendingSave.lang || '') === lang.value) {
|
||||||
savedResolve?.()
|
saveError.value = ''
|
||||||
savedResolve = null
|
// The saved text becomes the shadow base for further saves.
|
||||||
|
shadowBase = pendingSave.markdown
|
||||||
|
pendingSave = null
|
||||||
|
dirty.value = false
|
||||||
|
titleTouched = false
|
||||||
|
stashes.delete(stashKey(path.value, lang.value))
|
||||||
|
savedResolve?.()
|
||||||
|
savedResolve = null
|
||||||
|
}
|
||||||
} else if (msg.type === 'error') {
|
} else if (msg.type === 'error') {
|
||||||
saveError.value = '⚠️ changes could not be saved'
|
saveError.value = '⚠️ changes could not be saved'
|
||||||
}
|
}
|
||||||
@@ -929,11 +1052,34 @@ onUnmounted(() => {
|
|||||||
<template>
|
<template>
|
||||||
<div class="page-editor">
|
<div class="page-editor">
|
||||||
<header class="toolbar">
|
<header class="toolbar">
|
||||||
|
<span v-if="langOptions.length > 1" class="picker lang-picker">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="lang-current"
|
||||||
|
:class="{ active: langPickerOpen }"
|
||||||
|
:title="lang
|
||||||
|
? `editing the ${currentLang.name} translation`
|
||||||
|
: `editing the primary language (${currentLang.name})`"
|
||||||
|
@click="langPickerOpen = !langPickerOpen"
|
||||||
|
><span v-if="currentLang.flag" class="flag" v-html="currentLang.flag" /> {{ currentLang.code }}</button>
|
||||||
|
<span v-if="langPickerOpen" class="picker-pop lang-pop" @mouseleave="langPickerOpen = false">
|
||||||
|
<button
|
||||||
|
v-for="o in langOptions"
|
||||||
|
:key="o.code"
|
||||||
|
type="button"
|
||||||
|
:class="{ active: o.tag === lang }"
|
||||||
|
:title="o.primary
|
||||||
|
? `${o.name} — the primary language`
|
||||||
|
: `${o.name} — translation`"
|
||||||
|
@click="switchLang(o.tag); langPickerOpen = false"
|
||||||
|
><span v-if="o.flag" class="flag" v-html="o.flag" /> {{ o.name }}<small v-if="o.primary"> (primary)</small></button>
|
||||||
|
</span>
|
||||||
|
</span>
|
||||||
<label class="title-field">
|
<label class="title-field">
|
||||||
<span class="field-label">title</span>
|
<span class="field-label">title</span>
|
||||||
<input v-model="title" class="title" @input="requestRender" />
|
<input v-model="title" class="title" @input="requestRender(); titleTouched = true" />
|
||||||
</label>
|
</label>
|
||||||
<label><input v-model="published" type="checkbox" /> published</label>
|
<label :title="lang ? 'translations follow the original page’s published state' : ''"><input v-model="published" type="checkbox" :disabled="!!lang" /> published</label>
|
||||||
<input
|
<input
|
||||||
ref="fileInput"
|
ref="fileInput"
|
||||||
type="file"
|
type="file"
|
||||||
@@ -949,6 +1095,14 @@ onUnmounted(() => {
|
|||||||
@click="saveAndRefresh"
|
@click="saveAndRefresh"
|
||||||
>💾</button>
|
>💾</button>
|
||||||
</header>
|
</header>
|
||||||
|
<div v-if="langOptions.length > 1" class="lang-note">
|
||||||
|
<template v-if="lang">
|
||||||
|
{{ currentLang.name }} translation — edits affect only this language.
|
||||||
|
</template>
|
||||||
|
<template v-else>
|
||||||
|
{{ currentLang.name }} is the primary language — edits here affect all translations.
|
||||||
|
</template>
|
||||||
|
</div>
|
||||||
<div class="format-bar">
|
<div class="format-bar">
|
||||||
<button type="button" class="code-btn" title="code — inline wrap, or a fenced block for line-spanning selections; click again to unwrap" @click="insertCode"><code></></code></button>
|
<button type="button" class="code-btn" title="code — inline wrap, or a fenced block for line-spanning selections; click again to unwrap" @click="insertCode"><code></></code></button>
|
||||||
<button type="button" title="link (toggle: click inside a link to unwrap it)" @click="insertLink">🔗︎</button>
|
<button type="button" title="link (toggle: click inside a link to unwrap it)" @click="insertLink">🔗︎</button>
|
||||||
@@ -1089,6 +1243,78 @@ onUnmounted(() => {
|
|||||||
cursor: default;
|
cursor: default;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Language picker (toolbar, left): a flag + code button opening a vertical
|
||||||
|
list of the primary language and its translations. Flags render like in
|
||||||
|
the analytics visitor cells. */
|
||||||
|
.lang-picker {
|
||||||
|
position: relative;
|
||||||
|
display: flex;
|
||||||
|
}
|
||||||
|
|
||||||
|
.lang-picker > button {
|
||||||
|
transform: none; /* undo the icon-button scale (.picker > button) */
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.35rem;
|
||||||
|
padding: 0.15rem 0.4rem;
|
||||||
|
font: inherit;
|
||||||
|
font-size: 0.9rem;
|
||||||
|
color: var(--text);
|
||||||
|
background: none;
|
||||||
|
border: 1px solid var(--line);
|
||||||
|
border-radius: 4px;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.lang-pop {
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: stretch;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.lang-pop button {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.4rem;
|
||||||
|
font-family: inherit;
|
||||||
|
text-align: left;
|
||||||
|
}
|
||||||
|
|
||||||
|
.lang-pop button.active {
|
||||||
|
color: var(--accent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.lang-pop small {
|
||||||
|
color: var(--muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.flag {
|
||||||
|
display: inline-flex;
|
||||||
|
width: 18px;
|
||||||
|
height: 12px;
|
||||||
|
flex: 0 0 auto;
|
||||||
|
border-radius: 2px;
|
||||||
|
overflow: hidden;
|
||||||
|
border: 1px solid var(--line);
|
||||||
|
box-shadow: 0 0 0 1px rgba(0, 0, 0, 0.2) inset;
|
||||||
|
}
|
||||||
|
|
||||||
|
.flag :deep(svg) {
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
display: block;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Why a language is highlighted: primary edits fan out to translations,
|
||||||
|
translation edits stay local to that language. */
|
||||||
|
.lang-note {
|
||||||
|
padding: 0.2rem 1rem;
|
||||||
|
border-bottom: 1px solid var(--line);
|
||||||
|
background: var(--surface);
|
||||||
|
color: var(--muted);
|
||||||
|
font-size: 0.8rem;
|
||||||
|
}
|
||||||
|
|
||||||
/* Markdown helpers: plain icon buttons under the toolbar. */
|
/* Markdown helpers: plain icon buttons under the toolbar. */
|
||||||
.format-bar {
|
.format-bar {
|
||||||
position: relative;
|
position: relative;
|
||||||
|
|||||||
+85
-21
@@ -396,7 +396,14 @@ def _render_html(kind: str, path: str, base_url: str, lang: str = i18n.ORIGINAL_
|
|||||||
translation = i18n.get_translation(path, lang, data) if lang != i18n.ORIGINAL_LANGUAGE else None
|
translation = i18n.get_translation(path, lang, data) if lang != i18n.ORIGINAL_LANGUAGE else None
|
||||||
return views.render_page(data.menu, data, path, data.brand, data.custom_css, data.theme, data.favicon, data.brand_html, base_url, transition=data.transition, lang=lang, translation=translation, link_lang=link_lang)
|
return views.render_page(data.menu, data, path, data.brand, data.custom_css, data.theme, data.favicon, data.brand_html, base_url, transition=data.transition, lang=lang, translation=translation, link_lang=link_lang)
|
||||||
if kind == "category":
|
if kind == "category":
|
||||||
return views.render_category(data.menu, data, path, data.brand, data.custom_css, data.theme, data.favicon, data.brand_html, transition=data.transition)
|
# A category has no Markdown of its own; only the title map
|
||||||
|
# localizes (heading, navigation, card text).
|
||||||
|
translation = (
|
||||||
|
i18n.Translation(titles=i18n.title_map(data, lang))
|
||||||
|
if lang != i18n.ORIGINAL_LANGUAGE
|
||||||
|
else None
|
||||||
|
)
|
||||||
|
return views.render_category(data.menu, data, path, data.brand, data.custom_css, data.theme, data.favicon, data.brand_html, transition=data.transition, lang=lang, translation=translation, link_lang=link_lang)
|
||||||
if kind == "not-found":
|
if kind == "not-found":
|
||||||
return views.render_not_found(data.menu, path, data.brand, data.custom_css, data.theme, data.favicon, data.brand_html, transition=data.transition)
|
return views.render_not_found(data.menu, path, data.brand, data.custom_css, data.theme, data.favicon, data.brand_html, transition=data.transition)
|
||||||
return views.render_analytics(data.menu, data.brand, data.custom_css, data.theme, data.favicon, data.brand_html, transition=data.transition)
|
return views.render_analytics(data.menu, data.brand, data.custom_css, data.theme, data.favicon, data.brand_html, transition=data.transition)
|
||||||
@@ -1455,19 +1462,23 @@ async def editor_ws(ws: WebSocket) -> None:
|
|||||||
Stateless protocol (each message carries the path):
|
Stateless protocol (each message carries the path):
|
||||||
<- {"type": "open", "path", "lang"?}
|
<- {"type": "open", "path", "lang"?}
|
||||||
-> {"type": "doc", "path", "exists", "title", "markdown", "published",
|
-> {"type": "doc", "path", "exists", "title", "markdown", "published",
|
||||||
"banner", "banner_design"}
|
"banner", "banner_design", "lang", "primary_lang", "langs",
|
||||||
|
"translate_langs"}
|
||||||
<- {"type": "render", "path", "markdown"}
|
<- {"type": "render", "path", "markdown"}
|
||||||
-> {"type": "html", "path", "html"}
|
-> {"type": "html", "path", "html"}
|
||||||
<- {"type": "save", "path", "title"?, "markdown"?, "published"?,
|
<- {"type": "save", "path", "title"?, "markdown"?, "published"?,
|
||||||
"banner"?, "banner_design"?, "move_from"?, "lang"?} (absent fields
|
"banner"?, "banner_design"?, "move_from"?, "lang"?, "base"?}
|
||||||
keep their old values; move_from: rename/move a page, subtree
|
(absent fields keep their old values; move_from: rename/move a
|
||||||
included)
|
page, subtree included)
|
||||||
-> {"type": "saved", "path"} | {"type": "error", "detail"}
|
-> {"type": "saved", "path"} | {"type": "error", "detail"}
|
||||||
|
|
||||||
With "lang" (a translation, not the primary language), open returns the
|
With "lang" (a translation, not the primary language), open returns the
|
||||||
served hybrid Markdown for that language and save stores a diff against
|
effective hybrid Markdown and title for that language plus the language
|
||||||
it as a user Patch — node.chunks and the other fields stay untouched
|
metadata the picker's UI needs; save diffs the submitted Markdown
|
||||||
(docs/localization.md).
|
against "base" (the editor's shadow copy of the hybrid it started from
|
||||||
|
— absent: the current hybrid) and stores it as a user Patch, and a
|
||||||
|
changed title becomes a fragment in Data.trans — node.chunks and the
|
||||||
|
other fields stay untouched (docs/localization.md).
|
||||||
"""
|
"""
|
||||||
await ws.accept()
|
await ws.accept()
|
||||||
try:
|
try:
|
||||||
@@ -1483,19 +1494,25 @@ async def editor_ws(ws: WebSocket) -> None:
|
|||||||
case "open":
|
case "open":
|
||||||
chain = resolve(data.menu, path)
|
chain = resolve(data.menu, path)
|
||||||
node = chain[-1] if chain else None
|
node = chain[-1] if chain else None
|
||||||
|
lang = i18n.base_tag(str(msg.get("lang") or ""))
|
||||||
|
if lang == i18n.ORIGINAL_LANGUAGE:
|
||||||
|
lang = ""
|
||||||
markdown = ""
|
markdown = ""
|
||||||
|
title = node.title if node else ""
|
||||||
if node is not None:
|
if node is not None:
|
||||||
markdown = node_markdown(data, node) or ""
|
markdown = node_markdown(data, node) or ""
|
||||||
# ?lang= view: the served hybrid, not the raw
|
if lang and node.chunks is not None:
|
||||||
# original (docs/localization.md editor flow).
|
# Translation view: the effective (hybrid)
|
||||||
lang = i18n.base_tag(str(msg.get("lang") or ""))
|
# Markdown and title for that language —
|
||||||
if lang and (t := i18n.get_translation(path, lang, data)) is not None:
|
# machine fragments + user patches over the
|
||||||
markdown = t.markdown or markdown
|
# original (docs/localization.md editor flow).
|
||||||
|
markdown = i18n.hybrid_markdown(data, node, path, lang)
|
||||||
|
title = i18n.title_map(data, lang).get(path) or title
|
||||||
await ws.send_json({
|
await ws.send_json({
|
||||||
"type": "doc",
|
"type": "doc",
|
||||||
"path": path,
|
"path": path,
|
||||||
"exists": node is not None,
|
"exists": node is not None,
|
||||||
"title": node.title if node else "",
|
"title": title,
|
||||||
"markdown": markdown,
|
"markdown": markdown,
|
||||||
"published": node.published if node else True,
|
"published": node.published if node else True,
|
||||||
"banner": node.banner if node else "",
|
"banner": node.banner if node else "",
|
||||||
@@ -1519,6 +1536,15 @@ async def editor_ws(ws: WebSocket) -> None:
|
|||||||
if src is not None
|
if src is not None
|
||||||
else views.theme_banner_design(data.theme)
|
else views.theme_banner_design(data.theme)
|
||||||
),
|
),
|
||||||
|
# Language context for the editor's picker: the
|
||||||
|
# language this Markdown represents ("" = primary),
|
||||||
|
# the site's primary language, the translations this
|
||||||
|
# page already has, and the site-wide configured
|
||||||
|
# target languages.
|
||||||
|
"lang": lang,
|
||||||
|
"primary_lang": i18n.ORIGINAL_LANGUAGE,
|
||||||
|
"langs": sorted(node.langs) if node else [],
|
||||||
|
"translate_langs": sorted(data.translate_langs),
|
||||||
})
|
})
|
||||||
case "render":
|
case "render":
|
||||||
markdown = msg.get("markdown", "")
|
markdown = msg.get("markdown", "")
|
||||||
@@ -1584,6 +1610,14 @@ async def editor_ws(ws: WebSocket) -> None:
|
|||||||
# original; it cannot create or move pages.
|
# original; it cannot create or move pages.
|
||||||
await ws.send_json({"type": "error", "detail": "no such page"})
|
await ws.send_json({"type": "error", "detail": "no such page"})
|
||||||
continue
|
continue
|
||||||
|
if translated and "markdown" in msg and not msg["markdown"].strip():
|
||||||
|
# Saving never deletes; an emptied translation would
|
||||||
|
# render as a blank page in that language.
|
||||||
|
await ws.send_json({
|
||||||
|
"type": "error",
|
||||||
|
"detail": "a translation cannot be emptied",
|
||||||
|
})
|
||||||
|
continue
|
||||||
with kanta.transaction("editor save", extra=path):
|
with kanta.transaction("editor save", extra=path):
|
||||||
if move_from != path:
|
if move_from != path:
|
||||||
same_menu = (
|
same_menu = (
|
||||||
@@ -1602,20 +1636,36 @@ async def editor_ws(ws: WebSocket) -> None:
|
|||||||
else:
|
else:
|
||||||
node = old if old is not None else _ensure(data.menu, path)
|
node = old if old is not None else _ensure(data.menu, path)
|
||||||
if translated:
|
if translated:
|
||||||
# Diff against the currently served hybrid and
|
# Diff the editor's shadow base (the hybrid the
|
||||||
# append a Patch; node.chunks and the
|
# user started editing, sent along as "base";
|
||||||
# original-language fields stay untouched.
|
# absent: the current hybrid) against the
|
||||||
|
# submitted text and append a Patch; node.chunks
|
||||||
|
# and the original-language fields stay
|
||||||
|
# untouched.
|
||||||
if "markdown" in msg:
|
if "markdown" in msg:
|
||||||
patch = i18n.make_patch(
|
base = msg.get("base")
|
||||||
i18n.hybrid_markdown(data, node, path, lang),
|
if not isinstance(base, str):
|
||||||
msg["markdown"],
|
base = i18n.hybrid_markdown(data, node, path, lang)
|
||||||
)
|
patch = i18n.make_patch(base, msg["markdown"])
|
||||||
if patch.hunks:
|
if patch.hunks:
|
||||||
# Patches alone make the translated
|
# Patches alone make the translated
|
||||||
# version exist.
|
# version exist.
|
||||||
data.patches.setdefault(f"{path}:{lang}", []).append(patch)
|
data.patches.setdefault(f"{path}:{lang}", []).append(patch)
|
||||||
node.langs[lang] = True
|
node.langs[lang] = True
|
||||||
_invalidate_pages()
|
_invalidate_pages()
|
||||||
|
if "title" in msg and node.title:
|
||||||
|
# A changed title becomes a fragment in
|
||||||
|
# Data.trans, keyed by the original title's
|
||||||
|
# chunk hash — same storage as machine
|
||||||
|
# title translations.
|
||||||
|
effective = (
|
||||||
|
i18n.title_map(data, lang).get(path) or node.title
|
||||||
|
)
|
||||||
|
if msg["title"] != effective:
|
||||||
|
key = i18n.chunk_key(node.title)
|
||||||
|
data.trans.setdefault(key, {})[lang] = msg["title"]
|
||||||
|
node.langs[lang] = True
|
||||||
|
_invalidate_pages()
|
||||||
else:
|
else:
|
||||||
if "markdown" in msg:
|
if "markdown" in msg:
|
||||||
# Saving never deletes; empty markdown is an
|
# Saving never deletes; empty markdown is an
|
||||||
@@ -1792,6 +1842,18 @@ async def show_page(request: Request, path: str) -> Response:
|
|||||||
if node is not None and node.published and node.chunks is None:
|
if node is not None and node.published and node.chunks is None:
|
||||||
# Category label without a landing page: placeholder with the pen
|
# Category label without a landing page: placeholder with the pen
|
||||||
# to create it (404 — no page here, but the node is real).
|
# to create it (404 — no page here, but the node is real).
|
||||||
|
# Language selection as on content pages, but over the whole
|
||||||
|
# subtree's availability: the category has no chunks of its own —
|
||||||
|
# its heading, the navigation and the cards' text localize from
|
||||||
|
# the title map and the target articles' translations.
|
||||||
|
query_lang = request.query_params.get("lang")
|
||||||
|
subtree_langs = i18n.subtree_languages(node)
|
||||||
|
lang = i18n.select_language(
|
||||||
|
query_lang,
|
||||||
|
accept_language,
|
||||||
|
lambda l: l in subtree_langs,
|
||||||
|
)
|
||||||
|
link_lang = i18n.base_tag(query_lang or "")
|
||||||
if _is_trackable_path(path):
|
if _is_trackable_path(path):
|
||||||
flushed = _track_entry(path, request, status=404)
|
flushed = _track_entry(path, request, status=404)
|
||||||
_schedule_client_enrichment(flushed)
|
_schedule_client_enrichment(flushed)
|
||||||
@@ -1804,6 +1866,8 @@ async def show_page(request: Request, path: str) -> Response:
|
|||||||
"last-modified": _http_date(node.modified),
|
"last-modified": _http_date(node.modified),
|
||||||
"cache-control": "no-cache",
|
"cache-control": "no-cache",
|
||||||
},
|
},
|
||||||
|
lang=lang,
|
||||||
|
link_lang=link_lang,
|
||||||
)
|
)
|
||||||
if node is None and not path:
|
if node is None and not path:
|
||||||
# No front page (no top-level node with slug ""): "/" opens the
|
# No front page (no top-level node with slug ""): "/" opens the
|
||||||
|
|||||||
@@ -163,6 +163,17 @@ def title_map(data: Data, lang: str) -> dict[str, str]:
|
|||||||
return titles
|
return titles
|
||||||
|
|
||||||
|
|
||||||
|
def subtree_languages(node: Node) -> set[str]:
|
||||||
|
"""Languages available anywhere in the node's subtree (the union of the
|
||||||
|
``langs`` indexes). Category placeholder pages select their language
|
||||||
|
from this: they have no chunks of their own, but their title,
|
||||||
|
navigation and card text localize wherever a translation exists."""
|
||||||
|
langs = set(node.langs)
|
||||||
|
for child in node.children.values():
|
||||||
|
langs |= subtree_languages(child)
|
||||||
|
return langs
|
||||||
|
|
||||||
|
|
||||||
def get_translation(path: str, lang: str, data: Data) -> Translation | None:
|
def get_translation(path: str, lang: str, data: Data) -> Translation | None:
|
||||||
"""The translation of the page at ``path`` for ``lang``, or None.
|
"""The translation of the page at ``path`` for ``lang``, or None.
|
||||||
|
|
||||||
|
|||||||
+33
-17
@@ -701,13 +701,14 @@ def banner_source(menu: dict[str, Node], path: str) -> str | None:
|
|||||||
return None
|
return None
|
||||||
|
|
||||||
|
|
||||||
def page_content(menu: dict[str, Node], data: Data, path: str, translation: Translation | None = None, link_lang: str = "") -> HTML:
|
def page_content(menu: dict[str, Node], data: Data, path: str, translation: Translation | None = None, link_lang: str = "", lang: str = "") -> HTML:
|
||||||
"""Render the contents of the #main element for a page.
|
"""Render the contents of the #main element for a page.
|
||||||
|
|
||||||
A page with published children (a category page) lists them as cards
|
A page with published children (a category page) lists them as cards
|
||||||
after the markdown content. With a translation, its Markdown goes
|
after the markdown content. With a translation, its Markdown goes
|
||||||
through the same render pipeline; missing pieces (markdown=None, absent
|
through the same render pipeline; missing pieces (markdown=None, absent
|
||||||
title entries) fall back to the original.
|
title entries) fall back to the original. ``lang`` feeds the cards'
|
||||||
|
per-target localization.
|
||||||
"""
|
"""
|
||||||
node = resolve(menu, path)[-1]
|
node = resolve(menu, path)[-1]
|
||||||
content = node_markdown(data, node) or ""
|
content = node_markdown(data, node) or ""
|
||||||
@@ -726,11 +727,11 @@ def page_content(menu: dict[str, Node], data: Data, path: str, translation: Tran
|
|||||||
doc = E.article(class_="multicol") if rendered.multicol else E.article
|
doc = E.article(class_="multicol") if rendered.multicol else E.article
|
||||||
with doc:
|
with doc:
|
||||||
doc(HTML(rendered.html))
|
doc(HTML(rendered.html))
|
||||||
_cards(doc, menu, data, node, path, translation, link_lang)
|
_cards(doc, menu, data, node, path, translation, link_lang, lang)
|
||||||
return HTML(str(doc))
|
return HTML(str(doc))
|
||||||
|
|
||||||
|
|
||||||
def _cards(doc, menu: dict[str, Node], data: Data, node: Node, path: str, translation: Translation | None = None, link_lang: str = "") -> None:
|
def _cards(doc, menu: dict[str, Node], data: Data, node: Node, path: str, translation: Translation | None = None, link_lang: str = "", lang: str = "") -> None:
|
||||||
"""Card stacks of the node's published children (nothing when childless).
|
"""Card stacks of the node's published children (nothing when childless).
|
||||||
|
|
||||||
One column per direct child, all in a single full-width row (the .wide
|
One column per direct child, all in a single full-width row (the .wide
|
||||||
@@ -756,7 +757,7 @@ def _cards(doc, menu: dict[str, Node], data: Data, node: Node, path: str, transl
|
|||||||
continue
|
continue
|
||||||
with doc.div(class_="stack"):
|
with doc.div(class_="stack"):
|
||||||
for epath, enode in entries:
|
for epath, enode in entries:
|
||||||
_card(doc, data, enode, epath, translation, link_lang)
|
_card(doc, data, enode, epath, translation, link_lang, lang)
|
||||||
|
|
||||||
|
|
||||||
def _walk(node: Node, path: str):
|
def _walk(node: Node, path: str):
|
||||||
@@ -770,16 +771,21 @@ def _walk(node: Node, path: str):
|
|||||||
yield from _walk(child, f"{path}/{slug}")
|
yield from _walk(child, f"{path}/{slug}")
|
||||||
|
|
||||||
|
|
||||||
def _card(doc, data: Data, node: Node, path: str, translation: Translation | None = None, link_lang: str = "") -> None:
|
def _card(doc, data: Data, node: Node, path: str, translation: Translation | None = None, link_lang: str = "", lang: str = "") -> None:
|
||||||
"""One card in a stack: cover + title, plus the description when the
|
"""One card in a stack: cover + title, plus the description when the
|
||||||
page has no image (its card shows a gradient cover instead).
|
page has no image (its card shows a gradient cover instead).
|
||||||
|
|
||||||
Cover/description heuristics run on the original Markdown even when
|
The card text localizes per target article where that page is
|
||||||
translated (only this page's own Markdown is translated); the title
|
available in the language: the title comes from the translation's
|
||||||
uses the translation's title map."""
|
title map and the cover/description heuristics run on the target's
|
||||||
|
hybrid Markdown — with per-card fallback to the original otherwise.
|
||||||
|
"""
|
||||||
image = description = ""
|
image = description = ""
|
||||||
if node.chunks:
|
if node.chunks:
|
||||||
html = render(node_markdown(data, node) or "", path, node.created, node.modified).html
|
md = node_markdown(data, node) or ""
|
||||||
|
if lang and lang in node.langs:
|
||||||
|
md = i18n.hybrid_markdown(data, node, path, lang)
|
||||||
|
html = render(md, path, node.created, node.modified).html
|
||||||
image, _ = _media(html)
|
image, _ = _media(html)
|
||||||
if not image:
|
if not image:
|
||||||
description = _description(html, 150)
|
description = _description(html, 150)
|
||||||
@@ -933,7 +939,7 @@ def render_page(
|
|||||||
if translation is None:
|
if translation is None:
|
||||||
lang = i18n.ORIGINAL_LANGUAGE
|
lang = i18n.ORIGINAL_LANGUAGE
|
||||||
title = _title(path.rpartition("/")[2], node, translation, path)
|
title = _title(path.rpartition("/")[2], node, translation, path)
|
||||||
main = page_content(menu, data, path, translation, link_lang)
|
main = page_content(menu, data, path, translation, link_lang, lang)
|
||||||
social = _social_meta(node, path, title, str(main), brand, base_url)
|
social = _social_meta(node, path, title, str(main), brand, base_url)
|
||||||
# Canonical/hreflang URLs (docs/localization.md): the canonical names
|
# Canonical/hreflang URLs (docs/localization.md): the canonical names
|
||||||
# the actually served language — the plain URL for the original (for
|
# the actually served language — the plain URL for the original (for
|
||||||
@@ -978,6 +984,9 @@ def render_category(
|
|||||||
favicon: str = "",
|
favicon: str = "",
|
||||||
brand_html: str = "",
|
brand_html: str = "",
|
||||||
transition: str = "cube",
|
transition: str = "cube",
|
||||||
|
lang: str = i18n.ORIGINAL_LANGUAGE,
|
||||||
|
translation: Translation | None = None,
|
||||||
|
link_lang: str = "",
|
||||||
) -> str:
|
) -> str:
|
||||||
"""Render the listing for a content-less category label (404).
|
"""Render the listing for a content-less category label (404).
|
||||||
|
|
||||||
@@ -985,22 +994,29 @@ def render_category(
|
|||||||
children are listed as cards, like on a category page with content.
|
children are listed as cards, like on a category page with content.
|
||||||
Nav links point straight at the first child, so this is mainly seen
|
Nav links point straight at the first child, so this is mainly seen
|
||||||
in the site editor, where the pen creates the landing page.
|
in the site editor, where the pen creates the landing page.
|
||||||
|
|
||||||
|
With a translation (titles only — the category has no Markdown) the
|
||||||
|
heading, navigation and card text localize per target article
|
||||||
|
(docs/localization.md); ``link_lang`` replicates the ?lang= override
|
||||||
|
onto the navigation links as on content pages.
|
||||||
"""
|
"""
|
||||||
node = resolve(menu, path)[-1]
|
node = resolve(menu, path)[-1]
|
||||||
title = _title(path.rpartition("/")[2], node)
|
if translation is None:
|
||||||
|
lang = i18n.ORIGINAL_LANGUAGE
|
||||||
|
title = _title(path.rpartition("/")[2], node, translation, path)
|
||||||
doc = E.article
|
doc = E.article
|
||||||
with doc:
|
with doc:
|
||||||
doc.h1(title)
|
doc.h1(title)
|
||||||
if any(c.published for c in node.children.values()):
|
if any(c.published for c in node.children.values()):
|
||||||
_cards(doc, menu, data, node, path)
|
_cards(doc, menu, data, node, path, translation, link_lang, lang)
|
||||||
else:
|
else:
|
||||||
doc.p("This section has no page of its own yet.")
|
doc.p("This section has no page of its own yet.")
|
||||||
return str(
|
return str(
|
||||||
_layout(*_page_assets(), custom_css, theme, banner_design(menu, path, theme), transition, favicon)(
|
_layout(*_page_assets(), custom_css, theme, banner_design(menu, path, theme), transition, favicon, lang=lang)(
|
||||||
Title=f"{title} – {brand}" if brand else title,
|
Title=f"{title} – {brand}" if brand else title,
|
||||||
Brand=_brand_link(brand, brand_html),
|
Brand=_brand_link(brand, brand_html, link_lang),
|
||||||
Nav=nav_html(menu, path),
|
Nav=nav_html(menu, path, translation, link_lang),
|
||||||
Sidebar=sidebar_html(menu, path),
|
Sidebar=sidebar_html(menu, path, translation, link_lang),
|
||||||
Banner=banner_html(menu, path, theme),
|
Banner=banner_html(menu, path, theme),
|
||||||
Main=HTML(str(doc)),
|
Main=HTML(str(doc)),
|
||||||
),
|
),
|
||||||
|
|||||||
+1
-1
@@ -21,7 +21,7 @@ dependencies = [
|
|||||||
"fastapi[standard]>=0.141.1",
|
"fastapi[standard]>=0.141.1",
|
||||||
"html5tagger>=2.0.0",
|
"html5tagger>=2.0.0",
|
||||||
"httpx>=0.28.1",
|
"httpx>=0.28.1",
|
||||||
"kanta>=0.8.1",
|
"kanta>=0.9.0",
|
||||||
"markdown-it-py>=4.2.0",
|
"markdown-it-py>=4.2.0",
|
||||||
"maxminddb>=3.1.1",
|
"maxminddb>=3.1.1",
|
||||||
"mdit-py-plugins>=0.6.1",
|
"mdit-py-plugins>=0.6.1",
|
||||||
|
|||||||
Reference in New Issue
Block a user