diff --git a/README.md b/README.md index 2f5b6bc..787c9eb 100644 --- a/README.md +++ b/README.md @@ -50,8 +50,11 @@ cmjs/ │ ├── discovery.js # discoverArticles(), fetchJson(), article cache │ ├── cache.js # IndexedDB cache for discovered articles, keyed by post directory │ ├── concurrency.js # mapWithConcurrency() — caps simultaneous per-article fetches + │ ├── i18n.js # t(), loadStrings(), getLocale() — UI string lookup │ ├── views.js # loadArticleList, loadArticle, loadAuthorView, loadCategoryView, loadSearch │ └── router.js # Hash router and app entry point + ├── strings/ + │ └── en.json # UI strings (default/fallback locale) — see "Translations" below ├── templates/ │ ├── card.html # Article list card layout │ └── article.html # Full article view layout @@ -74,7 +77,7 @@ cmjs/ publish date; a `date:` frontmatter field is no longer read. 4. Add tag/category markers as needed: `cmjs/articles/author/my-article/tag-id.tag` (e.g. `kometa.tag`, `expa-2026.tag`) — empty files, any number. A tag id matching `expa-YYYY` is - treated as the article's "year"/category and shown in the Ročníky sidebar; every other tag + treated as the article's "year"/category and shown in the "Years" sidebar; every other tag id is a normal topic tag. 5. Add a frontmatter block at the top of the Markdown file 6. Write your content — it will appear in the list automatically @@ -165,6 +168,30 @@ The HTML structure for the article list card and the full article view live in ` Edit these files directly to change layout — no JS changes needed. Elements are identified by `data-field` attributes, which is how the JS modules find and populate them. Keep existing `data-field` values when rearranging; add new ones if you add new fields. +## Translations + +Every UI string the JS renders (loading/error states, pagination, filter labels, the table of +contents heading, etc.) is looked up through `t(key, params)` in `cmjs/js/i18n.js` rather than +hardcoded — `cmjs/strings/en.json` is the default/fallback string table. + +To add a translation: + +1. Set `` in your `index.html` to the target language code (e.g. `cs`). +2. Add `cmjs/strings/xx.json` with the same keys as `en.json` (copy it as a starting point) — + you only need to include the keys you're translating; anything missing quietly falls back to + English rather than breaking. +3. `{placeholder}` tokens in a string (e.g. `"{page} of {totalPages}"`) are substituted at + render time — keep them in your translated string, in whatever order/position reads + naturally in that language. + +The resolved locale also drives date formatting (`Intl.DateTimeFormat`), search matching, and +tag-label sorting (`localeCompare`), via `getLocale()` in the same module — so a translation +file doesn't just relabel text, it affects how dates and sorting behave too. + +Site-specific branding that lives in `index.html` itself (the page ``, the header search +placeholder, the sidebar section headings) is plain static HTML, not JS-templated — translate it +by editing `index.html` directly, the same way you'd customise it for any other embedding site. + ## Embedding in an existing page To add cmjs to any HTML page, three things are needed: the CSS, the two mount-point `<div>`s, and the script. diff --git a/cmjs/js/discovery.js b/cmjs/js/discovery.js index 8364258..a4d123d 100644 --- a/cmjs/js/discovery.js +++ b/cmjs/js/discovery.js @@ -2,6 +2,7 @@ import { parseFrontmatter } from './frontmatter.js'; import { BASE_URL, PICTURES_URL } from './config.js'; import { mapWithConcurrency } from './concurrency.js'; import { getCacheEntry, setCacheEntry, pruneCache } from './cache.js'; +import { t } from './i18n.js'; // Caps simultaneous per-article fetches (see the "Wave 3+4" step below) so a // large site doesn't burst hundreds of requests at once. Raise it for a @@ -237,7 +238,7 @@ export async function discoverArticles() { export async function findArticle(hash) { const article = (await discoverArticles()).find(candidate => candidate.hash === hash); - if (!article) throw new Error('Článek nebyl nalezen'); + if (!article) throw new Error(t('articleNotFound')); return article; } diff --git a/cmjs/js/i18n.js b/cmjs/js/i18n.js new file mode 100644 index 0000000..9885376 --- /dev/null +++ b/cmjs/js/i18n.js @@ -0,0 +1,53 @@ +// UI string lookup, so every user-facing message lives in a translatable +// JSON file (cmjs/strings/{locale}.json) instead of being hardcoded in the +// JS. English (cmjs/strings/en.json) is always fetched first as the +// fallback set; if the page's declared language (<html lang>) names another +// locale, that file is fetched too and merged on top — a translation file +// only needs to cover the keys it actually translates, missing ones still +// render in English rather than breaking. +import { BASE_URL } from './config.js'; + +const FALLBACK_LOCALE = 'en'; + +let strings = null; +let locale = FALLBACK_LOCALE; + +async function fetchStrings(code) { + try { + const response = await fetch(`${BASE_URL}strings/${code}.json`); + if (!response.ok) return null; + return await response.json(); + } catch { + return null; + } +} + +// Must be awaited before the first render — see router.js's `ready` chain — +// since t()/getLocale() below assume this has already resolved. +export async function loadStrings() { + const fallback = await fetchStrings(FALLBACK_LOCALE); + const requested = (document.documentElement.lang || FALLBACK_LOCALE).trim().toLowerCase().split('-')[0]; + if (requested === FALLBACK_LOCALE || !requested) { + strings = fallback; + locale = FALLBACK_LOCALE; + return; + } + const translated = await fetchStrings(requested); + strings = translated ? { ...fallback, ...translated } : fallback; + locale = translated ? requested : FALLBACK_LOCALE; +} + +// The resolved locale code (e.g. "en", "cs") — used for Intl.* calls +// elsewhere so dates/sorting/search match whatever language is displayed. +export function getLocale() { + return locale; +} + +// Looks up `key` and substitutes any `{name}` placeholders from `params`. +// Falls back to the raw key if strings never loaded (e.g. en.json itself +// failed to fetch) — a visible-but-not-crashing degradation, same spirit as +// the rest of this codebase's silent-skip fallbacks. +export function t(key, params = {}) { + const template = strings?.[key] ?? key; + return template.replace(/\{(\w+)\}/g, (match, name) => (params[name] != null ? String(params[name]) : match)); +} diff --git a/cmjs/js/router.js b/cmjs/js/router.js index 74d02d5..7bd2584 100644 --- a/cmjs/js/router.js +++ b/cmjs/js/router.js @@ -13,6 +13,7 @@ import { MARKED_URL } from './config.js'; import { loadTemplates } from './templates.js'; +import { loadStrings, t } from './i18n.js'; import { loadArticleList, loadAuthorView, loadCategoryView, loadTagView, loadArticle, loadSearch } from './views.js'; // Dynamically inject marked.js as a plain global script before the app starts. @@ -61,11 +62,13 @@ function init() { if (q) location.hash = `#search/${encodeURIComponent(q)}`; }); - ready = loadScript(MARKED_URL).then(loadTemplates); + // Strings load first — everything else (including the error handler + // right below) may call t(), so it needs to already have resolved. + ready = loadStrings().then(() => Promise.all([loadScript(MARKED_URL), loadTemplates()])); // Show a top-level error if dependencies fail to load ready.catch(err => { - document.body.textContent = `Blog se nepodařilo spustit: ${err.message}`; + document.body.textContent = t('appStartError', { message: err.message }); }); ready.then(router).catch(() => {}); diff --git a/cmjs/js/views.js b/cmjs/js/views.js index bfb1485..626d21e 100644 --- a/cmjs/js/views.js +++ b/cmjs/js/views.js @@ -2,6 +2,7 @@ import { templates } from './templates.js'; import { discoverArticles, fetchAuthorProfile, fetchTagRegistry, findArticle, getCrawlStats, resolveMediaUrl } from './discovery.js'; import { parseFrontmatter } from './frontmatter.js'; import { PICTURES_URL } from './config.js'; +import { t, getLocale } from './i18n.js'; const PAGE_SIZE = 20; let tagLabels = new Map(); @@ -10,16 +11,16 @@ async function ensureTags() { if (!tagLabels.size) tagLabels = new Map((await fetchTagRegistry()).map(tag => [tag.id, tag.label])); } -function czechDate(value) { +function formatDate(value) { if (!value) return ''; const date = new Date(`${String(value).slice(0, 10)}T12:00:00`); - return Number.isNaN(date.valueOf()) ? String(value) : new Intl.DateTimeFormat('cs-CZ', { day: 'numeric', month: 'long', year: 'numeric' }).format(date); + return Number.isNaN(date.valueOf()) ? String(value) : new Intl.DateTimeFormat(getLocale(), { day: 'numeric', month: 'long', year: 'numeric' }).format(date); } export function showListView() { document.getElementById('list-view').hidden = false; document.getElementById('article-view').hidden = true; - document.title = 'Expediční blogy'; + document.title = t('siteName'); } export function showArticleView() { @@ -39,9 +40,9 @@ function clearSearchInput() { function filterLabel(filter) { if (!filter) return ''; - if (filter.type === 'author') return `Autor: ${filter.value}`; - if (filter.type === 'tag') return `Štítek: ${tagLabels.get(filter.value) || filter.value}`; - return `Ročník: ${filter.value.replace(/^expa-/i, 'Expa ')}`; + if (filter.type === 'author') return t('filterAuthor', { value: filter.value }); + if (filter.type === 'tag') return t('filterTag', { value: tagLabels.get(filter.value) || filter.value }); + return t('filterYear', { value: filter.value.replace(/^expa-/i, 'Expa ') }); } function pageHash(page, filter) { @@ -53,18 +54,18 @@ function pageHash(page, filter) { function buildPaginationNav(page, totalPages, filter) { const nav = document.createElement('nav'); nav.className = 'pagination'; - nav.setAttribute('aria-label', 'Stránkování'); + nav.setAttribute('aria-label', t('paginationLabel')); const previous = document.createElement('a'); - previous.textContent = 'Předchozí'; + previous.textContent = t('paginationPrevious'); const next = document.createElement('a'); - next.textContent = 'Další'; + next.textContent = t('paginationNext'); if (page > 1) previous.href = pageHash(page - 1, filter); else { previous.classList.add('disabled'); previous.setAttribute('aria-disabled', 'true'); } if (page < totalPages) next.href = pageHash(page + 1, filter); else { next.classList.add('disabled'); next.setAttribute('aria-disabled', 'true'); } const info = document.createElement('span'); info.className = 'page-info'; - info.textContent = `${page} z ${totalPages}`; + info.textContent = t('paginationPageInfo', { page, totalPages }); nav.append(previous, info, next); return nav; } @@ -109,11 +110,11 @@ function renderFilterNavigation(articles, activeFilter = null) { }); const yearList = document.createElement('div'); yearList.className = 'filter-list'; - yearList.appendChild(renderFilterLink('Všechny články', '#', articles.length, !activeFilter)); + yearList.appendChild(renderFilterLink(t('filterAllArticles'), '#', articles.length, !activeFilter)); for (let year = 2026; year >= 2009; year -= 1) { const count = yearCounts.get(year) || 0; const active = activeFilter?.type === 'category' && activeFilter.value.toLowerCase() === `expa-${year}`; - yearList.appendChild(renderFilterLink(`Expa ${year}`, `#category/expa-${year}`, count, active)); + yearList.appendChild(renderFilterLink(t('yearLabel', { year }), `#category/expa-${year}`, count, active)); } years.appendChild(yearList); @@ -123,7 +124,7 @@ function renderFilterNavigation(articles, activeFilter = null) { tagList.className = 'filter-list'; [...tagLabels.entries()] .filter(([id]) => tagCounts.has(id)) - .sort((a, b) => (tagLabels.get(a[0]) || a[0]).localeCompare(tagLabels.get(b[0]) || b[0], 'cs')) + .sort((a, b) => (tagLabels.get(a[0]) || a[0]).localeCompare(tagLabels.get(b[0]) || b[0], getLocale())) .forEach(([id, label]) => { const active = activeFilter?.type === 'tag' && activeFilter.value === id; tagList.appendChild(renderFilterLink(label, `#tag/${encodeURIComponent(id)}`, tagCounts.get(id), active)); @@ -134,7 +135,7 @@ function renderFilterNavigation(articles, activeFilter = null) { function renderCard(article) { const fragment = document.importNode(templates.card, true); const element = fragment.firstElementChild; - if (!element) throw new Error('Šablona článku je prázdná.'); + if (!element) throw new Error(t('templateEmptyError')); const field = name => element.querySelector(`[data-field="${name}"]`); const imageLink = field('image-link'); setField(field('image'), article.coverUrl, (image, value) => { @@ -152,16 +153,16 @@ function renderCard(article) { } field('link').href = `#post/${article.hash}`; field('title').textContent = article.title || article.slug; - setField(field('date'), article.date, (time, value) => { time.dateTime = String(value); time.textContent = czechDate(value); }); + setField(field('date'), article.date, (time, value) => { time.dateTime = String(value); time.textContent = formatDate(value); }); setField(field('description'), article.description, (description, value) => { description.textContent = value; }); - setField(field('reading-time'), article.readingTime, (time, value) => { time.textContent = `${value} min čtení`; }); + setField(field('reading-time'), article.readingTime, (time, value) => { time.textContent = t('readingTime', { minutes: value }); }); const author = field('author'); author.textContent = article.authorName || article.author; author.href = `#author/${encodeURIComponent(article.author)}`; // A slug directory only counts as a "category" when one of its .tag // markers happens to name an expedition year — not every article has one. setField(field('category'), article.category, (category, value) => { - category.textContent = `Expa ${article.expa}`; + category.textContent = t('yearLabel', { year: article.expa }); category.href = `#category/${encodeURIComponent(value)}`; }); renderTags(field('tags'), article.tags); @@ -183,8 +184,8 @@ function renderCrawlHealthWarning(view) { const warning = document.createElement('p'); warning.className = 'error'; warning.textContent = authorsBroken - ? 'Pozor: nepodařilo se načíst žádného autora — zkontrolujte nastavení serveru.' - : `Pozor: načtena jen ${loaded} z ${total} položek — zkontrolujte nastavení serveru.`; + ? t('crawlHealthAuthorsFailed') + : t('crawlHealthArticlesFailed', { loaded, total }); view.appendChild(warning); } @@ -193,7 +194,7 @@ export async function loadArticleList(filter = null, page = 1) { window.scrollTo(0, 0); clearSearchInput(); const view = document.getElementById('list-view'); - view.innerHTML = '<p class="loading">Načítám články…</p>'; + view.innerHTML = `<p class="loading">${t('loadingArticles')}</p>`; try { await ensureTags(); const allArticles = await discoverArticles(); @@ -214,7 +215,7 @@ export async function loadArticleList(filter = null, page = 1) { if (!articles.length) { const empty = document.createElement('p'); empty.className = 'empty'; - empty.textContent = 'Nebyly nalezeny žádné články.'; + empty.textContent = t('emptyArticleList'); view.appendChild(empty); return; } @@ -223,11 +224,21 @@ export async function loadArticleList(filter = null, page = 1) { articles.slice((page - 1) * PAGE_SIZE, page * PAGE_SIZE).forEach(article => view.appendChild(renderCard(article))); if (pages > 1) view.appendChild(buildPaginationNav(page, pages, filter)); } catch (error) { - view.innerHTML = `<p class="error">Články se nepodařilo načíst: ${error.message}</p>`; + view.innerHTML = `<p class="error">${t('articlesLoadError', { message: error.message })}</p>`; } } -const SOCIAL_LABELS = { github: 'GitHub', twitter: 'Twitter', linkedin: 'LinkedIn', mastodon: 'Mastodon', website: 'Web' }; +// GitHub/Twitter/LinkedIn/Mastodon are brand names, not translatable text — +// only "Website" (below, via t()) is actual UI copy. +const SOCIAL_LABELS = { github: 'GitHub', twitter: 'Twitter', linkedin: 'LinkedIn', mastodon: 'Mastodon' }; +function makeSocialLink(href, label) { + const link = document.createElement('a'); + link.href = href; + link.textContent = label; + link.target = '_blank'; + link.rel = 'noopener noreferrer'; + return link; +} function renderAuthorProfile(profile) { const element = document.importNode(templates.authorProfile, true); const field = name => element.querySelector(`[data-field="${name}"]`); @@ -236,9 +247,8 @@ function renderAuthorProfile(profile) { const bio = field('bio'); if (profile.content?.trim()) bio.innerHTML = marked.parse(profile.content); else bio.remove(); const social = field('social'); - const links = Object.entries(SOCIAL_LABELS).filter(([key]) => profile[key]).map(([key, label]) => { - const link = document.createElement('a'); link.href = profile[key]; link.textContent = label; link.target = '_blank'; link.rel = 'noopener noreferrer'; return link; - }); + const links = Object.entries(SOCIAL_LABELS).filter(([key]) => profile[key]).map(([key, label]) => makeSocialLink(profile[key], label)); + if (profile.website) links.push(makeSocialLink(profile.website, t('socialWebsite'))); if (links.length) links.forEach(link => social.appendChild(link)); else social.remove(); return element; } @@ -246,13 +256,13 @@ function renderAuthorProfile(profile) { export async function loadAuthorView(author, page = 1) { showListView(); const view = document.getElementById('list-view'); - view.innerHTML = '<p class="loading">Načítám…</p>'; + view.innerHTML = `<p class="loading">${t('loadingGeneric')}</p>`; clearSearchInput(); try { const profile = await fetchAuthorProfile(author); await loadArticleList({ type: 'author', value: author }, page); if (page === 1 && profile) view.insertBefore(renderAuthorProfile(profile), view.firstChild); - } catch (error) { view.innerHTML = `<p class="error">Články se nepodařilo načíst: ${error.message}</p>`; } + } catch (error) { view.innerHTML = `<p class="error">${t('articlesLoadError', { message: error.message })}</p>`; } } export const loadCategoryView = (category, page = 1) => loadArticleList({ type: 'category', value: category }, page); @@ -262,20 +272,20 @@ export async function loadSearch(query) { showListView(); window.scrollTo(0, 0); const view = document.getElementById('list-view'); - view.innerHTML = '<p class="loading">Hledám…</p>'; + view.innerHTML = `<p class="loading">${t('searching')}</p>`; const input = document.getElementById('search-input'); if (input) input.value = query; try { await ensureTags(); - const needle = query.toLocaleLowerCase('cs'); + const needle = query.toLocaleLowerCase(getLocale()); const allArticles = await discoverArticles(); renderFilterNavigation(allArticles); const articles = allArticles.filter(article => [article.title, article.description, article.author, article.authorName, article.category, ...article.tags.map(tag => tagLabels.get(tag) || tag)] - .some(value => String(value || '').toLocaleLowerCase('cs').includes(needle))); - view.innerHTML = `<h2 class="category-heading">Hledání: „${query}“</h2>`; - if (!articles.length) view.insertAdjacentHTML('beforeend', '<p class="empty">Hledání neodpovídá žádný článek.</p>'); + .some(value => String(value || '').toLocaleLowerCase(getLocale()).includes(needle))); + view.innerHTML = `<h2 class="category-heading">${t('searchHeading', { query })}</h2>`; + if (!articles.length) view.insertAdjacentHTML('beforeend', `<p class="empty">${t('searchNoResults')}</p>`); else articles.forEach(article => view.appendChild(renderCard(article))); - } catch (error) { view.innerHTML = `<p class="error">Hledání se nepodařilo: ${error.message}</p>`; } + } catch (error) { view.innerHTML = `<p class="error">${t('searchError', { message: error.message })}</p>`; } } function buildTOC(content) { @@ -283,11 +293,11 @@ function buildTOC(content) { if (headings.length < 3) return null; const seen = new Map(); headings.forEach(heading => { - const base = heading.textContent.toLocaleLowerCase('cs').normalize('NFD').replace(/[\u0300-\u036f]/g, '').replace(/[^a-z0-9]+/g, '-').replace(/^-|-$/g, '') || 'sekce'; + const base = heading.textContent.toLocaleLowerCase(getLocale()).normalize('NFD').replace(/[\u0300-\u036f]/g, '').replace(/[^a-z0-9]+/g, '-').replace(/^-|-$/g, '') || t('tocFallbackHeading'); const count = (seen.get(base) || 0) + 1; seen.set(base, count); heading.id = count === 1 ? base : `${base}-${count}`; }); const nav = document.createElement('nav'); nav.className = 'toc'; - nav.innerHTML = '<p class="toc-title">Obsah</p><ol class="toc-list"></ol>'; + nav.innerHTML = `<p class="toc-title">${t('tocTitle')}</p><ol class="toc-list"></ol>`; headings.forEach(heading => { const item = document.createElement('li'); if (heading.tagName === 'H3') item.classList.add('toc-h3'); const link = document.createElement('a'); link.href = `#${heading.id}`; link.textContent = heading.textContent; item.appendChild(link); nav.lastChild.appendChild(item); @@ -300,7 +310,7 @@ export async function loadArticle(hash) { window.scrollTo(0, 0); clearSearchInput(); const view = document.getElementById('article-view'); - view.innerHTML = '<p class="loading">Načítám…</p>'; + view.innerHTML = `<p class="loading">${t('loadingGeneric')}</p>`; try { await ensureTags(); const article = await findArticle(hash); @@ -309,20 +319,20 @@ export async function loadArticle(hash) { fetchAuthorProfile(article.author), ]); const { meta, content } = parseFrontmatter(raw); - document.title = `${meta.title || article.slug} | Expediční blogy`; + document.title = `${meta.title || article.slug} | ${t('siteName')}`; const element = document.importNode(templates.article, true); const field = name => element.querySelector(`[data-field="${name}"]`); field('title').textContent = meta.title || article.slug; // Publish date comes from the slug directory's `.date` marker file // (already resolved onto `article.date`), not the raw frontmatter. - setField(field('date'), article.date, (time, value) => { time.dateTime = String(value); time.textContent = czechDate(value); }); + setField(field('date'), article.date, (time, value) => { time.dateTime = String(value); time.textContent = formatDate(value); }); setField(field('description'), meta.description, (description, value) => { description.textContent = value; }); - setField(field('reading-time'), article.readingTime, (time, value) => { time.textContent = `${value} min čtení`; }); + setField(field('reading-time'), article.readingTime, (time, value) => { time.textContent = t('readingTime', { minutes: value }); }); const image = field('image'); if (article.coverUrl) { image.src = article.coverUrl; image.alt = meta.title || article.slug; } else image.closest('figure')?.remove(); const author = field('author'); author.textContent = meta.authorName || article.author; author.href = `#author/${encodeURIComponent(article.author)}`; setField(field('category'), article.category, (category, value) => { - category.textContent = `Expa ${article.expa}`; + category.textContent = t('yearLabel', { year: article.expa }); category.href = `#category/${encodeURIComponent(value)}`; }); renderTags(field('tags'), article.tags); @@ -330,8 +340,8 @@ export async function loadArticle(hash) { contentElement.innerHTML = marked.parse(content); contentElement.querySelectorAll('img[src]').forEach(imageElement => { imageElement.src = resolveMediaUrl(imageElement.getAttribute('src'), article.path); }); const toc = buildTOC(contentElement); if (toc) contentElement.insertBefore(toc, contentElement.firstChild); - const back = document.createElement('a'); back.href = '#'; back.className = 'back-link'; back.textContent = 'Zpět na články'; + const back = document.createElement('a'); back.href = '#'; back.className = 'back-link'; back.textContent = t('backToArticles'); view.innerHTML = ''; view.append(back, element); if (profile) { const divider = document.createElement('hr'); divider.className = 'article-divider'; view.append(divider, renderAuthorProfile(profile)); } - } catch (error) { view.innerHTML = `<p class="error">Článek se nepodařilo načíst: ${error.message}</p>`; } + } catch (error) { view.innerHTML = `<p class="error">${t('articleLoadError', { message: error.message })}</p>`; } } diff --git a/cmjs/strings/en.json b/cmjs/strings/en.json new file mode 100644 index 0000000..53cd895 --- /dev/null +++ b/cmjs/strings/en.json @@ -0,0 +1,40 @@ +{ + "siteName": "Expedition Blogs", + "appStartError": "Failed to start the blog: {message}", + "articleNotFound": "Article not found", + + "filterAuthor": "Author: {value}", + "filterTag": "Tag: {value}", + "filterYear": "Year: {value}", + "filterAllArticles": "All articles", + "yearLabel": "Expa {year}", + + "paginationLabel": "Pagination", + "paginationPrevious": "Previous", + "paginationNext": "Next", + "paginationPageInfo": "{page} of {totalPages}", + + "templateEmptyError": "Article template is empty.", + "readingTime": "{minutes} min read", + + "crawlHealthAuthorsFailed": "Warning: couldn't load any authors — check your server configuration.", + "crawlHealthArticlesFailed": "Warning: only loaded {loaded} of {total} items — check your server configuration.", + + "loadingArticles": "Loading articles…", + "loadingGeneric": "Loading…", + "emptyArticleList": "No articles found.", + "articlesLoadError": "Failed to load articles: {message}", + + "searching": "Searching…", + "searchHeading": "Search: \"{query}\"", + "searchNoResults": "No articles match your search.", + "searchError": "Search failed: {message}", + + "tocTitle": "Contents", + "tocFallbackHeading": "section", + + "backToArticles": "Back to articles", + "articleLoadError": "Failed to load article: {message}", + + "socialWebsite": "Website" +} diff --git a/index.html b/index.html index 33cde5a..bf48fa4 100644 --- a/index.html +++ b/index.html @@ -1,9 +1,9 @@ <!DOCTYPE html> -<html lang="cs"> +<html lang="en"> <head> <meta charset="UTF-8"> <meta name="viewport" content="width=device-width, initial-scale=1.0, minimum-scale=1"> - <title>Expediční blogy + Expedition Blogs @@ -17,17 +17,17 @@
- Expediční blogy + Expedition Blogs
-
+
@@ -35,14 +35,14 @@
-