@binarynoir/vitepress-blog 0.3.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.
@@ -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,24 @@ 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}.",
152
163
  previous: "Previous",
153
164
  next: "Next",
154
165
  pages: "Pages",
@@ -319,6 +330,13 @@ function toDateTimeAttribute({ date, hasTime }, timeZone) {
319
330
  function yearIn(date, timeZone) {
320
331
  return Number(toZonedIsoDate(date, timeZone).slice(0, 4));
321
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
+ }
322
340
 
323
341
  // src/frontmatter.ts
324
342
  var POST_FRONTMATTER_KEY = "blog";
@@ -442,7 +460,8 @@ function resolvePostData(data, content, fallbackTitle, options = {}) {
442
460
  tags: toStringList(pick("tags")),
443
461
  category: toText(pick("category")),
444
462
  scope: toScope(post.scope),
445
- sticky: post.sticky === true,
463
+ // `sticky` is this setting's old name.
464
+ pinned: (post.pinned ?? post.sticky) === true,
446
465
  draft: post.draft === true,
447
466
  announcement: post.announcement === true || !!announcementText,
448
467
  announcementText,
@@ -455,6 +474,9 @@ function resolvePostData(data, content, fallbackTitle, options = {}) {
455
474
  function toLayout(value) {
456
475
  return value === "grid" || value === "compact" ? value : "list";
457
476
  }
477
+ function toGroupBy(value) {
478
+ return value === "year" || value === "month" ? value : "";
479
+ }
458
480
  function defaultFeedPath(relativePath) {
459
481
  const name = import_node_path.default.posix.basename(relativePath, ".md");
460
482
  return name === "index" ? "feed.xml" : `${name}-feed.xml`;
@@ -488,13 +510,17 @@ function resolveIndexConfig(data, relativePath, defaults = {}) {
488
510
  scope,
489
511
  folders: folders.length ? resolveFolders(folders, relativePath) : scope === "section" ? [ownFolder] : [],
490
512
  tags: toStringList(options.tags),
513
+ categories: toStringList(options.categories),
491
514
  limit: typeof options.limit === "number" && options.limit > 0 ? Math.floor(options.limit) : 0,
492
515
  layout: toLayout(options.layout),
493
- groupByYear: options.groupBy === "year",
516
+ groupBy: toGroupBy(options.groupBy),
494
517
  showTags: options.showTags !== false,
518
+ showCategory: options.showCategory !== false,
519
+ categoryList: options.categoryList !== false,
495
520
  tagList: options.tagList !== false,
521
+ archive: options.archive !== false,
496
522
  pageSize: toCount(options.pageSize, toCount(defaults.pageSize, 10)),
497
- stickyLimit: toCount(options.stickyLimit, toCount(defaults.stickyLimit, 0)),
523
+ pinLimit: toCount(options.pinLimit ?? options.stickyLimit, toCount(defaults.pinLimit ?? defaults.stickyLimit, 0)),
498
524
  rssLink: typeof options.rssLink === "boolean" ? options.rssLink : defaults.rssLink ?? true,
499
525
  feed: toFeed(options.feed, relativePath)
500
526
  };
@@ -652,27 +678,33 @@ function renderAuthors(authors, context) {
652
678
  function renderBadge(kind, context) {
653
679
  return `<span class="vp-blog-badge vp-blog-badge--${kind}">${escapeTemplateText(context.labels[kind])}</span>`;
654
680
  }
655
- function toTagUrl(blogRoutePath, tag) {
656
- 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");
657
683
  const page = blogRoutePath.replace(/(^|\/)index\.md$/, "$1");
658
- 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>`;
659
688
  }
660
689
  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(" ");
690
+ return tags.map((tag) => renderFilterLink(tag, "tag", blogRoutePath, "vp-blog-tag")).join(" ");
664
691
  }
665
692
  function toTagsAttribute(post) {
666
693
  return escapeHtml(post.post.tags.map((tag) => tag.toLowerCase()).join("|"));
667
694
  }
668
- function toItemAttributes(post) {
669
- return `data-vp-blog-item data-tags="${toTagsAttribute(post)}"${post.post.announcement ? " data-announcement" : ""}`;
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(" ");
670
702
  }
671
703
  function renderBadges(post, context) {
672
- const { draft, sticky, announcement } = post.post;
704
+ const { draft, pinned, announcement } = post.post;
673
705
  return [
674
706
  draft && renderBadge("draft", context),
675
- sticky && renderBadge("sticky", context),
707
+ pinned && renderBadge("pinned", context),
676
708
  announcement && renderBadge("announcement", context)
677
709
  ].filter(Boolean).join(" ");
678
710
  }
@@ -707,7 +739,7 @@ function renderCard(post, context, index) {
707
739
  const link = `/${post.routePath}`;
708
740
  const image = toImageSource(post, context.pageDirectory);
709
741
  const modifiers = [
710
- post.post.sticky && "sticky",
742
+ post.post.pinned && "pinned",
711
743
  post.post.draft && "draft",
712
744
  post.post.announcement && "announcement",
713
745
  image && "with-image",
@@ -715,9 +747,10 @@ function renderCard(post, context, index) {
715
747
  ].filter(Boolean).map((modifier) => ` vp-blog-card--${modifier}`).join("");
716
748
  const alt = escapeMarkdown(post.post.imageAlt);
717
749
  return [
718
- `<article class="vp-blog-card${modifiers}"${imageStyle(post)} ${toItemAttributes(post)}>`,
750
+ `<article class="vp-blog-card${modifiers}"${imageStyle(post)} ${toItemAttributes(post, context)}>`,
719
751
  ...image ? block("vp-blog-card__image", `[![${alt}](${toMarkdownUrl(image)})](${toMarkdownUrl(link)})`) : [],
720
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, "")) : [],
721
754
  ...block("vp-blog-card__title", toPageLink(post.post.title, post.routePath)),
722
755
  ...block("vp-blog-card__meta", renderMeta(post, context, { authors: true, updated: false, badges: true })),
723
756
  ...block("vp-blog-card__summary", escapeMarkdown(post.post.summary)),
@@ -729,7 +762,7 @@ function renderCard(post, context, index) {
729
762
  function renderCompactItem(post, context) {
730
763
  const date = post.post.date ? `${renderTime(post.post.date, post.post.dateHasTime, context)} ` : "";
731
764
  const badges = renderBadges(post, context);
732
- const marker = `<span hidden ${toItemAttributes(post)}></span>`;
765
+ const marker = `<span hidden ${toItemAttributes(post, context)}></span>`;
733
766
  return `- ${marker}${date}${toPageLink(post.post.title, post.routePath)}${badges ? ` ${badges}` : ""}`;
734
767
  }
735
768
  function renderGroup(posts, index, context) {
@@ -749,18 +782,23 @@ function renderGroup(posts, index, context) {
749
782
  "</div>"
750
783
  ];
751
784
  }
752
- function renderYears(posts, index, context) {
753
- 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();
754
790
  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]);
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]);
757
794
  }
758
795
  const lines = [];
759
- 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" });
760
798
  lines.push(
761
- '<section class="vp-blog-year">',
799
+ `<section class="vp-blog-group vp-blog-${groupBy}">`,
762
800
  "",
763
- `## ${escapeMarkdown(year)}`,
801
+ `## ${escapeMarkdown(heading)}`,
764
802
  "",
765
803
  ...renderGroup(group, index, context),
766
804
  "",
@@ -769,44 +807,114 @@ function renderYears(posts, index, context) {
769
807
  }
770
808
  return lines;
771
809
  }
772
- function collectTags(posts) {
773
- const tags = /* @__PURE__ */ new Map();
810
+ function collectValues(posts, pick) {
811
+ const values = /* @__PURE__ */ new Map();
774
812
  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 };
813
+ for (const value of new Set(pick(post))) {
814
+ const entry = values.get(value.toLowerCase()) ?? { value, count: 0 };
777
815
  entry.count++;
778
- tags.set(tag.toLowerCase(), entry);
816
+ values.set(value.toLowerCase(), entry);
779
817
  }
780
818
  }
781
- 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" }));
782
820
  }
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>`;
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 = "") {
790
847
  return [
791
- `<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">',
792
851
  "",
793
- `${label} ${links.join(" ")}`,
852
+ chips.join(" "),
794
853
  "",
795
- "</nav>"
854
+ "</div>",
855
+ "</div>"
796
856
  ];
797
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
+ }
798
901
  function renderClientAttributes(index, context) {
799
902
  const { labels } = context;
800
903
  const attributes = {
801
904
  "data-page-size": String(index.index.pageSize),
802
- "data-label-tagged": labels.tagged,
905
+ // For writing a `?month=` filter's name: "October 2026".
906
+ "data-locale": context.locale,
803
907
  "data-label-show-all": labels.showAll,
804
- "data-label-no-tagged": labels.noTagged,
805
908
  "data-label-previous": labels.previous,
806
909
  "data-label-next": labels.next,
807
910
  "data-label-pages": labels.pages,
808
911
  "data-label-announcements": labels.announcements,
809
- "data-label-no-announcements": labels.noAnnouncements
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
810
918
  };
811
919
  return Object.entries(attributes).map(([name, value]) => `${name}="${escapeHtml(value)}"`).join(" ");
812
920
  }
@@ -817,17 +925,16 @@ function renderPostList(pinned, rest, index, context) {
817
925
  const lines = [
818
926
  `<div class="vp-blog" data-vp-blog ${renderClientAttributes(index, context)}>`,
819
927
  ...block("vp-blog-subscribe", renderRssLink(index, context)),
820
- ...renderTagList(all, index, context),
821
- '<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>'
822
930
  ];
823
931
  if (pinned.length)
824
932
  lines.push(
825
- '<section class="vp-blog-sticky" data-vp-blog-sticky>',
933
+ '<section class="vp-blog-pinned" data-vp-blog-pinned>',
826
934
  ...renderGroup(pinned, index, context),
827
935
  "</section>"
828
936
  );
829
- if (rest.length)
830
- lines.push(...index.index.groupByYear ? renderYears(rest, index, context) : renderGroup(rest, index, context));
937
+ if (rest.length) lines.push(...renderGroups(rest, index, context));
831
938
  lines.push(`<nav class="vp-blog-pagination" data-vp-blog-pagination hidden></nav>`, "</div>");
832
939
  return `${lines.join("\n")}
833
940
  `;
@@ -836,8 +943,12 @@ function renderPostHeader(post, context, show, blogRoutePath = null) {
836
943
  const lines = [];
837
944
  if (show.byline) {
838
945
  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")));
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")));
841
952
  }
842
953
  if (show.cover && post.post.image) {
843
954
  const image = /^[./@~]/.test(post.post.image) || isExternalUrl(post.post.image) ? post.post.image : `./${post.post.image}`;
@@ -928,7 +1039,7 @@ function toOptionsKey(options) {
928
1039
  options.wordsPerMinute,
929
1040
  options.stripTags,
930
1041
  options.pageSize,
931
- options.stickyLimit,
1042
+ options.pinLimit ?? options.stickyLimit,
932
1043
  options.rssLink,
933
1044
  options.timeZone
934
1045
  ]);
@@ -1017,10 +1128,13 @@ function resolveWidgetConfig(body) {
1017
1128
  config: {
1018
1129
  blog: typeof data.blog === "string" ? data.blog.trim() : "",
1019
1130
  limit: toCount2(data.limit) ?? DEFAULT_LIMIT,
1020
- stickyLimit: toCount2(data.stickyLimit),
1131
+ // `stickyLimit` is this setting's old name.
1132
+ pinLimit: toCount2(data.pinLimit ?? data.stickyLimit),
1021
1133
  layout,
1022
1134
  showTags: typeof data.showTags === "boolean" ? data.showTags : void 0,
1135
+ showCategory: typeof data.showCategory === "boolean" ? data.showCategory : void 0,
1023
1136
  tags: toStringList(data.tags),
1137
+ categories: toStringList(data.categories),
1024
1138
  more: data.more === false ? null : typeof data.more === "string" && data.more.trim() ? data.more.trim() : "",
1025
1139
  rssLink: typeof data.rssLink === "boolean" ? data.rssLink : void 0
1026
1140
  },
@@ -1036,19 +1150,19 @@ function renderWidget(body, environment) {
1036
1150
  warn(config.blog ? `no blog page found at "${config.blog}"` : 'no blog covers this page: name one with "blog:"');
1037
1151
  return "";
1038
1152
  }
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()))
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)
1043
1156
  );
1044
- const { pinned, rest } = pinStickyPosts(posts, stickyLimit);
1157
+ const { pinned, rest } = pinPosts(posts, pinLimit);
1045
1158
  const shown = [...pinned, ...rest].slice(0, config.limit || void 0);
1046
1159
  const view = {
1047
1160
  ...blog,
1048
1161
  index: {
1049
1162
  ...blog.index,
1050
1163
  layout: config.layout ?? blog.index.layout,
1051
- showTags: config.showTags ?? blog.index.showTags
1164
+ showTags: config.showTags ?? blog.index.showTags,
1165
+ showCategory: config.showCategory ?? blog.index.showCategory
1052
1166
  }
1053
1167
  };
1054
1168
  const lines = [`<div class="vp-blog-widget">`];
@@ -1121,8 +1235,8 @@ function hasFeeds(options) {
1121
1235
  }
1122
1236
  function withPostList(source, page, pages, options) {
1123
1237
  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);
1238
+ const posts = listBlogPosts(pages, page, { drafts: options.drafts ?? false, pinnedFirst: true, applyLimit: true });
1239
+ const { pinned, rest } = pinPosts(posts, page.index.pinLimit);
1126
1240
  const context = createRenderContext(options, import_node_path6.default.dirname(page.absolutePath), hasFeeds(options));
1127
1241
  const generated = renderPostList(pinned, rest, page, context);
1128
1242
  return source.includes(BLOG_POSTS_PLACEHOLDER) ? source.replace(BLOG_POSTS_PLACEHOLDER, () => `
@@ -1145,7 +1259,7 @@ function withPostDecorations(source, page, pages, options) {
1145
1259
  let result = header ? insertAfterTitle(source, header) : source;
1146
1260
  if (page.post.postNav ?? options.postNav ?? true) {
1147
1261
  if (blog) {
1148
- const posts = listBlogPosts(pages, blog, { drafts, stickyFirst: false, applyLimit: false });
1262
+ const posts = listBlogPosts(pages, blog, { drafts, pinnedFirst: false, applyLimit: false });
1149
1263
  result = `${result.replace(/\s*$/, "")}
1150
1264
 
1151
1265
  ${renderPostNav(page, posts, blog, context)}
@@ -1222,7 +1336,7 @@ function attachAnnouncements(siteConfig, options = {}) {
1222
1336
  }
1223
1337
  function listFeedPosts(pages, index, options) {
1224
1338
  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);
1339
+ return listBlogPosts(pages, index, { drafts: false, pinnedFirst: false, applyLimit: false }).slice(0, limit);
1226
1340
  }
1227
1341
  function renderIndexFeed(pages, index, site, siteConfig, options) {
1228
1342
  return renderFeed({