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:
1 parent
469a19388e
commit
9d87cffeeb
7 files changed
+192
-58
No files matched your search
@@ -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,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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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
@@ -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
@@ -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>`; }
|
||||
}
|
||||
@@ -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
@@ -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>
|
||||
|
||||
Reference in new issue
Block a user