@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.
@@ -30,6 +30,7 @@ var __toCommonJS = (mod) => __copyProps(__defProp({}, "__esModule", { value: tru
30
30
  // src/vitepress.ts
31
31
  var vitepress_exports = {};
32
32
  __export(vitepress_exports, {
33
+ attachAnnouncements: () => attachAnnouncements,
33
34
  blogHead: () => blogHead,
34
35
  blogMarkdown: () => blogMarkdown,
35
36
  blogPageData: () => blogPageData,
@@ -63,6 +64,11 @@ function hasAnyTag(post, tags) {
63
64
  const wanted = new Set(tags.map((tag) => tag.toLowerCase()));
64
65
  return post.post.tags.some((tag) => wanted.has(tag.toLowerCase()));
65
66
  }
67
+ function inAnyCategory(post, categories) {
68
+ if (categories.length === 0) return true;
69
+ const category = post.post.category.toLowerCase();
70
+ return !!category && categories.some((wanted) => wanted.toLowerCase() === category);
71
+ }
66
72
  function blogCoversPost(index, post, sections) {
67
73
  if (index.index.scope === "section") return isInFolders(post.relativePath, index.index.folders);
68
74
  if (!isInFolders(post.relativePath, index.index.folders)) return false;
@@ -76,22 +82,22 @@ function compareDates(a, b) {
76
82
  function compareByDate(a, b) {
77
83
  return compareDates(a.post.date, b.post.date) || a.post.title.localeCompare(b.post.title);
78
84
  }
79
- function pinStickyPosts(posts, limit) {
80
- const sticky = posts.filter((post) => post.post.sticky);
81
- const pinned = new Set(limit ? sticky.slice(0, limit) : sticky);
85
+ function pinPosts(posts, limit) {
86
+ const candidates = posts.filter((post) => post.post.pinned);
87
+ const pinned = new Set(limit ? candidates.slice(0, limit) : candidates);
82
88
  return { pinned: [...pinned], rest: posts.filter((post) => !pinned.has(post)) };
83
89
  }
84
90
  function listBlogPosts(pages, index, options) {
85
91
  const sections = pages.filter(isIndex).filter((page) => page.index.scope === "section");
86
- let posts = pages.filter(isPost).filter((post) => options.drafts || !post.post.draft).filter((post) => blogCoversPost(index, post, sections) && hasAnyTag(post, index.index.tags)).sort(compareByDate);
87
- if (options.stickyFirst) {
88
- const { pinned, rest } = pinStickyPosts(posts, index.index.stickyLimit);
92
+ let posts = pages.filter(isPost).filter((post) => options.drafts || !post.post.draft).filter((post) => blogCoversPost(index, post, sections) && hasAnyTag(post, index.index.tags)).filter((post) => inAnyCategory(post, index.index.categories)).sort(compareByDate);
93
+ if (options.pinnedFirst) {
94
+ const { pinned, rest } = pinPosts(posts, index.index.pinLimit);
89
95
  posts = [...pinned, ...rest];
90
96
  }
91
97
  return options.applyLimit && index.index.limit ? posts.slice(0, index.index.limit) : posts;
92
98
  }
93
99
  function findHomeBlog(pages, post, drafts) {
94
- const listing = pages.filter(isIndex).filter((index) => listBlogPosts(pages, index, { drafts, stickyFirst: false, applyLimit: false }).includes(post));
100
+ const listing = pages.filter(isIndex).filter((index) => listBlogPosts(pages, index, { drafts, pinnedFirst: false, applyLimit: false }).includes(post));
95
101
  const sections = listing.filter((index) => index.index.scope === "section");
96
102
  if (sections.length) return deepestFirst(sections, post.relativePath)[0];
97
103
  return listing[0] ?? null;
@@ -110,9 +116,120 @@ function findBlogForPage(pages, relativePath) {
110
116
  }
111
117
  function findBlogsListing(pages, post) {
112
118
  return pages.filter(isIndex).filter(
113
- (index) => listBlogPosts(pages, index, { drafts: false, stickyFirst: false, applyLimit: false }).includes(post)
119
+ (index) => listBlogPosts(pages, index, { drafts: false, pinnedFirst: false, applyLimit: false }).includes(post)
114
120
  );
115
121
  }
122
+ function listAnnouncements(pages, drafts) {
123
+ return pages.filter(isPost).filter((post) => post.post.announcement && (drafts || !post.post.draft)).sort(compareByDate);
124
+ }
125
+ function findAnnouncementBlog(pages, announcements, drafts) {
126
+ if (announcements.length === 0) return null;
127
+ const listingAll = pages.filter(isIndex).filter((index) => {
128
+ const listed = listBlogPosts(pages, index, { drafts, pinnedFirst: false, applyLimit: false });
129
+ return announcements.every((post) => listed.includes(post));
130
+ });
131
+ return listingAll.find((index) => index.index.scope === "site") ?? listingAll[0] ?? findHomeBlog(pages, announcements[0], drafts);
132
+ }
133
+
134
+ // src/types.ts
135
+ var DEFAULT_LABELS = {
136
+ by: "By",
137
+ published: "Published",
138
+ updated: "Updated",
139
+ dateTime: "{date} at {time}",
140
+ readingTime: "{n} min read",
141
+ newer: "Newer post",
142
+ older: "Older post",
143
+ allPosts: "All posts in {blog}",
144
+ draft: "Draft",
145
+ pinned: "Pinned",
146
+ announcement: "Announcement",
147
+ announcements: "Announcements",
148
+ announcementCount: "{n} announcements",
149
+ dismiss: "Dismiss",
150
+ noPosts: "No posts yet.",
151
+ undated: "Undated",
152
+ browse: "Browse posts",
153
+ categories: "Categories",
154
+ tags: "Keywords",
155
+ archive: "Archive",
156
+ category: "Category",
157
+ tag: "Keyword",
158
+ postCount: "{n} posts",
159
+ postCountOne: "{n} post",
160
+ noMatches: "No posts match these filters.",
161
+ removeFilter: "Remove filter: {filter}",
162
+ showAll: "Show all posts",
163
+ previous: "Previous",
164
+ next: "Next",
165
+ pages: "Pages",
166
+ subscribe: "Subscribe",
167
+ rssFeed: "RSS feed for {blog}"
168
+ };
169
+
170
+ // src/util.ts
171
+ var HTML_ESCAPES = {
172
+ "&": "&",
173
+ "<": "&lt;",
174
+ ">": "&gt;",
175
+ '"': "&quot;",
176
+ "'": "&#39;"
177
+ };
178
+ function escapeHtml(value) {
179
+ return value.replace(/[&<>"']/g, (character) => HTML_ESCAPES[character]);
180
+ }
181
+ function escapeMarkdown(text) {
182
+ const escaped = text.replace(/[\\`*_[\]#|~]/g, "\\$&").replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;");
183
+ return /[{}]/.test(escaped) ? `<span v-pre>${escaped}</span>` : escaped;
184
+ }
185
+ function escapeTemplateText(text) {
186
+ const escaped = escapeHtml(text);
187
+ return /[{}]/.test(escaped) ? `<span v-pre>${escaped}</span>` : escaped;
188
+ }
189
+ function isExternalUrl(url) {
190
+ return /^(?:[a-z][a-z\d+.-]*:|\/\/)/i.test(url);
191
+ }
192
+ function toMarkdownUrl(url) {
193
+ return encodeURI(url).replace(/[()]/g, (character) => character === "(" ? "%28" : "%29");
194
+ }
195
+ function toPageLink(text, routePath) {
196
+ return `[${escapeMarkdown(text)}](${toMarkdownUrl(`/${routePath}`)})`;
197
+ }
198
+ function toUrlPath(routePath, cleanUrls) {
199
+ return `/${routePath.replace(/(^|\/)index\.md$/, "$1").replace(/\.md$/, cleanUrls ? "" : ".html")}`;
200
+ }
201
+ function joinUrl(base, urlPath) {
202
+ return `${base.replace(/\/+$/, "")}/${urlPath.replace(/^\/+/, "")}`;
203
+ }
204
+ var ANNOUNCEMENTS_KEY = "blogAnnouncements";
205
+ var DISMISSED_STORAGE_KEY = "vp-blog-announcements-dismissed";
206
+ var DISMISSED_CLASS = "vp-blog-announcement-dismissed";
207
+
208
+ // src/announcements.ts
209
+ function resolveAnnouncements(pages, options, cleanUrls) {
210
+ const drafts = options.drafts ?? false;
211
+ const announcements = listAnnouncements(pages, drafts);
212
+ if (announcements.length === 0) return null;
213
+ const blog = findAnnouncementBlog(pages, announcements, drafts);
214
+ const labels = { ...DEFAULT_LABELS, ...options.labels };
215
+ return {
216
+ items: announcements.map((post) => ({
217
+ id: `${post.routePath}@${post.post.date?.toISOString() ?? ""}`,
218
+ text: post.post.announcementText || post.post.title,
219
+ link: toUrlPath(post.routePath, cleanUrls)
220
+ })),
221
+ listLink: blog ? `${toUrlPath(blog.routePath, cleanUrls)}?announcements` : "",
222
+ labels: {
223
+ announcement: labels.announcement,
224
+ announcementCount: labels.announcementCount,
225
+ dismiss: labels.dismiss
226
+ }
227
+ };
228
+ }
229
+ function renderDismissedScript(data) {
230
+ const id = JSON.stringify(data.items[0].id).replace(/</g, "\\u003c");
231
+ return `try{var d=JSON.parse(localStorage.getItem(${JSON.stringify(DISMISSED_STORAGE_KEY)})||'[]');if(Array.isArray(d)&&d.indexOf(${id})>-1)document.documentElement.classList.add(${JSON.stringify(DISMISSED_CLASS)})}catch(e){}`;
232
+ }
116
233
 
117
234
  // src/frontmatter.ts
118
235
  var import_node_path = __toESM(require("path"), 1);
@@ -213,6 +330,13 @@ function toDateTimeAttribute({ date, hasTime }, timeZone) {
213
330
  function yearIn(date, timeZone) {
214
331
  return Number(toZonedIsoDate(date, timeZone).slice(0, 4));
215
332
  }
333
+ function monthIn(date, timeZone) {
334
+ return toZonedIsoDate(date, timeZone).slice(0, 7);
335
+ }
336
+ function formatMonth(month, locale, format) {
337
+ const [year, number] = month.split("-").map(Number);
338
+ return new Intl.DateTimeFormat(locale, { timeZone: "UTC", ...format }).format(Date.UTC(year, number - 1, 1, 12));
339
+ }
216
340
 
217
341
  // src/frontmatter.ts
218
342
  var POST_FRONTMATTER_KEY = "blog";
@@ -320,6 +444,7 @@ function resolvePostData(data, content, fallbackTitle, options = {}) {
320
444
  const timeZone = resolveTimeZone(options.timeZone);
321
445
  const date = parsePostDate(pick("date"), timeZone);
322
446
  const updated = parsePostDate(pick("updated"), timeZone);
447
+ const announcementText = typeof post.announcement === "string" ? clean(post.announcement.trim()) : "";
323
448
  return {
324
449
  title: clean(toText(post.title) || toText(data.title) || readHeading(content)) || fallbackTitle,
325
450
  summary: clean(toText(post.summary) || toText(data.description) || readExcerpt(content)),
@@ -335,8 +460,11 @@ function resolvePostData(data, content, fallbackTitle, options = {}) {
335
460
  tags: toStringList(pick("tags")),
336
461
  category: toText(pick("category")),
337
462
  scope: toScope(post.scope),
338
- sticky: post.sticky === true,
463
+ // `sticky` is this setting's old name.
464
+ pinned: (post.pinned ?? post.sticky) === true,
339
465
  draft: post.draft === true,
466
+ announcement: post.announcement === true || !!announcementText,
467
+ announcementText,
340
468
  byline: toBoolean(post.byline),
341
469
  cover: toBoolean(post.cover),
342
470
  postNav: toBoolean(post.postNav),
@@ -346,6 +474,9 @@ function resolvePostData(data, content, fallbackTitle, options = {}) {
346
474
  function toLayout(value) {
347
475
  return value === "grid" || value === "compact" ? value : "list";
348
476
  }
477
+ function toGroupBy(value) {
478
+ return value === "year" || value === "month" ? value : "";
479
+ }
349
480
  function defaultFeedPath(relativePath) {
350
481
  const name = import_node_path.default.posix.basename(relativePath, ".md");
351
482
  return name === "index" ? "feed.xml" : `${name}-feed.xml`;
@@ -379,13 +510,17 @@ function resolveIndexConfig(data, relativePath, defaults = {}) {
379
510
  scope,
380
511
  folders: folders.length ? resolveFolders(folders, relativePath) : scope === "section" ? [ownFolder] : [],
381
512
  tags: toStringList(options.tags),
513
+ categories: toStringList(options.categories),
382
514
  limit: typeof options.limit === "number" && options.limit > 0 ? Math.floor(options.limit) : 0,
383
515
  layout: toLayout(options.layout),
384
- groupByYear: options.groupBy === "year",
516
+ groupBy: toGroupBy(options.groupBy),
385
517
  showTags: options.showTags !== false,
518
+ showCategory: options.showCategory !== false,
519
+ categoryList: options.categoryList !== false,
386
520
  tagList: options.tagList !== false,
521
+ archive: options.archive !== false,
387
522
  pageSize: toCount(options.pageSize, toCount(defaults.pageSize, 10)),
388
- stickyLimit: toCount(options.stickyLimit, toCount(defaults.stickyLimit, 0)),
523
+ pinLimit: toCount(options.pinLimit ?? options.stickyLimit, toCount(defaults.pinLimit ?? defaults.stickyLimit, 0)),
389
524
  rssLink: typeof options.rssLink === "boolean" ? options.rssLink : defaults.rssLink ?? true,
390
525
  feed: toFeed(options.feed, relativePath)
391
526
  };
@@ -395,43 +530,6 @@ function resolveIndexConfig(data, relativePath, defaults = {}) {
395
530
  var import_node_crypto = require("crypto");
396
531
  var import_node_fs = require("fs");
397
532
  var import_node_path2 = __toESM(require("path"), 1);
398
-
399
- // src/util.ts
400
- var HTML_ESCAPES = {
401
- "&": "&amp;",
402
- "<": "&lt;",
403
- ">": "&gt;",
404
- '"': "&quot;",
405
- "'": "&#39;"
406
- };
407
- function escapeHtml(value) {
408
- return value.replace(/[&<>"']/g, (character) => HTML_ESCAPES[character]);
409
- }
410
- function escapeMarkdown(text) {
411
- const escaped = text.replace(/[\\`*_[\]#|~]/g, "\\$&").replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;");
412
- return /[{}]/.test(escaped) ? `<span v-pre>${escaped}</span>` : escaped;
413
- }
414
- function escapeTemplateText(text) {
415
- const escaped = escapeHtml(text);
416
- return /[{}]/.test(escaped) ? `<span v-pre>${escaped}</span>` : escaped;
417
- }
418
- function isExternalUrl(url) {
419
- return /^(?:[a-z][a-z\d+.-]*:|\/\/)/i.test(url);
420
- }
421
- function toMarkdownUrl(url) {
422
- return encodeURI(url).replace(/[()]/g, (character) => character === "(" ? "%28" : "%29");
423
- }
424
- function toPageLink(text, routePath) {
425
- return `[${escapeMarkdown(text)}](${toMarkdownUrl(`/${routePath}`)})`;
426
- }
427
- function toUrlPath(routePath, cleanUrls) {
428
- return `/${routePath.replace(/(^|\/)index\.md$/, "$1").replace(/\.md$/, cleanUrls ? "" : ".html")}`;
429
- }
430
- function joinUrl(base, urlPath) {
431
- return `${base.replace(/\/+$/, "")}/${urlPath.replace(/^\/+/, "")}`;
432
- }
433
-
434
- // src/feed.ts
435
533
  function toAbsoluteUrl(urlPath, site) {
436
534
  return joinUrl(site.siteUrl, joinUrl(site.base, urlPath));
437
535
  }
@@ -527,33 +625,6 @@ var import_node_path6 = __toESM(require("path"), 1);
527
625
 
528
626
  // src/render.ts
529
627
  var import_node_path3 = __toESM(require("path"), 1);
530
-
531
- // src/types.ts
532
- var DEFAULT_LABELS = {
533
- by: "By",
534
- published: "Published",
535
- updated: "Updated",
536
- dateTime: "{date} at {time}",
537
- readingTime: "{n} min read",
538
- newer: "Newer post",
539
- older: "Older post",
540
- allPosts: "All posts in {blog}",
541
- draft: "Draft",
542
- sticky: "Pinned",
543
- noPosts: "No posts yet.",
544
- undated: "Undated",
545
- tags: "Keywords",
546
- tagged: "Posts tagged {tag}",
547
- showAll: "Show all posts",
548
- noTagged: "No posts tagged {tag}.",
549
- previous: "Previous",
550
- next: "Next",
551
- pages: "Pages",
552
- subscribe: "Subscribe",
553
- rssFeed: "RSS feed for {blog}"
554
- };
555
-
556
- // src/render.ts
557
628
  var BLOG_POSTS_PLACEHOLDER = "<!-- blog-posts -->";
558
629
  function createRenderContext(options, pageDirectory, feeds = !!options.siteUrl) {
559
630
  const locale = options.locale ?? "en-US";
@@ -607,25 +678,41 @@ function renderAuthors(authors, context) {
607
678
  function renderBadge(kind, context) {
608
679
  return `<span class="vp-blog-badge vp-blog-badge--${kind}">${escapeTemplateText(context.labels[kind])}</span>`;
609
680
  }
610
- function toTagUrl(blogRoutePath, tag) {
611
- const value = encodeURIComponent(tag.toLowerCase()).replace(/[()]/g, (c) => c === "(" ? "%28" : "%29");
681
+ function toFilterUrl(blogRoutePath, param, value) {
682
+ const encoded = encodeURIComponent(value.toLowerCase()).replace(/[()]/g, (c) => c === "(" ? "%28" : "%29");
612
683
  const page = blogRoutePath.replace(/(^|\/)index\.md$/, "$1");
613
- return `${toMarkdownUrl(`/${page}`)}?tag=${value}`;
684
+ return `${toMarkdownUrl(`/${page}`)}?${param}=${encoded}`;
685
+ }
686
+ function renderFilterLink(text, param, blogRoutePath, className) {
687
+ return blogRoutePath ? `[${escapeMarkdown(text)}](${toFilterUrl(blogRoutePath, param, text)})` : `<span class="${className}">${escapeTemplateText(text)}</span>`;
614
688
  }
615
689
  function renderTags(tags, blogRoutePath) {
616
- return tags.map(
617
- (tag) => blogRoutePath ? `[${escapeMarkdown(tag)}](${toTagUrl(blogRoutePath, tag)})` : `<span class="vp-blog-tag">${escapeTemplateText(tag)}</span>`
618
- ).join(" ");
690
+ return tags.map((tag) => renderFilterLink(tag, "tag", blogRoutePath, "vp-blog-tag")).join(" ");
619
691
  }
620
692
  function toTagsAttribute(post) {
621
693
  return escapeHtml(post.post.tags.map((tag) => tag.toLowerCase()).join("|"));
622
694
  }
695
+ function toItemAttributes(post, context) {
696
+ const { category, date, announcement } = post.post;
697
+ const attributes = [`data-vp-blog-item data-tags="${toTagsAttribute(post)}"`];
698
+ if (category) attributes.push(`data-category="${escapeHtml(category.toLowerCase())}"`);
699
+ if (date) attributes.push(`data-month="${monthIn(date, context.dates.timeZone)}"`);
700
+ if (announcement) attributes.push("data-announcement");
701
+ return attributes.join(" ");
702
+ }
703
+ function renderBadges(post, context) {
704
+ const { draft, pinned, announcement } = post.post;
705
+ return [
706
+ draft && renderBadge("draft", context),
707
+ pinned && renderBadge("pinned", context),
708
+ announcement && renderBadge("announcement", context)
709
+ ].filter(Boolean).join(" ");
710
+ }
623
711
  function renderMeta(post, context, include) {
624
- const { date, dateHasTime, updated, updatedHasTime, readingMinutes, authors, draft, sticky } = post.post;
712
+ const { date, dateHasTime, updated, updatedHasTime, readingMinutes, authors } = post.post;
625
713
  const parts = [];
626
714
  if (include.badges) {
627
- const badges = [draft && renderBadge("draft", context), sticky && renderBadge("sticky", context)];
628
- const shown = badges.filter(Boolean).join(" ");
715
+ const shown = renderBadges(post, context);
629
716
  if (shown) parts.push(shown);
630
717
  }
631
718
  if (date) {
@@ -652,16 +739,18 @@ function renderCard(post, context, index) {
652
739
  const link = `/${post.routePath}`;
653
740
  const image = toImageSource(post, context.pageDirectory);
654
741
  const modifiers = [
655
- post.post.sticky && "sticky",
742
+ post.post.pinned && "pinned",
656
743
  post.post.draft && "draft",
744
+ post.post.announcement && "announcement",
657
745
  image && "with-image",
658
746
  image && post.post.imageFit === "contain" && "contain"
659
747
  ].filter(Boolean).map((modifier) => ` vp-blog-card--${modifier}`).join("");
660
748
  const alt = escapeMarkdown(post.post.imageAlt);
661
749
  return [
662
- `<article class="vp-blog-card${modifiers}"${imageStyle(post)} data-vp-blog-item data-tags="${toTagsAttribute(post)}">`,
750
+ `<article class="vp-blog-card${modifiers}"${imageStyle(post)} ${toItemAttributes(post, context)}>`,
663
751
  ...image ? block("vp-blog-card__image", `[![${alt}](${toMarkdownUrl(image)})](${toMarkdownUrl(link)})`) : [],
664
752
  '<div class="vp-blog-card__body">',
753
+ ...index.index.showCategory && post.post.category ? block("vp-blog-card__category", renderFilterLink(post.post.category, "category", index.routePath, "")) : [],
665
754
  ...block("vp-blog-card__title", toPageLink(post.post.title, post.routePath)),
666
755
  ...block("vp-blog-card__meta", renderMeta(post, context, { authors: true, updated: false, badges: true })),
667
756
  ...block("vp-blog-card__summary", escapeMarkdown(post.post.summary)),
@@ -672,8 +761,8 @@ function renderCard(post, context, index) {
672
761
  }
673
762
  function renderCompactItem(post, context) {
674
763
  const date = post.post.date ? `${renderTime(post.post.date, post.post.dateHasTime, context)} ` : "";
675
- const badges = [post.post.draft && renderBadge("draft", context), post.post.sticky && renderBadge("sticky", context)].filter(Boolean).join(" ");
676
- const marker = `<span hidden data-vp-blog-item data-tags="${toTagsAttribute(post)}"></span>`;
764
+ const badges = renderBadges(post, context);
765
+ const marker = `<span hidden ${toItemAttributes(post, context)}></span>`;
677
766
  return `- ${marker}${date}${toPageLink(post.post.title, post.routePath)}${badges ? ` ${badges}` : ""}`;
678
767
  }
679
768
  function renderGroup(posts, index, context) {
@@ -693,18 +782,23 @@ function renderGroup(posts, index, context) {
693
782
  "</div>"
694
783
  ];
695
784
  }
696
- function renderYears(posts, index, context) {
697
- const years = /* @__PURE__ */ new Map();
785
+ function renderGroups(posts, index, context) {
786
+ const { groupBy } = index.index;
787
+ if (!groupBy) return renderGroup(posts, index, context);
788
+ const { timeZone, locale } = context.dates;
789
+ const groups = /* @__PURE__ */ new Map();
698
790
  for (const post of posts) {
699
- const year = post.post.date ? String(yearIn(post.post.date, context.dates.timeZone)) : context.labels.undated;
700
- years.set(year, [...years.get(year) ?? [], post]);
791
+ const { date } = post.post;
792
+ const key = !date ? "" : groupBy === "year" ? String(yearIn(date, timeZone)) : monthIn(date, timeZone);
793
+ groups.set(key, [...groups.get(key) ?? [], post]);
701
794
  }
702
795
  const lines = [];
703
- for (const [year, group] of years) {
796
+ for (const [key, group] of groups) {
797
+ const heading = !key ? context.labels.undated : groupBy === "year" ? key : formatMonth(key, locale, { month: "long", year: "numeric" });
704
798
  lines.push(
705
- '<section class="vp-blog-year">',
799
+ `<section class="vp-blog-group vp-blog-${groupBy}">`,
706
800
  "",
707
- `## ${escapeMarkdown(year)}`,
801
+ `## ${escapeMarkdown(heading)}`,
708
802
  "",
709
803
  ...renderGroup(group, index, context),
710
804
  "",
@@ -713,42 +807,114 @@ function renderYears(posts, index, context) {
713
807
  }
714
808
  return lines;
715
809
  }
716
- function collectTags(posts) {
717
- const tags = /* @__PURE__ */ new Map();
810
+ function collectValues(posts, pick) {
811
+ const values = /* @__PURE__ */ new Map();
718
812
  for (const post of posts) {
719
- for (const tag of new Set(post.post.tags)) {
720
- const entry = tags.get(tag.toLowerCase()) ?? { tag, count: 0 };
813
+ for (const value of new Set(pick(post))) {
814
+ const entry = values.get(value.toLowerCase()) ?? { value, count: 0 };
721
815
  entry.count++;
722
- tags.set(tag.toLowerCase(), entry);
816
+ values.set(value.toLowerCase(), entry);
723
817
  }
724
818
  }
725
- return [...tags.values()].sort((a, b) => a.tag.localeCompare(b.tag, void 0, { sensitivity: "base" }));
819
+ return [...values.values()].sort((a, b) => a.value.localeCompare(b.value, void 0, { sensitivity: "base" }));
726
820
  }
727
- function renderTagList(posts, index, context) {
728
- const tags = collectTags(posts);
729
- if (!index.index.tagList || tags.length === 0) return [];
730
- const links = tags.map(
731
- ({ tag, count }) => `[${escapeMarkdown(tag)} <span class="vp-blog-tags__count">${count}</span>](${toTagUrl(index.routePath, tag)})`
732
- );
733
- const label = `<span class="vp-blog-tags__label">${escapeTemplateText(context.labels.tags)}</span>`;
821
+ function collectTags(posts) {
822
+ return collectValues(posts, (post) => post.post.tags).map(({ value, count }) => ({ tag: value, count }));
823
+ }
824
+ function collectCategories(posts) {
825
+ return collectValues(posts, (post) => post.post.category ? [post.post.category] : []);
826
+ }
827
+ function collectArchive(posts, timeZone) {
828
+ const years = /* @__PURE__ */ new Map();
829
+ for (const post of posts) {
830
+ if (!post.post.date) continue;
831
+ const month = monthIn(post.post.date, timeZone);
832
+ const year = years.get(month.slice(0, 4)) ?? { value: month.slice(0, 4), count: 0, months: [] };
833
+ year.count++;
834
+ const entry = year.months.find((candidate) => candidate.value === month);
835
+ if (entry) entry.count++;
836
+ else year.months.push({ value: month, count: 1 });
837
+ years.set(year.value, year);
838
+ }
839
+ const newestFirst = (a, b) => b.value.localeCompare(a.value);
840
+ return [...years.values()].sort(newestFirst).map((year) => ({ ...year, months: [...year.months].sort(newestFirst) }));
841
+ }
842
+ function renderChip(name, count, url, title = "") {
843
+ const tooltip = title ? ` "${title.replace(/"/g, "&quot;")}"` : "";
844
+ return `[<span class="vp-blog-facet__name">${escapeMarkdown(name)}</span> <span class="vp-blog-facet__count">${count}</span>](${url}${tooltip})`;
845
+ }
846
+ function renderFacet(facet, label, chips, ariaLabel = label, attributes = "") {
734
847
  return [
735
- `<nav class="vp-blog-tags" aria-label="${escapeHtml(context.labels.tags)}">`,
848
+ `<div class="vp-blog-facet vp-blog-facet--${facet}" role="group" aria-label="${escapeHtml(ariaLabel)}" data-vp-blog-facet="${facet}"${attributes}>`,
849
+ `<span class="vp-blog-facet__label" aria-hidden="true">${escapeTemplateText(label)}</span>`,
850
+ '<div class="vp-blog-facet__chips">',
736
851
  "",
737
- `${label} ${links.join(" ")}`,
852
+ chips.join(" "),
738
853
  "",
739
- "</nav>"
854
+ "</div>",
855
+ "</div>"
740
856
  ];
741
857
  }
858
+ function renderBrowse(posts, index, context) {
859
+ const { labels } = context;
860
+ const { locale, timeZone } = context.dates;
861
+ const blog = index.routePath;
862
+ const rows = [];
863
+ const categories = index.index.categoryList ? collectCategories(posts) : [];
864
+ if (categories.length) {
865
+ const chips = categories.map(({ value, count }) => renderChip(value, count, toFilterUrl(blog, "category", value)));
866
+ rows.push(...renderFacet("category", labels.categories, chips));
867
+ }
868
+ const tags = index.index.tagList ? collectTags(posts) : [];
869
+ if (tags.length) {
870
+ const chips = tags.map(({ tag, count }) => renderChip(tag, count, toFilterUrl(blog, "tag", tag)));
871
+ rows.push(...renderFacet("tag", labels.tags, chips));
872
+ }
873
+ const archive = index.index.archive ? collectArchive(posts, timeZone) : [];
874
+ if (archive.reduce((months, year) => months + year.months.length, 0) > 1) {
875
+ const years = archive.map(({ value, count }) => renderChip(value, count, toFilterUrl(blog, "year", value)));
876
+ rows.push(...renderFacet("year", labels.archive, years));
877
+ for (const year of archive) {
878
+ const months = year.months.map(
879
+ ({ value, count }) => renderChip(
880
+ formatMonth(value, locale, { month: "short" }),
881
+ count,
882
+ toFilterUrl(blog, "month", value),
883
+ formatMonth(value, locale, { month: "long", year: "numeric" })
884
+ )
885
+ );
886
+ const shown = archive.length === 1 ? "" : " hidden";
887
+ rows.push(
888
+ ...renderFacet(
889
+ "month",
890
+ year.value,
891
+ months,
892
+ `${labels.archive} ${year.value}`,
893
+ ` data-year="${year.value}"${shown}`
894
+ )
895
+ );
896
+ }
897
+ }
898
+ if (rows.length === 0) return [];
899
+ return [`<nav class="vp-blog-browse" aria-label="${escapeHtml(labels.browse)}">`, ...rows, "</nav>"];
900
+ }
742
901
  function renderClientAttributes(index, context) {
743
902
  const { labels } = context;
744
903
  const attributes = {
745
904
  "data-page-size": String(index.index.pageSize),
746
- "data-label-tagged": labels.tagged,
905
+ // For writing a `?month=` filter's name: "October 2026".
906
+ "data-locale": context.locale,
747
907
  "data-label-show-all": labels.showAll,
748
- "data-label-no-tagged": labels.noTagged,
749
908
  "data-label-previous": labels.previous,
750
909
  "data-label-next": labels.next,
751
- "data-label-pages": labels.pages
910
+ "data-label-pages": labels.pages,
911
+ "data-label-announcements": labels.announcements,
912
+ "data-label-category": labels.category,
913
+ "data-label-tag": labels.tag,
914
+ "data-label-post-count": labels.postCount,
915
+ "data-label-post-count-one": labels.postCountOne,
916
+ "data-label-no-matches": labels.noMatches,
917
+ "data-label-remove-filter": labels.removeFilter
752
918
  };
753
919
  return Object.entries(attributes).map(([name, value]) => `${name}="${escapeHtml(value)}"`).join(" ");
754
920
  }
@@ -759,17 +925,16 @@ function renderPostList(pinned, rest, index, context) {
759
925
  const lines = [
760
926
  `<div class="vp-blog" data-vp-blog ${renderClientAttributes(index, context)}>`,
761
927
  ...block("vp-blog-subscribe", renderRssLink(index, context)),
762
- ...renderTagList(all, index, context),
763
- '<div class="vp-blog-filter" data-vp-blog-filter hidden></div>'
928
+ ...renderBrowse(all, index, context),
929
+ '<div class="vp-blog-filter" data-vp-blog-filter role="status" hidden></div>'
764
930
  ];
765
931
  if (pinned.length)
766
932
  lines.push(
767
- '<section class="vp-blog-sticky" data-vp-blog-sticky>',
933
+ '<section class="vp-blog-pinned" data-vp-blog-pinned>',
768
934
  ...renderGroup(pinned, index, context),
769
935
  "</section>"
770
936
  );
771
- if (rest.length)
772
- lines.push(...index.index.groupByYear ? renderYears(rest, index, context) : renderGroup(rest, index, context));
937
+ if (rest.length) lines.push(...renderGroups(rest, index, context));
773
938
  lines.push(`<nav class="vp-blog-pagination" data-vp-blog-pagination hidden></nav>`, "</div>");
774
939
  return `${lines.join("\n")}
775
940
  `;
@@ -778,8 +943,12 @@ function renderPostHeader(post, context, show, blogRoutePath = null) {
778
943
  const lines = [];
779
944
  if (show.byline) {
780
945
  const meta = renderMeta(post, context, { authors: true, updated: true, badges: true });
781
- const tags = post.post.tags.length ? `<span class="vp-blog-byline__tags">${renderTags(post.post.tags, blogRoutePath)}</span>` : "";
782
- lines.push(...block("vp-blog-byline", [meta, tags].filter(Boolean).join("\n\n")));
946
+ const { category, tags } = post.post;
947
+ const chips = [
948
+ category && `<span class="vp-blog-byline__category">${renderFilterLink(category, "category", blogRoutePath, "vp-blog-category")}</span>`,
949
+ tags.length && `<span class="vp-blog-byline__tags">${renderTags(tags, blogRoutePath)}</span>`
950
+ ].filter(Boolean).join(" ");
951
+ lines.push(...block("vp-blog-byline", [meta, chips].filter(Boolean).join("\n\n")));
783
952
  }
784
953
  if (show.cover && post.post.image) {
785
954
  const image = /^[./@~]/.test(post.post.image) || isExternalUrl(post.post.image) ? post.post.image : `./${post.post.image}`;
@@ -870,7 +1039,7 @@ function toOptionsKey(options) {
870
1039
  options.wordsPerMinute,
871
1040
  options.stripTags,
872
1041
  options.pageSize,
873
- options.stickyLimit,
1042
+ options.pinLimit ?? options.stickyLimit,
874
1043
  options.rssLink,
875
1044
  options.timeZone
876
1045
  ]);
@@ -959,10 +1128,13 @@ function resolveWidgetConfig(body) {
959
1128
  config: {
960
1129
  blog: typeof data.blog === "string" ? data.blog.trim() : "",
961
1130
  limit: toCount2(data.limit) ?? DEFAULT_LIMIT,
962
- stickyLimit: toCount2(data.stickyLimit),
1131
+ // `stickyLimit` is this setting's old name.
1132
+ pinLimit: toCount2(data.pinLimit ?? data.stickyLimit),
963
1133
  layout,
964
1134
  showTags: typeof data.showTags === "boolean" ? data.showTags : void 0,
1135
+ showCategory: typeof data.showCategory === "boolean" ? data.showCategory : void 0,
965
1136
  tags: toStringList(data.tags),
1137
+ categories: toStringList(data.categories),
966
1138
  more: data.more === false ? null : typeof data.more === "string" && data.more.trim() ? data.more.trim() : "",
967
1139
  rssLink: typeof data.rssLink === "boolean" ? data.rssLink : void 0
968
1140
  },
@@ -978,19 +1150,19 @@ function renderWidget(body, environment) {
978
1150
  warn(config.blog ? `no blog page found at "${config.blog}"` : 'no blog covers this page: name one with "blog:"');
979
1151
  return "";
980
1152
  }
981
- const stickyLimit = config.stickyLimit ?? blog.index.stickyLimit;
982
- const wanted = new Set(config.tags.map((tag) => tag.toLowerCase()));
983
- const posts = listBlogPosts(pages, blog, { drafts, stickyFirst: false, applyLimit: false }).filter(
984
- (post) => wanted.size === 0 || post.post.tags.some((tag) => wanted.has(tag.toLowerCase()))
1153
+ const pinLimit = config.pinLimit ?? blog.index.pinLimit;
1154
+ const posts = listBlogPosts(pages, blog, { drafts, pinnedFirst: false, applyLimit: false }).filter(
1155
+ (post) => hasAnyTag(post, config.tags) && inAnyCategory(post, config.categories)
985
1156
  );
986
- const { pinned, rest } = pinStickyPosts(posts, stickyLimit);
1157
+ const { pinned, rest } = pinPosts(posts, pinLimit);
987
1158
  const shown = [...pinned, ...rest].slice(0, config.limit || void 0);
988
1159
  const view = {
989
1160
  ...blog,
990
1161
  index: {
991
1162
  ...blog.index,
992
1163
  layout: config.layout ?? blog.index.layout,
993
- showTags: config.showTags ?? blog.index.showTags
1164
+ showTags: config.showTags ?? blog.index.showTags,
1165
+ showCategory: config.showCategory ?? blog.index.showCategory
994
1166
  }
995
1167
  };
996
1168
  const lines = [`<div class="vp-blog-widget">`];
@@ -1063,8 +1235,8 @@ function hasFeeds(options) {
1063
1235
  }
1064
1236
  function withPostList(source, page, pages, options) {
1065
1237
  if (!isIndex(page)) return source;
1066
- const posts = listBlogPosts(pages, page, { drafts: options.drafts ?? false, stickyFirst: true, applyLimit: true });
1067
- const { pinned, rest } = pinStickyPosts(posts, page.index.stickyLimit);
1238
+ const posts = listBlogPosts(pages, page, { drafts: options.drafts ?? false, pinnedFirst: true, applyLimit: true });
1239
+ const { pinned, rest } = pinPosts(posts, page.index.pinLimit);
1068
1240
  const context = createRenderContext(options, import_node_path6.default.dirname(page.absolutePath), hasFeeds(options));
1069
1241
  const generated = renderPostList(pinned, rest, page, context);
1070
1242
  return source.includes(BLOG_POSTS_PLACEHOLDER) ? source.replace(BLOG_POSTS_PLACEHOLDER, () => `
@@ -1087,7 +1259,7 @@ function withPostDecorations(source, page, pages, options) {
1087
1259
  let result = header ? insertAfterTitle(source, header) : source;
1088
1260
  if (page.post.postNav ?? options.postNav ?? true) {
1089
1261
  if (blog) {
1090
- const posts = listBlogPosts(pages, blog, { drafts, stickyFirst: false, applyLimit: false });
1262
+ const posts = listBlogPosts(pages, blog, { drafts, pinnedFirst: false, applyLimit: false });
1091
1263
  result = `${result.replace(/\s*$/, "")}
1092
1264
 
1093
1265
  ${renderPostNav(page, posts, blog, context)}
@@ -1135,6 +1307,9 @@ function blogMarkdown(md, options = {}) {
1135
1307
 
1136
1308
  // src/vitepress.ts
1137
1309
  var DEFAULT_FEED_LIMIT = 20;
1310
+ var PACKAGE_NAME = "@binarynoir/vitepress-blog";
1311
+ var SITE_DATA_MODULE = "/@siteData";
1312
+ var SCRIPT_MARKER = "data-vp-blog-announcement";
1138
1313
  function resolveSiteLocation(siteConfig, options) {
1139
1314
  const siteUrl = options.siteUrl ?? siteConfig.userConfig?.sitemap?.hostname ?? siteConfig.sitemap?.hostname;
1140
1315
  if (!siteUrl) return null;
@@ -1149,9 +1324,19 @@ function resolveSiteLocation(siteConfig, options) {
1149
1324
  function loadPages(siteConfig, options) {
1150
1325
  return loadBlogPages(siteConfig.srcDir, options, siteConfig);
1151
1326
  }
1327
+ function attachAnnouncements(siteConfig, options = {}) {
1328
+ const site = siteConfig.site;
1329
+ const data = resolveAnnouncements(loadPages(siteConfig, options), options, !!siteConfig.cleanUrls);
1330
+ const changed = JSON.stringify(site[ANNOUNCEMENTS_KEY] ?? null) !== JSON.stringify(data);
1331
+ if (data) site[ANNOUNCEMENTS_KEY] = data;
1332
+ else delete site[ANNOUNCEMENTS_KEY];
1333
+ site.head = (site.head ?? []).filter(([, attributes]) => !(SCRIPT_MARKER in (attributes ?? {})));
1334
+ if (data) site.head.push(["script", { [SCRIPT_MARKER]: "" }, renderDismissedScript(data)]);
1335
+ return changed;
1336
+ }
1152
1337
  function listFeedPosts(pages, index, options) {
1153
1338
  const limit = index.index.feed?.limit ?? options.feedLimit ?? DEFAULT_FEED_LIMIT;
1154
- return listBlogPosts(pages, index, { drafts: false, stickyFirst: false, applyLimit: false }).slice(0, limit);
1339
+ return listBlogPosts(pages, index, { drafts: false, pinnedFirst: false, applyLimit: false }).slice(0, limit);
1155
1340
  }
1156
1341
  function renderIndexFeed(pages, index, site, siteConfig, options) {
1157
1342
  return renderFeed({
@@ -1253,11 +1438,26 @@ function blogVitePlugin(options = {}) {
1253
1438
  let siteConfig;
1254
1439
  const currentPages = () => siteConfig ? loadPages(siteConfig, options) : [];
1255
1440
  const dependentFiles = () => [...currentPages().map((page) => page.absolutePath), ...widgetPages];
1441
+ const invalidate = (server, files) => {
1442
+ for (const file of files) {
1443
+ for (const module2 of server.moduleGraph.getModulesByFile(file) ?? []) server.moduleGraph.invalidateModule(module2);
1444
+ }
1445
+ };
1446
+ const refreshAnnouncements = (server) => {
1447
+ if (!siteConfig || !attachAnnouncements(siteConfig, options)) return false;
1448
+ const module2 = server.moduleGraph.getModuleById(SITE_DATA_MODULE);
1449
+ if (module2) server.moduleGraph.invalidateModule(module2);
1450
+ return true;
1451
+ };
1256
1452
  return {
1257
1453
  name: "vitepress-blog",
1258
1454
  enforce: "pre",
1455
+ config() {
1456
+ return { ssr: { noExternal: [PACKAGE_NAME] } };
1457
+ },
1259
1458
  configResolved(config) {
1260
1459
  siteConfig = config.vitepress;
1460
+ if (siteConfig) attachAnnouncements(siteConfig, options);
1261
1461
  },
1262
1462
  transform(code, id) {
1263
1463
  if (!siteConfig || !id.endsWith(".md") || !(mayHaveBlogFrontmatter(code) || hasWidget(code))) return null;
@@ -1268,11 +1468,8 @@ function blogVitePlugin(options = {}) {
1268
1468
  configureServer(server) {
1269
1469
  const refresh = (file) => {
1270
1470
  if (!siteConfig || !file.endsWith(".md")) return;
1271
- for (const dependent of dependentFiles()) {
1272
- for (const module2 of server.moduleGraph.getModulesByFile(dependent) ?? []) {
1273
- server.moduleGraph.invalidateModule(module2);
1274
- }
1275
- }
1471
+ invalidate(server, dependentFiles());
1472
+ refreshAnnouncements(server);
1276
1473
  server.ws.send({ type: "full-reload" });
1277
1474
  };
1278
1475
  server.watcher.on("add", refresh).on("unlink", refresh);
@@ -1303,6 +1500,11 @@ function blogVitePlugin(options = {}) {
1303
1500
  },
1304
1501
  handleHotUpdate({ file, modules, server }) {
1305
1502
  if (!siteConfig || !file.endsWith(".md")) return;
1503
+ if (refreshAnnouncements(server)) {
1504
+ invalidate(server, dependentFiles());
1505
+ server.ws.send({ type: "full-reload" });
1506
+ return [];
1507
+ }
1306
1508
  const pages = currentPages();
1307
1509
  if (!pages.some((page) => page.absolutePath === file)) return;
1308
1510
  const affected = dependentFiles().filter((dependent) => dependent !== file).flatMap((dependent) => [...server.moduleGraph.getModulesByFile(dependent) ?? []]);
@@ -1339,6 +1541,7 @@ function withBlog(config, options = {}) {
1339
1541
  }
1340
1542
  // Annotate the CommonJS export names for ESM import in node:
1341
1543
  0 && (module.exports = {
1544
+ attachAnnouncements,
1342
1545
  blogHead,
1343
1546
  blogMarkdown,
1344
1547
  blogPageData,