@binarynoir/vitepress-blog 0.2.0 → 0.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/index.js CHANGED
@@ -5,7 +5,8 @@ import {
5
5
  POST_FRONTMATTER_KEY,
6
6
  WIDGET_NAME,
7
7
  blogMarkdown
8
- } from "./chunk-PWNWSYI4.js";
8
+ } from "./chunk-LYTJBMVK.js";
9
+ import "./chunk-42Z6H7IN.js";
9
10
  export {
10
11
  BLOG_POSTS_PLACEHOLDER,
11
12
  DEFAULT_LABELS,
@@ -96,6 +96,16 @@ interface BlogLabels {
96
96
  draft: string;
97
97
  /** Badge on a sticky post. */
98
98
  sticky: string;
99
+ /** Badge on an announcement, and the accessible name of the announcement banner. */
100
+ announcement: string;
101
+ /** Above a blog page filtered to announcements (`?announcements`). */
102
+ announcements: string;
103
+ /** When the blog page has no announcements to show. */
104
+ noAnnouncements: string;
105
+ /** Tooltip of the banner's count badge: "{n} announcements". */
106
+ announcementCount: string;
107
+ /** Accessible name of the banner's close button. */
108
+ dismiss: string;
99
109
  noPosts: string;
100
110
  /** Year heading for posts without a date, when a blog groups by year. */
101
111
  undated: string;
@@ -176,12 +186,32 @@ interface BlogPostData {
176
186
  scope: BlogScope;
177
187
  sticky: boolean;
178
188
  draft: boolean;
189
+ /** Shown in the announcement banner above the nav bar. */
190
+ announcement: boolean;
191
+ /** The banner's text, from `announcement: "…"`. `''` uses the title. */
192
+ announcementText: string;
179
193
  /** Per-post overrides of the `byline`, `cover` and `postNav` options. */
180
194
  byline?: boolean;
181
195
  cover?: boolean;
182
196
  postNav?: boolean;
183
197
  readingMinutes: number;
184
198
  }
199
+ /** One announcement, as the banner shows it. */
200
+ interface BlogAnnouncementItem {
201
+ /** The post's path and date: what a dismissal remembers, so re-dating a post announces it again. */
202
+ id: string;
203
+ text: string;
204
+ /** The post's URL path, without the site's base. */
205
+ link: string;
206
+ }
207
+ /** What the announcement banner reads from VitePress's site data. */
208
+ interface BlogAnnouncementData {
209
+ /** Newest first: the banner shows the first one that hasn't been dismissed. */
210
+ items: BlogAnnouncementItem[];
211
+ /** The blog page filtered to announcements (`/blog/?announcements`), without the base, or `''` when no blog lists them. */
212
+ listLink: string;
213
+ labels: Pick<BlogLabels, 'announcement' | 'announcementCount' | 'dismiss'>;
214
+ }
185
215
 
186
216
  /**
187
217
  * A markdown-it plugin that turns pages into a blog.
@@ -210,4 +240,4 @@ interface BlogPostData {
210
240
  */
211
241
  declare function blogMarkdown(md: MarkdownIt, options?: BlogOptions): void;
212
242
 
213
- export { type BlogAuthor as B, DEFAULT_LABELS as D, type BlogFeedConfig as a, type BlogIndexConfig as b, type BlogLabels as c, type BlogLayout as d, type BlogOptions as e, type BlogPostData as f, type BlogScope as g, blogMarkdown as h };
243
+ export { type BlogAnnouncementData as B, DEFAULT_LABELS as D, type BlogAnnouncementItem as a, type BlogAuthor as b, type BlogFeedConfig as c, type BlogIndexConfig as d, type BlogLabels as e, type BlogLayout as f, type BlogOptions as g, type BlogPostData as h, type BlogScope as i, blogMarkdown as j };
@@ -96,6 +96,16 @@ interface BlogLabels {
96
96
  draft: string;
97
97
  /** Badge on a sticky post. */
98
98
  sticky: string;
99
+ /** Badge on an announcement, and the accessible name of the announcement banner. */
100
+ announcement: string;
101
+ /** Above a blog page filtered to announcements (`?announcements`). */
102
+ announcements: string;
103
+ /** When the blog page has no announcements to show. */
104
+ noAnnouncements: string;
105
+ /** Tooltip of the banner's count badge: "{n} announcements". */
106
+ announcementCount: string;
107
+ /** Accessible name of the banner's close button. */
108
+ dismiss: string;
99
109
  noPosts: string;
100
110
  /** Year heading for posts without a date, when a blog groups by year. */
101
111
  undated: string;
@@ -176,12 +186,32 @@ interface BlogPostData {
176
186
  scope: BlogScope;
177
187
  sticky: boolean;
178
188
  draft: boolean;
189
+ /** Shown in the announcement banner above the nav bar. */
190
+ announcement: boolean;
191
+ /** The banner's text, from `announcement: "…"`. `''` uses the title. */
192
+ announcementText: string;
179
193
  /** Per-post overrides of the `byline`, `cover` and `postNav` options. */
180
194
  byline?: boolean;
181
195
  cover?: boolean;
182
196
  postNav?: boolean;
183
197
  readingMinutes: number;
184
198
  }
199
+ /** One announcement, as the banner shows it. */
200
+ interface BlogAnnouncementItem {
201
+ /** The post's path and date: what a dismissal remembers, so re-dating a post announces it again. */
202
+ id: string;
203
+ text: string;
204
+ /** The post's URL path, without the site's base. */
205
+ link: string;
206
+ }
207
+ /** What the announcement banner reads from VitePress's site data. */
208
+ interface BlogAnnouncementData {
209
+ /** Newest first: the banner shows the first one that hasn't been dismissed. */
210
+ items: BlogAnnouncementItem[];
211
+ /** The blog page filtered to announcements (`/blog/?announcements`), without the base, or `''` when no blog lists them. */
212
+ listLink: string;
213
+ labels: Pick<BlogLabels, 'announcement' | 'announcementCount' | 'dismiss'>;
214
+ }
185
215
 
186
216
  /**
187
217
  * A markdown-it plugin that turns pages into a blog.
@@ -210,4 +240,4 @@ interface BlogPostData {
210
240
  */
211
241
  declare function blogMarkdown(md: MarkdownIt, options?: BlogOptions): void;
212
242
 
213
- export { type BlogAuthor as B, DEFAULT_LABELS as D, type BlogFeedConfig as a, type BlogIndexConfig as b, type BlogLabels as c, type BlogLayout as d, type BlogOptions as e, type BlogPostData as f, type BlogScope as g, blogMarkdown as h };
243
+ export { type BlogAnnouncementData as B, DEFAULT_LABELS as D, type BlogAnnouncementItem as a, type BlogAuthor as b, type BlogFeedConfig as c, type BlogIndexConfig as d, type BlogLabels as e, type BlogLayout as f, type BlogOptions as g, type BlogPostData as h, type BlogScope as i, blogMarkdown as j };
package/dist/style.css CHANGED
@@ -10,6 +10,9 @@
10
10
  --vp-blog-tag-bg: var(--vp-c-default-soft);
11
11
  --vp-blog-tag-color: var(--vp-c-text-2);
12
12
  --vp-blog-accent: var(--vp-c-brand-1);
13
+ --vp-blog-announcement-height: 40px;
14
+ --vp-blog-announcement-bg: var(--vp-button-brand-bg);
15
+ --vp-blog-announcement-color: var(--vp-button-brand-text);
13
16
  }
14
17
 
15
18
  /* Markdown inside the generated blocks is wrapped in <p>; its margins are handled here instead. */
@@ -201,6 +204,11 @@
201
204
  color: var(--vp-c-brand-1);
202
205
  }
203
206
 
207
+ .vp-blog-badge--announcement {
208
+ background: var(--vp-c-tip-soft);
209
+ color: var(--vp-c-tip-1);
210
+ }
211
+
204
212
  .vp-blog-badge--draft {
205
213
  background: var(--vp-c-warning-soft);
206
214
  color: var(--vp-c-warning-1);
@@ -416,3 +424,108 @@
416
424
  /* The RSS orange, as feed readers expect. */
417
425
  color: #f26522;
418
426
  }
427
+
428
+ /*
429
+ * The announcement banner, in the theme's `layout-top` slot: fixed across the
430
+ * top of the window, one line high. While it shows, VitePress's
431
+ * --vp-layout-top-height moves the nav bar, sidebar and content down by its
432
+ * height. The class on <html> hides it before hydration once dismissed.
433
+ */
434
+ :root:not(.vp-blog-announcement-dismissed):has(.vp-blog-announcement) {
435
+ --vp-layout-top-height: var(--vp-blog-announcement-height);
436
+ }
437
+
438
+ .vp-blog-announcement-dismissed .vp-blog-announcement {
439
+ display: none;
440
+ }
441
+
442
+ .vp-blog-announcement {
443
+ position: fixed;
444
+ top: 0;
445
+ right: 0;
446
+ left: 0;
447
+ z-index: var(--vp-z-index-layout-top);
448
+ display: flex;
449
+ gap: 8px;
450
+ align-items: center;
451
+ justify-content: center;
452
+ box-sizing: border-box;
453
+ height: var(--vp-blog-announcement-height);
454
+ padding: 0 48px;
455
+ background: var(--vp-blog-announcement-bg);
456
+ color: var(--vp-blog-announcement-color);
457
+ font-size: 14px;
458
+ font-weight: 500;
459
+ line-height: var(--vp-blog-announcement-height);
460
+ }
461
+
462
+ .vp-blog-announcement__content {
463
+ display: flex;
464
+ gap: 10px;
465
+ align-items: center;
466
+ min-width: 0;
467
+ }
468
+
469
+ .vp-blog-announcement__text {
470
+ overflow: hidden;
471
+ color: inherit;
472
+ text-decoration: none;
473
+ text-overflow: ellipsis;
474
+ white-space: nowrap;
475
+ }
476
+
477
+ .vp-blog-announcement__text:hover {
478
+ text-decoration: underline;
479
+ text-underline-offset: 3px;
480
+ }
481
+
482
+ /* How many announcements there are: a counter pill that opens the list of them. */
483
+ .vp-blog-announcement__count {
484
+ flex: none;
485
+ min-width: 22px;
486
+ padding: 0 7px;
487
+ border-radius: 999px;
488
+ background: var(--vp-blog-announcement-color);
489
+ color: var(--vp-blog-announcement-bg);
490
+ font-size: 12px;
491
+ font-weight: 700;
492
+ font-variant-numeric: tabular-nums;
493
+ line-height: 20px;
494
+ text-align: center;
495
+ text-decoration: none;
496
+ transition: opacity 0.25s;
497
+ }
498
+
499
+ a.vp-blog-announcement__count:hover {
500
+ opacity: 0.85;
501
+ }
502
+
503
+ .vp-blog-announcement__dismiss {
504
+ position: absolute;
505
+ top: 50%;
506
+ right: 12px;
507
+ display: flex;
508
+ align-items: center;
509
+ justify-content: center;
510
+ width: 28px;
511
+ height: 28px;
512
+ border-radius: 6px;
513
+ color: inherit;
514
+ opacity: 0.8;
515
+ transform: translateY(-50%);
516
+ transition:
517
+ background-color 0.25s,
518
+ opacity 0.25s;
519
+ }
520
+
521
+ .vp-blog-announcement__dismiss:hover {
522
+ background: rgba(255, 255, 255, 0.16);
523
+ opacity: 1;
524
+ }
525
+
526
+ @media (max-width: 639px) {
527
+ .vp-blog-announcement {
528
+ justify-content: flex-start;
529
+ padding: 0 48px 0 16px;
530
+ }
531
+ }
@@ -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,
@@ -113,6 +114,111 @@ function findBlogsListing(pages, post) {
113
114
  (index) => listBlogPosts(pages, index, { drafts: false, stickyFirst: false, applyLimit: false }).includes(post)
114
115
  );
115
116
  }
