Translate UI to English and extract all messages into strings files (AI)

Adds cmjs/js/i18n.js (t()/loadStrings()/getLocale()) and
cmjs/strings/en.json as the default string table; every hardcoded
Czech UI string in views.js/router.js/discovery.js and the static
index.html shell now goes through it, so a translation just means
adding cmjs/strings/<locale>.json and setting <html lang>.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AXSVPS3UmmASiiprPfcnUd
This commit is contained in:
BiosiasandClaude Sonnet 5 committed 2026-09-11 12:16:19 +02:00
1 parent 469a19388e
commit 9d87cffeeb
7 files changed
+192 -58

No files matched your search

+28 -1
View File
@@ -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 `<html lang="xx">` 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 `<title>`, 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.
+2 -1
View File
@@ -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;
}
+53
View File
@@ -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));
}
+5 -2
View File
@@ -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(() => {});
+53 -43
View File
@@ -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>`; }
}
+40
View File
@@ -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"
}
+11 -11
View File
@@ -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</title>
<title>Expedition Blogs</title>
<link rel="stylesheet" href="css/reset.css">
<link rel="stylesheet" href="css/header.css">
<link rel="stylesheet" href="css/layout.css">
@@ -17,17 +17,17 @@
<body class="blog-page">
<header>
<!-- Site title always links back to the article list -->
<a href="#" id="site-title">Expediční blogy</a>
<a href="#" id="site-title">Expedition Blogs</a>
<!-- Search form — submits to #search/query via router.js -->
<form id="search-form" role="search">
<input type="search" id="search-input" placeholder="Hledat v článcích" autocomplete="off" aria-label="Hledat v článcích">
<button type="submit">Hledat</button>
<input type="search" id="search-input" placeholder="Search articles…" autocomplete="off" aria-label="Search articles">
<button type="submit">Search</button>
</form>
</header>
<main class="blog-shell">
<section class="blog-content" aria-label="Články">
<section class="blog-content" aria-label="Articles">
<!-- Article list: shown when no hash is present -->
<div id="list-view"></div>
@@ -35,14 +35,14 @@
<div id="article-view" hidden></div>
</section>
<aside class="blog-sidebar" aria-label="Filtrování blogu">
<aside class="blog-sidebar" aria-label="Blog filters">
<section class="filter-section">
<h2>Ročníky</h2>
<nav id="year-filters" aria-label="Filtrovat podle ročníku"></nav>
<h2>Years</h2>
<nav id="year-filters" aria-label="Filter by year"></nav>
</section>
<section class="filter-section">
<h2>Rubriky</h2>
<nav id="tag-filters" aria-label="Filtrovat podle rubriky"></nav>
<h2>Tags</h2>
<nav id="tag-filters" aria-label="Filter by tag"></nav>
</section>
</aside>
</main>