@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.
package/dist/client.d.cts CHANGED
@@ -1,29 +1,72 @@
1
+ import * as vue from 'vue';
2
+
1
3
  /**
2
- * Keyword filtering and pagination for blog pages, in the browser. The build
3
- * writes every post into the page (see `renderPostList`); this hides the ones
4
- * the URL's `?tag=` and `?page=` leave out, and fills in the filter banner and
5
- * the page links. Without it, every post simply shows.
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
+
29
+ /**
30
+ * Filtering and pagination for blog pages, in the browser. The build writes
31
+ * every post into the page (see `renderPostList`); this hides the ones the
32
+ * URL's `?category=`, `?tag=`, `?year=`, `?month=`, `?announcements` and
33
+ * `?page=` leave out, keeps the browse panel's chips and counts in step with
34
+ * them, and fills in the active filters and the page links. Without it, every
35
+ * post simply shows.
36
+ */
37
+ /**
38
+ * What the URL filters by. Every filter must match. The category and keyword
39
+ * are lowercased; `date` is `''`, a year (`2026`) or a month (`2026-10`).
6
40
  */
7
- interface BlogViewState {
41
+ interface BlogFilter {
42
+ category: string;
8
43
  tag: string;
44
+ date: string;
45
+ /** Whether only announcements show (`?announcements`). */
46
+ announcements: boolean;
47
+ }
48
+ /** Reads the filter from `?category=`, `?tag=`, `?year=` and `?month=`. A month names its year too, so it wins over `?year=`. */
49
+ declare function readFilter(url: URL): BlogFilter;
50
+ interface BlogViewState extends BlogFilter {
9
51
  page: number;
10
52
  pageCount: number;
11
53
  }
12
54
  /**
13
- * Shows the posts of one blog page that `url` asks for: those with its
14
- * `?tag=` keyword (any case), and of those, the ones on its `?page=`. Pinned
15
- * sticky posts only show on the first page and aren't counted in the page
16
- * size. Returns what is showing.
55
+ * Shows the posts of one blog page that `url` asks for: those matching every
56
+ * filter it gives (category and keyword in any case, a year or a month, only
57
+ * announcements), and of those, the ones on its `?page=`. Pinned posts
58
+ * only show on the first page and aren't counted in the page size. Returns
59
+ * what is showing.
17
60
  */
18
61
  declare function applyBlogView(blog: HTMLElement, url: URL): BlogViewState;
19
62
  /**
20
- * Applies `url` to every blog page in `root`. When the page or keyword of a
63
+ * Applies `url` to every blog page in `root`. When the page or a filter of a
21
64
  * blog already on screen changes, it is scrolled back to its top.
22
65
  */
23
66
  declare function applyBlogViews(root?: ParentNode, url?: URL): void;
24
67
 
25
68
  /**
26
- * Turns on keyword filtering (`?tag=`) and pagination (`?page=`) on blog
69
+ * Turns on filtering (`?category=`, `?tag=`, `?year=`, `?month=`) and pagination (`?page=`) on blog
27
70
  * pages. Call it from your theme's `setup()`:
28
71
  *
29
72
  * ```ts
@@ -40,7 +83,7 @@ declare function applyBlogViews(root?: ParentNode, url?: URL): void;
40
83
  * };
41
84
  * ```
42
85
  *
43
- * Keyword and page links are ordinary links: VitePress's router follows them
86
+ * Filter and page links are ordinary links: VitePress's router follows them
44
87
  * without reloading the page, and this re-applies the view whenever the path
45
88
  * or query string changes. It runs after the page has hydrated, so the
46
89
  * server-rendered HTML (every post) and the first client render agree.
@@ -48,4 +91,4 @@ declare function applyBlogViews(root?: ParentNode, url?: URL): void;
48
91
  */
49
92
  declare function useBlog(): void;
50
93
 
51
- export { type BlogViewState, applyBlogView, applyBlogViews, useBlog };
94
+ export { BlogAnnouncementBanner, type BlogFilter, type BlogViewState, applyBlogView, applyBlogViews, readFilter, useBlog };
package/dist/client.d.ts CHANGED
@@ -1,29 +1,72 @@
1
+ import * as vue from 'vue';
2
+
1
3
  /**
2
- * Keyword filtering and pagination for blog pages, in the browser. The build
3
- * writes every post into the page (see `renderPostList`); this hides the ones
4
- * the URL's `?tag=` and `?page=` leave out, and fills in the filter banner and
5
- * the page links. Without it, every post simply shows.
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
+
29
+ /**
30
+ * Filtering and pagination for blog pages, in the browser. The build writes
31
+ * every post into the page (see `renderPostList`); this hides the ones the
32
+ * URL's `?category=`, `?tag=`, `?year=`, `?month=`, `?announcements` and
33
+ * `?page=` leave out, keeps the browse panel's chips and counts in step with
34
+ * them, and fills in the active filters and the page links. Without it, every
35
+ * post simply shows.
36
+ */
37
+ /**
38
+ * What the URL filters by. Every filter must match. The category and keyword
39
+ * are lowercased; `date` is `''`, a year (`2026`) or a month (`2026-10`).
6
40
  */
7
- interface BlogViewState {
41
+ interface BlogFilter {
42
+ category: string;
8
43
  tag: string;
44
+ date: string;
45
+ /** Whether only announcements show (`?announcements`). */
46
+ announcements: boolean;
47
+ }
48
+ /** Reads the filter from `?category=`, `?tag=`, `?year=` and `?month=`. A month names its year too, so it wins over `?year=`. */
49
+ declare function readFilter(url: URL): BlogFilter;
50
+ interface BlogViewState extends BlogFilter {
9
51
  page: number;
10
52
  pageCount: number;
11
53
  }
12
54
  /**
13
- * Shows the posts of one blog page that `url` asks for: those with its
14
- * `?tag=` keyword (any case), and of those, the ones on its `?page=`. Pinned
15
- * sticky posts only show on the first page and aren't counted in the page
16
- * size. Returns what is showing.
55
+ * Shows the posts of one blog page that `url` asks for: those matching every
56
+ * filter it gives (category and keyword in any case, a year or a month, only
57
+ * announcements), and of those, the ones on its `?page=`. Pinned posts
58
+ * only show on the first page and aren't counted in the page size. Returns
59
+ * what is showing.
17
60
  */
18
61
  declare function applyBlogView(blog: HTMLElement, url: URL): BlogViewState;
19
62
  /**
20
- * Applies `url` to every blog page in `root`. When the page or keyword of a
63
+ * Applies `url` to every blog page in `root`. When the page or a filter of a
21
64
  * blog already on screen changes, it is scrolled back to its top.
22
65
  */
23
66
  declare function applyBlogViews(root?: ParentNode, url?: URL): void;
24
67
 
25
68
  /**
26
- * Turns on keyword filtering (`?tag=`) and pagination (`?page=`) on blog
69
+ * Turns on filtering (`?category=`, `?tag=`, `?year=`, `?month=`) and pagination (`?page=`) on blog
27
70
  * pages. Call it from your theme's `setup()`:
28
71
  *
29
72
  * ```ts
@@ -40,7 +83,7 @@ declare function applyBlogViews(root?: ParentNode, url?: URL): void;
40
83
  * };
41
84
  * ```
42
85
  *
43
- * Keyword and page links are ordinary links: VitePress's router follows them
86
+ * Filter and page links are ordinary links: VitePress's router follows them
44
87
  * without reloading the page, and this re-applies the view whenever the path
45
88
  * or query string changes. It runs after the page has hydrated, so the
46
89
  * server-rendered HTML (every post) and the first client render agree.
@@ -48,4 +91,4 @@ declare function applyBlogViews(root?: ParentNode, url?: URL): void;
48
91
  */
49
92
  declare function useBlog(): void;
50
93
 
51
- export { type BlogViewState, applyBlogView, applyBlogViews, useBlog };
94
+ export { BlogAnnouncementBanner, type BlogFilter, type BlogViewState, applyBlogView, applyBlogViews, readFilter, useBlog };
package/dist/client.js CHANGED
@@ -1,139 +1,352 @@
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 vitepress from "vitepress";
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]";
13
+ var FILTER_LINKS = ".vp-blog-facet a, .vp-blog-card__category a, .vp-blog-card__tags a";
7
14
  function readLabels(blog) {
8
15
  const { dataset } = blog;
9
16
  return {
10
- tagged: dataset.labelTagged ?? "Posts tagged {tag}",
11
17
  showAll: dataset.labelShowAll ?? "Show all posts",
12
- noTagged: dataset.labelNoTagged ?? "No posts tagged {tag}.",
13
18
  previous: dataset.labelPrevious ?? "Previous",
14
19
  next: dataset.labelNext ?? "Next",
15
- pages: dataset.labelPages ?? "Pages"
20
+ pages: dataset.labelPages ?? "Pages",
21
+ announcements: dataset.labelAnnouncements ?? "Announcements",
22
+ category: dataset.labelCategory ?? "Category",
23
+ tag: dataset.labelTag ?? "Keyword",
24
+ postCount: dataset.labelPostCount ?? "{n} posts",
25
+ postCountOne: dataset.labelPostCountOne ?? "{n} post",
26
+ noMatches: dataset.labelNoMatches ?? "No posts match these filters.",
27
+ removeFilter: dataset.labelRemoveFilter ?? "Remove filter: {filter}"
28
+ };
29
+ }
30
+ function readFilter(url) {
31
+ const text = (name) => (url.searchParams.get(name) ?? "").trim().toLowerCase();
32
+ const month = text("month");
33
+ const year = text("year");
34
+ return {
35
+ category: text("category"),
36
+ tag: text("tag"),
37
+ date: /^\d{4}-(?:0[1-9]|1[0-2])$/.test(month) ? month : /^\d{4}$/.test(year) ? year : "",
38
+ announcements: url.searchParams.has("announcements")
16
39
  };
17
40
  }
18
41
  function itemElement(marker) {
19
42
  return marker.tagName === "ARTICLE" ? marker : marker.closest("li") ?? marker;
20
43
  }
21
- function hasTag(marker, tag) {
22
- return !tag || (marker.getAttribute("data-tags") ?? "").split("|").includes(tag);
44
+ function matches(marker, filter) {
45
+ const { category, tag, date, announcements } = filter;
46
+ return (!tag || (marker.getAttribute("data-tags") ?? "").split("|").includes(tag)) && (!category || marker.getAttribute("data-category") === category) && // `2026` matches every month of 2026; `2026-10` only October.
47
+ (!date || (marker.getAttribute("data-month") ?? "").startsWith(date)) && (!announcements || marker.hasAttribute("data-announcement"));
23
48
  }
24
- function toHref(url, tag, page) {
49
+ function toHref(url, filter, page = 1) {
25
50
  const next = new URL(url.href);
26
51
  next.hash = "";
27
- if (tag) next.searchParams.set("tag", tag);
28
- else next.searchParams.delete("tag");
29
- if (page > 1) next.searchParams.set("page", String(page));
30
- else next.searchParams.delete("page");
31
- return next.pathname + next.search;
32
- }
33
- function fillWithTag(element, template, tag) {
34
- const [before, after = ""] = template.split("{tag}");
35
- const strong = document.createElement("strong");
36
- strong.textContent = tag;
37
- element.replaceChildren(document.createTextNode(before), ...template.includes("{tag}") ? [strong] : [], after);
52
+ const set = (name, value) => value ? next.searchParams.set(name, value) : next.searchParams.delete(name);
53
+ set("category", filter.category);
54
+ set("tag", filter.tag);
55
+ set("year", filter.date.length === 4 ? filter.date : "");
56
+ set("month", filter.date.length === 7 ? filter.date : "");
57
+ if (filter.announcements) next.searchParams.set("announcements", "");
58
+ else next.searchParams.delete("announcements");
59
+ set("page", page > 1 ? String(page) : "");
60
+ return next.pathname + next.search.replace(/([?&]announcements)=(?=&|$)/, "$1");
61
+ }
62
+ function readFilterLink(anchor) {
63
+ if (!anchor.dataset.vpBlogFacet) {
64
+ let params;
65
+ try {
66
+ params = new URL(anchor.href, location.href).searchParams;
67
+ } catch {
68
+ return null;
69
+ }
70
+ const param = ["category", "tag", "month", "year"].find((name) => params.has(name));
71
+ if (!param) return null;
72
+ anchor.dataset.vpBlogFacet = param === "year" || param === "month" ? "date" : param;
73
+ anchor.dataset.vpBlogValue = (params.get(param) ?? "").trim().toLowerCase();
74
+ }
75
+ return { facet: anchor.dataset.vpBlogFacet, value: anchor.dataset.vpBlogValue ?? "" };
76
+ }
77
+ function withValue(filter, { facet, value }) {
78
+ return { ...filter, [facet]: value };
79
+ }
80
+ function isActive({ facet, value }, filter) {
81
+ if (facet !== "date") return filter[facet] === value;
82
+ return value.length === 4 ? filter.date.startsWith(value) : filter.date === value;
83
+ }
84
+ function toggle(filter, link2) {
85
+ if (link2.facet !== "date")
86
+ return withValue(filter, { ...link2, value: filter[link2.facet] === link2.value ? "" : link2.value });
87
+ if (filter.date !== link2.value) return withValue(filter, link2);
88
+ return { ...filter, date: link2.value.length === 7 ? link2.value.slice(0, 4) : "" };
89
+ }
90
+ function markActive(anchor, active) {
91
+ anchor.classList.toggle("is-active", active);
92
+ if (active) anchor.setAttribute("aria-current", "true");
93
+ else anchor.removeAttribute("aria-current");
94
+ }
95
+ function syncBrowse(blog, url, filter, markers) {
96
+ const browse = blog.querySelector(".vp-blog-browse");
97
+ if (!browse) return;
98
+ for (const anchor of browse.querySelectorAll(".vp-blog-facet a")) {
99
+ const link2 = readFilterLink(anchor);
100
+ if (!link2) continue;
101
+ const active = isActive(link2, filter);
102
+ const count = markers.filter((marker) => matches(marker, withValue(filter, link2))).length;
103
+ anchor.href = toHref(url, toggle(filter, link2));
104
+ markActive(anchor, active);
105
+ anchor.classList.toggle("is-empty", count === 0 && !active);
106
+ const counter = anchor.querySelector(".vp-blog-facet__count");
107
+ if (counter) counter.textContent = String(count);
108
+ }
109
+ const months = [...browse.querySelectorAll('[data-vp-blog-facet="month"]')];
110
+ for (const row of months) row.hidden = months.length > 1 && row.dataset.year !== filter.date.slice(0, 4);
111
+ }
112
+ function findName(blog, facet, value) {
113
+ for (const anchor of blog.querySelectorAll(FILTER_LINKS)) {
114
+ const link2 = readFilterLink(anchor);
115
+ if (link2?.facet === facet && link2.value === value) {
116
+ const name = (anchor.querySelector(".vp-blog-facet__name") ?? anchor).textContent?.trim();
117
+ if (name) return name;
118
+ }
119
+ }
120
+ return value;
121
+ }
122
+ function formatDate(date, locale) {
123
+ if (date.length === 4) return date;
124
+ const [year, month] = date.split("-").map(Number);
125
+ try {
126
+ return new Intl.DateTimeFormat(locale, { timeZone: "UTC", month: "long", year: "numeric" }).format(
127
+ Date.UTC(year, month - 1, 1, 12)
128
+ );
129
+ } catch {
130
+ return date;
131
+ }
132
+ }
133
+ function element(tag, className, text = "") {
134
+ const node = document.createElement(tag);
135
+ node.className = className;
136
+ node.textContent = text;
137
+ return node;
38
138
  }
39
139
  function link(text, href, className) {
40
- const anchor = document.createElement("a");
41
- anchor.className = className;
140
+ const anchor = element("a", className, text);
42
141
  anchor.href = href;
43
- anchor.textContent = text;
44
142
  return anchor;
45
143
  }
46
- function renderFilter(slot, url, tag, matches, labels) {
47
- slot.hidden = !tag;
48
- if (!tag) return slot.replaceChildren();
49
- const message = document.createElement("span");
50
- fillWithTag(message, matches ? labels.tagged : labels.noTagged, tag);
51
- slot.replaceChildren(message, " ", link(labels.showAll, toHref(url, "", 1), "vp-blog-filter__clear"));
144
+ function listActiveFilters(blog, filter, labels) {
145
+ const active = [];
146
+ if (filter.announcements) {
147
+ active.push({ kind: "", name: labels.announcements, without: { ...filter, announcements: false } });
148
+ }
149
+ if (filter.category) {
150
+ const name = findName(blog, "category", filter.category);
151
+ active.push({ kind: labels.category, name, without: { ...filter, category: "" } });
152
+ }
153
+ if (filter.tag) {
154
+ active.push({ kind: labels.tag, name: findName(blog, "tag", filter.tag), without: { ...filter, tag: "" } });
155
+ }
156
+ if (filter.date) {
157
+ const name = formatDate(filter.date, blog.dataset.locale || document.documentElement.lang || "en-US");
158
+ active.push({ kind: "", name, without: { ...filter, date: "" } });
159
+ }
160
+ return active;
161
+ }
162
+ function renderFilter(slot, blog, url, filter, count, labels) {
163
+ const active = listActiveFilters(blog, filter, labels);
164
+ slot.hidden = active.length === 0;
165
+ if (slot.hidden) return slot.replaceChildren();
166
+ const summary = count === 0 ? labels.noMatches : count === 1 ? labels.postCountOne : labels.postCount;
167
+ const parts = [element("span", "vp-blog-filter__count", summary.replace("{n}", String(count)))];
168
+ for (const { kind, name, without } of active) {
169
+ const chip = link("", toHref(url, without), "vp-blog-filter__chip");
170
+ chip.setAttribute("aria-label", labels.removeFilter.replace("{filter}", kind ? `${kind} ${name}` : name));
171
+ if (kind) chip.append(element("span", "vp-blog-filter__kind", kind), " ");
172
+ const remove = element("span", "vp-blog-filter__remove", "\xD7");
173
+ remove.setAttribute("aria-hidden", "true");
174
+ chip.append(name, remove);
175
+ parts.push(chip);
176
+ }
177
+ const clear = { category: "", tag: "", date: "", announcements: false };
178
+ parts.push(link(labels.showAll, toHref(url, clear), "vp-blog-filter__clear"));
179
+ slot.replaceChildren(...parts);
52
180
  }
53
- function renderPagination(slot, url, tag, page, pageCount, labels) {
181
+ function renderPagination(slot, url, filter, page, pageCount, labels) {
54
182
  slot.hidden = pageCount < 2;
55
183
  slot.setAttribute("aria-label", labels.pages);
56
184
  if (pageCount < 2) return slot.replaceChildren();
57
185
  const parts = [];
58
- if (page > 1) parts.push(link(labels.previous, toHref(url, tag, page - 1), "vp-blog-pagination__previous"));
186
+ if (page > 1) parts.push(link(labels.previous, toHref(url, filter, page - 1), "vp-blog-pagination__previous"));
59
187
  for (let number = 1; number <= pageCount; number++) {
60
188
  if (number === page) {
61
- const current = document.createElement("span");
62
- current.className = "vp-blog-pagination__page is-current";
189
+ const current = element("span", "vp-blog-pagination__page is-current", String(number));
63
190
  current.setAttribute("aria-current", "page");
64
- current.textContent = String(number);
65
191
  parts.push(current);
66
192
  } else {
67
- parts.push(link(String(number), toHref(url, tag, number), "vp-blog-pagination__page"));
193
+ parts.push(link(String(number), toHref(url, filter, number), "vp-blog-pagination__page"));
68
194
  }
69
195
  }
70
- if (page < pageCount) parts.push(link(labels.next, toHref(url, tag, page + 1), "vp-blog-pagination__next"));
196
+ if (page < pageCount) parts.push(link(labels.next, toHref(url, filter, page + 1), "vp-blog-pagination__next"));
71
197
  slot.replaceChildren(...parts);
72
198
  }
73
- function markActiveTags(blog, tag) {
74
- for (const anchor of blog.querySelectorAll(".vp-blog-tags a, .vp-blog-card__tags a")) {
75
- let linkTag = "";
76
- try {
77
- linkTag = new URL(anchor.href, location.href).searchParams.get("tag") ?? "";
78
- } catch {
79
- }
80
- const active = !!tag && linkTag.toLowerCase() === tag;
81
- anchor.classList.toggle("is-active", active);
82
- if (active) anchor.setAttribute("aria-current", "true");
83
- else anchor.removeAttribute("aria-current");
199
+ function markCardLinks(blog, filter) {
200
+ for (const anchor of blog.querySelectorAll(".vp-blog-card__category a, .vp-blog-card__tags a")) {
201
+ const link2 = readFilterLink(anchor);
202
+ markActive(anchor, !!link2 && isActive(link2, filter));
84
203
  }
85
204
  }
86
205
  function applyBlogView(blog, url) {
87
206
  const labels = readLabels(blog);
88
- const tag = (url.searchParams.get("tag") ?? "").trim().toLowerCase();
207
+ const filter = readFilter(url);
89
208
  const pageSize = Math.max(0, Number(blog.dataset.pageSize) || 0);
90
209
  const markers = [...blog.querySelectorAll(ITEM)];
91
- const isSticky = (marker) => !!marker.closest("[data-vp-blog-sticky]");
92
- const sticky = markers.filter((marker) => isSticky(marker) && hasTag(marker, tag));
93
- const rest = markers.filter((marker) => !isSticky(marker) && hasTag(marker, tag));
210
+ const isPinned = (marker) => !!marker.closest("[data-vp-blog-pinned]");
211
+ const pinned = markers.filter((marker) => isPinned(marker) && matches(marker, filter));
212
+ const rest = markers.filter((marker) => !isPinned(marker) && matches(marker, filter));
94
213
  const pageCount = pageSize ? Math.max(1, Math.ceil(rest.length / pageSize)) : 1;
95
214
  const requested = Number.parseInt(url.searchParams.get("page") ?? "1", 10);
96
215
  const page = Math.min(pageCount, Math.max(1, Number.isFinite(requested) ? requested : 1));
97
216
  const shown = /* @__PURE__ */ new Set([
98
- ...page === 1 ? sticky : [],
217
+ ...page === 1 ? pinned : [],
99
218
  ...pageSize ? rest.slice((page - 1) * pageSize, page * pageSize) : rest
100
219
  ]);
101
220
  for (const marker of markers) itemElement(marker).hidden = !shown.has(marker);
102
- for (const group of blog.querySelectorAll("[data-vp-blog-sticky], .vp-blog-year")) {
221
+ for (const group of blog.querySelectorAll("[data-vp-blog-pinned], .vp-blog-group")) {
103
222
  group.hidden = ![...group.querySelectorAll(ITEM)].some((marker) => shown.has(marker));
104
223
  }
105
- const filter = blog.querySelector("[data-vp-blog-filter]");
106
- if (filter) renderFilter(filter, url, tag, sticky.length + rest.length, labels);
224
+ const banner = blog.querySelector("[data-vp-blog-filter]");
225
+ if (banner) renderFilter(banner, blog, url, filter, pinned.length + rest.length, labels);
107
226
  const pagination = blog.querySelector("[data-vp-blog-pagination]");
108
- if (pagination) renderPagination(pagination, url, tag, page, pageCount, labels);
109
- markActiveTags(blog, tag);
110
- return { tag, page, pageCount };
227
+ if (pagination) renderPagination(pagination, url, filter, page, pageCount, labels);
228
+ syncBrowse(blog, url, filter, markers);
229
+ markCardLinks(blog, filter);
230
+ return { ...filter, page, pageCount };
111
231
  }
112
232
  var lastViews = /* @__PURE__ */ new WeakMap();
233
+ function viewChanged(previous, view) {
234
+ return ["category", "tag", "date", "announcements", "page"].some((key) => previous[key] !== view[key]);
235
+ }
113
236
  function applyBlogViews(root = document, url = new URL(location.href)) {
114
237
  for (const blog of root.querySelectorAll("[data-vp-blog]")) {
115
238
  const previous = lastViews.get(blog);
116
239
  const view = applyBlogView(blog, url);
117
240
  lastViews.set(blog, view);
118
- if (previous && (previous.page !== view.page || previous.tag !== view.tag) && blog.getBoundingClientRect().top < 0) {
241
+ if (previous && viewChanged(previous, view) && blog.getBoundingClientRect().top < 0) {
119
242
  blog.scrollIntoView({ block: "start" });
120
243
  }
121
244
  }
122
245
  }
123
246
 
247
+ // src/banner.ts
248
+ import * as vitepress from "vitepress";
249
+ import { computed, defineComponent, h, onMounted, ref } from "vue";
250
+ var closeIcon = () => h("svg", { viewBox: "0 0 24 24", width: 16, height: 16, "aria-hidden": "true" }, [
251
+ h("path", {
252
+ fill: "currentColor",
253
+ 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"
254
+ })
255
+ ]);
256
+ function readDismissed() {
257
+ try {
258
+ const value = JSON.parse(localStorage.getItem(DISMISSED_STORAGE_KEY) ?? "[]");
259
+ return Array.isArray(value) ? value.filter((id) => typeof id === "string") : [];
260
+ } catch {
261
+ return [];
262
+ }
263
+ }
264
+ function writeDismissed(ids) {
265
+ try {
266
+ localStorage.setItem(DISMISSED_STORAGE_KEY, JSON.stringify(ids));
267
+ } catch {
268
+ }
269
+ }
270
+ var BlogAnnouncementBanner = defineComponent({
271
+ name: "BlogAnnouncementBanner",
272
+ setup() {
273
+ if (typeof vitepress.useData !== "function") return () => null;
274
+ const { site } = vitepress.useData();
275
+ const data = computed(
276
+ () => site.value[ANNOUNCEMENTS_KEY]
277
+ );
278
+ const dismissed = ref(null);
279
+ onMounted(() => {
280
+ dismissed.value = readDismissed();
281
+ });
282
+ const latest = computed(() => data.value?.items[0]);
283
+ const active = computed(() => (data.value?.items ?? []).filter((item) => !dismissed.value?.includes(item.id)));
284
+ const dismiss = () => {
285
+ const ids = (data.value?.items ?? []).map((item) => item.id);
286
+ dismissed.value = ids;
287
+ writeDismissed(ids);
288
+ document.documentElement.classList.add(DISMISSED_CLASS);
289
+ };
290
+ return () => {
291
+ const announcement = latest.value;
292
+ if (!data.value || !announcement || dismissed.value?.includes(announcement.id)) return null;
293
+ const { labels, listLink } = data.value;
294
+ const count = active.value.length;
295
+ const countLabel = labels.announcementCount.replace("{n}", String(count));
296
+ const badge = count > 1 ? listLink ? h(
297
+ "a",
298
+ {
299
+ class: "vp-blog-announcement__count",
300
+ href: vitepress.withBase(listLink),
301
+ title: countLabel,
302
+ "aria-label": countLabel
303
+ },
304
+ String(count)
305
+ ) : h(
306
+ "span",
307
+ { class: "vp-blog-announcement__count", title: countLabel, "aria-label": countLabel },
308
+ String(count)
309
+ ) : null;
310
+ return h("div", { class: "vp-blog-announcement", role: "region", "aria-label": labels.announcement }, [
311
+ h("div", { class: "vp-blog-announcement__content" }, [
312
+ h(
313
+ "a",
314
+ { class: "vp-blog-announcement__text", href: vitepress.withBase(announcement.link) },
315
+ announcement.text
316
+ ),
317
+ badge
318
+ ]),
319
+ h(
320
+ "button",
321
+ {
322
+ class: "vp-blog-announcement__dismiss",
323
+ type: "button",
324
+ title: labels.dismiss,
325
+ "aria-label": labels.dismiss,
326
+ onClick: dismiss
327
+ },
328
+ [closeIcon()]
329
+ )
330
+ ]);
331
+ };
332
+ }
333
+ });
334
+
124
335
  // src/client.ts
125
336
  function useBlog() {
126
- if (!vitepress.inBrowser) return;
127
- const route = vitepress.useRoute();
337
+ if (!vitepress2.inBrowser) return;
338
+ const route = vitepress2.useRoute();
128
339
  const apply = () => nextTick(() => applyBlogViews());
129
- onMounted(() => {
340
+ onMounted2(() => {
130
341
  watch(() => [route.path, route.query], apply, { immediate: true });
131
342
  });
132
- vitepress.onContentUpdated(apply);
343
+ vitepress2.onContentUpdated(apply);
133
344
  }
134
345
  export {
346
+ BlogAnnouncementBanner,
135
347
  applyBlogView,
136
348
  applyBlogViews,
349
+ readFilter,
137
350
  useBlog
138
351
  };
139
352
  //# sourceMappingURL=client.js.map