Real bugs: - loadArticle()/loadAuthorView() had no stale-render guard (unlike loadArticleList()/loadSearch()) -- a slower, superseded request could overwrite a newer one's render. Both now capture and check a generation snapshot. - #post/... links interpolated article.hash (raw, unencoded "author/slug") directly, but router.js unconditionally decodeURIComponent()s each segment when parsing -- would throw (silently swallowed) for any author/slug containing a literal "%" or other reserved character. Links now go through a new postHref() helper that encodes each segment individually. - A startup failure replaced the entire document.body, which would wipe out a host site's own page content when cmjs is embedded alongside it. Now scoped to cmjs's own mount points when at least one exists. - hydrateArticle() had no in-flight de-duplication -- two concurrent callers for the same not-yet-hydrated stub (e.g. a page's hydration racing the next page's prefetch) could each fire a duplicate fetch. Now tracked via a WeakMap keyed by the stub. - Leftover "expedition blog"/"expedition year" wording missed by the earlier astronomy-club genericization pass (that pass's grep didn't cover the English word "expedition"). Robustness gaps: - cache.js no longer permanently disables the whole IndexedDB cache after a single failed transaction -- only openDb() itself failing does that now. - Listing mtimes are normalized to minute precision at the one point they enter the system, so cache comparisons stay consistent even if a server flips between JSON and HTML-fallback autoindex. - The root author-listing fetch failing outright is now treated like "every author's listing failed" (a new rootFailed crawl stat) instead of surfacing a generic, less-specific error. - router.js now checks both required mount points exist right after startup and fails with a clear, catchable message instead of a bare TypeError the first time showListView()/showArticleView() runs. - The article view's "back" link now returns to whatever list/search/ filter page the user actually came from (tracked via lastListHash) instead of always resetting to page 1 of everything. - filterLabel() now checks filter.type === 'category' explicitly instead of falling through to it as the default case. - Author-name filtering is now case-sensitive, matching how author paths/profile lookups are looked up everywhere else. - Documented (in code comments and a new README note) that article/bio Markdown renders via marked.js with no HTML-sanitization pass -- only trusted authors should be adding content. Minor: - Extracted applyArticleMetaFields() to share the date/reading-time/ category-badge logic between renderCard() and loadArticle() instead of duplicating it. - Clarified resolveMediaUrl()'s WordPress-migration special case as legacy compatibility, not a general-purpose feature. - frontmatter.js no longer requires a trailing newline after the closing "---" -- a file that's nothing but frontmatter now parses instead of falling through to "no frontmatter at all". Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01AXSVPS3UmmASiiprPfcnUd
571 lines
30 KiB
JavaScript
571 lines
30 KiB
JavaScript
import { templates } from './templates.js';
|
|
import { deriveContentFields, discoverArticles, fetchAuthorProfile, findArticle, getCrawlStats, hydrateArticles, onArticlesProgress, resolveMediaUrl } from './discovery.js';
|
|
import { parseFrontmatter } from './frontmatter.js';
|
|
import { t, getLocale } from './i18n.js';
|
|
|
|
const PAGE_SIZE = 20;
|
|
|
|
// 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;
|
|
|
|
// The hash of the last list view shown (all articles, a filter, a search,
|
|
// any page) — used by loadArticle()'s "back" link so it returns to wherever
|
|
// the user actually came from instead of always resetting to page 1 of
|
|
// everything.
|
|
let lastListHash = '#';
|
|
|
|
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: filter.value });
|
|
if (filter.type === 'category') return t('filterYear', { value: filter.value.replace(/^year-/i, '') });
|
|
return filter.value;
|
|
}
|
|
|
|
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 = 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.categoryYear);
|
|
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() === `year-${year}`;
|
|
yearList.appendChild(renderFilterLink(t('yearLabel', { year }), `#category/year-${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());
|
|
// A `year-YYYY` id is also a normal entry in `article.tags` (that's how
|
|
// it's recognised as the category — see buildStub() in discovery.js),
|
|
// but it belongs in the years nav above, not duplicated here.
|
|
[...tagCounts.keys()]
|
|
.filter(id => !/^year-20\d{2}$/i.test(id))
|
|
.sort((a, b) => a.localeCompare(b, getLocale()))
|
|
.forEach(id => {
|
|
const active = activeFilter?.type === 'tag' && activeFilter.value === id;
|
|
tagList.appendChild(renderFilterLink(id, `#tag/${encodeURIComponent(id)}`, tagCounts.get(id), active));
|
|
});
|
|
tags.appendChild(tagList);
|
|
}
|
|
}
|
|
|
|
// Builds the #post/author/slug hash for an article, encoding each segment
|
|
// individually. article.hash itself (a raw, unencoded "author/slug") is only
|
|
// ever used for internal matching in findArticle() — router.js decodes the
|
|
// two segments of a #post/... hash separately when parsing, so the link
|
|
// needs to encode them separately too, rather than interpolating the
|
|
// combined hash as-is (which breaks — decodeURIComponent throws — for any
|
|
// author/slug containing a literal "%" or other reserved character).
|
|
function postHref(article) {
|
|
return `#post/${encodeURIComponent(article.author)}/${encodeURIComponent(article.slug)}`;
|
|
}
|
|
|
|
// Shared between renderCard() and loadArticle() — date, reading-time, and
|
|
// the category/year badge are populated identically in both places.
|
|
function applyArticleMetaFields(field, { date, readingTime, category, categoryYear }) {
|
|
setField(field('date'), date, (time, value) => { time.dateTime = String(value); time.textContent = formatDate(value); });
|
|
setField(field('reading-time'), readingTime, (time, value) => { time.textContent = t('readingTime', { minutes: value }); });
|
|
// A post only counts as a "category" when one of its directory-name
|
|
// tags happens to name a year — not every article has one.
|
|
setField(field('category'), category, (categoryEl, value) => {
|
|
categoryEl.textContent = t('yearLabel', { year: categoryYear });
|
|
categoryEl.href = `#category/${encodeURIComponent(value)}`;
|
|
});
|
|
}
|
|
|
|
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 = postHref(article);
|
|
imageLink.removeAttribute('aria-hidden');
|
|
imageLink.removeAttribute('tabindex');
|
|
}
|
|
});
|
|
if (!article.coverUrl) {
|
|
imageLink?.remove();
|
|
element.classList.add('card--no-image');
|
|
}
|
|
field('link').href = postHref(article);
|
|
field('title').textContent = article.title || article.slug;
|
|
applyArticleMetaFields(field, article);
|
|
setField(field('description'), article.description, (description, value) => { description.textContent = value; });
|
|
const author = field('author');
|
|
author.textContent = article.authorName || article.author;
|
|
author.href = `#author/${encodeURIComponent(article.author)}`;
|
|
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.
|
|
// Three independent signals feed this: the root author-listing fetch itself
|
|
// failing outright (rootFailed), every individual 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, rootFailed } = getCrawlStats();
|
|
// rootFailed means the very first listing fetch (the author directories
|
|
// themselves) couldn't be read at all — a total breakage same in spirit
|
|
// as every author's own listing failing, just one level higher up.
|
|
const authorsBroken = rootFailed || (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();
|
|
lastListHash = location.hash || '#';
|
|
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);
|
|
// Author names are exact, case-sensitive directory names (used
|
|
// as-is for paths and profile lookups elsewhere) — comparing
|
|
// them case-insensitively here could merge two distinctly-named
|
|
// author directories that differ only in case.
|
|
if (filter.type === 'author') return article.author === 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 {
|
|
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');
|
|
// See the matching note in loadArticle() — same no-sanitization caveat
|
|
// applies here (an author bio's raw HTML, if any, renders as-is).
|
|
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();
|
|
let generation;
|
|
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 profilePromise = fetchAuthorProfile(author);
|
|
const listPromise = loadArticleList({ type: 'author', value: author }, page);
|
|
// loadArticleList() bumps currentGeneration synchronously as one of
|
|
// its first steps (before any await), so reading it right after
|
|
// starting — not awaiting — that call captures the exact generation
|
|
// this navigation owns. Without this, a slower-resolving call here
|
|
// could insert a stale profile card into a list-view the user has
|
|
// since navigated away from (it stays in the DOM invisibly, since
|
|
// list-view would be hidden, only to reappear stale later).
|
|
generation = currentGeneration;
|
|
const [profile] = await Promise.all([profilePromise, listPromise]);
|
|
if (generation === currentGeneration && page === 1 && profile) {
|
|
view.insertBefore(renderAuthorProfile(profile), view.firstChild);
|
|
}
|
|
} catch (error) {
|
|
if (generation === currentGeneration) 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);
|
|
|
|
// How many stubs to hydrate-and-check per scanning round (see loadSearch
|
|
// below) — small enough that a search which only needs page 1 doesn't fire
|
|
// a big burst of simultaneous requests, large enough to keep the number of
|
|
// rounds (and their per-round render) reasonable on a big site.
|
|
const SEARCH_SCAN_BATCH = PAGE_SIZE;
|
|
|
|
// Scan progress for the current search query — lets paging forward resume
|
|
// exactly where the last call left off instead of re-checking articles
|
|
// already ruled in/out, and lets paging *backward* (or re-rendering the
|
|
// same page) skip scanning entirely, since `matches` only ever grows and
|
|
// stub order never changes. Reset whenever the query text changes; holds
|
|
// only the most recent query, so a new search simply replaces it.
|
|
let searchState = null;
|
|
|
|
export async function loadSearch(query, page = 1) {
|
|
showListView();
|
|
window.scrollTo(0, 0);
|
|
const generation = ++currentGeneration;
|
|
lastListHash = location.hash || '#';
|
|
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 {
|
|
const needle = query.toLocaleLowerCase(getLocale());
|
|
const matchesQuery = article => [article.title, article.description, article.author, article.authorName, article.category, ...article.tags]
|
|
.some(value => String(value || '').toLocaleLowerCase(getLocale()).includes(needle));
|
|
|
|
const stubs = await discoverArticles();
|
|
if (generation !== currentGeneration) return;
|
|
renderFilterNavigation(stubs);
|
|
|
|
if (!searchState || searchState.query !== query || searchState.stubs !== stubs) {
|
|
searchState = { query, stubs, cursor: 0, matches: [] };
|
|
}
|
|
const state = searchState;
|
|
|
|
// Only scan far enough to fill the requested page, plus one extra
|
|
// match so pagination knows whether a next page exists — never the
|
|
// whole (possibly huge) stub set. Jumping straight to a deep page
|
|
// (typed/bookmarked link) just scans further up front, but still
|
|
// only as far as *that* page needs.
|
|
const target = page * PAGE_SIZE + 1;
|
|
|
|
const renderResults = () => {
|
|
if (generation !== currentGeneration) return;
|
|
const scanDone = state.cursor >= state.stubs.length;
|
|
const haveEnough = state.matches.length >= target;
|
|
// Once either is true, nothing left running will change what's
|
|
// shown for this page — that's what `done` gates downstream
|
|
// (crawl-health check, hiding the "still working" footer).
|
|
const done = scanDone || haveEnough;
|
|
// Exact once the whole set's been scanned; otherwise it's only a
|
|
// lower bound (what's already been found, or at least one more
|
|
// page than requested, whichever is bigger) — same "can still
|
|
// shift until done" contract loadArticleList already uses for
|
|
// its own page count.
|
|
const knownPages = Math.max(1, Math.ceil(state.matches.length / PAGE_SIZE));
|
|
const pages = scanDone ? knownPages : Math.max(knownPages, page + 1);
|
|
const clampedPage = Math.max(1, Math.min(page, pages));
|
|
const pageMatches = state.matches.slice((clampedPage - 1) * PAGE_SIZE, clampedPage * PAGE_SIZE);
|
|
renderArticleListBody(view, {
|
|
total: state.matches.length,
|
|
pageArticles: pageMatches,
|
|
page: clampedPage,
|
|
pages,
|
|
filter,
|
|
done,
|
|
heading: t('searchHeading', { query }),
|
|
emptyKey: 'searchNoResults',
|
|
loadingKey: 'searching',
|
|
});
|
|
};
|
|
renderResults();
|
|
|
|
// Scan forward in small batches, stopping as soon as `target` is
|
|
// met — resuming this same loop (via a later loadSearch call
|
|
// reusing `state`) picks up right after the last-scanned stub.
|
|
// The generation check guards against a superseded call's loop
|
|
// (user navigated away, or to a further page) mutating shared
|
|
// `state` after the fact — only the most recently started call is
|
|
// allowed to keep scanning or rendering.
|
|
while (state.cursor < state.stubs.length && state.matches.length < target) {
|
|
if (generation !== currentGeneration) return;
|
|
const batch = state.stubs.slice(state.cursor, state.cursor + SEARCH_SCAN_BATCH);
|
|
await hydrateArticles(batch);
|
|
if (generation !== currentGeneration) return;
|
|
state.cursor += batch.length;
|
|
batch.forEach(article => { if (matchesQuery(article)) state.matches.push(article); });
|
|
renderResults();
|
|
}
|
|
} 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);
|
|
const generation = ++currentGeneration; // also invalidates any still-running loadArticleList progress listener
|
|
clearSearchInput();
|
|
const view = document.getElementById('article-view');
|
|
view.innerHTML = `<p class="loading">${t('loadingGeneric')}</p>`;
|
|
try {
|
|
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),
|
|
]);
|
|
// Nothing below has any visible effect until this point, so this is
|
|
// the one place worth checking: without it, a slower-resolving
|
|
// (superseded) loadArticle() call could still render over whatever
|
|
// the user has since navigated to.
|
|
if (generation !== currentGeneration) return;
|
|
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 article file's own name (already
|
|
// resolved onto `article.date`), not the raw frontmatter.
|
|
applyArticleMetaFields(field, { date: article.date, readingTime, category: article.category, categoryYear: article.categoryYear });
|
|
setField(field('description'), meta.description, (description, value) => { description.textContent = 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)}`;
|
|
renderTags(field('tags'), article.tags);
|
|
const contentElement = field('content');
|
|
// Rendered directly via innerHTML with no HTML-sanitization pass —
|
|
// anyone who can write a .md file here can include raw HTML,
|
|
// including <script> tags (same for renderAuthorProfile()'s bio; the
|
|
// description field above skips this — it's plain textContent).
|
|
// Only let people you trust author 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 = lastListHash; 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) {
|
|
if (generation === currentGeneration) view.innerHTML = `<p class="error">${t('articleLoadError', { message: error.message })}</p>`;
|
|
}
|
|
}
|