@binarynoir/vitepress-blog 0.3.0 → 0.5.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.
@@ -64,6 +64,11 @@ function hasAnyTag(post, tags) {
64
64
  const wanted = new Set(tags.map((tag) => tag.toLowerCase()));
65
65
  return post.post.tags.some((tag) => wanted.has(tag.toLowerCase()));
66
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
+ }
67
72
  function blogCoversPost(index, post, sections) {
68
73
  if (index.index.scope === "section") return isInFolders(post.relativePath, index.index.folders);
69
74
  if (!isInFolders(post.relativePath, index.index.folders)) return false;
@@ -77,22 +82,22 @@ function compareDates(a, b) {
77
82
  function compareByDate(a, b) {
78
83
  return compareDates(a.post.date, b.post.date) || a.post.title.localeCompare(b.post.title);
79
84
  }
80
- function pinStickyPosts(posts, limit) {
81
- const sticky = posts.filter((post) => post.post.sticky);
82
- 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);
83
88
  return { pinned: [...pinned], rest: posts.filter((post) => !pinned.has(post)) };
84
89
  }
85
90
  function listBlogPosts(pages, index, options) {
86
91
  const sections = pages.filter(isIndex).filter((page) => page.index.scope === "section");
87
- 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);
88
- if (options.stickyFirst) {
89
- 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);
90
95
  posts = [...pinned, ...rest];
91
96
  }
92
97
  return options.applyLimit && index.index.limit ? posts.slice(0, index.index.limit) : posts;
93
98
  }
94
99
  function findHomeBlog(pages, post, drafts) {
95
- 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));
96
101
  const sections = listing.filter((index) => index.index.scope === "section");
97
102
  if (sections.length) return deepestFirst(sections, post.relativePath)[0];
98
103
  return listing[0] ?? null;
@@ -111,7 +116,7 @@ function findBlogForPage(pages, relativePath) {
111
116
  }
112
117
  function findBlogsListing(pages, post) {
113
118
  return pages.filter(isIndex).filter(
114
- (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)
115
120
  );
116
121
  }
