@binarynoir/vitepress-blog 0.2.0 → 0.3.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.
- package/README.md +99 -10
- package/dist/chunk-42Z6H7IN.js +52 -0
- package/dist/chunk-42Z6H7IN.js.map +1 -0
- package/dist/{chunk-PWNWSYI4.js → chunk-LYTJBMVK.js} +51 -42
- package/dist/chunk-LYTJBMVK.js.map +1 -0
- package/dist/client.cjs +140 -28
- package/dist/client.cjs.map +1 -1
- package/dist/client.d.cts +35 -4
- package/dist/client.d.ts +35 -4
- package/dist/client.js +136 -26
- package/dist/client.js.map +1 -1
- package/dist/index.cjs +28 -7
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +1 -1
- package/dist/index.d.ts +1 -1
- package/dist/index.js +2 -1
- package/dist/{plugin-BlfpqyX4.d.cts → plugin-Bb4rRwO8.d.cts} +31 -1
- package/dist/{plugin-BlfpqyX4.d.ts → plugin-Bb4rRwO8.d.ts} +31 -1
- package/dist/style.css +113 -0
- package/dist/vitepress.cjs +165 -76
- package/dist/vitepress.cjs.map +1 -1
- package/dist/vitepress.d.cts +17 -7
- package/dist/vitepress.d.ts +17 -7
- package/dist/vitepress.js +75 -7
- package/dist/vitepress.js.map +1 -1
- package/package.json +1 -1
- package/dist/chunk-PWNWSYI4.js.map +0 -1
package/dist/client.cjs.map
CHANGED
|
@@ -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","../src/util.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 { BlogAnnouncementBanner } from './banner.js';\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=`, `?announcements` and `?page=` leave out, and fills in the\n * filter banner and 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 announcements: string;\n noAnnouncements: 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 announcements: dataset.labelAnnouncements ?? 'Announcements',\n noAnnouncements: dataset.labelNoAnnouncements ?? 'No 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 hasTag(marker: Element, tag: string): boolean {\n return !tag || (marker.getAttribute('data-tags') ?? '').split('|').includes(tag);\n}\n\n/** What the URL filters by: a keyword, lowercased, and whether only announcements show. */\ninterface Filter {\n tag: string;\n announcements: boolean;\n}\n\nfunction matches(marker: Element, filter: Filter): boolean {\n return hasTag(marker, filter.tag) && (!filter.announcements || marker.hasAttribute('data-announcement'));\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: Filter, page: number): string {\n const next = new URL(url.href);\n next.hash = '';\n if (filter.tag) next.searchParams.set('tag', filter.tag);\n else next.searchParams.delete('tag');\n if (filter.announcements) next.searchParams.set('announcements', '');\n else next.searchParams.delete('announcements');\n if (page > 1) next.searchParams.set('page', String(page));\n else next.searchParams.delete('page');\n // A bare `?announcements`, not `?announcements=`.\n return next.pathname + next.search.replace(/([?&]announcements)=(?=&|$)/, '$1');\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, filter: Filter, count: number, labels: BlogLabels): void {\n const { tag, announcements } = filter;\n slot.hidden = !tag && !announcements;\n if (slot.hidden) return slot.replaceChildren();\n const message = document.createElement('span');\n if (tag) {\n fillWithTag(message, count ? labels.tagged : labels.noTagged, tag);\n if (announcements) message.prepend(`${labels.announcements} · `);\n } else {\n message.textContent = count ? labels.announcements : labels.noAnnouncements;\n }\n const clear = { tag: '', announcements: false };\n slot.replaceChildren(message, ' ', link(labels.showAll, toHref(url, clear, 1), 'vp-blog-filter__clear'));\n}\n\nfunction renderPagination(\n slot: HTMLElement,\n url: URL,\n filter: Filter,\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 = 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, 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 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 /** Whether only announcements show (`?announcements`). */\n announcements: boolean;\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), only announcements with `?announcements`, and\n * 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 filter: Filter = { tag, announcements: url.searchParams.has('announcements') };\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) && matches(marker, filter));\n const rest = markers.filter((marker) => !isSticky(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 ? 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 banner = blog.querySelector<HTMLElement>('[data-vp-blog-filter]');\n if (banner) renderFilter(banner, url, filter, sticky.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 markActiveTags(blog, tag);\n\n return { tag, announcements: filter.announcements, 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 || previous.announcements !== view.announcements) &&\n blog.getBoundingClientRect().top < 0\n ) {\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","const HTML_ESCAPES: Record<string, string> = {\n '&': '&',\n '<': '<',\n '>': '>',\n '\"': '"',\n \"'\": ''',\n};\n\n/** Escapes the characters that are special in HTML text and attribute values. */\nexport function escapeHtml(value: string): string {\n return value.replace(/[&<>\"']/g, (character) => HTML_ESCAPES[character]);\n}\n\n/**\n * Escapes text so it stays literal in Markdown and in VitePress's Vue\n * templates. Text with braces goes in a `v-pre` span, since Vue would otherwise\n * evaluate `{{ }}` even when the Markdown escaped it.\n */\nexport function escapeMarkdown(text: string): string {\n const escaped = text\n .replace(/[\\\\`*_[\\]#|~]/g, '\\\\$&')\n .replace(/&/g, '&')\n .replace(/</g, '<')\n .replace(/>/g, '>');\n return /[{}]/.test(escaped) ? `<span v-pre>${escaped}</span>` : escaped;\n}\n\n/** HTML text that Vue leaves alone: braces go in a `v-pre` span. */\nexport function escapeTemplateText(text: string): string {\n const escaped = escapeHtml(text);\n return /[{}]/.test(escaped) ? `<span v-pre>${escaped}</span>` : escaped;\n}\n\n/** A URL with a scheme (`https:`, `mailto:`) or protocol-relative (`//cdn`). */\nexport function isExternalUrl(url: string): boolean {\n return /^(?:[a-z][a-z\\d+.-]*:|\\/\\/)/i.test(url);\n}\n\n/** Encodes a URL for a Markdown link destination: spaces and parentheses would end it early. */\nexport function toMarkdownUrl(url: string): string {\n return encodeURI(url).replace(/[()]/g, (character) => (character === '(' ? '%28' : '%29'));\n}\n\n/** A Markdown link to a page by its served path, which VitePress then checks and gives the site's base. */\nexport function toPageLink(text: string, routePath: string): string {\n return `[${escapeMarkdown(text)}](${toMarkdownUrl(`/${routePath}`)})`;\n}\n\n/** A page's URL path, without the site's base: `news/index.md` is `/news/`, `news/launch.md` is `/news/launch`. */\nexport function toUrlPath(routePath: string, cleanUrls: boolean): string {\n return `/${routePath.replace(/(^|\\/)index\\.md$/, '$1').replace(/\\.md$/, cleanUrls ? '' : '.html')}`;\n}\n\n/** `base` and `urlPath` joined with exactly one slash between them. */\nexport function joinUrl(base: string, urlPath: string): string {\n return `${base.replace(/\\/+$/, '')}/${urlPath.replace(/^\\/+/, '')}`;\n}\n\n/** Where the announcement banner's data sits on VitePress's site data: `useData().site.value.blogAnnouncements`. */\nexport const ANNOUNCEMENTS_KEY = 'blogAnnouncements';\n\n/** The `localStorage` key holding the ids of the announcements a visitor dismissed, as a JSON array. */\nexport const DISMISSED_STORAGE_KEY = 'vp-blog-announcements-dismissed';\n\n/** The class on `<html>` that hides a dismissed banner before the page hydrates. */\nexport const DISMISSED_CLASS = 'vp-blog-announcement-dismissed';\n"],"mappings":"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","names":["vitepress","import_vue"]}
|
package/dist/client.d.cts
CHANGED
|
@@ -1,17 +1,48 @@
|
|
|
1
|
+
import * as vue from 'vue';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* The announcement banner: one line across the top of the page, above the
|
|
5
|
+
* nav bar, with the newest announcement linked to its post. With more than
|
|
6
|
+
* one not yet dismissed, a badge counts them and links to the blog page
|
|
7
|
+
* filtered to announcements. Its close button dismisses every current one;
|
|
8
|
+
* a newer announcement brings it back. Put it in the theme's `layout-top` slot:
|
|
9
|
+
*
|
|
10
|
+
* ```ts
|
|
11
|
+
* // .vitepress/theme/index.ts
|
|
12
|
+
* import { h } from 'vue';
|
|
13
|
+
* import DefaultTheme from 'vitepress/theme';
|
|
14
|
+
* import { BlogAnnouncementBanner } from '@binarynoir/vitepress-blog/client';
|
|
15
|
+
*
|
|
16
|
+
* export default {
|
|
17
|
+
* extends: DefaultTheme,
|
|
18
|
+
* Layout: () => h(DefaultTheme.Layout, null, { 'layout-top': () => h(BlogAnnouncementBanner) }),
|
|
19
|
+
* };
|
|
20
|
+
* ```
|
|
21
|
+
*
|
|
22
|
+
* It reads what `blogVitePlugin` (or `withBlog`) puts on the site data, and
|
|
23
|
+
* renders nothing without it.
|
|
24
|
+
*/
|
|
25
|
+
declare const BlogAnnouncementBanner: vue.DefineComponent<{}, () => vue.VNode<vue.RendererNode, vue.RendererElement, {
|
|
26
|
+
[key: string]: any;
|
|
27
|
+
}> | null, {}, {}, {}, vue.ComponentOptionsMixin, vue.ComponentOptionsMixin, {}, string, vue.PublicProps, Readonly<{}> & Readonly<{}>, {}, {}, {}, {}, string, vue.ComponentProvideOptions, true, {}, any>;
|
|
28
|
+
|
|
1
29
|
/**
|
|
2
30
|
* Keyword filtering and pagination for blog pages, in the browser. The build
|
|
3
31
|
* writes every post into the page (see `renderPostList`); this hides the ones
|
|
4
|
-
* the URL's `?tag
|
|
5
|
-
* the page links. Without it, every post simply shows.
|
|
32
|
+
* the URL's `?tag=`, `?announcements` and `?page=` leave out, and fills in the
|
|
33
|
+
* filter banner and the page links. Without it, every post simply shows.
|
|
6
34
|
*/
|
|
7
35
|
interface BlogViewState {
|
|
8
36
|
tag: string;
|
|
37
|
+
/** Whether only announcements show (`?announcements`). */
|
|
38
|
+
announcements: boolean;
|
|
9
39
|
page: number;
|
|
10
40
|
pageCount: number;
|
|
11
41
|
}
|
|
12
42
|
/**
|
|
13
43
|
* Shows the posts of one blog page that `url` asks for: those with its
|
|
14
|
-
* `?tag=` keyword (any case),
|
|
44
|
+
* `?tag=` keyword (any case), only announcements with `?announcements`, and
|
|
45
|
+
* of those, the ones on its `?page=`. Pinned
|
|
15
46
|
* sticky posts only show on the first page and aren't counted in the page
|
|
16
47
|
* size. Returns what is showing.
|
|
17
48
|
*/
|
|
@@ -48,4 +79,4 @@ declare function applyBlogViews(root?: ParentNode, url?: URL): void;
|
|
|
48
79
|
*/
|
|
49
80
|
declare function useBlog(): void;
|
|
50
81
|
|
|
51
|
-
export { type BlogViewState, applyBlogView, applyBlogViews, useBlog };
|
|
82
|
+
export { BlogAnnouncementBanner, type BlogViewState, applyBlogView, applyBlogViews, useBlog };
|
package/dist/client.d.ts
CHANGED
|
@@ -1,17 +1,48 @@
|
|
|
1
|
+
import * as vue from 'vue';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* The announcement banner: one line across the top of the page, above the
|
|
5
|
+
* nav bar, with the newest announcement linked to its post. With more than
|
|
6
|
+
* one not yet dismissed, a badge counts them and links to the blog page
|
|
7
|
+
* filtered to announcements. Its close button dismisses every current one;
|
|
8
|
+
* a newer announcement brings it back. Put it in the theme's `layout-top` slot:
|
|
9
|
+
*
|
|
10
|
+
* ```ts
|
|
11
|
+
* // .vitepress/theme/index.ts
|
|
12
|
+
* import { h } from 'vue';
|
|
13
|
+
* import DefaultTheme from 'vitepress/theme';
|
|
14
|
+
* import { BlogAnnouncementBanner } from '@binarynoir/vitepress-blog/client';
|
|
15
|
+
*
|
|
16
|
+
* export default {
|
|
17
|
+
* extends: DefaultTheme,
|
|
18
|
+
* Layout: () => h(DefaultTheme.Layout, null, { 'layout-top': () => h(BlogAnnouncementBanner) }),
|
|
19
|
+
* };
|
|
20
|
+
* ```
|
|
21
|
+
*
|
|
22
|
+
* It reads what `blogVitePlugin` (or `withBlog`) puts on the site data, and
|
|
23
|
+
* renders nothing without it.
|
|
24
|
+
*/
|
|
25
|
+
declare const BlogAnnouncementBanner: vue.DefineComponent<{}, () => vue.VNode<vue.RendererNode, vue.RendererElement, {
|
|
26
|
+
[key: string]: any;
|
|
27
|
+
}> | null, {}, {}, {}, vue.ComponentOptionsMixin, vue.ComponentOptionsMixin, {}, string, vue.PublicProps, Readonly<{}> & Readonly<{}>, {}, {}, {}, {}, string, vue.ComponentProvideOptions, true, {}, any>;
|
|
28
|
+
|
|
1
29
|
/**
|
|
2
30
|
* Keyword filtering and pagination for blog pages, in the browser. The build
|
|
3
31
|
* writes every post into the page (see `renderPostList`); this hides the ones
|
|
4
|
-
* the URL's `?tag
|
|
5
|
-
* the page links. Without it, every post simply shows.
|
|
32
|
+
* the URL's `?tag=`, `?announcements` and `?page=` leave out, and fills in the
|
|
33
|
+
* filter banner and the page links. Without it, every post simply shows.
|
|
6
34
|
*/
|
|
7
35
|
interface BlogViewState {
|
|
8
36
|
tag: string;
|
|
37
|
+
/** Whether only announcements show (`?announcements`). */
|
|
38
|
+
announcements: boolean;
|
|
9
39
|
page: number;
|
|
10
40
|
pageCount: number;
|
|
11
41
|
}
|
|
12
42
|
/**
|
|
13
43
|
* Shows the posts of one blog page that `url` asks for: those with its
|
|
14
|
-
* `?tag=` keyword (any case),
|
|
44
|
+
* `?tag=` keyword (any case), only announcements with `?announcements`, and
|
|
45
|
+
* of those, the ones on its `?page=`. Pinned
|
|
15
46
|
* sticky posts only show on the first page and aren't counted in the page
|
|
16
47
|
* size. Returns what is showing.
|
|
17
48
|
*/
|
|
@@ -48,4 +79,4 @@ declare function applyBlogViews(root?: ParentNode, url?: URL): void;
|
|
|
48
79
|
*/
|
|
49
80
|
declare function useBlog(): void;
|
|
50
81
|
|
|
51
|
-
export { type BlogViewState, applyBlogView, applyBlogViews, useBlog };
|
|
82
|
+
export { BlogAnnouncementBanner, type BlogViewState, applyBlogView, applyBlogViews, useBlog };
|
package/dist/client.js
CHANGED
|
@@ -1,6 +1,12 @@
|
|
|
1
|
+
import {
|
|
2
|
+
ANNOUNCEMENTS_KEY,
|
|
3
|
+
DISMISSED_CLASS,
|
|
4
|
+
DISMISSED_STORAGE_KEY
|
|
5
|
+
} from "./chunk-42Z6H7IN.js";
|
|
6
|
+
|
|
1
7
|
// src/client.ts
|
|
2
|
-
import * as
|
|
3
|
-
import { nextTick, onMounted, watch } from "vue";
|
|
8
|
+
import * as vitepress2 from "vitepress";
|
|
9
|
+
import { nextTick, onMounted as onMounted2, watch } from "vue";
|
|
4
10
|
|
|
5
11
|
// src/view.ts
|
|
6
12
|
var ITEM = "[data-vp-blog-item]";
|
|
@@ -12,7 +18,9 @@ function readLabels(blog) {
|
|
|
12
18
|
noTagged: dataset.labelNoTagged ?? "No posts tagged {tag}.",
|
|
13
19
|
previous: dataset.labelPrevious ?? "Previous",
|
|
14
20
|
next: dataset.labelNext ?? "Next",
|
|
15
|
-
pages: dataset.labelPages ?? "Pages"
|
|
21
|
+
pages: dataset.labelPages ?? "Pages",
|
|
22
|
+
announcements: dataset.labelAnnouncements ?? "Announcements",
|
|
23
|
+
noAnnouncements: dataset.labelNoAnnouncements ?? "No announcements."
|
|
16
24
|
};
|
|
17
25
|
}
|
|
18
26
|
function itemElement(marker) {
|
|
@@ -21,14 +29,19 @@ function itemElement(marker) {
|
|
|
21
29
|
function hasTag(marker, tag) {
|
|
22
30
|
return !tag || (marker.getAttribute("data-tags") ?? "").split("|").includes(tag);
|
|
23
31
|
}
|
|
24
|
-
function
|
|
32
|
+
function matches(marker, filter) {
|
|
33
|
+
return hasTag(marker, filter.tag) && (!filter.announcements || marker.hasAttribute("data-announcement"));
|
|
34
|
+
}
|
|
35
|
+
function toHref(url, filter, page) {
|
|
25
36
|
const next = new URL(url.href);
|
|
26
37
|
next.hash = "";
|
|
27
|
-
if (tag) next.searchParams.set("tag", tag);
|
|
38
|
+
if (filter.tag) next.searchParams.set("tag", filter.tag);
|
|
28
39
|
else next.searchParams.delete("tag");
|
|
40
|
+
if (filter.announcements) next.searchParams.set("announcements", "");
|
|
41
|
+
else next.searchParams.delete("announcements");
|
|
29
42
|
if (page > 1) next.searchParams.set("page", String(page));
|
|
30
43
|
else next.searchParams.delete("page");
|
|
31
|
-
return next.pathname + next.search;
|
|
44
|
+
return next.pathname + next.search.replace(/([?&]announcements)=(?=&|$)/, "$1");
|
|
32
45
|
}
|
|
33
46
|
function fillWithTag(element, template, tag) {
|
|
34
47
|
const [before, after = ""] = template.split("{tag}");
|
|
@@ -43,19 +56,26 @@ function link(text, href, className) {
|
|
|
43
56
|
anchor.textContent = text;
|
|
44
57
|
return anchor;
|
|
45
58
|
}
|
|
46
|
-
function renderFilter(slot, url,
|
|
47
|
-
|
|
48
|
-
|
|
59
|
+
function renderFilter(slot, url, filter, count, labels) {
|
|
60
|
+
const { tag, announcements } = filter;
|
|
61
|
+
slot.hidden = !tag && !announcements;
|
|
62
|
+
if (slot.hidden) return slot.replaceChildren();
|
|
49
63
|
const message = document.createElement("span");
|
|
50
|
-
|
|
51
|
-
|
|
64
|
+
if (tag) {
|
|
65
|
+
fillWithTag(message, count ? labels.tagged : labels.noTagged, tag);
|
|
66
|
+
if (announcements) message.prepend(`${labels.announcements} \xB7 `);
|
|
67
|
+
} else {
|
|
68
|
+
message.textContent = count ? labels.announcements : labels.noAnnouncements;
|
|
69
|
+
}
|
|
70
|
+
const clear = { tag: "", announcements: false };
|
|
71
|
+
slot.replaceChildren(message, " ", link(labels.showAll, toHref(url, clear, 1), "vp-blog-filter__clear"));
|
|
52
72
|
}
|
|
53
|
-
function renderPagination(slot, url,
|
|
73
|
+
function renderPagination(slot, url, filter, page, pageCount, labels) {
|
|
54
74
|
slot.hidden = pageCount < 2;
|
|
55
75
|
slot.setAttribute("aria-label", labels.pages);
|
|
56
76
|
if (pageCount < 2) return slot.replaceChildren();
|
|
57
77
|
const parts = [];
|
|
58
|
-
if (page > 1) parts.push(link(labels.previous, toHref(url,
|
|
78
|
+
if (page > 1) parts.push(link(labels.previous, toHref(url, filter, page - 1), "vp-blog-pagination__previous"));
|
|
59
79
|
for (let number = 1; number <= pageCount; number++) {
|
|
60
80
|
if (number === page) {
|
|
61
81
|
const current = document.createElement("span");
|
|
@@ -64,10 +84,10 @@ function renderPagination(slot, url, tag, page, pageCount, labels) {
|
|
|
64
84
|
current.textContent = String(number);
|
|
65
85
|
parts.push(current);
|
|
66
86
|
} else {
|
|
67
|
-
parts.push(link(String(number), toHref(url,
|
|
87
|
+
parts.push(link(String(number), toHref(url, filter, number), "vp-blog-pagination__page"));
|
|
68
88
|
}
|
|
69
89
|
}
|
|
70
|
-
if (page < pageCount) parts.push(link(labels.next, toHref(url,
|
|
90
|
+
if (page < pageCount) parts.push(link(labels.next, toHref(url, filter, page + 1), "vp-blog-pagination__next"));
|
|
71
91
|
slot.replaceChildren(...parts);
|
|
72
92
|
}
|
|
73
93
|
function markActiveTags(blog, tag) {
|
|
@@ -86,11 +106,12 @@ function markActiveTags(blog, tag) {
|
|
|
86
106
|
function applyBlogView(blog, url) {
|
|
87
107
|
const labels = readLabels(blog);
|
|
88
108
|
const tag = (url.searchParams.get("tag") ?? "").trim().toLowerCase();
|
|
109
|
+
const filter = { tag, announcements: url.searchParams.has("announcements") };
|
|
89
110
|
const pageSize = Math.max(0, Number(blog.dataset.pageSize) || 0);
|
|
90
111
|
const markers = [...blog.querySelectorAll(ITEM)];
|
|
91
112
|
const isSticky = (marker) => !!marker.closest("[data-vp-blog-sticky]");
|
|
92
|
-
const sticky = markers.filter((marker) => isSticky(marker) &&
|
|
93
|
-
const rest = markers.filter((marker) => !isSticky(marker) &&
|
|
113
|
+
const sticky = markers.filter((marker) => isSticky(marker) && matches(marker, filter));
|
|
114
|
+
const rest = markers.filter((marker) => !isSticky(marker) && matches(marker, filter));
|
|
94
115
|
const pageCount = pageSize ? Math.max(1, Math.ceil(rest.length / pageSize)) : 1;
|
|
95
116
|
const requested = Number.parseInt(url.searchParams.get("page") ?? "1", 10);
|
|
96
117
|
const page = Math.min(pageCount, Math.max(1, Number.isFinite(requested) ? requested : 1));
|
|
@@ -102,12 +123,12 @@ function applyBlogView(blog, url) {
|
|
|
102
123
|
for (const group of blog.querySelectorAll("[data-vp-blog-sticky], .vp-blog-year")) {
|
|
103
124
|
group.hidden = ![...group.querySelectorAll(ITEM)].some((marker) => shown.has(marker));
|
|
104
125
|
}
|
|
105
|
-
const
|
|
106
|
-
if (
|
|
126
|
+
const banner = blog.querySelector("[data-vp-blog-filter]");
|
|
127
|
+
if (banner) renderFilter(banner, url, filter, sticky.length + rest.length, labels);
|
|
107
128
|
const pagination = blog.querySelector("[data-vp-blog-pagination]");
|
|
108
|
-
if (pagination) renderPagination(pagination, url,
|
|
129
|
+
if (pagination) renderPagination(pagination, url, filter, page, pageCount, labels);
|
|
109
130
|
markActiveTags(blog, tag);
|
|
110
|
-
return { tag, page, pageCount };
|
|
131
|
+
return { tag, announcements: filter.announcements, page, pageCount };
|
|
111
132
|
}
|
|
112
133
|
var lastViews = /* @__PURE__ */ new WeakMap();
|
|
113
134
|
function applyBlogViews(root = document, url = new URL(location.href)) {
|
|
@@ -115,23 +136,112 @@ function applyBlogViews(root = document, url = new URL(location.href)) {
|
|
|
115
136
|
const previous = lastViews.get(blog);
|
|
116
137
|
const view = applyBlogView(blog, url);
|
|
117
138
|
lastViews.set(blog, view);
|
|
118
|
-
if (previous && (previous.page !== view.page || previous.tag !== view.tag) && blog.getBoundingClientRect().top < 0) {
|
|
139
|
+
if (previous && (previous.page !== view.page || previous.tag !== view.tag || previous.announcements !== view.announcements) && blog.getBoundingClientRect().top < 0) {
|
|
119
140
|
blog.scrollIntoView({ block: "start" });
|
|
120
141
|
}
|
|
121
142
|
}
|
|
122
143
|
}
|
|
123
144
|
|
|
145
|
+
// src/banner.ts
|
|
146
|
+
import * as vitepress from "vitepress";
|
|
147
|
+
import { computed, defineComponent, h, onMounted, ref } from "vue";
|
|
148
|
+
var closeIcon = () => h("svg", { viewBox: "0 0 24 24", width: 16, height: 16, "aria-hidden": "true" }, [
|
|
149
|
+
h("path", {
|
|
150
|
+
fill: "currentColor",
|
|
151
|
+
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"
|
|
152
|
+
})
|
|
153
|
+
]);
|
|
154
|
+
function readDismissed() {
|
|
155
|
+
try {
|
|
156
|
+
const value = JSON.parse(localStorage.getItem(DISMISSED_STORAGE_KEY) ?? "[]");
|
|
157
|
+
return Array.isArray(value) ? value.filter((id) => typeof id === "string") : [];
|
|
158
|
+
} catch {
|
|
159
|
+
return [];
|
|
160
|
+
}
|
|
161
|
+
}
|
|
162
|
+
function writeDismissed(ids) {
|
|
163
|
+
try {
|
|
164
|
+
localStorage.setItem(DISMISSED_STORAGE_KEY, JSON.stringify(ids));
|
|
165
|
+
} catch {
|
|
166
|
+
}
|
|
167
|
+
}
|
|
168
|
+
var BlogAnnouncementBanner = defineComponent({
|
|
169
|
+
name: "BlogAnnouncementBanner",
|
|
170
|
+
setup() {
|
|
171
|
+
if (typeof vitepress.useData !== "function") return () => null;
|
|
172
|
+
const { site } = vitepress.useData();
|
|
173
|
+
const data = computed(
|
|
174
|
+
() => site.value[ANNOUNCEMENTS_KEY]
|
|
175
|
+
);
|
|
176
|
+
const dismissed = ref(null);
|
|
177
|
+
onMounted(() => {
|
|
178
|
+
dismissed.value = readDismissed();
|
|
179
|
+
});
|
|
180
|
+
const latest = computed(() => data.value?.items[0]);
|
|
181
|
+
const active = computed(() => (data.value?.items ?? []).filter((item) => !dismissed.value?.includes(item.id)));
|
|
182
|
+
const dismiss = () => {
|
|
183
|
+
const ids = (data.value?.items ?? []).map((item) => item.id);
|
|
184
|
+
dismissed.value = ids;
|
|
185
|
+
writeDismissed(ids);
|
|
186
|
+
document.documentElement.classList.add(DISMISSED_CLASS);
|
|
187
|
+
};
|
|
188
|
+
return () => {
|
|
189
|
+
const announcement = latest.value;
|
|
190
|
+
if (!data.value || !announcement || dismissed.value?.includes(announcement.id)) return null;
|
|
191
|
+
const { labels, listLink } = data.value;
|
|
192
|
+
const count = active.value.length;
|
|
193
|
+
const countLabel = labels.announcementCount.replace("{n}", String(count));
|
|
194
|
+
const badge = count > 1 ? listLink ? h(
|
|
195
|
+
"a",
|
|
196
|
+
{
|
|
197
|
+
class: "vp-blog-announcement__count",
|
|
198
|
+
href: vitepress.withBase(listLink),
|
|
199
|
+
title: countLabel,
|
|
200
|
+
"aria-label": countLabel
|
|
201
|
+
},
|
|
202
|
+
String(count)
|
|
203
|
+
) : h(
|
|
204
|
+
"span",
|
|
205
|
+
{ class: "vp-blog-announcement__count", title: countLabel, "aria-label": countLabel },
|
|
206
|
+
String(count)
|
|
207
|
+
) : null;
|
|
208
|
+
return h("div", { class: "vp-blog-announcement", role: "region", "aria-label": labels.announcement }, [
|
|
209
|
+
h("div", { class: "vp-blog-announcement__content" }, [
|
|
210
|
+
h(
|
|
211
|
+
"a",
|
|
212
|
+
{ class: "vp-blog-announcement__text", href: vitepress.withBase(announcement.link) },
|
|
213
|
+
announcement.text
|
|
214
|
+
),
|
|
215
|
+
badge
|
|
216
|
+
]),
|
|
217
|
+
h(
|
|
218
|
+
"button",
|
|
219
|
+
{
|
|
220
|
+
class: "vp-blog-announcement__dismiss",
|
|
221
|
+
type: "button",
|
|
222
|
+
title: labels.dismiss,
|
|
223
|
+
"aria-label": labels.dismiss,
|
|
224
|
+
onClick: dismiss
|
|
225
|
+
},
|
|
226
|
+
[closeIcon()]
|
|
227
|
+
)
|
|
228
|
+
]);
|
|
229
|
+
};
|
|
230
|
+
}
|
|
231
|
+
});
|
|
232
|
+
|
|
124
233
|
// src/client.ts
|
|
125
234
|
function useBlog() {
|
|
126
|
-
if (!
|
|
127
|
-
const route =
|
|
235
|
+
if (!vitepress2.inBrowser) return;
|
|
236
|
+
const route = vitepress2.useRoute();
|
|
128
237
|
const apply = () => nextTick(() => applyBlogViews());
|
|
129
|
-
|
|
238
|
+
onMounted2(() => {
|
|
130
239
|
watch(() => [route.path, route.query], apply, { immediate: true });
|
|
131
240
|
});
|
|
132
|
-
|
|
241
|
+
vitepress2.onContentUpdated(apply);
|
|
133
242
|
}
|
|
134
243
|
export {
|
|
244
|
+
BlogAnnouncementBanner,
|
|
135
245
|
applyBlogView,
|
|
136
246
|
applyBlogViews,
|
|
137
247
|
useBlog
|
package/dist/client.js.map
CHANGED
|
@@ -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 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 { BlogAnnouncementBanner } from './banner.js';\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=`, `?announcements` and `?page=` leave out, and fills in the\n * filter banner and 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 announcements: string;\n noAnnouncements: 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 announcements: dataset.labelAnnouncements ?? 'Announcements',\n noAnnouncements: dataset.labelNoAnnouncements ?? 'No 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 hasTag(marker: Element, tag: string): boolean {\n return !tag || (marker.getAttribute('data-tags') ?? '').split('|').includes(tag);\n}\n\n/** What the URL filters by: a keyword, lowercased, and whether only announcements show. */\ninterface Filter {\n tag: string;\n announcements: boolean;\n}\n\nfunction matches(marker: Element, filter: Filter): boolean {\n return hasTag(marker, filter.tag) && (!filter.announcements || marker.hasAttribute('data-announcement'));\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: Filter, page: number): string {\n const next = new URL(url.href);\n next.hash = '';\n if (filter.tag) next.searchParams.set('tag', filter.tag);\n else next.searchParams.delete('tag');\n if (filter.announcements) next.searchParams.set('announcements', '');\n else next.searchParams.delete('announcements');\n if (page > 1) next.searchParams.set('page', String(page));\n else next.searchParams.delete('page');\n // A bare `?announcements`, not `?announcements=`.\n return next.pathname + next.search.replace(/([?&]announcements)=(?=&|$)/, '$1');\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, filter: Filter, count: number, labels: BlogLabels): void {\n const { tag, announcements } = filter;\n slot.hidden = !tag && !announcements;\n if (slot.hidden) return slot.replaceChildren();\n const message = document.createElement('span');\n if (tag) {\n fillWithTag(message, count ? labels.tagged : labels.noTagged, tag);\n if (announcements) message.prepend(`${labels.announcements} · `);\n } else {\n message.textContent = count ? labels.announcements : labels.noAnnouncements;\n }\n const clear = { tag: '', announcements: false };\n slot.replaceChildren(message, ' ', link(labels.showAll, toHref(url, clear, 1), 'vp-blog-filter__clear'));\n}\n\nfunction renderPagination(\n slot: HTMLElement,\n url: URL,\n filter: Filter,\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 = 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, 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 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 /** Whether only announcements show (`?announcements`). */\n announcements: boolean;\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), only announcements with `?announcements`, and\n * 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 filter: Filter = { tag, announcements: url.searchParams.has('announcements') };\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) && matches(marker, filter));\n const rest = markers.filter((marker) => !isSticky(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 ? 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 banner = blog.querySelector<HTMLElement>('[data-vp-blog-filter]');\n if (banner) renderFilter(banner, url, filter, sticky.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 markActiveTags(blog, tag);\n\n return { tag, announcements: filter.announcements, 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 || previous.announcements !== view.announcements) &&\n blog.getBoundingClientRect().top < 0\n ) {\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","onMounted"]}
|
package/dist/index.cjs
CHANGED
|
@@ -259,6 +259,11 @@ var DEFAULT_LABELS = {
|
|
|
259
259
|
allPosts: "All posts in {blog}",
|
|
260
260
|
draft: "Draft",
|
|
261
261
|
sticky: "Pinned",
|
|
262
|
+
announcement: "Announcement",
|
|
263
|
+
announcements: "Announcements",
|
|
264
|
+
noAnnouncements: "No announcements.",
|
|
265
|
+
announcementCount: "{n} announcements",
|
|
266
|
+
dismiss: "Dismiss",
|
|
262
267
|
noPosts: "No posts yet.",
|
|
263
268
|
undated: "Undated",
|
|
264
269
|
tags: "Keywords",
|
|
@@ -339,12 +344,22 @@ function renderTags(tags, blogRoutePath) {
|
|
|
339
344
|
function toTagsAttribute(post) {
|
|
340
345
|
return escapeHtml(post.post.tags.map((tag) => tag.toLowerCase()).join("|"));
|
|
341
346
|
}
|
|
347
|
+
function toItemAttributes(post) {
|
|
348
|
+
return `data-vp-blog-item data-tags="${toTagsAttribute(post)}"${post.post.announcement ? " data-announcement" : ""}`;
|
|
349
|
+
}
|
|
350
|
+
function renderBadges(post, context) {
|
|
351
|
+
const { draft, sticky, announcement } = post.post;
|
|
352
|
+
return [
|
|
353
|
+
draft && renderBadge("draft", context),
|
|
354
|
+
sticky && renderBadge("sticky", context),
|
|
355
|
+
announcement && renderBadge("announcement", context)
|
|
356
|
+
].filter(Boolean).join(" ");
|
|
357
|
+
}
|
|
342
358
|
function renderMeta(post, context, include) {
|
|
343
|
-
const { date, dateHasTime, updated, updatedHasTime, readingMinutes, authors
|
|
359
|
+
const { date, dateHasTime, updated, updatedHasTime, readingMinutes, authors } = post.post;
|
|
344
360
|
const parts = [];
|
|
345
361
|
if (include.badges) {
|
|
346
|
-
const
|
|
347
|
-
const shown = badges.filter(Boolean).join(" ");
|
|
362
|
+
const shown = renderBadges(post, context);
|
|
348
363
|
if (shown) parts.push(shown);
|
|
349
364
|
}
|
|
350
365
|
if (date) {
|
|
@@ -373,12 +388,13 @@ function renderCard(post, context, index) {
|
|
|
373
388
|
const modifiers = [
|
|
374
389
|
post.post.sticky && "sticky",
|
|
375
390
|
post.post.draft && "draft",
|
|
391
|
+
post.post.announcement && "announcement",
|
|
376
392
|
image && "with-image",
|
|
377
393
|
image && post.post.imageFit === "contain" && "contain"
|
|
378
394
|
].filter(Boolean).map((modifier) => ` vp-blog-card--${modifier}`).join("");
|
|
379
395
|
const alt = escapeMarkdown(post.post.imageAlt);
|
|
380
396
|
return [
|
|
381
|
-
`<article class="vp-blog-card${modifiers}"${imageStyle(post)}
|
|
397
|
+
`<article class="vp-blog-card${modifiers}"${imageStyle(post)} ${toItemAttributes(post)}>`,
|
|
382
398
|
...image ? block("vp-blog-card__image", `[})](${toMarkdownUrl(link)})`) : [],
|
|
383
399
|
'<div class="vp-blog-card__body">',
|
|
384
400
|
...block("vp-blog-card__title", toPageLink(post.post.title, post.routePath)),
|
|
@@ -391,8 +407,8 @@ function renderCard(post, context, index) {
|
|
|
391
407
|
}
|
|
392
408
|
function renderCompactItem(post, context) {
|
|
393
409
|
const date = post.post.date ? `${renderTime(post.post.date, post.post.dateHasTime, context)} ` : "";
|
|
394
|
-
const badges =
|
|
395
|
-
const marker = `<span hidden
|
|
410
|
+
const badges = renderBadges(post, context);
|
|
411
|
+
const marker = `<span hidden ${toItemAttributes(post)}></span>`;
|
|
396
412
|
return `- ${marker}${date}${toPageLink(post.post.title, post.routePath)}${badges ? ` ${badges}` : ""}`;
|
|
397
413
|
}
|
|
398
414
|
function renderGroup(posts, index, context) {
|
|
@@ -467,7 +483,9 @@ function renderClientAttributes(index, context) {
|
|
|
467
483
|
"data-label-no-tagged": labels.noTagged,
|
|
468
484
|
"data-label-previous": labels.previous,
|
|
469
485
|
"data-label-next": labels.next,
|
|
470
|
-
"data-label-pages": labels.pages
|
|
486
|
+
"data-label-pages": labels.pages,
|
|
487
|
+
"data-label-announcements": labels.announcements,
|
|
488
|
+
"data-label-no-announcements": labels.noAnnouncements
|
|
471
489
|
};
|
|
472
490
|
return Object.entries(attributes).map(([name, value]) => `${name}="${escapeHtml(value)}"`).join(" ");
|
|
473
491
|
}
|
|
@@ -638,6 +656,7 @@ function resolvePostData(data, content, fallbackTitle, options = {}) {
|
|
|
638
656
|
const timeZone = resolveTimeZone(options.timeZone);
|
|
639
657
|
const date = parsePostDate(pick("date"), timeZone);
|
|
640
658
|
const updated = parsePostDate(pick("updated"), timeZone);
|
|
659
|
+
const announcementText = typeof post.announcement === "string" ? clean(post.announcement.trim()) : "";
|
|
641
660
|
return {
|
|
642
661
|
title: clean(toText(post.title) || toText(data.title) || readHeading(content)) || fallbackTitle,
|
|
643
662
|
summary: clean(toText(post.summary) || toText(data.description) || readExcerpt(content)),
|
|
@@ -655,6 +674,8 @@ function resolvePostData(data, content, fallbackTitle, options = {}) {
|
|
|
655
674
|
scope: toScope(post.scope),
|
|
656
675
|
sticky: post.sticky === true,
|
|
657
676
|
draft: post.draft === true,
|
|
677
|
+
announcement: post.announcement === true || !!announcementText,
|
|
678
|
+
announcementText,
|
|
658
679
|
byline: toBoolean(post.byline),
|
|
659
680
|
cover: toBoolean(post.cover),
|
|
660
681
|
postNav: toBoolean(post.postNav),
|