@binarynoir/vitepress-blog 0.2.0 → 0.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1 +1 @@
1
- {"version":3,"sources":["../src/client.ts","../src/view.ts"],"sourcesContent":["// A namespace import, not named ones: during the server-side build Node loads\n// VitePress's node entry, which has no `inBrowser`, `useRoute` or\n// `onContentUpdated`. Named imports would fail to link there; on the\n// namespace they are just `undefined`.\nimport * as vitepress from 'vitepress';\nimport { nextTick, onMounted, watch } from 'vue';\nimport { applyBlogViews } from './view.js';\n\n/**\n * Turns on keyword filtering (`?tag=`) and pagination (`?page=`) on blog\n * pages. Call it from your theme's `setup()`:\n *\n * ```ts\n * // .vitepress/theme/index.ts\n * import DefaultTheme from 'vitepress/theme';\n * import { useBlog } from '@binarynoir/vitepress-blog/client';\n * import '@binarynoir/vitepress-blog/style.css';\n *\n * export default {\n * extends: DefaultTheme,\n * setup() {\n * useBlog();\n * },\n * };\n * ```\n *\n * Keyword and page links are ordinary links: VitePress's router follows them\n * without reloading the page, and this re-applies the view whenever the path\n * or query string changes. It runs after the page has hydrated, so the\n * server-rendered HTML (every post) and the first client render agree.\n * Does nothing during the server-side build.\n */\nexport function useBlog(): void {\n if (!vitepress.inBrowser) return;\n const route = vitepress.useRoute();\n const apply = () => nextTick(() => applyBlogViews());\n onMounted(() => {\n watch(() => [route.path, route.query], apply, { immediate: true });\n });\n // `vitepress dev` swaps a page's content in place when it is edited.\n vitepress.onContentUpdated(apply);\n}\n\nexport { applyBlogView, applyBlogViews, type BlogViewState } from './view.js';\n","/**\n * Keyword filtering and pagination for blog pages, in the browser. The build\n * writes every post into the page (see `renderPostList`); this hides the ones\n * the URL's `?tag=` and `?page=` leave out, and fills in the filter banner and\n * the page links. Without it, every post simply shows.\n */\n\nconst ITEM = '[data-vp-blog-item]';\n\ninterface BlogLabels {\n tagged: string;\n showAll: string;\n noTagged: string;\n previous: string;\n next: string;\n pages: string;\n}\n\nfunction readLabels(blog: HTMLElement): BlogLabels {\n const { dataset } = blog;\n return {\n tagged: dataset.labelTagged ?? 'Posts tagged {tag}',\n showAll: dataset.labelShowAll ?? 'Show all posts',\n noTagged: dataset.labelNoTagged ?? 'No posts tagged {tag}.',\n previous: dataset.labelPrevious ?? 'Previous',\n next: dataset.labelNext ?? 'Next',\n pages: dataset.labelPages ?? 'Pages',\n };\n}\n\n/** The element a marker stands for: a card is its own marker; a compact list item holds one. */\nfunction itemElement(marker: Element): HTMLElement {\n return (marker.tagName === 'ARTICLE' ? marker : (marker.closest('li') ?? marker)) as HTMLElement;\n}\n\nfunction hasTag(marker: Element, tag: string): boolean {\n return !tag || (marker.getAttribute('data-tags') ?? '').split('|').includes(tag);\n}\n\n/** `url` with `tag` and `page` set (or removed when empty or page 1), as a path the router can follow. */\nfunction toHref(url: URL, tag: string, page: number): string {\n const next = new URL(url.href);\n next.hash = '';\n if (tag) next.searchParams.set('tag', tag);\n else next.searchParams.delete('tag');\n if (page > 1) next.searchParams.set('page', String(page));\n else next.searchParams.delete('page');\n return next.pathname + next.search;\n}\n\n/** `template` with `{tag}` replaced by a `<strong>` holding the tag, as text: the tag comes from the URL. */\nfunction fillWithTag(element: HTMLElement, template: string, tag: string): void {\n const [before, after = ''] = template.split('{tag}');\n const strong = document.createElement('strong');\n strong.textContent = tag;\n element.replaceChildren(document.createTextNode(before), ...(template.includes('{tag}') ? [strong] : []), after);\n}\n\nfunction link(text: string, href: string, className: string): HTMLAnchorElement {\n const anchor = document.createElement('a');\n anchor.className = className;\n anchor.href = href;\n anchor.textContent = text;\n return anchor;\n}\n\nfunction renderFilter(slot: HTMLElement, url: URL, tag: string, matches: number, labels: BlogLabels): void {\n slot.hidden = !tag;\n if (!tag) return slot.replaceChildren();\n const message = document.createElement('span');\n fillWithTag(message, matches ? labels.tagged : labels.noTagged, tag);\n slot.replaceChildren(message, ' ', link(labels.showAll, toHref(url, '', 1), 'vp-blog-filter__clear'));\n}\n\nfunction renderPagination(\n slot: HTMLElement,\n url: URL,\n tag: string,\n page: number,\n pageCount: number,\n labels: BlogLabels,\n): void {\n slot.hidden = pageCount < 2;\n slot.setAttribute('aria-label', labels.pages);\n if (pageCount < 2) return slot.replaceChildren();\n\n const parts: Node[] = [];\n if (page > 1) parts.push(link(labels.previous, toHref(url, tag, page - 1), 'vp-blog-pagination__previous'));\n for (let number = 1; number <= pageCount; number++) {\n if (number === page) {\n const current = document.createElement('span');\n current.className = 'vp-blog-pagination__page is-current';\n current.setAttribute('aria-current', 'page');\n current.textContent = String(number);\n parts.push(current);\n } else {\n parts.push(link(String(number), toHref(url, tag, number), 'vp-blog-pagination__page'));\n }\n }\n if (page < pageCount) parts.push(link(labels.next, toHref(url, tag, page + 1), 'vp-blog-pagination__next'));\n slot.replaceChildren(...parts);\n}\n\n/** Marks the keyword links (in the keyword list and on cards) that match the active keyword. */\nfunction markActiveTags(blog: HTMLElement, tag: string): void {\n for (const anchor of blog.querySelectorAll<HTMLAnchorElement>('.vp-blog-tags a, .vp-blog-card__tags a')) {\n let linkTag = '';\n try {\n linkTag = new URL(anchor.href, location.href).searchParams.get('tag') ?? '';\n } catch {\n // Leave a link that isn't a URL unmarked.\n }\n const active = !!tag && linkTag.toLowerCase() === tag;\n anchor.classList.toggle('is-active', active);\n if (active) anchor.setAttribute('aria-current', 'true');\n else anchor.removeAttribute('aria-current');\n }\n}\n\nexport interface BlogViewState {\n tag: string;\n page: number;\n pageCount: number;\n}\n\n/**\n * Shows the posts of one blog page that `url` asks for: those with its\n * `?tag=` keyword (any case), and of those, the ones on its `?page=`. Pinned\n * sticky posts only show on the first page and aren't counted in the page\n * size. Returns what is showing.\n */\nexport function applyBlogView(blog: HTMLElement, url: URL): BlogViewState {\n const labels = readLabels(blog);\n const tag = (url.searchParams.get('tag') ?? '').trim().toLowerCase();\n const pageSize = Math.max(0, Number(blog.dataset.pageSize) || 0);\n\n const markers = [...blog.querySelectorAll(ITEM)];\n const isSticky = (marker: Element) => !!marker.closest('[data-vp-blog-sticky]');\n const sticky = markers.filter((marker) => isSticky(marker) && hasTag(marker, tag));\n const rest = markers.filter((marker) => !isSticky(marker) && hasTag(marker, tag));\n\n const pageCount = pageSize ? Math.max(1, Math.ceil(rest.length / pageSize)) : 1;\n const requested = Number.parseInt(url.searchParams.get('page') ?? '1', 10);\n const page = Math.min(pageCount, Math.max(1, Number.isFinite(requested) ? requested : 1));\n\n const shown = new Set<Element>([\n ...(page === 1 ? sticky : []),\n ...(pageSize ? rest.slice((page - 1) * pageSize, page * pageSize) : rest),\n ]);\n for (const marker of markers) itemElement(marker).hidden = !shown.has(marker);\n\n // A sticky section or year with nothing showing is hidden, heading and all.\n for (const group of blog.querySelectorAll<HTMLElement>('[data-vp-blog-sticky], .vp-blog-year')) {\n group.hidden = ![...group.querySelectorAll(ITEM)].some((marker) => shown.has(marker));\n }\n\n const filter = blog.querySelector<HTMLElement>('[data-vp-blog-filter]');\n if (filter) renderFilter(filter, url, tag, sticky.length + rest.length, labels);\n const pagination = blog.querySelector<HTMLElement>('[data-vp-blog-pagination]');\n if (pagination) renderPagination(pagination, url, tag, page, pageCount, labels);\n markActiveTags(blog, tag);\n\n return { tag, page, pageCount };\n}\n\nconst lastViews = new WeakMap<HTMLElement, BlogViewState>();\n\n/**\n * Applies `url` to every blog page in `root`. When the page or keyword of a\n * blog already on screen changes, it is scrolled back to its top.\n */\nexport function applyBlogViews(root: ParentNode = document, url: URL = new URL(location.href)): void {\n for (const blog of root.querySelectorAll<HTMLElement>('[data-vp-blog]')) {\n const previous = lastViews.get(blog);\n const view = applyBlogView(blog, url);\n lastViews.set(blog, view);\n if (\n previous &&\n (previous.page !== view.page || previous.tag !== view.tag) &&\n blog.getBoundingClientRect().top < 0\n ) {\n blog.scrollIntoView({ block: 'start' });\n }\n }\n}\n"],"mappings":"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","names":[]}
1
+ {"version":3,"sources":["../src/client.ts","../src/view.ts","../src/banner.ts"],"sourcesContent":["// A namespace import, not named ones: during the server-side build Node loads\n// VitePress's node entry, which has no `inBrowser`, `useRoute` or\n// `onContentUpdated`. Named imports would fail to link there; on the\n// namespace they are just `undefined`.\nimport * as vitepress from 'vitepress';\nimport { nextTick, onMounted, watch } from 'vue';\nimport { applyBlogViews } from './view.js';\n\n/**\n * Turns on filtering (`?category=`, `?tag=`, `?year=`, `?month=`) and pagination (`?page=`) on blog\n * pages. Call it from your theme's `setup()`:\n *\n * ```ts\n * // .vitepress/theme/index.ts\n * import DefaultTheme from 'vitepress/theme';\n * import { useBlog } from '@binarynoir/vitepress-blog/client';\n * import '@binarynoir/vitepress-blog/style.css';\n *\n * export default {\n * extends: DefaultTheme,\n * setup() {\n * useBlog();\n * },\n * };\n * ```\n *\n * Filter and page links are ordinary links: VitePress's router follows them\n * without reloading the page, and this re-applies the view whenever the path\n * or query string changes. It runs after the page has hydrated, so the\n * server-rendered HTML (every post) and the first client render agree.\n * Does nothing during the server-side build.\n */\nexport function useBlog(): void {\n if (!vitepress.inBrowser) return;\n const route = vitepress.useRoute();\n const apply = () => nextTick(() => applyBlogViews());\n onMounted(() => {\n watch(() => [route.path, route.query], apply, { immediate: true });\n });\n // `vitepress dev` swaps a page's content in place when it is edited.\n vitepress.onContentUpdated(apply);\n}\n\nexport { BlogAnnouncementBanner } from './banner.js';\nexport { applyBlogView, applyBlogViews, readFilter, type BlogFilter, type BlogViewState } from './view.js';\n","/**\n * Filtering and pagination for blog pages, in the browser. The build writes\n * every post into the page (see `renderPostList`); this hides the ones the\n * URL's `?category=`, `?tag=`, `?year=`, `?month=`, `?announcements` and\n * `?page=` leave out, keeps the browse panel's chips and counts in step with\n * them, and fills in the active filters and the page links. Without it, every\n * post simply shows.\n */\n\nconst ITEM = '[data-vp-blog-item]';\n/** Links that filter the blog: the browse panel's chips, and the category and keyword links on cards. */\nconst FILTER_LINKS = '.vp-blog-facet a, .vp-blog-card__category a, .vp-blog-card__tags a';\n\ninterface BlogLabels {\n showAll: string;\n previous: string;\n next: string;\n pages: string;\n announcements: string;\n category: string;\n tag: string;\n postCount: string;\n postCountOne: string;\n noMatches: string;\n removeFilter: string;\n}\n\nfunction readLabels(blog: HTMLElement): BlogLabels {\n const { dataset } = blog;\n return {\n showAll: dataset.labelShowAll ?? 'Show all posts',\n previous: dataset.labelPrevious ?? 'Previous',\n next: dataset.labelNext ?? 'Next',\n pages: dataset.labelPages ?? 'Pages',\n announcements: dataset.labelAnnouncements ?? 'Announcements',\n category: dataset.labelCategory ?? 'Category',\n tag: dataset.labelTag ?? 'Keyword',\n postCount: dataset.labelPostCount ?? '{n} posts',\n postCountOne: dataset.labelPostCountOne ?? '{n} post',\n noMatches: dataset.labelNoMatches ?? 'No posts match these filters.',\n removeFilter: dataset.labelRemoveFilter ?? 'Remove filter: {filter}',\n };\n}\n\n/**\n * What the URL filters by. Every filter must match. The category and keyword\n * are lowercased; `date` is `''`, a year (`2026`) or a month (`2026-10`).\n */\nexport interface BlogFilter {\n category: string;\n tag: string;\n date: string;\n /** Whether only announcements show (`?announcements`). */\n announcements: boolean;\n}\n\ntype Facet = 'category' | 'tag' | 'date';\n\n/** Reads the filter from `?category=`, `?tag=`, `?year=` and `?month=`. A month names its year too, so it wins over `?year=`. */\nexport function readFilter(url: URL): BlogFilter {\n const text = (name: string) => (url.searchParams.get(name) ?? '').trim().toLowerCase();\n const month = text('month');\n const year = text('year');\n return {\n category: text('category'),\n tag: text('tag'),\n date: /^\\d{4}-(?:0[1-9]|1[0-2])$/.test(month) ? month : /^\\d{4}$/.test(year) ? year : '',\n announcements: url.searchParams.has('announcements'),\n };\n}\n\n/** The element a marker stands for: a card is its own marker; a compact list item holds one. */\nfunction itemElement(marker: Element): HTMLElement {\n return (marker.tagName === 'ARTICLE' ? marker : (marker.closest('li') ?? marker)) as HTMLElement;\n}\n\nfunction matches(marker: Element, filter: BlogFilter): boolean {\n const { category, tag, date, announcements } = filter;\n return (\n (!tag || (marker.getAttribute('data-tags') ?? '').split('|').includes(tag)) &&\n (!category || marker.getAttribute('data-category') === category) &&\n // `2026` matches every month of 2026; `2026-10` only October.\n (!date || (marker.getAttribute('data-month') ?? '').startsWith(date)) &&\n (!announcements || marker.hasAttribute('data-announcement'))\n );\n}\n\n/** `url` with the filter and `page` set (or removed when empty or page 1), as a path the router can follow. */\nfunction toHref(url: URL, filter: BlogFilter, page = 1): string {\n const next = new URL(url.href);\n next.hash = '';\n const set = (name: string, value: string) =>\n value ? next.searchParams.set(name, value) : next.searchParams.delete(name);\n set('category', filter.category);\n set('tag', filter.tag);\n set('year', filter.date.length === 4 ? filter.date : '');\n set('month', filter.date.length === 7 ? filter.date : '');\n if (filter.announcements) next.searchParams.set('announcements', '');\n else next.searchParams.delete('announcements');\n set('page', page > 1 ? String(page) : '');\n // A bare `?announcements`, not `?announcements=`.\n return next.pathname + next.search.replace(/([?&]announcements)=(?=&|$)/, '$1');\n}\n\n/** The filter a link applies, as the build wrote it: `?category=x`, `?tag=x`, `?year=2026` or `?month=2026-10`. */\ninterface FilterLink {\n facet: Facet;\n value: string;\n}\n\n/**\n * The filter a link stands for. It is read from the link's URL the first time\n * and remembered, because the browse panel's links are then rewritten to\n * combine it with the other active filters.\n */\nfunction readFilterLink(anchor: HTMLAnchorElement): FilterLink | null {\n if (!anchor.dataset.vpBlogFacet) {\n let params: URLSearchParams;\n try {\n params = new URL(anchor.href, location.href).searchParams;\n } catch {\n return null;\n }\n const param = (['category', 'tag', 'month', 'year'] as const).find((name) => params.has(name));\n if (!param) return null;\n anchor.dataset.vpBlogFacet = param === 'year' || param === 'month' ? 'date' : param;\n anchor.dataset.vpBlogValue = (params.get(param) ?? '').trim().toLowerCase();\n }\n return { facet: anchor.dataset.vpBlogFacet as Facet, value: anchor.dataset.vpBlogValue ?? '' };\n}\n\nfunction withValue(filter: BlogFilter, { facet, value }: FilterLink): BlogFilter {\n return { ...filter, [facet]: value };\n}\n\n/** Whether the link's filter is applied. A year is also active while one of its months is. */\nfunction isActive({ facet, value }: FilterLink, filter: BlogFilter): boolean {\n if (facet !== 'date') return filter[facet] === value;\n return value.length === 4 ? filter.date.startsWith(value) : filter.date === value;\n}\n\n/**\n * Where a browse chip leads: its filter added to the active ones (replacing\n * any other value of the same kind), or, when it is the active one, taken off\n * again. An active month steps back out to its year.\n */\nfunction toggle(filter: BlogFilter, link: FilterLink): BlogFilter {\n if (link.facet !== 'date')\n return withValue(filter, { ...link, value: filter[link.facet] === link.value ? '' : link.value });\n if (filter.date !== link.value) return withValue(filter, link);\n return { ...filter, date: link.value.length === 7 ? link.value.slice(0, 4) : '' };\n}\n\nfunction markActive(anchor: HTMLAnchorElement, active: boolean): void {\n anchor.classList.toggle('is-active', active);\n if (active) anchor.setAttribute('aria-current', 'true');\n else anchor.removeAttribute('aria-current');\n}\n\n/**\n * Brings the browse panel in step with the filter. Each chip links to the\n * view with it toggled, and counts the posts it would show with the other\n * filters kept, so a reader can see where every next step leads; chips that\n * would show nothing are dimmed. The months of the year in view show below\n * the years.\n */\nfunction syncBrowse(blog: HTMLElement, url: URL, filter: BlogFilter, markers: Element[]): void {\n const browse = blog.querySelector<HTMLElement>('.vp-blog-browse');\n if (!browse) return;\n for (const anchor of browse.querySelectorAll<HTMLAnchorElement>('.vp-blog-facet a')) {\n const link = readFilterLink(anchor);\n if (!link) continue;\n const active = isActive(link, filter);\n const count = markers.filter((marker) => matches(marker, withValue(filter, link))).length;\n anchor.href = toHref(url, toggle(filter, link));\n markActive(anchor, active);\n anchor.classList.toggle('is-empty', count === 0 && !active);\n const counter = anchor.querySelector('.vp-blog-facet__count');\n if (counter) counter.textContent = String(count);\n }\n // With only one year, its months always show.\n const months = [...browse.querySelectorAll<HTMLElement>('[data-vp-blog-facet=\"month\"]')];\n for (const row of months) row.hidden = months.length > 1 && row.dataset.year !== filter.date.slice(0, 4);\n}\n\n/** The name a filter value is shown with: as a chip or card link spells it, else as the URL gave it. */\nfunction findName(blog: HTMLElement, facet: 'category' | 'tag', value: string): string {\n for (const anchor of blog.querySelectorAll<HTMLAnchorElement>(FILTER_LINKS)) {\n const link = readFilterLink(anchor);\n if (link?.facet === facet && link.value === value) {\n const name = (anchor.querySelector('.vp-blog-facet__name') ?? anchor).textContent?.trim();\n if (name) return name;\n }\n }\n return value;\n}\n\n/** A year as written, or a month as \"October 2026\" in the blog's locale. */\nfunction formatDate(date: string, locale: string): string {\n if (date.length === 4) return date;\n const [year, month] = date.split('-').map(Number);\n try {\n return new Intl.DateTimeFormat(locale, { timeZone: 'UTC', month: 'long', year: 'numeric' }).format(\n Date.UTC(year, month - 1, 1, 12),\n );\n } catch {\n return date;\n }\n}\n\nfunction element(tag: string, className: string, text = ''): HTMLElement {\n const node = document.createElement(tag);\n node.className = className;\n node.textContent = text;\n return node;\n}\n\nfunction link(text: string, href: string, className: string): HTMLAnchorElement {\n const anchor = element('a', className, text) as HTMLAnchorElement;\n anchor.href = href;\n return anchor;\n}\n\n/** One active filter: what kind it is (`''` for a date or announcements), its name, and the view without it. */\ninterface ActiveFilter {\n kind: string;\n name: string;\n without: BlogFilter;\n}\n\nfunction listActiveFilters(blog: HTMLElement, filter: BlogFilter, labels: BlogLabels): ActiveFilter[] {\n const active: ActiveFilter[] = [];\n if (filter.announcements) {\n active.push({ kind: '', name: labels.announcements, without: { ...filter, announcements: false } });\n }\n if (filter.category) {\n const name = findName(blog, 'category', filter.category);\n active.push({ kind: labels.category, name, without: { ...filter, category: '' } });\n }\n if (filter.tag) {\n active.push({ kind: labels.tag, name: findName(blog, 'tag', filter.tag), without: { ...filter, tag: '' } });\n }\n if (filter.date) {\n const name = formatDate(filter.date, blog.dataset.locale || document.documentElement.lang || 'en-US');\n active.push({ kind: '', name, without: { ...filter, date: '' } });\n }\n return active;\n}\n\n/**\n * The active filters, above the posts: how many posts match, a chip for each\n * filter that removes just that one, and a link that clears them all. All of\n * it is written as text: the values come from the URL.\n */\nfunction renderFilter(\n slot: HTMLElement,\n blog: HTMLElement,\n url: URL,\n filter: BlogFilter,\n count: number,\n labels: BlogLabels,\n): void {\n const active = listActiveFilters(blog, filter, labels);\n slot.hidden = active.length === 0;\n if (slot.hidden) return slot.replaceChildren();\n\n const summary = count === 0 ? labels.noMatches : count === 1 ? labels.postCountOne : labels.postCount;\n const parts: Node[] = [element('span', 'vp-blog-filter__count', summary.replace('{n}', String(count)))];\n for (const { kind, name, without } of active) {\n const chip = link('', toHref(url, without), 'vp-blog-filter__chip');\n chip.setAttribute('aria-label', labels.removeFilter.replace('{filter}', kind ? `${kind} ${name}` : name));\n if (kind) chip.append(element('span', 'vp-blog-filter__kind', kind), ' ');\n const remove = element('span', 'vp-blog-filter__remove', '×');\n remove.setAttribute('aria-hidden', 'true');\n chip.append(name, remove);\n parts.push(chip);\n }\n const clear = { category: '', tag: '', date: '', announcements: false };\n parts.push(link(labels.showAll, toHref(url, clear), 'vp-blog-filter__clear'));\n slot.replaceChildren(...parts);\n}\n\nfunction renderPagination(\n slot: HTMLElement,\n url: URL,\n filter: BlogFilter,\n page: number,\n pageCount: number,\n labels: BlogLabels,\n): void {\n slot.hidden = pageCount < 2;\n slot.setAttribute('aria-label', labels.pages);\n if (pageCount < 2) return slot.replaceChildren();\n\n const parts: Node[] = [];\n if (page > 1) parts.push(link(labels.previous, toHref(url, filter, page - 1), 'vp-blog-pagination__previous'));\n for (let number = 1; number <= pageCount; number++) {\n if (number === page) {\n const current = element('span', 'vp-blog-pagination__page is-current', String(number));\n current.setAttribute('aria-current', 'page');\n parts.push(current);\n } else {\n parts.push(link(String(number), toHref(url, filter, number), 'vp-blog-pagination__page'));\n }\n }\n if (page < pageCount) parts.push(link(labels.next, toHref(url, filter, page + 1), 'vp-blog-pagination__next'));\n slot.replaceChildren(...parts);\n}\n\n/** Marks the category and keyword links on cards that match the active filter. */\nfunction markCardLinks(blog: HTMLElement, filter: BlogFilter): void {\n for (const anchor of blog.querySelectorAll<HTMLAnchorElement>('.vp-blog-card__category a, .vp-blog-card__tags a')) {\n const link = readFilterLink(anchor);\n markActive(anchor, !!link && isActive(link, filter));\n }\n}\n\nexport interface BlogViewState extends BlogFilter {\n page: number;\n pageCount: number;\n}\n\n/**\n * Shows the posts of one blog page that `url` asks for: those matching every\n * filter it gives (category and keyword in any case, a year or a month, only\n * announcements), and of those, the ones on its `?page=`. Pinned posts\n * only show on the first page and aren't counted in the page size. Returns\n * what is showing.\n */\nexport function applyBlogView(blog: HTMLElement, url: URL): BlogViewState {\n const labels = readLabels(blog);\n const filter = readFilter(url);\n const pageSize = Math.max(0, Number(blog.dataset.pageSize) || 0);\n\n const markers = [...blog.querySelectorAll(ITEM)];\n const isPinned = (marker: Element) => !!marker.closest('[data-vp-blog-pinned]');\n const pinned = markers.filter((marker) => isPinned(marker) && matches(marker, filter));\n const rest = markers.filter((marker) => !isPinned(marker) && matches(marker, filter));\n\n const pageCount = pageSize ? Math.max(1, Math.ceil(rest.length / pageSize)) : 1;\n const requested = Number.parseInt(url.searchParams.get('page') ?? '1', 10);\n const page = Math.min(pageCount, Math.max(1, Number.isFinite(requested) ? requested : 1));\n\n const shown = new Set<Element>([\n ...(page === 1 ? pinned : []),\n ...(pageSize ? rest.slice((page - 1) * pageSize, page * pageSize) : rest),\n ]);\n for (const marker of markers) itemElement(marker).hidden = !shown.has(marker);\n\n // A pinned section, year or month with nothing showing is hidden, heading and all.\n for (const group of blog.querySelectorAll<HTMLElement>('[data-vp-blog-pinned], .vp-blog-group')) {\n group.hidden = ![...group.querySelectorAll(ITEM)].some((marker) => shown.has(marker));\n }\n\n const banner = blog.querySelector<HTMLElement>('[data-vp-blog-filter]');\n if (banner) renderFilter(banner, blog, url, filter, pinned.length + rest.length, labels);\n const pagination = blog.querySelector<HTMLElement>('[data-vp-blog-pagination]');\n if (pagination) renderPagination(pagination, url, filter, page, pageCount, labels);\n syncBrowse(blog, url, filter, markers);\n markCardLinks(blog, filter);\n\n return { ...filter, page, pageCount };\n}\n\nconst lastViews = new WeakMap<HTMLElement, BlogViewState>();\n\nfunction viewChanged(previous: BlogViewState, view: BlogViewState): boolean {\n return (['category', 'tag', 'date', 'announcements', 'page'] as const).some((key) => previous[key] !== view[key]);\n}\n\n/**\n * Applies `url` to every blog page in `root`. When the page or a filter of a\n * blog already on screen changes, it is scrolled back to its top.\n */\nexport function applyBlogViews(root: ParentNode = document, url: URL = new URL(location.href)): void {\n for (const blog of root.querySelectorAll<HTMLElement>('[data-vp-blog]')) {\n const previous = lastViews.get(blog);\n const view = applyBlogView(blog, url);\n lastViews.set(blog, view);\n if (previous && viewChanged(previous, view) && blog.getBoundingClientRect().top < 0) {\n blog.scrollIntoView({ block: 'start' });\n }\n }\n}\n","// A namespace import, as in `client.ts`: loaded by Node without the Vite\n// plugin, VitePress's node entry has no `useData` or `withBase`.\nimport * as vitepress from 'vitepress';\nimport { computed, defineComponent, h, onMounted, ref } from 'vue';\nimport type { BlogAnnouncementData } from './types.js';\nimport { ANNOUNCEMENTS_KEY, DISMISSED_CLASS, DISMISSED_STORAGE_KEY } from './util.js';\n\nconst closeIcon = () =>\n h('svg', { viewBox: '0 0 24 24', width: 16, height: 16, 'aria-hidden': 'true' }, [\n h('path', {\n fill: 'currentColor',\n d: 'M18.3 5.71a1 1 0 0 0-1.41 0L12 10.59 7.11 5.7A1 1 0 0 0 5.7 7.11L10.59 12 5.7 16.89a1 1 0 1 0 1.41 1.41L12 13.41l4.89 4.89a1 1 0 0 0 1.41-1.41L13.41 12l4.89-4.89a1 1 0 0 0 0-1.4z',\n }),\n ]);\n\nfunction readDismissed(): string[] {\n try {\n const value: unknown = JSON.parse(localStorage.getItem(DISMISSED_STORAGE_KEY) ?? '[]');\n return Array.isArray(value) ? value.filter((id): id is string => typeof id === 'string') : [];\n } catch {\n return [];\n }\n}\n\nfunction writeDismissed(ids: string[]): void {\n try {\n localStorage.setItem(DISMISSED_STORAGE_KEY, JSON.stringify(ids));\n } catch {\n // Storage blocked (a private window, say): dismissed until the next page load.\n }\n}\n\n/**\n * The announcement banner: one line across the top of the page, above the\n * nav bar, with the newest announcement linked to its post. With more than\n * one not yet dismissed, a badge counts them and links to the blog page\n * filtered to announcements. Its close button dismisses every current one;\n * a newer announcement brings it back. Put it in the theme's `layout-top` slot:\n *\n * ```ts\n * // .vitepress/theme/index.ts\n * import { h } from 'vue';\n * import DefaultTheme from 'vitepress/theme';\n * import { BlogAnnouncementBanner } from '@binarynoir/vitepress-blog/client';\n *\n * export default {\n * extends: DefaultTheme,\n * Layout: () => h(DefaultTheme.Layout, null, { 'layout-top': () => h(BlogAnnouncementBanner) }),\n * };\n * ```\n *\n * It reads what `blogVitePlugin` (or `withBlog`) puts on the site data, and\n * renders nothing without it.\n */\nexport const BlogAnnouncementBanner = defineComponent({\n name: 'BlogAnnouncementBanner',\n setup() {\n if (typeof vitepress.useData !== 'function') return () => null;\n const { site } = vitepress.useData();\n const data = computed(\n () => (site.value as unknown as Record<string, unknown>)[ANNOUNCEMENTS_KEY] as BlogAnnouncementData | undefined,\n );\n // Unknown until mounted: the server can't read storage, so the first render\n // matches its HTML, and the `<head>` script has already hidden it if dismissed.\n const dismissed = ref<string[] | null>(null);\n onMounted(() => {\n dismissed.value = readDismissed();\n });\n\n const latest = computed(() => data.value?.items[0]);\n const active = computed(() => (data.value?.items ?? []).filter((item) => !dismissed.value?.includes(item.id)));\n\n const dismiss = () => {\n const ids = (data.value?.items ?? []).map((item) => item.id);\n dismissed.value = ids;\n writeDismissed(ids);\n document.documentElement.classList.add(DISMISSED_CLASS);\n };\n\n return () => {\n const announcement = latest.value;\n if (!data.value || !announcement || dismissed.value?.includes(announcement.id)) return null;\n const { labels, listLink } = data.value;\n const count = active.value.length;\n const countLabel = labels.announcementCount.replace('{n}', String(count));\n const badge =\n count > 1\n ? listLink\n ? h(\n 'a',\n {\n class: 'vp-blog-announcement__count',\n href: vitepress.withBase(listLink),\n title: countLabel,\n 'aria-label': countLabel,\n },\n String(count),\n )\n : h(\n 'span',\n { class: 'vp-blog-announcement__count', title: countLabel, 'aria-label': countLabel },\n String(count),\n )\n : null;\n return h('div', { class: 'vp-blog-announcement', role: 'region', 'aria-label': labels.announcement }, [\n h('div', { class: 'vp-blog-announcement__content' }, [\n h(\n 'a',\n { class: 'vp-blog-announcement__text', href: vitepress.withBase(announcement.link) },\n announcement.text,\n ),\n badge,\n ]),\n h(\n 'button',\n {\n class: 'vp-blog-announcement__dismiss',\n type: 'button',\n title: labels.dismiss,\n 'aria-label': labels.dismiss,\n onClick: dismiss,\n },\n [closeIcon()],\n ),\n ]);\n };\n },\n});\n"],"mappings":"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","names":["vitepress","onMounted","link","onMounted"]}
package/dist/index.cjs CHANGED
@@ -63,6 +63,11 @@ function hasAnyTag(post, tags) {
63
63
  const wanted = new Set(tags.map((tag) => tag.toLowerCase()));
64
64
  return post.post.tags.some((tag) => wanted.has(tag.toLowerCase()));
65
65
  }