117
122
  function listAnnouncements(pages, drafts) {
@@ -120,7 +125,7 @@ function listAnnouncements(pages, drafts) {
120
125
  function findAnnouncementBlog(pages, announcements, drafts) {
121
126
  if (announcements.length === 0) return null;
122
127
  const listingAll = pages.filter(isIndex).filter((index) => {
123
- const listed = listBlogPosts(pages, index, { drafts, stickyFirst: false, applyLimit: false });
128
+ const listed = listBlogPosts(pages, index, { drafts, pinnedFirst: false, applyLimit: false });
124
129
  return announcements.every((post) => listed.includes(post));
125
130
  });
126
131
  return listingAll.find((index) => index.index.scope === "site") ?? listingAll[0] ?? findHomeBlog(pages, announcements[0], drafts);
@@ -137,18 +142,28 @@ var DEFAULT_LABELS = {
137
142
  older: "Older post",
138
143
  allPosts: "All posts in {blog}",
139
144
  draft: "Draft",
140
- sticky: "Pinned",
145
+ pinned: "Pinned",
141
146
  announcement: "Announcement",
142
147
  announcements: "Announcements",
143
- noAnnouncements: "No announcements.",
144
148
  announcementCount: "{n} announcements",
145
149
  dismiss: "Dismiss",
146
150
  noPosts: "No posts yet.",
147
151
  undated: "Undated",
152
+ browse: "Browse posts",
153
+ categories: "Categories",
148
154
  tags: "Keywords",
149
- tagged: "Posts tagged {tag}",
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}",
150
162
  showAll: "Show all posts",
151
- noTagged: "No posts tagged {tag}.",
163
+ more: "+{n} more",
164
+ less: "Show fewer",
165
+ filter: "Filter {row}",
166
+ noFilterMatches: "Nothing matches.",
152
167
  previous: "Previous",
153
168
  next: "Next",
154
169
  pages: "Pages",
@@ -319,6 +334,13 @@ function toDateTimeAttribute({ date, hasTime }, timeZone) {
319
334
  function yearIn(date, timeZone) {
320
335
  return Number(toZonedIsoDate(date, timeZone).slice(0, 4));
321
336
  }
337
+ function monthIn(date, timeZone) {
338
+ return toZonedIsoDate(date, timeZone).slice(0, 7);
339
+ }
340
+ function formatMonth(month, locale, format) {
341
+ const [year, number] = month.split("-").map(Number);
342
+ return new Intl.DateTimeFormat(locale, { timeZone: "UTC", ...format }).format(Date.UTC(year, number - 1, 1, 12));
343
+ }
322
344
 
323
345
  // src/frontmatter.ts
324
346
  var POST_FRONTMATTER_KEY = "blog";
@@ -329,6 +351,8 @@ var FENCED_CODE = /^[ \t]*(`{3,}|~{3,})[^\n]*\n[\s\S]*?(?:^[ \t]*\1[`~]*[ \t]*$|
329
351
  var HEADING_1 = /^ {0,3}#[ \t]+(.*?)(?:[ \t]+#+)?[ \t]*$/m;
330
352
  var TAG_SYNTAX = /\(\(<?tag([|/])[^|/)\r\n]*(?:\1[^|/)\r\n]*){0,2}\)\)/g;
331
353
  var MAX_SUMMARY_LENGTH = 200;
354
+ var DEFAULT_CATEGORY_LIMIT = 8;
355
+ var DEFAULT_TAG_LIMIT = 12;
332
356
  function readFrontmatter(raw) {
333
357
  const source = raw.replace(/^/, "");
334
358
  const match = FRONTMATTER.exec(source);
@@ -442,7 +466,8 @@ function resolvePostData(data, content, fallbackTitle, options = {}) {
442
466
  tags: toStringList(pick("tags")),
443
467
  category: toText(pick("category")),
444
468
  scope: toScope(post.scope),
445
- sticky: post.sticky === true,
469
+ // `sticky` is this setting's old name.
470
+ pinned: (post.pinned ?? post.sticky) === true,
446
471
  draft: post.draft === true,
447
472
  announcement: post.announcement === true || !!announcementText,
448
473
  announcementText,
@@ -455,6 +480,9 @@ function resolvePostData(data, content, fallbackTitle, options = {}) {
455
480
  function toLayout(value) {
456
481
  return value === "grid" || value === "compact" ? value : "list";
457
482
  }
483
+ function toGroupBy(value) {
484
+ return value === "year" || value === "month" ? value : "";
485
+ }
458
486
  function defaultFeedPath(relativePath) {
459
487
  const name = import_node_path.default.posix.basename(relativePath, ".md");
460
488
  return name === "index" ? "feed.xml" : `${name}-feed.xml`;
@@ -488,13 +516,20 @@ function resolveIndexConfig(data, relativePath, defaults = {}) {
488
516
  scope,
489
517
  folders: folders.length ? resolveFolders(folders, relativePath) : scope === "section" ? [ownFolder] : [],
490
518
  tags: toStringList(options.tags),
519
+ categories: toStringList(options.categories),
491
520
  limit: typeof options.limit === "number" && options.limit > 0 ? Math.floor(options.limit) : 0,
492
521
  layout: toLayout(options.layout),
493
- groupByYear: options.groupBy === "year",
522
+ groupBy: toGroupBy(options.groupBy),
494
523
  showTags: options.showTags !== false,
524
+ showCategory: options.showCategory !== false,
525
+ categoryList: options.categoryList !== false,
526
+ categoryLimit: toCount(options.categoryLimit, DEFAULT_CATEGORY_LIMIT),
495
527
  tagList: options.tagList !== false,
528
+ tagLimit: toCount(options.tagLimit, DEFAULT_TAG_LIMIT),
529
+ tagMinCount: toCount(options.tagMinCount, 1),
530
+ archive: options.archive !== false,
496
531
  pageSize: toCount(options.pageSize, toCount(defaults.pageSize, 10)),
497
- stickyLimit: toCount(options.stickyLimit, toCount(defaults.stickyLimit, 0)),
532
+ pinLimit: toCount(options.pinLimit ?? options.stickyLimit, toCount(defaults.pinLimit ?? defaults.stickyLimit, 0)),
498
533
  rssLink: typeof options.rssLink === "boolean" ? options.rssLink : defaults.rssLink ?? true,
499
534
  feed: toFeed(options.feed, relativePath)
500
535
  };
@@ -622,7 +657,8 @@ function renderRssLink(index, context, show = index.index.rssLink) {
622
657
  if (!show || !context.feeds || !index.index.feed) return "";
623
658
  const title = fill(context.labels.rssFeed, { blog: index.title }).replace(/"/g, "&quot;");
624
659
  const url = toMarkdownUrl(`/${toFeedPath(index)}`);
625
- return `<span class="vp-blog-rss">[${RSS_ICON} ${escapeMarkdown(context.labels.subscribe)}](${url} "${title}")</span>`;
660
+ const text = `<span class="vp-blog-rss__text">${escapeMarkdown(context.labels.subscribe)}</span>`;
661
+ return `<span class="vp-blog-rss">[${RSS_ICON} ${text}](${url} "${title}")</span>`;
626
662
  }
627
663
  function fill(template, values) {
628
664
  return template.replace(/\{(\w+)\}/g, (match, key) => values[key] ?? match);
@@ -652,27 +688,33 @@ function renderAuthors(authors, context) {
652
688
  function renderBadge(kind, context) {
653
689
  return `<span class="vp-blog-badge vp-blog-badge--${kind}">${escapeTemplateText(context.labels[kind])}</span>`;
654
690
  }
655
- function toTagUrl(blogRoutePath, tag) {
656
- const value = encodeURIComponent(tag.toLowerCase()).replace(/[()]/g, (c) => c === "(" ? "%28" : "%29");
691
+ function toFilterUrl(blogRoutePath, param, value) {
692
+ const encoded = encodeURIComponent(value.toLowerCase()).replace(/[()]/g, (c) => c === "(" ? "%28" : "%29");
657
693
  const page = blogRoutePath.replace(/(^|\/)index\.md$/, "$1");
658
- return `${toMarkdownUrl(`/${page}`)}?tag=${value}`;
694
+ return `${toMarkdownUrl(`/${page}`)}?${param}=${encoded}`;
695
+ }
696
+ function renderFilterLink(text, param, blogRoutePath, className) {
697
+ return blogRoutePath ? `[${escapeMarkdown(text)}](${toFilterUrl(blogRoutePath, param, text)})` : `<span class="${className}">${escapeTemplateText(text)}</span>`;
659
698
  }
660
699
  function renderTags(tags, blogRoutePath) {
661
- return tags.map(
662
- (tag) => blogRoutePath ? `[${escapeMarkdown(tag)}](${toTagUrl(blogRoutePath, tag)})` : `<span class="vp-blog-tag">${escapeTemplateText(tag)}</span>`
663
- ).join(" ");
700
+ return tags.map((tag) => renderFilterLink(tag, "tag", blogRoutePath, "vp-blog-tag")).join(" ");
664
701
  }
665
702
  function toTagsAttribute(post) {
666
703
  return escapeHtml(post.post.tags.map((tag) => tag.toLowerCase()).join("|"));
667
704
  }
668
- function toItemAttributes(post) {
669
- return `data-vp-blog-item data-tags="${toTagsAttribute(post)}"${post.post.announcement ? " data-announcement" : ""}`;
705
+ function toItemAttributes(post, context) {
706
+ const { category, date, announcement } = post.post;
707
+ const attributes = [`data-vp-blog-item data-tags="${toTagsAttribute(post)}"`];
708
+ if (category) attributes.push(`data-category="${escapeHtml(category.toLowerCase())}"`);
709
+ if (date) attributes.push(`data-month="${monthIn(date, context.dates.timeZone)}"`);
710
+ if (announcement) attributes.push("data-announcement");
711
+ return attributes.join(" ");
670
712
  }
671
713
  function renderBadges(post, context) {
672
- const { draft, sticky, announcement } = post.post;
714
+ const { draft, pinned, announcement } = post.post;
673
715
  return [
674
716
  draft && renderBadge("draft", context),
675
- sticky && renderBadge("sticky", context),
717
+ pinned && renderBadge("pinned", context),
676
718
  announcement && renderBadge("announcement", context)
677
719
  ].filter(Boolean).join(" ");
678
720
  }
@@ -707,7 +749,7 @@ function renderCard(post, context, index) {
707
749
  const link = `/${post.routePath}`;
708
750
  const image = toImageSource(post, context.pageDirectory);
709
751
  const modifiers = [
710
- post.post.sticky && "sticky",
752
+ post.post.pinned && "pinned",
711
753
  post.post.draft && "draft",
712
754
  post.post.announcement && "announcement",
713
755
  image && "with-image",
@@ -715,9 +757,10 @@ function renderCard(post, context, index) {
715
757
  ].filter(Boolean).map((modifier) => ` vp-blog-card--${modifier}`).join("");
716
758
  const alt = escapeMarkdown(post.post.imageAlt);
717
759
  return [
718
- `<article class="vp-blog-card${modifiers}"${imageStyle(post)} ${toItemAttributes(post)}>`,
760
+ `<article class="vp-blog-card${modifiers}"${imageStyle(post)} ${toItemAttributes(post, context)}>`,
719
761
  ...image ? block("vp-blog-card__image", `[![${alt}](${toMarkdownUrl(image)})](${toMarkdownUrl(link)})`) : [],
720
762
  '<div class="vp-blog-card__body">',
763
+ ...index.index.showCategory && post.post.category ? block("vp-blog-card__category", renderFilterLink(post.post.category, "category", index.routePath, "")) : [],
721
764
  ...block("vp-blog-card__title", toPageLink(post.post.title, post.routePath)),
722
765
  ...block("vp-blog-card__meta", renderMeta(post, context, { authors: true, updated: false, badges: true })),
723
766
  ...block("vp-blog-card__summary", escapeMarkdown(post.post.summary)),
@@ -729,7 +772,7 @@ function renderCard(post, context, index) {
729
772
  function renderCompactItem(post, context) {
730
773
  const date = post.post.date ? `${renderTime(post.post.date, post.post.dateHasTime, context)} ` : "";
731
774
  const badges = renderBadges(post, context);
732
- const marker = `<span hidden ${toItemAttributes(post)}></span>`;
775
+ const marker = `<span hidden ${toItemAttributes(post, context)}></span>`;
733
776
  return `- ${marker}${date}${toPageLink(post.post.title, post.routePath)}${badges ? ` ${badges}` : ""}`;
734
777
  }
735
778
  function renderGroup(posts, index, context) {
@@ -749,18 +792,23 @@ function renderGroup(posts, index, context) {
749
792
  "</div>"
750
793
  ];
751
794
  }
752
- function renderYears(posts, index, context) {
753
- const years = /* @__PURE__ */ new Map();
795
+ function renderGroups(posts, index, context) {
796
+ const { groupBy } = index.index;
797
+ if (!groupBy) return renderGroup(posts, index, context);
798
+ const { timeZone, locale } = context.dates;
799
+ const groups = /* @__PURE__ */ new Map();
754
800
  for (const post of posts) {
755
- const year = post.post.date ? String(yearIn(post.post.date, context.dates.timeZone)) : context.labels.undated;
756
- years.set(year, [...years.get(year) ?? [], post]);
801
+ const { date } = post.post;
802
+ const key = !date ? "" : groupBy === "year" ? String(yearIn(date, timeZone)) : monthIn(date, timeZone);
803
+ groups.set(key, [...groups.get(key) ?? [], post]);
757
804
  }
758
805
  const lines = [];
759
- for (const [year, group] of years) {
806
+ for (const [key, group] of groups) {
807
+ const heading = !key ? context.labels.undated : groupBy === "year" ? key : formatMonth(key, locale, { month: "long", year: "numeric" });
760
808
  lines.push(
761
- '<section class="vp-blog-year">',
809
+ `<section class="vp-blog-group vp-blog-${groupBy}">`,
762
810
  "",
763
- `## ${escapeMarkdown(year)}`,
811
+ `## ${escapeMarkdown(heading)}`,
764
812
  "",
765
813
  ...renderGroup(group, index, context),
766
814
  "",
@@ -769,29 +817,107 @@ function renderYears(posts, index, context) {
769
817
  }
770
818
  return lines;
771
819
  }
772
- function collectTags(posts) {
773
- const tags = /* @__PURE__ */ new Map();
820
+ function collectValues(posts, pick) {
821
+ const values = /* @__PURE__ */ new Map();
774
822
  for (const post of posts) {
775
- for (const tag of new Set(post.post.tags)) {
776
- const entry = tags.get(tag.toLowerCase()) ?? { tag, count: 0 };
823
+ for (const value of new Set(pick(post))) {
824
+ const entry = values.get(value.toLowerCase()) ?? { value, count: 0 };
777
825
  entry.count++;
778
- tags.set(tag.toLowerCase(), entry);
826
+ values.set(value.toLowerCase(), entry);
779
827
  }
780
828
  }
781
- return [...tags.values()].sort((a, b) => a.tag.localeCompare(b.tag, void 0, { sensitivity: "base" }));
829
+ return [...values.values()].sort((a, b) => a.value.localeCompare(b.value, void 0, { sensitivity: "base" }));
782
830
  }
783
- function renderTagList(posts, index, context) {
784
- const tags = collectTags(posts);
785
- if (!index.index.tagList || tags.length === 0) return [];
786
- const links = tags.map(
787
- ({ tag, count }) => `[${escapeMarkdown(tag)} <span class="vp-blog-tags__count">${count}</span>](${toTagUrl(index.routePath, tag)})`
788
- );
789
- const label = `<span class="vp-blog-tags__label">${escapeTemplateText(context.labels.tags)}</span>`;
831
+ function collectTags(posts) {
832
+ return collectValues(posts, (post) => post.post.tags).map(({ value, count }) => ({ tag: value, count }));
833
+ }
834
+ function collectCategories(posts) {
835
+ return collectValues(posts, (post) => post.post.category ? [post.post.category] : []);
836
+ }
837
+ function collectArchive(posts, timeZone) {
838
+ const years = /* @__PURE__ */ new Map();
839
+ for (const post of posts) {
840
+ if (!post.post.date) continue;
841
+ const month = monthIn(post.post.date, timeZone);
842
+ const year = years.get(month.slice(0, 4)) ?? { value: month.slice(0, 4), count: 0, months: [] };
843
+ year.count++;
844
+ const entry = year.months.find((candidate) => candidate.value === month);
845
+ if (entry) entry.count++;
846
+ else year.months.push({ value: month, count: 1 });
847
+ years.set(year.value, year);
848
+ }
849
+ const newestFirst = (a, b) => b.value.localeCompare(a.value);
850
+ return [...years.values()].sort(newestFirst).map((year) => ({ ...year, months: [...year.months].sort(newestFirst) }));
851
+ }
852
+ function renderChip(name, count, url, title = "") {
853
+ const tooltip = title ? ` "${title.replace(/"/g, "&quot;")}"` : "";
854
+ return `[<span class="vp-blog-facet__name">${escapeMarkdown(name)}</span> <span class="vp-blog-facet__count">${count}</span>](${url}${tooltip})`;
855
+ }
856
+ function renderFacet(row) {
857
+ const { facet, label, chips, ariaLabel = label, limit = 0, attributes = "", aside = "" } = row;
858
+ const limited = limit && chips.length > limit ? ` data-limit="${limit}"` : "";
790
859
  return [
791
- `<nav class="vp-blog-tags" aria-label="${escapeHtml(context.labels.tags)}">`,
860
+ `<div class="vp-blog-facet vp-blog-facet--${facet}" role="group" aria-label="${escapeHtml(ariaLabel)}" data-vp-blog-facet="${facet}"${limited}${attributes}>`,
861
+ `<span class="vp-blog-facet__label" aria-hidden="true">${escapeTemplateText(label)}</span>`,
862
+ '<div class="vp-blog-facet__chips">',
792
863
  "",
793
- `${label} ${links.join(" ")}`,
864
+ chips.join(" "),
794
865
  "",
866
+ "</div>",
867
+ ...block("vp-blog-facet__aside", aside),
868
+ "</div>"
869
+ ];
870
+ }
871
+ function canNarrow(values, total) {
872
+ return values.length > 1 || values.length === 1 && values[0].count < total;
873
+ }
874
+ function byUse(a, b) {
875
+ return b.count - a.count || a.value.localeCompare(b.value, void 0, { sensitivity: "base" });
876
+ }
877
+ function renderBrowse(posts, index, context) {
878
+ const { labels } = context;
879
+ const { locale, timeZone } = context.dates;
880
+ const { categoryList, categoryLimit, tagList, tagLimit, tagMinCount, archive: showArchive } = index.index;
881
+ const blog = index.routePath;
882
+ const rows = [];
883
+ const categories = categoryList ? collectCategories(posts).sort(byUse) : [];
884
+ if (canNarrow(categories, posts.length)) {
885
+ const chips = categories.map(({ value, count }) => renderChip(value, count, toFilterUrl(blog, "category", value)));
886
+ rows.push({ facet: "category", label: labels.categories, chips, limit: categoryLimit });
887
+ }
888
+ const tags = tagList ? collectTags(posts).map(({ tag, count }) => ({ value: tag, count })).filter(({ count }) => count >= tagMinCount).sort(byUse) : [];
889
+ if (canNarrow(tags, posts.length)) {
890
+ const chips = tags.map(({ value, count }) => renderChip(value, count, toFilterUrl(blog, "tag", value)));
891
+ rows.push({ facet: "tag", label: labels.tags, chips, limit: tagLimit });
892
+ }
893
+ const archive = showArchive ? collectArchive(posts, timeZone) : [];
894
+ const monthChips = (months) => months.map(
895
+ ({ value, count }) => renderChip(
896
+ formatMonth(value, locale, { month: "short" }),
897
+ count,
898
+ toFilterUrl(blog, "month", value),
899
+ formatMonth(value, locale, { month: "long", year: "numeric" })
900
+ )
901
+ );
902
+ if (archive.length === 1 && canNarrow(archive[0].months, posts.length)) {
903
+ const [year] = archive;
904
+ const ariaLabel = `${labels.archive} ${year.value}`;
905
+ const attributes = ` data-year="${year.value}"`;
906
+ rows.push({ facet: "month", label: labels.archive, chips: monthChips(year.months), ariaLabel, attributes });
907
+ } else if (archive.length > 1) {
908
+ const years = archive.map(({ value, count }) => renderChip(value, count, toFilterUrl(blog, "year", value)));
909
+ rows.push({ facet: "year", label: labels.archive, chips: years });
910
+ for (const year of archive) {
911
+ const ariaLabel = `${labels.archive} ${year.value}`;
912
+ const attributes = ` data-year="${year.value}" hidden`;
913
+ rows.push({ facet: "month", label: year.value, chips: monthChips(year.months), ariaLabel, attributes });
914
+ }
915
+ }
916
+ if (rows.length === 0) return [];
917
+ rows[0].aside = renderRssLink(index, context);
918
+ return [
919
+ `<nav class="vp-blog-browse" aria-label="${escapeHtml(labels.browse)}">`,
920
+ ...rows.flatMap(renderFacet),
795
921
  "</nav>"
796
922
  ];
797
923
  }
@@ -799,14 +925,23 @@ function renderClientAttributes(index, context) {
799
925
  const { labels } = context;
800
926
  const attributes = {
801
927
  "data-page-size": String(index.index.pageSize),
802
- "data-label-tagged": labels.tagged,
928
+ // For writing a `?month=` filter's name: "October 2026".
929
+ "data-locale": context.locale,
803
930
  "data-label-show-all": labels.showAll,
804
- "data-label-no-tagged": labels.noTagged,
805
931
  "data-label-previous": labels.previous,
806
932
  "data-label-next": labels.next,
807
933
  "data-label-pages": labels.pages,
808
934
  "data-label-announcements": labels.announcements,
809
- "data-label-no-announcements": labels.noAnnouncements
935
+ "data-label-category": labels.category,
936
+ "data-label-tag": labels.tag,
937
+ "data-label-post-count": labels.postCount,
938
+ "data-label-post-count-one": labels.postCountOne,
939
+ "data-label-no-matches": labels.noMatches,
940
+ "data-label-remove-filter": labels.removeFilter,
941
+ "data-label-more": labels.more,
942
+ "data-label-less": labels.less,
943
+ "data-label-filter": labels.filter,
944
+ "data-label-no-filter-matches": labels.noFilterMatches
810
945
  };
811
946
  return Object.entries(attributes).map(([name, value]) => `${name}="${escapeHtml(value)}"`).join(" ");
812
947
  }
@@ -814,20 +949,19 @@ function renderPostList(pinned, rest, index, context) {
814
949
  const all = [...pinned, ...rest];
815
950
  if (all.length === 0) return `<p class="vp-blog-empty">${escapeTemplateText(context.labels.noPosts)}</p>
816
951
  `;
952
+ const browse = renderBrowse(all, index, context);
817
953
  const lines = [
818
954
  `<div class="vp-blog" data-vp-blog ${renderClientAttributes(index, context)}>`,
819
- ...block("vp-blog-subscribe", renderRssLink(index, context)),
820
- ...renderTagList(all, index, context),
821
- '<div class="vp-blog-filter" data-vp-blog-filter hidden></div>'
955
+ ...browse.length ? browse : block("vp-blog-subscribe", renderRssLink(index, context)),
956
+ '<div class="vp-blog-filter" data-vp-blog-filter role="status" hidden></div>'
822
957
  ];
823
958
  if (pinned.length)
824
959
  lines.push(
825
- '<section class="vp-blog-sticky" data-vp-blog-sticky>',
960
+ '<section class="vp-blog-pinned" data-vp-blog-pinned>',
826
961
  ...renderGroup(pinned, index, context),
827
962
  "</section>"
828
963
  );
829
- if (rest.length)
830
- lines.push(...index.index.groupByYear ? renderYears(rest, index, context) : renderGroup(rest, index, context));
964
+ if (rest.length) lines.push(...renderGroups(rest, index, context));
831
965
  lines.push(`<nav class="vp-blog-pagination" data-vp-blog-pagination hidden></nav>`, "</div>");
832
966
  return `${lines.join("\n")}
833
967
  `;
@@ -836,8 +970,12 @@ function renderPostHeader(post, context, show, blogRoutePath = null) {
836
970
  const lines = [];
837
971
  if (show.byline) {
838
972
  const meta = renderMeta(post, context, { authors: true, updated: true, badges: true });
839
- const tags = post.post.tags.length ? `<span class="vp-blog-byline__tags">${renderTags(post.post.tags, blogRoutePath)}</span>` : "";
840
- lines.push(...block("vp-blog-byline", [meta, tags].filter(Boolean).join("\n\n")));
973
+ const { category, tags } = post.post;
974
+ const chips = [
975
+ category && `<span class="vp-blog-byline__category">${renderFilterLink(category, "category", blogRoutePath, "vp-blog-category")}</span>`,
976
+ tags.length && `<span class="vp-blog-byline__tags">${renderTags(tags, blogRoutePath)}</span>`
977
+ ].filter(Boolean).join(" ");
978
+ lines.push(...block("vp-blog-byline", [meta, chips].filter(Boolean).join("\n\n")));
841
979
  }
842
980
  if (show.cover && post.post.image) {
843
981
  const image = /^[./@~]/.test(post.post.image) || isExternalUrl(post.post.image) ? post.post.image : `./${post.post.image}`;
@@ -928,7 +1066,7 @@ function toOptionsKey(options) {
928
1066
  options.wordsPerMinute,
929
1067
  options.stripTags,
930
1068
  options.pageSize,
931
- options.stickyLimit,
1069
+ options.pinLimit ?? options.stickyLimit,
932
1070
  options.rssLink,
933
1071
  options.timeZone
934
1072
  ]);
@@ -1017,10 +1155,13 @@ function resolveWidgetConfig(body) {
1017
1155
  config: {
1018
1156
  blog: typeof data.blog === "string" ? data.blog.trim() : "",
1019
1157
  limit: toCount2(data.limit) ?? DEFAULT_LIMIT,
1020
- stickyLimit: toCount2(data.stickyLimit),
1158
+ // `stickyLimit` is this setting's old name.
1159
+ pinLimit: toCount2(data.pinLimit ?? data.stickyLimit),
1021
1160
  layout,
1022
1161
  showTags: typeof data.showTags === "boolean" ? data.showTags : void 0,
1162
+ showCategory: typeof data.showCategory === "boolean" ? data.showCategory : void 0,
1023
1163
  tags: toStringList(data.tags),
1164
+ categories: toStringList(data.categories),
1024
1165
  more: data.more === false ? null : typeof data.more === "string" && data.more.trim() ? data.more.trim() : "",
1025
1166
  rssLink: typeof data.rssLink === "boolean" ? data.rssLink : void 0
1026
1167
  },
@@ -1036,19 +1177,19 @@ function renderWidget(body, environment) {
1036
1177
  warn(config.blog ? `no blog page found at "${config.blog}"` : 'no blog covers this page: name one with "blog:"');
1037
1178
  return "";
1038
1179
  }
1039
- const stickyLimit = config.stickyLimit ?? blog.index.stickyLimit;
1040
- const wanted = new Set(config.tags.map((tag) => tag.toLowerCase()));
1041
- const posts = listBlogPosts(pages, blog, { drafts, stickyFirst: false, applyLimit: false }).filter(
1042
- (post) => wanted.size === 0 || post.post.tags.some((tag) => wanted.has(tag.toLowerCase()))
1180
+ const pinLimit = config.pinLimit ?? blog.index.pinLimit;
1181
+ const posts = listBlogPosts(pages, blog, { drafts, pinnedFirst: false, applyLimit: false }).filter(
1182
+ (post) => hasAnyTag(post, config.tags) && inAnyCategory(post, config.categories)
1043
1183
  );
1044
- const { pinned, rest } = pinStickyPosts(posts, stickyLimit);
1184
+ const { pinned, rest } = pinPosts(posts, pinLimit);
1045
1185
  const shown = [...pinned, ...rest].slice(0, config.limit || void 0);
1046
1186
  const view = {
1047
1187
  ...blog,
1048
1188
  index: {
1049
1189
  ...blog.index,
1050
1190
  layout: config.layout ?? blog.index.layout,
1051
- showTags: config.showTags ?? blog.index.showTags
1191
+ showTags: config.showTags ?? blog.index.showTags,
1192
+ showCategory: config.showCategory ?? blog.index.showCategory
1052
1193
  }
1053
1194
  };
1054
1195
  const lines = [`<div class="vp-blog-widget">`];
@@ -1121,8 +1262,8 @@ function hasFeeds(options) {
1121
1262
  }
1122
1263
  function withPostList(source, page, pages, options) {
1123
1264
  if (!isIndex(page)) return source;
1124
- const posts = listBlogPosts(pages, page, { drafts: options.drafts ?? false, stickyFirst: true, applyLimit: true });
1125
- const { pinned, rest } = pinStickyPosts(posts, page.index.stickyLimit);
1265
+ const posts = listBlogPosts(pages, page, { drafts: options.drafts ?? false, pinnedFirst: true, applyLimit: true });
1266
+ const { pinned, rest } = pinPosts(posts, page.index.pinLimit);
1126
1267
  const context = createRenderContext(options, import_node_path6.default.dirname(page.absolutePath), hasFeeds(options));
1127
1268
  const generated = renderPostList(pinned, rest, page, context);
1128
1269
  return source.includes(BLOG_POSTS_PLACEHOLDER) ? source.replace(BLOG_POSTS_PLACEHOLDER, () => `
@@ -1145,7 +1286,7 @@ function withPostDecorations(source, page, pages, options) {
1145
1286
  let result = header ? insertAfterTitle(source, header) : source;
1146
1287
  if (page.post.postNav ?? options.postNav ?? true) {
1147
1288
  if (blog) {
1148
- const posts = listBlogPosts(pages, blog, { drafts, stickyFirst: false, applyLimit: false });
1289
+ const posts = listBlogPosts(pages, blog, { drafts, pinnedFirst: false, applyLimit: false });
1149
1290
  result = `${result.replace(/\s*$/, "")}
1150
1291
 
1151
1292
  ${renderPostNav(page, posts, blog, context)}
@@ -1222,7 +1363,7 @@ function attachAnnouncements(siteConfig, options = {}) {
1222
1363
  }
1223
1364
  function listFeedPosts(pages, index, options) {
1224
1365
  const limit = index.index.feed?.limit ?? options.feedLimit ?? DEFAULT_FEED_LIMIT;
1225
- return listBlogPosts(pages, index, { drafts: false, stickyFirst: false, applyLimit: false }).slice(0, limit);
1366
+ return listBlogPosts(pages, index, { drafts: false, pinnedFirst: false, applyLimit: false }).slice(0, limit);
1226
1367
  }
1227
1368
  function renderIndexFeed(pages, index, site, siteConfig, options) {
1228
1369
  return renderFeed({