Files
cmkiss/cmjs/js/views.js
T
biosiasandClaude Sonnet 5 122593da24 Encode article metadata into the post directory name (AI)
Replaces .tag/.date/draft marker files with a single naming
convention: <slug>--<DDMMYYYY>--<tag1>+<tag2>+... (optionally
prefixed with "_" for draft). This eliminates the per-article
listing fetch entirely — every stub field is now parsed straight
out of the directory name the author-level listing already returns,
so indexing costs one request per author, full stop, regardless of
how many articles exist.

discovery.js: buildStub()/resolveStub() become synchronous (pure
string parsing, no fetch); runCrawl() drops the wave-3
crawl/mapWithConcurrency/mtime-priority-ordering entirely, since
there's no more per-item async work to stage. hydrateArticle() and
everything downstream in views.js is unchanged, since the derived
article shape is identical either way.

Removes the example admin/hello-world article (old format, git
was tracking it) and regenerates 10 authors x 20 articles in the new
format for testing.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AXSVPS3UmmASiiprPfcnUd
2026-09-12 00:05:48 +02:00

483 lines
25 KiB
JavaScript

import { templates } from './templates.js';
import { deriveContentFields, discoverArticles, fetchAuthorProfile, fetchTagRegistry, findArticle, getCrawlStats, hydrateArticles, onArticlesProgress, resolveMediaUrl } from './discovery.js';
import { parseFrontmatter } from './frontmatter.js';
import { t, getLocale } from './i18n.js';
const PAGE_SIZE = 20;
let tagLabels = new Map();
// Bumped at the start of every list-rendering view. A progress listener
// checks its own snapshot of this before touching the DOM, so a background
// crawl's late update can't render into a view the user has since
// navigated away from (e.g. clicked into a different filter/page).
let currentGeneration = 0;
async function ensureTags() {
if (!tagLabels.size) tagLabels = new Map((await fetchTagRegistry()).map(tag => [tag.id, tag.label]));
}
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(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 = t('siteName');
}
export function showArticleView() {
document.getElementById('list-view').hidden = true;
document.getElementById('article-view').hidden = false;
}
function setField(element, value, apply) {
if (element && value != null && value !== '') apply(element, value);
else element?.remove();
}
function clearSearchInput() {
const input = document.getElementById('search-input');
if (input) input.value = '';
}
function filterLabel(filter) {
if (!filter) return '';
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) {
if (!filter) return page === 1 ? '#' : `#page/${page}`;
const base = `${filter.type}/${encodeURIComponent(filter.value)}`;
return page === 1 ? `#${base}` : `#${base}/page/${page}`;
}
function buildPaginationNav(page, totalPages, filter) {
const nav = document.createElement('nav');
nav.className = 'pagination';
nav.setAttribute('aria-label', t('paginationLabel'));
const previous = document.createElement('a');
previous.textContent = t('paginationPrevious');
const next = document.createElement('a');
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 = t('paginationPageInfo', { page, totalPages });
nav.append(previous, info, next);
return nav;
}
function renderTags(container, tags) {
if (!container || !tags?.length) { container?.remove(); return; }
tags.forEach(identifier => {
const link = document.createElement('a');
link.className = 'tag';
link.href = `#tag/${encodeURIComponent(identifier)}`;
link.textContent = tagLabels.get(identifier) || identifier;
container.appendChild(link);
});
}
function renderFilterLink(label, href, count, active = false) {
const link = document.createElement('a');
link.className = `filter-link${active ? ' is-active' : ''}`;
link.href = href;
if (active) link.setAttribute('aria-current', 'page');
link.textContent = label;
if (count != null) {
const countElement = document.createElement('span');
countElement.className = 'filter-count';
countElement.textContent = `(${count})`;
link.appendChild(countElement);
}
return link;
}
// Years and tags render independently — either `<nav>` can be absent from
// the page (e.g. a site that only wants tag filtering, like this project's
// own index.html) without the other silently stopping too. Whichever
// section renders first gets the "all articles" reset link, so it's never
// lost just because the years nav isn't on the page.
function renderFilterNavigation(articles, activeFilter = null) {
const years = document.getElementById('year-filters');
const tags = document.getElementById('tag-filters');
if (!years && !tags) return;
const allArticlesLink = () => renderFilterLink(t('filterAllArticles'), '#', articles.length, !activeFilter);
if (years) {
years.innerHTML = '';
const yearCounts = new Map();
articles.forEach(article => {
const year = Number(article.expa);
if (year) yearCounts.set(year, (yearCounts.get(year) || 0) + 1);
});
const yearList = document.createElement('div');
yearList.className = 'filter-list';
yearList.appendChild(allArticlesLink());
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(t('yearLabel', { year }), `#category/expa-${year}`, count, active));
}
years.appendChild(yearList);
}
if (tags) {
tags.innerHTML = '';
const tagCounts = new Map();
articles.forEach(article => (article.tags || []).forEach(tag => tagCounts.set(tag, (tagCounts.get(tag) || 0) + 1)));
const tagList = document.createElement('div');
tagList.className = 'filter-list';
if (!years) tagList.appendChild(allArticlesLink());
[...tagLabels.entries()]
.filter(([id]) => tagCounts.has(id))
.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));
});
tags.appendChild(tagList);
}
}
function renderCard(article) {
const fragment = document.importNode(templates.card, true);
const element = fragment.firstElementChild;
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) => {
image.src = value;
image.alt = article.title || article.slug;
if (imageLink) {
imageLink.href = `#post/${article.hash}`;
imageLink.removeAttribute('aria-hidden');
imageLink.removeAttribute('tabindex');
}
});
if (!article.coverUrl) {
imageLink?.remove();
element.classList.add('card--no-image');
}
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 = formatDate(value); });
setField(field('description'), article.description, (description, value) => { description.textContent = value; });
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 post only counts as a "category" when one of its directory-name
// tags happens to name an expedition year — not every article has one.
setField(field('category'), article.category, (category, value) => {
category.textContent = t('yearLabel', { year: article.expa });
category.href = `#category/${encodeURIComponent(value)}`;
});
renderTags(field('tags'), article.tags);
return element;
}
// Near-total crawl failure (wrong origin after a migration, CORS broken,
// autoindex disabled) should be visible rather than silently rendering an
// empty/near-empty list forever — see .claude/nginx-sitemap-crawler-plan.md L3.
// Two independent signals feed this: every author's own listing failing
// (checked regardless of site size, since discovery.js now silently skips
// individual author failures — see the wave-2 comment in discovery.js) and,
// for larger sites, a near-zero loaded/total ratio at the article level.
// Only meaningful once the crawl has actually finished (`done`) — mid-crawl
// stats are incomplete and would misreport a healthy crawl as broken.
function renderCrawlHealthWarning(view, done) {
if (!done) return;
const { total, loaded, authorsTotal, authorsFailed } = getCrawlStats();
const authorsBroken = authorsTotal > 0 && authorsFailed === authorsTotal;
const articlesBroken = total >= 5 && loaded / total < 0.1;
if (!authorsBroken && !articlesBroken) return;
const warning = document.createElement('p');
warning.className = 'error';
warning.textContent = authorsBroken
? t('crawlHealthAuthorsFailed')
: t('crawlHealthArticlesFailed', { loaded, total });
view.appendChild(warning);
}
// Renders one snapshot of a paginated article page into `view` — shared by
// the article list (live progress updates and the final render, so a large
// site's first page shows up as soon as it's indexed instead of blocking on
// the whole tree — see onArticlesProgress() in discovery.js) and search
// (see loadSearch). `pageArticles` must already be hydrated — this function
// is purely synchronous DOM-building, no fetching of its own. `heading`,
// `emptyKey`/`loadingKey` let each caller supply its own copy; `filter`
// still drives pagination links (buildPaginationNav) even when it's not
// one of the sidebar's own filter types (search uses `{type: 'search',
// value: query}`, matched by pageHash() the same way as any other filter).
function renderArticleListBody(view, { total, pageArticles, page, pages, filter, done, heading, emptyKey = 'emptyArticleList', loadingKey = 'loadingArticles' }) {
view.innerHTML = '';
renderCrawlHealthWarning(view, done);
if (heading) {
const headingElement = document.createElement('h2');
headingElement.className = 'category-heading';
headingElement.textContent = heading;
view.appendChild(headingElement);
}
if (!total) {
const message = document.createElement('p');
message.className = done ? 'empty' : 'loading';
message.textContent = done ? t(emptyKey) : t(loadingKey);
view.appendChild(message);
return;
}
pageArticles.forEach(article => view.appendChild(renderCard(article)));
if (pages > 1) view.appendChild(buildPaginationNav(page, pages, filter));
if (!done) {
const indexing = document.createElement('p');
indexing.className = 'loading';
indexing.textContent = t('indexingBackground');
view.appendChild(indexing);
}
}
export async function loadArticleList(filter = null, page = 1) {
showListView();
window.scrollTo(0, 0);
clearSearchInput();
const view = document.getElementById('list-view');
view.innerHTML = `<p class="loading">${t('loadingArticles')}</p>`;
const generation = ++currentGeneration;
const applyFilter = allArticles => {
renderFilterNavigation(allArticles, filter);
if (!filter) return allArticles;
return allArticles.filter(article => {
if (filter.type === 'tag') return article.tags.includes(filter.value);
return String(article[filter.type] || '').toLowerCase() === filter.value.toLowerCase();
});
};
// Sorting/filtering/pagination all work on stubs alone (tags, date,
// category — none of that needs a content fetch). Only the current
// page's cards need real content, so only that slice gets hydrated —
// this is what keeps a large site's list fast: the rest of the tree's
// markdown is never fetched until the user actually pages to it.
// `renderToken` guards against two overlapping hydration+render cycles
// (e.g. two progress ticks arriving close together) applying out of
// order — only the most recently *started* one is allowed to render.
let renderToken = 0;
const render = async (rawArticles, done) => {
if (generation !== currentGeneration) return;
const myToken = ++renderToken;
const articles = applyFilter(rawArticles);
const pages = Math.max(1, Math.ceil(articles.length / PAGE_SIZE));
const clampedPage = Math.max(1, Math.min(page, pages));
const pageStubs = articles.slice((clampedPage - 1) * PAGE_SIZE, clampedPage * PAGE_SIZE);
const pageArticles = pageStubs.length ? await hydrateArticles(pageStubs) : [];
if (generation !== currentGeneration || myToken !== renderToken) return;
renderArticleListBody(view, { total: articles.length, pageArticles, page: clampedPage, pages, filter, done, heading: filter ? filterLabel(filter) : undefined });
// Once the list is fully indexed (not mid-background-crawl, where
// page boundaries could still shift), warm the cache for the next
// page so clicking "next" is instant. Fire-and-forget — it touches
// no DOM, just fetches and caches, so it's safe to keep running
// even if the user has since navigated elsewhere.
if (done && clampedPage < pages) {
hydrateArticles(articles.slice(clampedPage * PAGE_SIZE, (clampedPage + 1) * PAGE_SIZE));
}
};
const unsubscribe = onArticlesProgress((partialArticles, done) => { render(partialArticles, done); });
try {
await ensureTags();
const allArticles = await discoverArticles();
await render(allArticles, true);
} catch (error) {
if (generation === currentGeneration) view.innerHTML = `<p class="error">${t('articlesLoadError', { message: error.message })}</p>`;
} finally {
unsubscribe();
}
}
// 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}"]`);
setField(field('avatar'), profile.avatar, (image, value) => { image.src = resolveMediaUrl(value, profile.path); image.alt = profile.title || profile.author; });
field('name').textContent = profile.title || profile.author;
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]) => 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;
}
export async function loadAuthorView(author, page = 1) {
showListView();
const view = document.getElementById('list-view');
view.innerHTML = `<p class="loading">${t('loadingGeneric')}</p>`;
clearSearchInput();
try {
// Independent of each other — fetching in parallel instead of
// sequentially shaves a round-trip. Safe to run together:
// fetchAuthorProfile() never touches the DOM, and the profile card
// below is only inserted once both have settled, so there's
// nothing for loadArticleList()'s own view updates to race with.
const [profile] = await Promise.all([
fetchAuthorProfile(author),
loadArticleList({ type: 'author', value: author }, page),
]);
if (page === 1 && profile) view.insertBefore(renderAuthorProfile(profile), view.firstChild);
} 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);
export const loadTagView = (tag, page = 1) => loadArticleList({ type: 'tag', value: tag }, page);
export async function loadSearch(query, page = 1) {
showListView();
window.scrollTo(0, 0);
const generation = ++currentGeneration;
const view = document.getElementById('list-view');
view.innerHTML = `<p class="loading">${t('searching')}</p>`;
const input = document.getElementById('search-input');
if (input) input.value = query;
// Drives pagination links the same way any other filter does (see
// pageHash()) — `#search/<query>` for page 1, `#search/<query>/page/N`
// after, which router.js recognises and parses back into (query, page).
const filter = { type: 'search', value: query };
try {
await ensureTags();
const needle = query.toLocaleLowerCase(getLocale());
const matchesQuery = article => [article.title, article.description, article.author, article.authorName, article.category, ...article.tags.map(tag => tagLabels.get(tag) || tag)]
.some(value => String(value || '').toLocaleLowerCase(getLocale()).includes(needle));
const stubs = await discoverArticles();
if (generation !== currentGeneration) return;
renderFilterNavigation(stubs);
// Unlike the list view, search needs every article's real title and
// description — text that only exists in its markdown content — so
// it hydrates the whole set rather than just a page's worth. Most
// of that is typically already cached from earlier browsing/search.
// Hydrating is progressive (like indexing) so results appear as
// they're found instead of one long wait for the whole site's
// content — hydrateArticle() mutates stubs in place, so checking
// each stub's own `hydrated` flag is enough to know what's ready,
// without tracking a separately-growing list. Pagination applies to
// whatever's matched so far, the same page math as the list view —
// the page count can shift while hydration is still in progress,
// settling once it's done.
const renderResults = () => {
if (generation !== currentGeneration) return;
const hydratedSoFar = stubs.filter(stub => stub.hydrated);
const done = hydratedSoFar.length === stubs.length;
const matches = hydratedSoFar.filter(matchesQuery);
const pages = Math.max(1, Math.ceil(matches.length / PAGE_SIZE));
const clampedPage = Math.max(1, Math.min(page, pages));
const pageMatches = matches.slice((clampedPage - 1) * PAGE_SIZE, clampedPage * PAGE_SIZE);
renderArticleListBody(view, {
total: matches.length,
pageArticles: pageMatches,
page: clampedPage,
pages,
filter,
done,
heading: t('searchHeading', { query }),
emptyKey: 'searchNoResults',
loadingKey: 'searching',
});
};
await hydrateArticles(stubs, resolvedCount => {
if (resolvedCount === 1 || resolvedCount % 5 === 0 || resolvedCount === stubs.length) renderResults();
});
renderResults(); // guaranteed-final render — also covers the empty-site case, where the loop above never calls back at all
} catch (error) {
if (generation === currentGeneration) view.innerHTML = `<p class="error">${t('searchError', { message: error.message })}</p>`;
}
}
function buildTOC(content) {
const headings = [...content.querySelectorAll('h2, h3')];
if (headings.length < 3) return null;
const seen = new Map();
headings.forEach(heading => {
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">${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);
});
return nav;
}
export async function loadArticle(hash) {
showArticleView();
window.scrollTo(0, 0);
currentGeneration += 1; // invalidates any still-running loadArticleList progress listener
clearSearchInput();
const view = document.getElementById('article-view');
view.innerHTML = `<p class="loading">${t('loadingGeneric')}</p>`;
try {
await ensureTags();
const article = await findArticle(hash);
const [raw, profile] = await Promise.all([
fetch(article.path).then(response => { if (!response.ok) throw new Error(`HTTP ${response.status}`); return response.text(); }),
fetchAuthorProfile(article.author),
]);
const { meta, content } = parseFrontmatter(raw);
// This view always fetches its own content directly (above) rather
// than relying on discoverArticles()'s stub — the list view defers
// that fetch until a page is actually rendered (see
// loadArticleList), and a single visited article is exactly such a
// case, so readingTime/coverUrl are derived fresh here. Unlike
// cards, the description field here stays frontmatter-only (no
// auto-excerpt) — the full content is right below it either way.
const { readingTime, coverUrl } = deriveContentFields(meta, content, article.path);
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 post directory's own name (already
// resolved onto `article.date`), not the raw frontmatter.
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'), readingTime, (time, value) => { time.textContent = t('readingTime', { minutes: value }); });
const image = field('image');
if (coverUrl) { image.src = 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 = t('yearLabel', { year: article.expa });
category.href = `#category/${encodeURIComponent(value)}`;
});
renderTags(field('tags'), article.tags);
const contentElement = field('content');
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 = 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">${t('articleLoadError', { message: error.message })}</p>`; }
}