66
+ function inAnyCategory(post, categories) {
67
+ if (categories.length === 0) return true;
68
+ const category = post.post.category.toLowerCase();
69
+ return !!category && categories.some((wanted) => wanted.toLowerCase() === category);
70
+ }
66
71
  function blogCoversPost(index, post, sections) {
67
72
  if (index.index.scope === "section") return isInFolders(post.relativePath, index.index.folders);
68
73
  if (!isInFolders(post.relativePath, index.index.folders)) return false;
@@ -76,22 +81,22 @@ function compareDates(a, b) {
76
81
  function compareByDate(a, b) {
77
82
  return compareDates(a.post.date, b.post.date) || a.post.title.localeCompare(b.post.title);
78
83
  }
79
- function pinStickyPosts(posts, limit) {
80
- const sticky = posts.filter((post) => post.post.sticky);
81
- const pinned = new Set(limit ? sticky.slice(0, limit) : sticky);
84
+ function pinPosts(posts, limit) {
85
+ const candidates = posts.filter((post) => post.post.pinned);
86
+ const pinned = new Set(limit ? candidates.slice(0, limit) : candidates);
82
87
  return { pinned: [...pinned], rest: posts.filter((post) => !pinned.has(post)) };
83
88
  }
84
89
  function listBlogPosts(pages, index, options) {
85
90
  const sections = pages.filter(isIndex).filter((page) => page.index.scope === "section");
86
- let posts = pages.filter(isPost).filter((post) => options.drafts || !post.post.draft).filter((post) => blogCoversPost(index, post, sections) && hasAnyTag(post, index.index.tags)).sort(compareByDate);
87
- if (options.stickyFirst) {
88
- const { pinned, rest } = pinStickyPosts(posts, index.index.stickyLimit);
91
+ let posts = pages.filter(isPost).filter((post) => options.drafts || !post.post.draft).filter((post) => blogCoversPost(index, post, sections) && hasAnyTag(post, index.index.tags)).filter((post) => inAnyCategory(post, index.index.categories)).sort(compareByDate);
92
+ if (options.pinnedFirst) {
93
+ const { pinned, rest } = pinPosts(posts, index.index.pinLimit);
89
94
  posts = [...pinned, ...rest];
90
95
  }
91
96
  return options.applyLimit && index.index.limit ? posts.slice(0, index.index.limit) : posts;
92
97
  }
93
98
  function findHomeBlog(pages, post, drafts) {
94
- const listing = pages.filter(isIndex).filter((index) => listBlogPosts(pages, index, { drafts, stickyFirst: false, applyLimit: false }).includes(post));
99
+ const listing = pages.filter(isIndex).filter((index) => listBlogPosts(pages, index, { drafts, pinnedFirst: false, applyLimit: false }).includes(post));
95
100
  const sections = listing.filter((index) => index.index.scope === "section");
96
101
  if (sections.length) return deepestFirst(sections, post.relativePath)[0];
97
102
  return listing[0] ?? null;
@@ -207,6 +212,13 @@ function toDateTimeAttribute({ date, hasTime }, timeZone) {
207
212
  function yearIn(date, timeZone) {
208
213
  return Number(toZonedIsoDate(date, timeZone).slice(0, 4));
209
214
  }
215
+ function monthIn(date, timeZone) {
216
+ return toZonedIsoDate(date, timeZone).slice(0, 7);
217
+ }
218
+ function formatMonth(month, locale, format) {
219
+ const [year, number] = month.split("-").map(Number);
220
+ return new Intl.DateTimeFormat(locale, { timeZone: "UTC", ...format }).format(Date.UTC(year, number - 1, 1, 12));
221
+ }
210
222
 
211
223
  // src/feed.ts
212
224
  var import_node_crypto = require("crypto");
@@ -258,13 +270,24 @@ var DEFAULT_LABELS = {
258
270
  older: "Older post",
259
271
  allPosts: "All posts in {blog}",
260
272
  draft: "Draft",
261
- sticky: "Pinned",
273
+ pinned: "Pinned",
274
+ announcement: "Announcement",
275
+ announcements: "Announcements",
276
+ announcementCount: "{n} announcements",
277
+ dismiss: "Dismiss",
262
278
  noPosts: "No posts yet.",
263
279
  undated: "Undated",
280
+ browse: "Browse posts",
281
+ categories: "Categories",
264
282
  tags: "Keywords",
265
- tagged: "Posts tagged {tag}",
283
+ archive: "Archive",
284
+ category: "Category",
285
+ tag: "Keyword",
286
+ postCount: "{n} posts",
287
+ postCountOne: "{n} post",
288
+ noMatches: "No posts match these filters.",
289
+ removeFilter: "Remove filter: {filter}",
266
290
  showAll: "Show all posts",
267
- noTagged: "No posts tagged {tag}.",
268
291
  previous: "Previous",
269
292
  next: "Next",
270
293
  pages: "Pages",
@@ -326,25 +349,41 @@ function renderAuthors(authors, context) {
326
349
  function renderBadge(kind, context) {
327
350
  return `<span class="vp-blog-badge vp-blog-badge--${kind}">${escapeTemplateText(context.labels[kind])}</span>`;
328
351
  }
329
- function toTagUrl(blogRoutePath, tag) {
330
- const value = encodeURIComponent(tag.toLowerCase()).replace(/[()]/g, (c) => c === "(" ? "%28" : "%29");
352
+ function toFilterUrl(blogRoutePath, param, value) {
353
+ const encoded = encodeURIComponent(value.toLowerCase()).replace(/[()]/g, (c) => c === "(" ? "%28" : "%29");
331
354
  const page = blogRoutePath.replace(/(^|\/)index\.md$/, "$1");
332
- return `${toMarkdownUrl(`/${page}`)}?tag=${value}`;
355
+ return `${toMarkdownUrl(`/${page}`)}?${param}=${encoded}`;
356
+ }
357
+ function renderFilterLink(text, param, blogRoutePath, className) {
358
+ return blogRoutePath ? `[${escapeMarkdown(text)}](${toFilterUrl(blogRoutePath, param, text)})` : `<span class="${className}">${escapeTemplateText(text)}</span>`;
333
359
  }
334
360
  function renderTags(tags, blogRoutePath) {
335
- return tags.map(
336
- (tag) => blogRoutePath ? `[${escapeMarkdown(tag)}](${toTagUrl(blogRoutePath, tag)})` : `<span class="vp-blog-tag">${escapeTemplateText(tag)}</span>`
337
- ).join(" ");
361
+ return tags.map((tag) => renderFilterLink(tag, "tag", blogRoutePath, "vp-blog-tag")).join(" ");
338
362
  }
339
363
  function toTagsAttribute(post) {
340
364
  return escapeHtml(post.post.tags.map((tag) => tag.toLowerCase()).join("|"));
341
365
  }
366
+ function toItemAttributes(post, context) {
367
+ const { category, date, announcement } = post.post;
368
+ const attributes = [`data-vp-blog-item data-tags="${toTagsAttribute(post)}"`];
369
+ if (category) attributes.push(`data-category="${escapeHtml(category.toLowerCase())}"`);
370
+ if (date) attributes.push(`data-month="${monthIn(date, context.dates.timeZone)}"`);
371
+ if (announcement) attributes.push("data-announcement");
372
+ return attributes.join(" ");
373
+ }
374
+ function renderBadges(post, context) {
375
+ const { draft, pinned, announcement } = post.post;
376
+ return [
377
+ draft && renderBadge("draft", context),
378
+ pinned && renderBadge("pinned", context),
379
+ announcement && renderBadge("announcement", context)
380
+ ].filter(Boolean).join(" ");
381
+ }
342
382
  function renderMeta(post, context, include) {
343
- const { date, dateHasTime, updated, updatedHasTime, readingMinutes, authors, draft, sticky } = post.post;
383
+ const { date, dateHasTime, updated, updatedHasTime, readingMinutes, authors } = post.post;
344
384
  const parts = [];
345
385
  if (include.badges) {
346
- const badges = [draft && renderBadge("draft", context), sticky && renderBadge("sticky", context)];
347
- const shown = badges.filter(Boolean).join(" ");
386
+ const shown = renderBadges(post, context);
348
387
  if (shown) parts.push(shown);
349
388
  }
350
389
  if (date) {
@@ -371,16 +410,18 @@ function renderCard(post, context, index) {
371
410
  const link = `/${post.routePath}`;
372
411
  const image = toImageSource(post, context.pageDirectory);
373
412
  const modifiers = [
374
- post.post.sticky && "sticky",
413
+ post.post.pinned && "pinned",
375
414
  post.post.draft && "draft",
415
+ post.post.announcement && "announcement",
376
416
  image && "with-image",
377
417
  image && post.post.imageFit === "contain" && "contain"
378
418
  ].filter(Boolean).map((modifier) => ` vp-blog-card--${modifier}`).join("");
379
419
  const alt = escapeMarkdown(post.post.imageAlt);
380
420
  return [
381
- `<article class="vp-blog-card${modifiers}"${imageStyle(post)} data-vp-blog-item data-tags="${toTagsAttribute(post)}">`,
421
+ `<article class="vp-blog-card${modifiers}"${imageStyle(post)} ${toItemAttributes(post, context)}>`,
382
422
  ...image ? block("vp-blog-card__image", `[![${alt}](${toMarkdownUrl(image)})](${toMarkdownUrl(link)})`) : [],
383
423
  '<div class="vp-blog-card__body">',
424
+ ...index.index.showCategory && post.post.category ? block("vp-blog-card__category", renderFilterLink(post.post.category, "category", index.routePath, "")) : [],
384
425
  ...block("vp-blog-card__title", toPageLink(post.post.title, post.routePath)),
385
426
  ...block("vp-blog-card__meta", renderMeta(post, context, { authors: true, updated: false, badges: true })),
386
427
  ...block("vp-blog-card__summary", escapeMarkdown(post.post.summary)),
@@ -391,8 +432,8 @@ function renderCard(post, context, index) {
391
432
  }
392
433
  function renderCompactItem(post, context) {
393
434
  const date = post.post.date ? `${renderTime(post.post.date, post.post.dateHasTime, context)} ` : "";
394
- const badges = [post.post.draft && renderBadge("draft", context), post.post.sticky && renderBadge("sticky", context)].filter(Boolean).join(" ");
395
- const marker = `<span hidden data-vp-blog-item data-tags="${toTagsAttribute(post)}"></span>`;
435
+ const badges = renderBadges(post, context);
436
+ const marker = `<span hidden ${toItemAttributes(post, context)}></span>`;
396
437
  return `- ${marker}${date}${toPageLink(post.post.title, post.routePath)}${badges ? ` ${badges}` : ""}`;
397
438
  }
398
439
  function renderGroup(posts, index, context) {
@@ -412,18 +453,23 @@ function renderGroup(posts, index, context) {
412
453
  "</div>"
413
454
  ];
414
455
  }
415
- function renderYears(posts, index, context) {
416
- const years = /* @__PURE__ */ new Map();
456
+ function renderGroups(posts, index, context) {
457
+ const { groupBy } = index.index;
458
+ if (!groupBy) return renderGroup(posts, index, context);
459
+ const { timeZone, locale } = context.dates;
460
+ const groups = /* @__PURE__ */ new Map();
417
461
  for (const post of posts) {
418
- const year = post.post.date ? String(yearIn(post.post.date, context.dates.timeZone)) : context.labels.undated;
419
- years.set(year, [...years.get(year) ?? [], post]);
462
+ const { date } = post.post;
463
+ const key = !date ? "" : groupBy === "year" ? String(yearIn(date, timeZone)) : monthIn(date, timeZone);
464
+ groups.set(key, [...groups.get(key) ?? [], post]);
420
465
  }
421
466
  const lines = [];
422
- for (const [year, group] of years) {
467
+ for (const [key, group] of groups) {
468
+ const heading = !key ? context.labels.undated : groupBy === "year" ? key : formatMonth(key, locale, { month: "long", year: "numeric" });
423
469
  lines.push(
424
- '<section class="vp-blog-year">',
470
+ `<section class="vp-blog-group vp-blog-${groupBy}">`,
425
471
  "",
426
- `## ${escapeMarkdown(year)}`,
472
+ `## ${escapeMarkdown(heading)}`,
427
473
  "",
428
474
  ...renderGroup(group, index, context),
429
475
  "",
@@ -432,42 +478,114 @@ function renderYears(posts, index, context) {
432
478
  }
433
479
  return lines;
434
480
  }
435
- function collectTags(posts) {
436
- const tags = /* @__PURE__ */ new Map();
481
+ function collectValues(posts, pick) {
482
+ const values = /* @__PURE__ */ new Map();
437
483
  for (const post of posts) {
438
- for (const tag of new Set(post.post.tags)) {
439
- const entry = tags.get(tag.toLowerCase()) ?? { tag, count: 0 };
484
+ for (const value of new Set(pick(post))) {
485
+ const entry = values.get(value.toLowerCase()) ?? { value, count: 0 };
440
486
  entry.count++;
441
- tags.set(tag.toLowerCase(), entry);
487
+ values.set(value.toLowerCase(), entry);
442
488
  }
443
489
  }
444
- return [...tags.values()].sort((a, b) => a.tag.localeCompare(b.tag, void 0, { sensitivity: "base" }));
490
+ return [...values.values()].sort((a, b) => a.value.localeCompare(b.value, void 0, { sensitivity: "base" }));
445
491
  }
446
- function renderTagList(posts, index, context) {
447
- const tags = collectTags(posts);
448
- if (!index.index.tagList || tags.length === 0) return [];
449
- const links = tags.map(
450
- ({ tag, count }) => `[${escapeMarkdown(tag)} <span class="vp-blog-tags__count">${count}</span>](${toTagUrl(index.routePath, tag)})`
451
- );
452
- const label = `<span class="vp-blog-tags__label">${escapeTemplateText(context.labels.tags)}</span>`;
492
+ function collectTags(posts) {
493
+ return collectValues(posts, (post) => post.post.tags).map(({ value, count }) => ({ tag: value, count }));
494
+ }
495
+ function collectCategories(posts) {
496
+ return collectValues(posts, (post) => post.post.category ? [post.post.category] : []);
497
+ }
498
+ function collectArchive(posts, timeZone) {
499
+ const years = /* @__PURE__ */ new Map();
500
+ for (const post of posts) {
501
+ if (!post.post.date) continue;
502
+ const month = monthIn(post.post.date, timeZone);
503
+ const year = years.get(month.slice(0, 4)) ?? { value: month.slice(0, 4), count: 0, months: [] };
504
+ year.count++;
505
+ const entry = year.months.find((candidate) => candidate.value === month);
506
+ if (entry) entry.count++;
507
+ else year.months.push({ value: month, count: 1 });
508
+ years.set(year.value, year);
509
+ }
510
+ const newestFirst = (a, b) => b.value.localeCompare(a.value);
511
+ return [...years.values()].sort(newestFirst).map((year) => ({ ...year, months: [...year.months].sort(newestFirst) }));
512
+ }
513
+ function renderChip(name, count, url, title = "") {
514
+ const tooltip = title ? ` "${title.replace(/"/g, "&quot;")}"` : "";
515
+ return `[<span class="vp-blog-facet__name">${escapeMarkdown(name)}</span> <span class="vp-blog-facet__count">${count}</span>](${url}${tooltip})`;
516
+ }
517
+ function renderFacet(facet, label, chips, ariaLabel = label, attributes = "") {
453
518
  return [
454
- `<nav class="vp-blog-tags" aria-label="${escapeHtml(context.labels.tags)}">`,
519
+ `<div class="vp-blog-facet vp-blog-facet--${facet}" role="group" aria-label="${escapeHtml(ariaLabel)}" data-vp-blog-facet="${facet}"${attributes}>`,
520
+ `<span class="vp-blog-facet__label" aria-hidden="true">${escapeTemplateText(label)}</span>`,
521
+ '<div class="vp-blog-facet__chips">',
455
522
  "",
456
- `${label} ${links.join(" ")}`,
523
+ chips.join(" "),
457
524
  "",
458
- "</nav>"
525
+ "</div>",
526
+ "</div>"
459
527
  ];
460
528
  }
529
+ function renderBrowse(posts, index, context) {
530
+ const { labels } = context;
531
+ const { locale, timeZone } = context.dates;
532
+ const blog = index.routePath;
533
+ const rows = [];
534
+ const categories = index.index.categoryList ? collectCategories(posts) : [];
535
+ if (categories.length) {
536
+ const chips = categories.map(({ value, count }) => renderChip(value, count, toFilterUrl(blog, "category", value)));
537
+ rows.push(...renderFacet("category", labels.categories, chips));
538
+ }
539
+ const tags = index.index.tagList ? collectTags(posts) : [];
540
+ if (tags.length) {
541
+ const chips = tags.map(({ tag, count }) => renderChip(tag, count, toFilterUrl(blog, "tag", tag)));
542
+ rows.push(...renderFacet("tag", labels.tags, chips));
543
+ }
544
+ const archive = index.index.archive ? collectArchive(posts, timeZone) : [];
545
+ if (archive.reduce((months, year) => months + year.months.length, 0) > 1) {
546
+ const years = archive.map(({ value, count }) => renderChip(value, count, toFilterUrl(blog, "year", value)));
547
+ rows.push(...renderFacet("year", labels.archive, years));
548
+ for (const year of archive) {
549
+ const months = year.months.map(
550
+ ({ value, count }) => renderChip(
551
+ formatMonth(value, locale, { month: "short" }),
552
+ count,
553
+ toFilterUrl(blog, "month", value),
554
+ formatMonth(value, locale, { month: "long", year: "numeric" })
555
+ )
556
+ );
557
+ const shown = archive.length === 1 ? "" : " hidden";
558
+ rows.push(
559
+ ...renderFacet(
560
+ "month",
561
+ year.value,
562
+ months,
563
+ `${labels.archive} ${year.value}`,
564
+ ` data-year="${year.value}"${shown}`
565
+ )
566
+ );
567
+ }
568
+ }
569
+ if (rows.length === 0) return [];
570
+ return [`<nav class="vp-blog-browse" aria-label="${escapeHtml(labels.browse)}">`, ...rows, "</nav>"];
571
+ }
461
572
  function renderClientAttributes(index, context) {
462
573
  const { labels } = context;
463
574
  const attributes = {
464
575
  "data-page-size": String(index.index.pageSize),
465
- "data-label-tagged": labels.tagged,
576
+ // For writing a `?month=` filter's name: "October 2026".
577
+ "data-locale": context.locale,
466
578
  "data-label-show-all": labels.showAll,
467
- "data-label-no-tagged": labels.noTagged,
468
579
  "data-label-previous": labels.previous,
469
580
  "data-label-next": labels.next,
470
- "data-label-pages": labels.pages
581
+ "data-label-pages": labels.pages,
582
+ "data-label-announcements": labels.announcements,
583
+ "data-label-category": labels.category,
584
+ "data-label-tag": labels.tag,
585
+ "data-label-post-count": labels.postCount,
586
+ "data-label-post-count-one": labels.postCountOne,
587
+ "data-label-no-matches": labels.noMatches,
588
+ "data-label-remove-filter": labels.removeFilter
471
589
  };
472
590
  return Object.entries(attributes).map(([name, value]) => `${name}="${escapeHtml(value)}"`).join(" ");
473
591
  }
@@ -478,17 +596,16 @@ function renderPostList(pinned, rest, index, context) {
478
596
  const lines = [
479
597
  `<div class="vp-blog" data-vp-blog ${renderClientAttributes(index, context)}>`,
480
598
  ...block("vp-blog-subscribe", renderRssLink(index, context)),
481
- ...renderTagList(all, index, context),
482
- '<div class="vp-blog-filter" data-vp-blog-filter hidden></div>'
599
+ ...renderBrowse(all, index, context),
600
+ '<div class="vp-blog-filter" data-vp-blog-filter role="status" hidden></div>'
483
601
  ];
484
602
  if (pinned.length)
485
603
  lines.push(
486
- '<section class="vp-blog-sticky" data-vp-blog-sticky>',
604
+ '<section class="vp-blog-pinned" data-vp-blog-pinned>',
487
605
  ...renderGroup(pinned, index, context),
488
606
  "</section>"
489
607
  );
490
- if (rest.length)
491
- lines.push(...index.index.groupByYear ? renderYears(rest, index, context) : renderGroup(rest, index, context));
608
+ if (rest.length) lines.push(...renderGroups(rest, index, context));
492
609
  lines.push(`<nav class="vp-blog-pagination" data-vp-blog-pagination hidden></nav>`, "</div>");
493
610
  return `${lines.join("\n")}
494
611
  `;
@@ -497,8 +614,12 @@ function renderPostHeader(post, context, show, blogRoutePath = null) {
497
614
  const lines = [];
498
615
  if (show.byline) {
499
616
  const meta = renderMeta(post, context, { authors: true, updated: true, badges: true });
500
- const tags = post.post.tags.length ? `<span class="vp-blog-byline__tags">${renderTags(post.post.tags, blogRoutePath)}</span>` : "";
501
- lines.push(...block("vp-blog-byline", [meta, tags].filter(Boolean).join("\n\n")));
617
+ const { category, tags } = post.post;
618
+ const chips = [
619
+ category && `<span class="vp-blog-byline__category">${renderFilterLink(category, "category", blogRoutePath, "vp-blog-category")}</span>`,
620
+ tags.length && `<span class="vp-blog-byline__tags">${renderTags(tags, blogRoutePath)}</span>`
621
+ ].filter(Boolean).join(" ");
622
+ lines.push(...block("vp-blog-byline", [meta, chips].filter(Boolean).join("\n\n")));
502
623
  }
503
624
  if (show.cover && post.post.image) {
504
625
  const image = /^[./@~]/.test(post.post.image) || isExternalUrl(post.post.image) ? post.post.image : `./${post.post.image}`;
@@ -638,6 +759,7 @@ function resolvePostData(data, content, fallbackTitle, options = {}) {
638
759
  const timeZone = resolveTimeZone(options.timeZone);
639
760
  const date = parsePostDate(pick("date"), timeZone);
640
761
  const updated = parsePostDate(pick("updated"), timeZone);
762
+ const announcementText = typeof post.announcement === "string" ? clean(post.announcement.trim()) : "";
641
763
  return {
642
764
  title: clean(toText(post.title) || toText(data.title) || readHeading(content)) || fallbackTitle,
643
765
  summary: clean(toText(post.summary) || toText(data.description) || readExcerpt(content)),
@@ -653,8 +775,11 @@ function resolvePostData(data, content, fallbackTitle, options = {}) {
653
775
  tags: toStringList(pick("tags")),
654
776
  category: toText(pick("category")),
655
777
  scope: toScope(post.scope),
656
- sticky: post.sticky === true,
778
+ // `sticky` is this setting's old name.
779
+ pinned: (post.pinned ?? post.sticky) === true,
657
780
  draft: post.draft === true,
781
+ announcement: post.announcement === true || !!announcementText,
782
+ announcementText,
658
783
  byline: toBoolean(post.byline),
659
784
  cover: toBoolean(post.cover),
660
785
  postNav: toBoolean(post.postNav),
@@ -664,6 +789,9 @@ function resolvePostData(data, content, fallbackTitle, options = {}) {
664
789
  function toLayout(value) {
665
790
  return value === "grid" || value === "compact" ? value : "list";
666
791
  }
792
+ function toGroupBy(value) {
793
+ return value === "year" || value === "month" ? value : "";
794
+ }
667
795
  function defaultFeedPath(relativePath) {
668
796
  const name = import_node_path3.default.posix.basename(relativePath, ".md");
669
797
  return name === "index" ? "feed.xml" : `${name}-feed.xml`;
@@ -697,13 +825,17 @@ function resolveIndexConfig(data, relativePath, defaults = {}) {
697
825
  scope,
698
826
  folders: folders.length ? resolveFolders(folders, relativePath) : scope === "section" ? [ownFolder] : [],
699
827
  tags: toStringList(options.tags),
828
+ categories: toStringList(options.categories),
700
829
  limit: typeof options.limit === "number" && options.limit > 0 ? Math.floor(options.limit) : 0,
701
830
  layout: toLayout(options.layout),
702
- groupByYear: options.groupBy === "year",
831
+ groupBy: toGroupBy(options.groupBy),
703
832
  showTags: options.showTags !== false,
833
+ showCategory: options.showCategory !== false,
834
+ categoryList: options.categoryList !== false,
704
835
  tagList: options.tagList !== false,
836
+ archive: options.archive !== false,
705
837
  pageSize: toCount(options.pageSize, toCount(defaults.pageSize, 10)),
706
- stickyLimit: toCount(options.stickyLimit, toCount(defaults.stickyLimit, 0)),
838
+ pinLimit: toCount(options.pinLimit ?? options.stickyLimit, toCount(defaults.pinLimit ?? defaults.stickyLimit, 0)),
707
839
  rssLink: typeof options.rssLink === "boolean" ? options.rssLink : defaults.rssLink ?? true,
708
840
  feed: toFeed(options.feed, relativePath)
709
841
  };
@@ -770,7 +902,7 @@ function toOptionsKey(options) {
770
902
  options.wordsPerMinute,
771
903
  options.stripTags,
772
904
  options.pageSize,
773
- options.stickyLimit,
905
+ options.pinLimit ?? options.stickyLimit,
774
906
  options.rssLink,
775
907
  options.timeZone
776
908
  ]);
@@ -850,10 +982,13 @@ function resolveWidgetConfig(body) {
850
982
  config: {
851
983
  blog: typeof data.blog === "string" ? data.blog.trim() : "",
852
984
  limit: toCount2(data.limit) ?? DEFAULT_LIMIT,
853
- stickyLimit: toCount2(data.stickyLimit),
985
+ // `stickyLimit` is this setting's old name.
986
+ pinLimit: toCount2(data.pinLimit ?? data.stickyLimit),
854
987
  layout,
855
988
  showTags: typeof data.showTags === "boolean" ? data.showTags : void 0,
989
+ showCategory: typeof data.showCategory === "boolean" ? data.showCategory : void 0,
856
990
  tags: toStringList(data.tags),
991
+ categories: toStringList(data.categories),
857
992
  more: data.more === false ? null : typeof data.more === "string" && data.more.trim() ? data.more.trim() : "",
858
993
  rssLink: typeof data.rssLink === "boolean" ? data.rssLink : void 0
859
994
  },
@@ -869,19 +1004,19 @@ function renderWidget(body, environment) {
869
1004
  warn(config.blog ? `no blog page found at "${config.blog}"` : 'no blog covers this page: name one with "blog:"');
870
1005
  return "";
871
1006
  }
872
- const stickyLimit = config.stickyLimit ?? blog.index.stickyLimit;
873
- const wanted = new Set(config.tags.map((tag) => tag.toLowerCase()));
874
- const posts = listBlogPosts(pages, blog, { drafts, stickyFirst: false, applyLimit: false }).filter(
875
- (post) => wanted.size === 0 || post.post.tags.some((tag) => wanted.has(tag.toLowerCase()))
1007
+ const pinLimit = config.pinLimit ?? blog.index.pinLimit;
1008
+ const posts = listBlogPosts(pages, blog, { drafts, pinnedFirst: false, applyLimit: false }).filter(
1009
+ (post) => hasAnyTag(post, config.tags) && inAnyCategory(post, config.categories)
876
1010
  );
877
- const { pinned, rest } = pinStickyPosts(posts, stickyLimit);
1011
+ const { pinned, rest } = pinPosts(posts, pinLimit);
878
1012
  const shown = [...pinned, ...rest].slice(0, config.limit || void 0);
879
1013
  const view = {
880
1014
  ...blog,
881
1015
  index: {
882
1016
  ...blog.index,
883
1017
  layout: config.layout ?? blog.index.layout,
884
- showTags: config.showTags ?? blog.index.showTags
1018
+ showTags: config.showTags ?? blog.index.showTags,
1019
+ showCategory: config.showCategory ?? blog.index.showCategory
885
1020
  }
886
1021
  };
887
1022
  const lines = [`<div class="vp-blog-widget">`];
@@ -954,8 +1089,8 @@ function hasFeeds(options) {
954
1089
  }
955
1090
  function withPostList(source, page, pages, options) {
956
1091
  if (!isIndex(page)) return source;
957
- const posts = listBlogPosts(pages, page, { drafts: options.drafts ?? false, stickyFirst: true, applyLimit: true });
958
- const { pinned, rest } = pinStickyPosts(posts, page.index.stickyLimit);
1092
+ const posts = listBlogPosts(pages, page, { drafts: options.drafts ?? false, pinnedFirst: true, applyLimit: true });
1093
+ const { pinned, rest } = pinPosts(posts, page.index.pinLimit);
959
1094
  const context = createRenderContext(options, import_node_path6.default.dirname(page.absolutePath), hasFeeds(options));
960
1095
  const generated = renderPostList(pinned, rest, page, context);
961
1096
  return source.includes(BLOG_POSTS_PLACEHOLDER) ? source.replace(BLOG_POSTS_PLACEHOLDER, () => `
@@ -978,7 +1113,7 @@ function withPostDecorations(source, page, pages, options) {
978
1113
  let result = header ? insertAfterTitle(source, header) : source;
979
1114
  if (page.post.postNav ?? options.postNav ?? true) {
980
1115
  if (blog) {
981
- const posts = listBlogPosts(pages, blog, { drafts, stickyFirst: false, applyLimit: false });
1116
+ const posts = listBlogPosts(pages, blog, { drafts, pinnedFirst: false, applyLimit: false });
982
1117
  result = `${result.replace(/\s*$/, "")}
983
1118
 
984
1119
  ${renderPostNav(page, posts, blog, context)}