117
+ function listAnnouncements(pages, drafts) {
118
+ return pages.filter(isPost).filter((post) => post.post.announcement && (drafts || !post.post.draft)).sort(compareByDate);
119
+ }
120
+ function findAnnouncementBlog(pages, announcements, drafts) {
121
+ if (announcements.length === 0) return null;
122
+ const listingAll = pages.filter(isIndex).filter((index) => {
123
+ const listed = listBlogPosts(pages, index, { drafts, stickyFirst: false, applyLimit: false });
124
+ return announcements.every((post) => listed.includes(post));
125
+ });
126
+ return listingAll.find((index) => index.index.scope === "site") ?? listingAll[0] ?? findHomeBlog(pages, announcements[0], drafts);
127
+ }
128
+
129
+ // src/types.ts
130
+ var DEFAULT_LABELS = {
131
+ by: "By",
132
+ published: "Published",
133
+ updated: "Updated",
134
+ dateTime: "{date} at {time}",
135
+ readingTime: "{n} min read",
136
+ newer: "Newer post",
137
+ older: "Older post",
138
+ allPosts: "All posts in {blog}",
139
+ draft: "Draft",
140
+ sticky: "Pinned",
141
+ announcement: "Announcement",
142
+ announcements: "Announcements",
143
+ noAnnouncements: "No announcements.",
144
+ announcementCount: "{n} announcements",
145
+ dismiss: "Dismiss",
146
+ noPosts: "No posts yet.",
147
+ undated: "Undated",
148
+ tags: "Keywords",
149
+ tagged: "Posts tagged {tag}",
150
+ showAll: "Show all posts",
151
+ noTagged: "No posts tagged {tag}.",
152
+ previous: "Previous",
153
+ next: "Next",
154
+ pages: "Pages",
155
+ subscribe: "Subscribe",
156
+ rssFeed: "RSS feed for {blog}"
157
+ };
158
+
159
+ // src/util.ts
160
+ var HTML_ESCAPES = {
161
+ "&": "&amp;",
162
+ "<": "&lt;",
163
+ ">": "&gt;",
164
+ '"': "&quot;",
165
+ "'": "&#39;"
166
+ };
167
+ function escapeHtml(value) {
168
+ return value.replace(/[&<>"']/g, (character) => HTML_ESCAPES[character]);
169
+ }
170
+ function escapeMarkdown(text) {
171
+ const escaped = text.replace(/[\\`*_[\]#|~]/g, "\\$&").replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;");
172
+ return /[{}]/.test(escaped) ? `<span v-pre>${escaped}</span>` : escaped;
173
+ }
174
+ function escapeTemplateText(text) {
175
+ const escaped = escapeHtml(text);
176
+ return /[{}]/.test(escaped) ? `<span v-pre>${escaped}</span>` : escaped;
177
+ }
178
+ function isExternalUrl(url) {
179
+ return /^(?:[a-z][a-z\d+.-]*:|\/\/)/i.test(url);
180
+ }
181
+ function toMarkdownUrl(url) {
182
+ return encodeURI(url).replace(/[()]/g, (character) => character === "(" ? "%28" : "%29");
183
+ }
184
+ function toPageLink(text, routePath) {
185
+ return `[${escapeMarkdown(text)}](${toMarkdownUrl(`/${routePath}`)})`;
186
+ }
187
+ function toUrlPath(routePath, cleanUrls) {
188
+ return `/${routePath.replace(/(^|\/)index\.md$/, "$1").replace(/\.md$/, cleanUrls ? "" : ".html")}`;
189
+ }
190
+ function joinUrl(base, urlPath) {
191
+ return `${base.replace(/\/+$/, "")}/${urlPath.replace(/^\/+/, "")}`;
192
+ }
193
+ var ANNOUNCEMENTS_KEY = "blogAnnouncements";
194
+ var DISMISSED_STORAGE_KEY = "vp-blog-announcements-dismissed";
195
+ var DISMISSED_CLASS = "vp-blog-announcement-dismissed";
196
+
197
+ // src/announcements.ts
198
+ function resolveAnnouncements(pages, options, cleanUrls) {
199
+ const drafts = options.drafts ?? false;
200
+ const announcements = listAnnouncements(pages, drafts);
201
+ if (announcements.length === 0) return null;
202
+ const blog = findAnnouncementBlog(pages, announcements, drafts);
203
+ const labels = { ...DEFAULT_LABELS, ...options.labels };
204
+ return {
205
+ items: announcements.map((post) => ({
206
+ id: `${post.routePath}@${post.post.date?.toISOString() ?? ""}`,
207
+ text: post.post.announcementText || post.post.title,
208
+ link: toUrlPath(post.routePath, cleanUrls)
209
+ })),
210
+ listLink: blog ? `${toUrlPath(blog.routePath, cleanUrls)}?announcements` : "",
211
+ labels: {
212
+ announcement: labels.announcement,
213
+ announcementCount: labels.announcementCount,
214
+ dismiss: labels.dismiss
215
+ }
216
+ };
217
+ }
218
+ function renderDismissedScript(data) {
219
+ const id = JSON.stringify(data.items[0].id).replace(/</g, "\\u003c");
220
+ 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){}`;
221
+ }
116
222
 
117
223
  // src/frontmatter.ts
118
224
  var import_node_path = __toESM(require("path"), 1);
@@ -320,6 +426,7 @@ function resolvePostData(data, content, fallbackTitle, options = {}) {
320
426
  const timeZone = resolveTimeZone(options.timeZone);
321
427
  const date = parsePostDate(pick("date"), timeZone);
322
428
  const updated = parsePostDate(pick("updated"), timeZone);
429
+ const announcementText = typeof post.announcement === "string" ? clean(post.announcement.trim()) : "";
323
430
  return {
324
431
  title: clean(toText(post.title) || toText(data.title) || readHeading(content)) || fallbackTitle,
325
432
  summary: clean(toText(post.summary) || toText(data.description) || readExcerpt(content)),
@@ -337,6 +444,8 @@ function resolvePostData(data, content, fallbackTitle, options = {}) {
337
444
  scope: toScope(post.scope),
338
445
  sticky: post.sticky === true,
339
446
  draft: post.draft === true,
447
+ announcement: post.announcement === true || !!announcementText,
448
+ announcementText,
340
449
  byline: toBoolean(post.byline),
341
450
  cover: toBoolean(post.cover),
342
451
  postNav: toBoolean(post.postNav),
@@ -395,43 +504,6 @@ function resolveIndexConfig(data, relativePath, defaults = {}) {
395
504
  var import_node_crypto = require("crypto");
396
505
  var import_node_fs = require("fs");
397
506
  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
507
  function toAbsoluteUrl(urlPath, site) {
436
508
  return joinUrl(site.siteUrl, joinUrl(site.base, urlPath));
437
509
  }
@@ -527,33 +599,6 @@ var import_node_path6 = __toESM(require("path"), 1);
527
599
 
528
600
  // src/render.ts
529
601
  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
602
  var BLOG_POSTS_PLACEHOLDER = "<!-- blog-posts -->";
558
603
  function createRenderContext(options, pageDirectory, feeds = !!options.siteUrl) {
559
604
  const locale = options.locale ?? "en-US";
@@ -620,12 +665,22 @@ function renderTags(tags, blogRoutePath) {
620
665
  function toTagsAttribute(post) {
621
666
  return escapeHtml(post.post.tags.map((tag) => tag.toLowerCase()).join("|"));
622
667
  }
668
+ function toItemAttributes(post) {
669
+ return `data-vp-blog-item data-tags="${toTagsAttribute(post)}"${post.post.announcement ? " data-announcement" : ""}`;
670
+ }
671
+ function renderBadges(post, context) {
672
+ const { draft, sticky, announcement } = post.post;
673
+ return [
674
+ draft && renderBadge("draft", context),
675
+ sticky && renderBadge("sticky", context),
676
+ announcement && renderBadge("announcement", context)
677
+ ].filter(Boolean).join(" ");
678
+ }
623
679
  function renderMeta(post, context, include) {
624
- const { date, dateHasTime, updated, updatedHasTime, readingMinutes, authors, draft, sticky } = post.post;
680
+ const { date, dateHasTime, updated, updatedHasTime, readingMinutes, authors } = post.post;
625
681
  const parts = [];
626
682
  if (include.badges) {
627
- const badges = [draft && renderBadge("draft", context), sticky && renderBadge("sticky", context)];
628
- const shown = badges.filter(Boolean).join(" ");
683
+ const shown = renderBadges(post, context);
629
684
  if (shown) parts.push(shown);
630
685
  }
631
686
  if (date) {
@@ -654,12 +709,13 @@ function renderCard(post, context, index) {
654
709
  const modifiers = [
655
710
  post.post.sticky && "sticky",
656
711
  post.post.draft && "draft",
712
+ post.post.announcement && "announcement",
657
713
  image && "with-image",
658
714
  image && post.post.imageFit === "contain" && "contain"
659
715
  ].filter(Boolean).map((modifier) => ` vp-blog-card--${modifier}`).join("");
660
716
  const alt = escapeMarkdown(post.post.imageAlt);
661
717
  return [
662
- `<article class="vp-blog-card${modifiers}"${imageStyle(post)} data-vp-blog-item data-tags="${toTagsAttribute(post)}">`,
718
+ `<article class="vp-blog-card${modifiers}"${imageStyle(post)} ${toItemAttributes(post)}>`,
663
719
  ...image ? block("vp-blog-card__image", `[![${alt}](${toMarkdownUrl(image)})](${toMarkdownUrl(link)})`) : [],
664
720
  '<div class="vp-blog-card__body">',
665
721
  ...block("vp-blog-card__title", toPageLink(post.post.title, post.routePath)),
@@ -672,8 +728,8 @@ function renderCard(post, context, index) {
672
728
  }
673
729
  function renderCompactItem(post, context) {
674
730
  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>`;
731
+ const badges = renderBadges(post, context);
732
+ const marker = `<span hidden ${toItemAttributes(post)}></span>`;
677
733
  return `- ${marker}${date}${toPageLink(post.post.title, post.routePath)}${badges ? ` ${badges}` : ""}`;
678
734
  }
679
735
  function renderGroup(posts, index, context) {
@@ -748,7 +804,9 @@ function renderClientAttributes(index, context) {
748
804
  "data-label-no-tagged": labels.noTagged,
749
805
  "data-label-previous": labels.previous,
750
806
  "data-label-next": labels.next,
751
- "data-label-pages": labels.pages
807
+ "data-label-pages": labels.pages,
808
+ "data-label-announcements": labels.announcements,
809
+ "data-label-no-announcements": labels.noAnnouncements
752
810
  };
753
811
  return Object.entries(attributes).map(([name, value]) => `${name}="${escapeHtml(value)}"`).join(" ");
754
812
  }
@@ -1135,6 +1193,9 @@ function blogMarkdown(md, options = {}) {
1135
1193
 
1136
1194
  // src/vitepress.ts
1137
1195
  var DEFAULT_FEED_LIMIT = 20;
1196
+ var PACKAGE_NAME = "@binarynoir/vitepress-blog";
1197
+ var SITE_DATA_MODULE = "/@siteData";
1198
+ var SCRIPT_MARKER = "data-vp-blog-announcement";
1138
1199
  function resolveSiteLocation(siteConfig, options) {
1139
1200
  const siteUrl = options.siteUrl ?? siteConfig.userConfig?.sitemap?.hostname ?? siteConfig.sitemap?.hostname;
1140
1201
  if (!siteUrl) return null;
@@ -1149,6 +1210,16 @@ function resolveSiteLocation(siteConfig, options) {
1149
1210
  function loadPages(siteConfig, options) {
1150
1211
  return loadBlogPages(siteConfig.srcDir, options, siteConfig);
1151
1212
  }
1213
+ function attachAnnouncements(siteConfig, options = {}) {
1214
+ const site = siteConfig.site;
1215
+ const data = resolveAnnouncements(loadPages(siteConfig, options), options, !!siteConfig.cleanUrls);
1216
+ const changed = JSON.stringify(site[ANNOUNCEMENTS_KEY] ?? null) !== JSON.stringify(data);
1217
+ if (data) site[ANNOUNCEMENTS_KEY] = data;
1218
+ else delete site[ANNOUNCEMENTS_KEY];
1219
+ site.head = (site.head ?? []).filter(([, attributes]) => !(SCRIPT_MARKER in (attributes ?? {})));
1220
+ if (data) site.head.push(["script", { [SCRIPT_MARKER]: "" }, renderDismissedScript(data)]);
1221
+ return changed;
1222
+ }
1152
1223
  function listFeedPosts(pages, index, options) {
1153
1224
  const limit = index.index.feed?.limit ?? options.feedLimit ?? DEFAULT_FEED_LIMIT;
1154
1225
  return listBlogPosts(pages, index, { drafts: false, stickyFirst: false, applyLimit: false }).slice(0, limit);
@@ -1253,11 +1324,26 @@ function blogVitePlugin(options = {}) {
1253
1324
  let siteConfig;
1254
1325
  const currentPages = () => siteConfig ? loadPages(siteConfig, options) : [];
1255
1326
  const dependentFiles = () => [...currentPages().map((page) => page.absolutePath), ...widgetPages];
1327
+ const invalidate = (server, files) => {
1328
+ for (const file of files) {
1329
+ for (const module2 of server.moduleGraph.getModulesByFile(file) ?? []) server.moduleGraph.invalidateModule(module2);
1330
+ }
1331
+ };
1332
+ const refreshAnnouncements = (server) => {
1333
+ if (!siteConfig || !attachAnnouncements(siteConfig, options)) return false;
1334
+ const module2 = server.moduleGraph.getModuleById(SITE_DATA_MODULE);
1335
+ if (module2) server.moduleGraph.invalidateModule(module2);
1336
+ return true;
1337
+ };
1256
1338
  return {
1257
1339
  name: "vitepress-blog",
1258
1340
  enforce: "pre",
1341
+ config() {
1342
+ return { ssr: { noExternal: [PACKAGE_NAME] } };
1343
+ },
1259
1344
  configResolved(config) {
1260
1345
  siteConfig = config.vitepress;
1346
+ if (siteConfig) attachAnnouncements(siteConfig, options);
1261
1347
  },
1262
1348
  transform(code, id) {
1263
1349
  if (!siteConfig || !id.endsWith(".md") || !(mayHaveBlogFrontmatter(code) || hasWidget(code))) return null;
@@ -1268,11 +1354,8 @@ function blogVitePlugin(options = {}) {
1268
1354
  configureServer(server) {
1269
1355
  const refresh = (file) => {
1270
1356
  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
- }
1357
+ invalidate(server, dependentFiles());
1358
+ refreshAnnouncements(server);
1276
1359
  server.ws.send({ type: "full-reload" });
1277
1360
  };
1278
1361
  server.watcher.on("add", refresh).on("unlink", refresh);
@@ -1303,6 +1386,11 @@ function blogVitePlugin(options = {}) {
1303
1386
  },
1304
1387
  handleHotUpdate({ file, modules, server }) {
1305
1388
  if (!siteConfig || !file.endsWith(".md")) return;
1389
+ if (refreshAnnouncements(server)) {
1390
+ invalidate(server, dependentFiles());
1391
+ server.ws.send({ type: "full-reload" });
1392
+ return [];
1393
+ }
1306
1394
  const pages = currentPages();
1307
1395
  if (!pages.some((page) => page.absolutePath === file)) return;
1308
1396
  const affected = dependentFiles().filter((dependent) => dependent !== file).flatMap((dependent) => [...server.moduleGraph.getModulesByFile(dependent) ?? []]);
@@ -1339,6 +1427,7 @@ function withBlog(config, options = {}) {
1339
1427
  }
1340
1428
  // Annotate the CommonJS export names for ESM import in node:
1341
1429
  0 && (module.exports = {
1430
+ attachAnnouncements,
1342
1431
  blogHead,
1343
1432
  blogMarkdown,
1344
1433
  blogPageData,