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 `
Načítám články…
'; + view.innerHTML = `${t('loadingArticles')}
`; 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 = `Články se nepodařilo načíst: ${error.message}
`; + view.innerHTML = `${t('articlesLoadError', { message: error.message })}
`; } } -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 = 'Načítám…
'; + view.innerHTML = `${t('loadingGeneric')}
`; 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 = `Články se nepodařilo načíst: ${error.message}
`; } + } catch (error) { view.innerHTML = `${t('articlesLoadError', { message: error.message })}
`; } } 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 = 'Hledám…
'; + view.innerHTML = `${t('searching')}
`; 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 = `Hledání neodpovídá žádný článek.
'); + .some(value => String(value || '').toLocaleLowerCase(getLocale()).includes(needle))); + view.innerHTML = `${t('searchNoResults')}
`); else articles.forEach(article => view.appendChild(renderCard(article))); - } catch (error) { view.innerHTML = `Hledání se nepodařilo: ${error.message}
`; } + } catch (error) { view.innerHTML = `${t('searchError', { message: error.message })}
`; } } 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 = 'Obsah
${t('tocTitle')}
Načítám…
'; + view.innerHTML = `${t('loadingGeneric')}
`; 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 = `Článek se nepodařilo načíst: ${error.message}
`; } + } catch (error) { view.innerHTML = `${t('articleLoadError', { message: error.message })}
`; } } 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 @@ - + -