Files
pagerite/frontend/src/swapdoc.js
T
LeoVasanko d3e2196c83 Localization (#1)
Implement comprehensive content localization, admin panels for editing each language, AI translation interface with automatic updates when base language version is changed.
- SEO tags for all language URLs
- Uses accept-language by default, ?lang=en overrides temporarily
- User edits patched on top of translations
- RTL language supportReviewed-on: #1
2026-09-03 03:54:27 +00:00

162 lines
7.5 KiB
JavaScript

// Shared in-place page re-rendering for the editor tabs: fetch a page
// without transitions and swap its dynamic regions into the live document.
// Used by BannerEditor (banner design changes), SiteEditor (theme changes)
// and StructureEditor (tree navigation).
// Drop the public page runtime's in-memory prefetch cache. Editors call this
// whenever a site-wide or page change invalidates the cached HTML of other
// pages (theme, headings, structure, banner, etc.). The cache is rebuilt by
// re-preloading visible links once the editor panel closes.
export function dropPageCache() {
dispatchEvent(new CustomEvent('pagerite:drop-page-cache'))
}
// The editor's language override (set by EditorShell): while the panel is
// open, its language selection wins over the normal preferences (?lang= /
// Accept-Language) — every in-place re-render asks for that language
// explicitly, and pagerite.js applies it to its own fetches and prefetches
// (pagerite:session-lang). The primary selection pins by its code:
// ?lang=<primary> selects the original explicitly (i18n.select_language).
let overrideLang = null // the ?lang= value in force, null = normal prefs
export function setLangOverride(queryLang) {
overrideLang = queryLang || null
dispatchEvent(new CustomEvent('pagerite:session-lang', { detail: { lang: overrideLang } }))
}
export function runScripts(root) {
// Scripts injected via innerHTML do not execute; re-create them.
if (!root) return
for (const old of root.querySelectorAll('script')) {
const s = document.createElement('script')
for (const a of old.attributes) s.setAttribute(a.name, a.value)
s.textContent = old.textContent
old.replaceWith(s)
}
}
function swapRegions(doc) {
for (const id of ['page-banner', 'nav', 'main']) {
const fresh = doc.getElementById(id)
const el = document.getElementById(id)
if (fresh && el) el.replaceWith(document.importNode(fresh, true))
}
// #sidebar is omitted entirely when the section has no sub-navigation,
// so it may be absent on either side: replace, insert, or remove.
const freshSidebar = doc.getElementById('sidebar')
const curSidebar = document.getElementById('sidebar')
if (freshSidebar && curSidebar) {
curSidebar.replaceWith(document.importNode(freshSidebar, true))
} else if (freshSidebar) {
document.getElementById('main')?.before(document.importNode(freshSidebar, true))
} else if (curSidebar) {
curSidebar.remove()
}
// The brand lives in the header, outside the swappable regions, and is
// absent entirely when neither a brand nor custom brand HTML is set. A
// plain link keeps its element (text swap only, preserving the shrink-
// to-fit observers); anything else (custom HTML wrapper) is replaced.
const freshBrand = doc.getElementById('brand')
const curBrand = document.getElementById('brand')
if (freshBrand && curBrand && freshBrand.tagName === 'A' && curBrand.tagName === 'A') {
curBrand.textContent = freshBrand.textContent
} else if (freshBrand && curBrand) {
curBrand.replaceWith(document.importNode(freshBrand, true))
runScripts(document.getElementById('brand'))
} else if (curBrand) {
curBrand.remove()
} else if (freshBrand) {
document.getElementById('nav')?.before(document.importNode(freshBrand, true))
runScripts(document.getElementById('brand'))
}
// Site-wide custom CSS is in <head> and must be swapped too.
const freshUserStyle = doc.getElementById('pagerite-user')
const curUserStyle = document.getElementById('pagerite-user')
if (freshUserStyle && curUserStyle) {
curUserStyle.textContent = freshUserStyle.textContent
} else if (freshUserStyle) {
document.head.appendChild(document.importNode(freshUserStyle, true))
} else if (curUserStyle) {
curUserStyle.remove()
}
// Theme and other public stylesheets live in <head>, rendered with stable
// ids by the backend (links in dev, inline <style> elements in prod);
// sync them positionally so the custom CSS (rendered last) always keeps
// winning by order. Diff-based: unchanged sheets keep their elements, so
// their @keyframes are never torn down (re-creating keyframes would
// replay the editor's slide-in animation).
const sel = 'link[rel="stylesheet"][id], style[id]'
const freshEls = [...doc.head.querySelectorAll(sel)]
const freshIds = new Set(freshEls.map((el) => el.id))
for (const el of [...document.head.querySelectorAll(sel)]) {
if (!freshIds.has(el.id)) el.remove()
}
// Insert missing sheets in the fresh document's order, each right after
// its predecessor's element. The first sheet rendered is always the base
// CSS, so its element doubles as the fallback anchor when nothing matched
// yet (e.g. no theme was selected before and the position is otherwise
// lost).
let anchor = null
for (const el of freshEls) {
const cur = el.id && document.getElementById(el.id)
if (cur && cur.outerHTML === el.outerHTML) {
anchor = cur
continue
}
const imported = document.importNode(el, true)
// Same id, new content (theme switch): replace in place, keeping position.
if (cur) cur.replaceWith(imported)
else if (anchor) anchor.after(imported)
else {
const base = document.getElementById('pagerite-base')
if (base) base.after(imported)
else document.head.append(imported)
}
anchor = imported
}
// The served language rides on <html> (lang + dir, rtl for e.g. Arabic):
// follow the swapped page. The editor panel carries its own lang="en"
// dir="ltr", so it is unaffected.
document.documentElement.lang = doc.documentElement.lang
document.documentElement.dir = doc.documentElement.dir
// The editor keeps its own title while open; only inherit the server title
// when navigating outside the editor (e.g. fetch-navigation swaps).
if (!document.body.classList.contains('editing')) {
document.title = doc.title
}
}
// Fetch /p, swap its regions into the live page and replaceState to it.
// Returns the final URL (after redirects), or null when the fetch did not
// yield a page. Category and missing URLs render a placeholder 404 page —
// fine to swap in (new pages are created by editing them). While the
// editor's language override is set the fetch pins that language.
export async function loadPlain(p) {
let doc
let finalUrl = `/${p}`
let html
try {
const res = await fetch(overrideLang ? `${finalUrl}?lang=${overrideLang}` : finalUrl)
const type = res.headers.get('content-type') || ''
if (!type.includes('text/html')) return null
if (res.redirected) finalUrl = res.url
html = await res.text()
doc = new DOMParser().parseFromString(html, 'text/html')
} catch { return null }
if (!doc.getElementById('main')) return null
swapRegions(doc)
// The address bar keeps the pretty URL: a language query is a fetch
// detail, never shown (pagerite.js's initial ?lang= works the same).
const pretty = new URL(finalUrl, location.href)
pretty.searchParams.delete('lang')
history.replaceState(history.state, '', pretty)
runScripts(document.getElementById('page-banner'))
runScripts(document.getElementById('main'))
// Keep pagerite.js's in-memory page cache in sync with the fresh copy.
// The URL is announced as fetched: a language-pinned copy caches under
// its own ?lang= key, where navigation with the same pin finds it.
dispatchEvent(new CustomEvent('pagerite:page-fetched', { detail: { url: finalUrl, html } }))
dispatchEvent(new CustomEvent('pagerite:preview')) // re-inject + re-tuck the edit pens
return finalUrl
}