@binarynoir/vitepress-blog 0.1.1 → 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/README.md CHANGED
@@ -127,8 +127,8 @@ with every default; an object sets its details.
127
127
  ---
128
128
  title: We launched
129
129
  blog:
130
- date: 2026-10-01
131
- updated: 2026-10-05
130
+ date: 2026-10-01 09:00
131
+ updated: 2026-10-05 16:30
132
132
  summary: Team A's first release is out.
133
133
  image: ./launch.png
134
134
  imageAlt: The release dashboard
@@ -144,8 +144,8 @@ blog:
144
144
  | --------------- | ------------------------------------------ | --------------------------------------------------------------------------------------------------------------------------------------------------------- |
145
145
  | `title` | the page's `title`, then its `#` heading | Title in lists, feeds and social cards. |
146
146
  | `summary` | the page's `description`, then first prose | One or two sentences for cards, feeds and `<meta name="description">`. |
147
- | `date` | none | Publication date. Posts are sorted newest first; undated posts go last. |
148
- | `updated` | none | Shown in the byline and used as the feed's last-updated time. |
147
+ | `date` | none | When the post was published: a date, or a date and time. See [Dates and times](#dates-and-times). Newest first; undated posts go last. |
148
+ | `updated` | none | When the post was last changed, in the same forms. The byline shows both: "Published … · Updated …". It is the feed's last-updated time. |
149
149
  | `image` | none | Card, social and feed image: a path relative to the post, a public path (`/cover.png`) or a URL. `{ src, alt, fit, position }` also works. |
150
150
  | `imageAlt` | `''` | Alt text for the image. |
151
151
  | `imageFit` | `cover` | How cards fit the image in their frame: `cover` crops it to fill the frame, `contain` shows all of it, letterboxed. Use `contain` for logos and diagrams. |
@@ -155,7 +155,8 @@ blog:
155
155
  | `category` | none | Written to the feed and `article:section`. |
156
156
  | `scope` | `section` | `site` (or `global`) also lists the post on the main blog. See [Scopes](#scopes). |
157
157
  | `sticky` | `false` | Pinned to the top of its blog pages, above the paginated posts, with a Pinned badge. See [Sticky posts](#sticky-posts). |
158
- | `draft` | `false` | Left out of lists and feeds. The page itself is still built: use `srcExclude` to hide it. |
158
+ | `announcement` | `false` | `true`, or the banner's text, shows the post in the announcement banner above the nav bar. See [Announcements](#announcements). |
159
+ | `draft` | `false` | Left out of lists and feeds. The page itself is still built: to leave drafts out of the build, the sidebar and the navbar too, see [Drafts](#drafts). |
159
160
  | `byline` | the `byline` option | `false` hides this post's byline. |
160
161
  | `cover` | the `cover` option | `true` shows this post's image under its byline. |
161
162
  | `postNav` | the `postNav` option | `false` hides this post's newer / older links. |
@@ -164,6 +165,67 @@ Title, description, date, author(s), image and tags may also be written at the
164
165
  top level of the frontmatter, as other blog tools expect. A value under `blog`
165
166
  wins.
166
167
 
168
+ ### Dates and times
169
+
170
+ `date` and `updated` take a date, or a date and a time, in any of these forms:
171
+
172
+ | Written | Means |
173
+ | --------------------------- | ------------------------------------------- |
174
+ | `2026-10-20` | A day. Shows "October 20, 2026". |
175
+ | `2026-10-20 14:30` | A day and a time, in the `timeZone` option. |
176
+ | `2026-10-20T14:30:00` | The same, with a `T` and seconds. |
177
+ | `2026-10-20T14:30:00-04:00` | An exact moment, with its offset from UTC. |
178
+ | `2026-10-20T18:30:00Z` | An exact moment, in UTC. |
179
+
180
+ ```yaml
181
+ blog:
182
+ date: 2026-10-20 14:30
183
+ updated: 2026-10-21 09:05
184
+ ```
185
+
186
+ - **A time shows only when you give one.** A date alone is shown as a date,
187
+ never as "12:00 AM".
188
+ - **Times without an offset are in the `timeZone` option**, `UTC` unless you
189
+ set one, so the same frontmatter means the same moment on every build
190
+ machine. Set `timeZone: 'America/New_York'` and write local times; daylight
191
+ saving is taken care of. Dates and times are also shown in that time zone.
192
+ - **The byline tells readers both:** "Published October 20, 2026 at 2:30 PM
193
+ EDT · Updated October 21, 2026 at 9:05 AM EDT". Cards show the published
194
+ date alone.
195
+ - **Posts on the same day sort by their times**, newest first, and a date
196
+ alone counts as the start of its day.
197
+ - Feeds and social tags use the exact moment: `pubDate`, `lastBuildDate`,
198
+ `article:published_time` and `article:modified_time`.
199
+
200
+ ### Drafts
201
+
202
+ `draft: true` keeps a post out of blog lists, `::: blog-posts` blocks and
203
+ feeds, but the page is still built and can be opened by its URL. With the
204
+ `drafts` option on, drafts are listed too, with a Draft badge, which is handy in
205
+ `vitepress dev`.
206
+
207
+ To leave drafts out of the build entirely, use
208
+ [`@binarynoir/vitepress-exclude`](https://binarynoir.tech/plugins/exclude/)'s
209
+ `excludeFrontmatter` option, and the same option in Auto Sidebar and Auto
210
+ Navbar to hide them from the menus. The blog lists only what VitePress builds,
211
+ so nothing else changes:
212
+
213
+ ```ts
214
+ // Drafts stay out of the production build, and show (marked) in `vitepress dev`.
215
+ const isBuild = process.env.NODE_ENV === 'production';
216
+ const excludeFrontmatter = isBuild ? ['draft', 'blog.draft'] : [];
217
+ const blogOptions = { drafts: !isBuild };
218
+
219
+ export default defineConfig({
220
+ srcExclude: excludeSrc(docsRoot, { excludeFrontmatter }),
221
+ themeConfig: {
222
+ sidebar: generateSidebar(docsRoot, { excludeFrontmatter }),
223
+ nav: generateNav(docsRoot, { excludeFrontmatter }),
224
+ },
225
+ // ...the blog's pieces, with blogOptions
226
+ });
227
+ ```
228
+
167
229
  ## Blog pages
168
230
 
169
231
  A page with `blog-index` in its frontmatter lists posts. `blog-index: true` is a
@@ -238,6 +300,57 @@ a blog page, on each card, and in a post's byline, where they link to the
238
300
  post's own blog, filtered. They are ordinary links, so VitePress follows them
239
301
  without reloading the page, and each filtered view has its own URL to share.
240
302
 
303
+ ## Announcements
304
+
305
+ `announcement: true` puts a post in a banner across the top of every page,
306
+ above the nav bar: one line, the full width of the window, linked to the post.
307
+ A string sets the banner's text instead of the post's title.
308
+
309
+ ```md
310
+ ---
311
+ blog:
312
+ date: 2026-10-20 09:00
313
+ announcement: Scheduled maintenance on Friday, 18:00 UTC
314
+ ---
315
+ ```
316
+
317
+ Add the banner to your theme's `layout-top` slot:
318
+
319
+ ```ts
320
+ // .vitepress/theme/index.ts
321
+ import { h } from 'vue';
322
+ import DefaultTheme from 'vitepress/theme';
323
+ import { BlogAnnouncementBanner, useBlog } from '@binarynoir/vitepress-blog/client';
324
+ import '@binarynoir/vitepress-blog/style.css';
325
+
326
+ export default {
327
+ extends: DefaultTheme,
328
+ Layout: () => h(DefaultTheme.Layout, null, { 'layout-top': () => h(BlogAnnouncementBanner) }),
329
+ setup() {
330
+ useBlog();
331
+ },
332
+ };
333
+ ```
334
+
335
+ - **The newest one wins.** With several, the banner shows the newest and a
336
+ badge with how many there are. The badge opens a blog page filtered to
337
+ announcements (`/blog/?announcements`): the main blog when it lists them all,
338
+ else another blog page that does, else the newest one's own blog.
339
+ - **Visitors can clear it.** The close button dismisses every current
340
+ announcement, remembered in the browser. A newer one brings the banner back,
341
+ and its badge counts only the ones not yet dismissed. Changing a post's
342
+ `date` announces it again.
343
+ - **No jump on load.** The banner is rendered on the server, and a small
344
+ `<head>` script hides it before the page shows if the visitor dismissed it.
345
+ While it shows, it sets VitePress's `--vp-layout-top-height`, so the nav bar,
346
+ sidebar and content move down to make room.
347
+ - Announcements are marked on blog pages with an Announcement badge. Drafts
348
+ are only announced with the `drafts` option.
349
+
350
+ The banner reads its posts from VitePress's site data, which `withBlog` (or
351
+ `blogVitePlugin`) fills in; `blogVitePlugin` also has Vite bundle this package
352
+ for the server-side build, so the banner renders there.
353
+
241
354
  ## Latest posts on any page
242
355
 
243
356
  A `::: blog-posts` block shows a blog's latest posts anywhere: on the home
@@ -333,38 +446,47 @@ link that copy. Public paths (`/cover.png`) and URLs are linked as they are.
333
446
 
334
447
  ## Options
335
448
 
336
- | Option | Default | What it does |
337
- | ---------------- | ----------------------- | -------------------------------------------------------------------------------------------------------- |
338
- | `siteUrl` | `sitemap.hostname` | Absolute URL of the published site, for feeds and social tags. |
339
- | `authors` | `{}` | Authors posts can name by key: `{ jane: { name, url, email } }` or `{ jane: 'Jane Doe' }`. |
340
- | `defaultAuthor` | none | Author for posts that don't name one. |
341
- | `locale` | `'en-US'` | Locale for dates and author lists ("Jane and Sam"). |
342
- | `dateFormat` | `{ dateStyle: 'long' }` | `Intl.DateTimeFormat` options. Dates are shown in UTC. |
343
- | `wordsPerMinute` | `200` | For "5 min read". `0` hides reading time. |
344
- | `byline` | `true` | Author, date and tags under each post's title. |
345
- | `cover` | `false` | Each post's image under its byline. |
346
- | `postNav` | `true` | Newer and older post links at the end of each post. |
347
- | `drafts` | `false` | List drafts, marked. Pass `process.env.NODE_ENV !== 'production'` for dev only. |
348
- | `stripTags` | `true` | Remove [Markdown Tags](https://binarynoir.tech/plugins/markdown-tags/) syntax from titles and summaries. |
349
- | `feedLimit` | `20` | Most posts in each feed. |
350
- | `imagesDir` | `'blog-images'` | Output folder for copied post images. |
351
- | `pageSize` | `10` | Posts per page on blog pages that don't set `pageSize`. `0` turns pages off. |
352
- | `stickyLimit` | `0` (all) | Most pinned sticky posts on blog pages that don't set `stickyLimit`. |
353
- | `rssLink` | `true` | A "Subscribe" RSS link on blog pages and blocks that don't set `rssLink`. |
354
- | `labels` | English | Words the plugin writes, for translating. See below. |
449
+ | Option | Default | What it does |
450
+ | ---------------- | --------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------- |
451
+ | `siteUrl` | `sitemap.hostname` | Absolute URL of the published site, for feeds and social tags. |
452
+ | `authors` | `{}` | Authors posts can name by key: `{ jane: { name, url, email } }` or `{ jane: 'Jane Doe' }`. |
453
+ | `defaultAuthor` | none | Author for posts that don't name one. |
454
+ | `locale` | `'en-US'` | Locale for dates and author lists ("Jane and Sam"). |
455
+ | `dateFormat` | `{ dateStyle: 'long' }` | `Intl.DateTimeFormat` options for dates. |
456
+ | `timeFormat` | `{ hour: 'numeric', minute: '2-digit', timeZoneName: 'short' }` | `Intl.DateTimeFormat` options for times, as in "2:30 PM EDT". Only dates given with a time show one. |
457
+ | `timeZone` | `'UTC'` | The IANA time zone (`'America/New_York'`) that times without an offset are in, and that dates and times are shown in. |
458
+ | `wordsPerMinute` | `200` | For "5 min read". `0` hides reading time. |
459
+ | `byline` | `true` | Author, date and tags under each post's title. |
460
+ | `cover` | `false` | Each post's image under its byline. |
461
+ | `postNav` | `true` | Newer and older post links at the end of each post. |
462
+ | `drafts` | `false` | List drafts, marked. Pass `process.env.NODE_ENV !== 'production'` for dev only. |
463
+ | `stripTags` | `true` | Remove [Markdown Tags](https://binarynoir.tech/plugins/markdown-tags/) syntax from titles and summaries. |
464
+ | `feedLimit` | `20` | Most posts in each feed. |
465
+ | `imagesDir` | `'blog-images'` | Output folder for copied post images. |
466
+ | `pageSize` | `10` | Posts per page on blog pages that don't set `pageSize`. `0` turns pages off. |
467
+ | `stickyLimit` | `0` (all) | Most pinned sticky posts on blog pages that don't set `stickyLimit`. |
468
+ | `rssLink` | `true` | A "Subscribe" RSS link on blog pages and blocks that don't set `rssLink`. |
469
+ | `labels` | English | Words the plugin writes, for translating. See below. |
355
470
 
356
471
  ```ts
357
472
  withBlog(config, {
358
473
  locale: 'de-DE',
359
474
  labels: {
360
475
  by: 'Von',
476
+ published: 'Veröffentlicht',
361
477
  updated: 'Aktualisiert',
478
+ dateTime: '{date} um {time}',
362
479
  readingTime: '{n} Min. Lesezeit',
363
480
  newer: 'Neuerer Beitrag',
364
481
  older: 'Älterer Beitrag',
365
482
  allPosts: 'Alle Beiträge in {blog}',
366
483
  draft: 'Entwurf',
367
484
  sticky: 'Angeheftet',
485
+ announcement: 'Ankündigung',
486
+ announcements: 'Ankündigungen',
487
+ noAnnouncements: 'Keine Ankündigungen.',
488
+ announcementCount: '{n} Ankündigungen',
489
+ dismiss: 'Schließen',
368
490
  noPosts: 'Noch keine Beiträge.',
369
491
  undated: 'Ohne Datum',
370
492
  tags: 'Schlagwörter',
@@ -385,15 +507,18 @@ withBlog(config, {
385
507
  `style.css` uses VitePress's theme variables, so it follows light and dark
386
508
  mode. Override these to restyle it without new rules:
387
509
 
388
- | Variable | Default |
389
- | ----------------------- | -------------------------- |
390
- | `--vp-blog-card-bg` | `var(--vp-c-bg-soft)` |
391
- | `--vp-blog-card-border` | `var(--vp-c-divider)` |
392
- | `--vp-blog-card-radius` | `12px` |
393
- | `--vp-blog-meta-color` | `var(--vp-c-text-2)` |
394
- | `--vp-blog-tag-bg` | `var(--vp-c-default-soft)` |
395
- | `--vp-blog-tag-color` | `var(--vp-c-text-2)` |
396
- | `--vp-blog-accent` | `var(--vp-c-brand-1)` |
510
+ | Variable | Default |
511
+ | ------------------------------- | ----------------------------- |
512
+ | `--vp-blog-card-bg` | `var(--vp-c-bg-soft)` |
513
+ | `--vp-blog-card-border` | `var(--vp-c-divider)` |
514
+ | `--vp-blog-card-radius` | `12px` |
515
+ | `--vp-blog-meta-color` | `var(--vp-c-text-2)` |
516
+ | `--vp-blog-tag-bg` | `var(--vp-c-default-soft)` |
517
+ | `--vp-blog-tag-color` | `var(--vp-c-text-2)` |
518
+ | `--vp-blog-accent` | `var(--vp-c-brand-1)` |
519
+ | `--vp-blog-announcement-height` | `40px` |
520
+ | `--vp-blog-announcement-bg` | `var(--vp-button-brand-bg)` |
521
+ | `--vp-blog-announcement-color` | `var(--vp-button-brand-text)` |
397
522
 
398
523
  Every generated element has a `vp-blog-` class (`vp-blog-card`,
399
524
  `vp-blog-byline`, `vp-blog-post-nav`, …) for anything more.
@@ -0,0 +1,52 @@
1
+ // src/util.ts
2
+ var HTML_ESCAPES = {
3
+ "&": "&amp;",
4
+ "<": "&lt;",
5
+ ">": "&gt;",
6
+ '"': "&quot;",
7
+ "'": "&#39;"
8
+ };
9
+ function escapeHtml(value) {
10
+ return value.replace(/[&<>"']/g, (character) => HTML_ESCAPES[character]);
11
+ }
12
+ function escapeMarkdown(text) {
13
+ const escaped = text.replace(/[\\`*_[\]#|~]/g, "\\$&").replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;");
14
+ return /[{}]/.test(escaped) ? `<span v-pre>${escaped}</span>` : escaped;
15
+ }
16
+ function escapeTemplateText(text) {
17
+ const escaped = escapeHtml(text);
18
+ return /[{}]/.test(escaped) ? `<span v-pre>${escaped}</span>` : escaped;
19
+ }
20
+ function isExternalUrl(url) {
21
+ return /^(?:[a-z][a-z\d+.-]*:|\/\/)/i.test(url);
22
+ }
23
+ function toMarkdownUrl(url) {
24
+ return encodeURI(url).replace(/[()]/g, (character) => character === "(" ? "%28" : "%29");
25
+ }
26
+ function toPageLink(text, routePath) {
27
+ return `[${escapeMarkdown(text)}](${toMarkdownUrl(`/${routePath}`)})`;
28
+ }
29
+ function toUrlPath(routePath, cleanUrls) {
30
+ return `/${routePath.replace(/(^|\/)index\.md$/, "$1").replace(/\.md$/, cleanUrls ? "" : ".html")}`;
31
+ }
32
+ function joinUrl(base, urlPath) {
33
+ return `${base.replace(/\/+$/, "")}/${urlPath.replace(/^\/+/, "")}`;
34
+ }
35
+ var ANNOUNCEMENTS_KEY = "blogAnnouncements";
36
+ var DISMISSED_STORAGE_KEY = "vp-blog-announcements-dismissed";
37
+ var DISMISSED_CLASS = "vp-blog-announcement-dismissed";
38
+
39
+ export {
40
+ escapeHtml,
41
+ escapeMarkdown,
42
+ escapeTemplateText,
43
+ isExternalUrl,
44
+ toMarkdownUrl,
45
+ toPageLink,
46
+ toUrlPath,
47
+ joinUrl,
48
+ ANNOUNCEMENTS_KEY,
49
+ DISMISSED_STORAGE_KEY,
50
+ DISMISSED_CLASS
51
+ };
52
+ //# sourceMappingURL=chunk-42Z6H7IN.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"sources":["../src/util.ts"],"sourcesContent":["const HTML_ESCAPES: Record<string, string> = {\n '&': '&amp;',\n '<': '&lt;',\n '>': '&gt;',\n '\"': '&quot;',\n \"'\": '&#39;',\n};\n\n/** Escapes the characters that are special in HTML text and attribute values. */\nexport function escapeHtml(value: string): string {\n return value.replace(/[&<>\"']/g, (character) => HTML_ESCAPES[character]);\n}\n\n/**\n * Escapes text so it stays literal in Markdown and in VitePress's Vue\n * templates. Text with braces goes in a `v-pre` span, since Vue would otherwise\n * evaluate `{{ }}` even when the Markdown escaped it.\n */\nexport function escapeMarkdown(text: string): string {\n const escaped = text\n .replace(/[\\\\`*_[\\]#|~]/g, '\\\\$&')\n .replace(/&/g, '&amp;')\n .replace(/</g, '&lt;')\n .replace(/>/g, '&gt;');\n return /[{}]/.test(escaped) ? `<span v-pre>${escaped}</span>` : escaped;\n}\n\n/** HTML text that Vue leaves alone: braces go in a `v-pre` span. */\nexport function escapeTemplateText(text: string): string {\n const escaped = escapeHtml(text);\n return /[{}]/.test(escaped) ? `<span v-pre>${escaped}</span>` : escaped;\n}\n\n/** A URL with a scheme (`https:`, `mailto:`) or protocol-relative (`//cdn`). */\nexport function isExternalUrl(url: string): boolean {\n return /^(?:[a-z][a-z\\d+.-]*:|\\/\\/)/i.test(url);\n}\n\n/** Encodes a URL for a Markdown link destination: spaces and parentheses would end it early. */\nexport function toMarkdownUrl(url: string): string {\n return encodeURI(url).replace(/[()]/g, (character) => (character === '(' ? '%28' : '%29'));\n}\n\n/** A Markdown link to a page by its served path, which VitePress then checks and gives the site's base. */\nexport function toPageLink(text: string, routePath: string): string {\n return `[${escapeMarkdown(text)}](${toMarkdownUrl(`/${routePath}`)})`;\n}\n\n/** A page's URL path, without the site's base: `news/index.md` is `/news/`, `news/launch.md` is `/news/launch`. */\nexport function toUrlPath(routePath: string, cleanUrls: boolean): string {\n return `/${routePath.replace(/(^|\\/)index\\.md$/, '$1').replace(/\\.md$/, cleanUrls ? '' : '.html')}`;\n}\n\n/** `base` and `urlPath` joined with exactly one slash between them. */\nexport function joinUrl(base: string, urlPath: string): string {\n return `${base.replace(/\\/+$/, '')}/${urlPath.replace(/^\\/+/, '')}`;\n}\n\n/** Where the announcement banner's data sits on VitePress's site data: `useData().site.value.blogAnnouncements`. */\nexport const ANNOUNCEMENTS_KEY = 'blogAnnouncements';\n\n/** The `localStorage` key holding the ids of the announcements a visitor dismissed, as a JSON array. */\nexport const DISMISSED_STORAGE_KEY = 'vp-blog-announcements-dismissed';\n\n/** The class on `<html>` that hides a dismissed banner before the page hydrates. */\nexport const DISMISSED_CLASS = 'vp-blog-announcement-dismissed';\n"],"mappings":";AAAA,IAAM,eAAuC;AAAA,EAC3C,KAAK;AAAA,EACL,KAAK;AAAA,EACL,KAAK;AAAA,EACL,KAAK;AAAA,EACL,KAAK;AACP;AAGO,SAAS,WAAW,OAAuB;AAChD,SAAO,MAAM,QAAQ,YAAY,CAAC,cAAc,aAAa,SAAS,CAAC;AACzE;AAOO,SAAS,eAAe,MAAsB;AACnD,QAAM,UAAU,KACb,QAAQ,kBAAkB,MAAM,EAChC,QAAQ,MAAM,OAAO,EACrB,QAAQ,MAAM,MAAM,EACpB,QAAQ,MAAM,MAAM;AACvB,SAAO,OAAO,KAAK,OAAO,IAAI,eAAe,OAAO,YAAY;AAClE;AAGO,SAAS,mBAAmB,MAAsB;AACvD,QAAM,UAAU,WAAW,IAAI;AAC/B,SAAO,OAAO,KAAK,OAAO,IAAI,eAAe,OAAO,YAAY;AAClE;AAGO,SAAS,cAAc,KAAsB;AAClD,SAAO,+BAA+B,KAAK,GAAG;AAChD;AAGO,SAAS,cAAc,KAAqB;AACjD,SAAO,UAAU,GAAG,EAAE,QAAQ,SAAS,CAAC,cAAe,cAAc,MAAM,QAAQ,KAAM;AAC3F;AAGO,SAAS,WAAW,MAAc,WAA2B;AAClE,SAAO,IAAI,eAAe,IAAI,CAAC,KAAK,cAAc,IAAI,SAAS,EAAE,CAAC;AACpE;AAGO,SAAS,UAAU,WAAmB,WAA4B;AACvE,SAAO,IAAI,UAAU,QAAQ,oBAAoB,IAAI,EAAE,QAAQ,SAAS,YAAY,KAAK,OAAO,CAAC;AACnG;AAGO,SAAS,QAAQ,MAAc,SAAyB;AAC7D,SAAO,GAAG,KAAK,QAAQ,QAAQ,EAAE,CAAC,IAAI,QAAQ,QAAQ,QAAQ,EAAE,CAAC;AACnE;AAGO,IAAM,oBAAoB;AAG1B,IAAM,wBAAwB;AAG9B,IAAM,kBAAkB;","names":[]}