@binarynoir/vitepress-blog 0.1.0 → 0.2.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. |
@@ -164,6 +164,38 @@ Title, description, date, author(s), image and tags may also be written at the
164
164
  top level of the frontmatter, as other blog tools expect. A value under `blog`
165
165
  wins.
166
166
 
167
+ ### Dates and times
168
+
169
+ `date` and `updated` take a date, or a date and a time, in any of these forms:
170
+
171
+ | Written | Means |
172
+ | --------------------------- | ------------------------------------------- |
173
+ | `2026-10-20` | A day. Shows "October 20, 2026". |
174
+ | `2026-10-20 14:30` | A day and a time, in the `timeZone` option. |
175
+ | `2026-10-20T14:30:00` | The same, with a `T` and seconds. |
176
+ | `2026-10-20T14:30:00-04:00` | An exact moment, with its offset from UTC. |
177
+ | `2026-10-20T18:30:00Z` | An exact moment, in UTC. |
178
+
179
+ ```yaml
180
+ blog:
181
+ date: 2026-10-20 14:30
182
+ updated: 2026-10-21 09:05
183
+ ```
184
+
185
+ - **A time shows only when you give one.** A date alone is shown as a date,
186
+ never as "12:00 AM".
187
+ - **Times without an offset are in the `timeZone` option**, `UTC` unless you
188
+ set one, so the same frontmatter means the same moment on every build
189
+ machine. Set `timeZone: 'America/New_York'` and write local times; daylight
190
+ saving is taken care of. Dates and times are also shown in that time zone.
191
+ - **The byline tells readers both:** "Published October 20, 2026 at 2:30 PM
192
+ EDT · Updated October 21, 2026 at 9:05 AM EDT". Cards show the published
193
+ date alone.
194
+ - **Posts on the same day sort by their times**, newest first, and a date
195
+ alone counts as the start of its day.
196
+ - Feeds and social tags use the exact moment: `pubDate`, `lastBuildDate`,
197
+ `article:published_time` and `article:modified_time`.
198
+
167
199
  ## Blog pages
168
200
 
169
201
  A page with `blog-index` in its frontmatter lists posts. `blog-index: true` is a
@@ -333,32 +365,36 @@ link that copy. Public paths (`/cover.png`) and URLs are linked as they are.
333
365
 
334
366
  ## Options
335
367
 
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. |
368
+ | Option | Default | What it does |
369
+ | ---------------- | --------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------- |
370
+ | `siteUrl` | `sitemap.hostname` | Absolute URL of the published site, for feeds and social tags. |
371
+ | `authors` | `{}` | Authors posts can name by key: `{ jane: { name, url, email } }` or `{ jane: 'Jane Doe' }`. |
372
+ | `defaultAuthor` | none | Author for posts that don't name one. |
373
+ | `locale` | `'en-US'` | Locale for dates and author lists ("Jane and Sam"). |
374
+ | `dateFormat` | `{ dateStyle: 'long' }` | `Intl.DateTimeFormat` options for dates. |
375
+ | `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. |
376
+ | `timeZone` | `'UTC'` | The IANA time zone (`'America/New_York'`) that times without an offset are in, and that dates and times are shown in. |
377
+ | `wordsPerMinute` | `200` | For "5 min read". `0` hides reading time. |
378
+ | `byline` | `true` | Author, date and tags under each post's title. |
379
+ | `cover` | `false` | Each post's image under its byline. |
380
+ | `postNav` | `true` | Newer and older post links at the end of each post. |
381
+ | `drafts` | `false` | List drafts, marked. Pass `process.env.NODE_ENV !== 'production'` for dev only. |
382
+ | `stripTags` | `true` | Remove [Markdown Tags](https://binarynoir.tech/plugins/markdown-tags/) syntax from titles and summaries. |
383
+ | `feedLimit` | `20` | Most posts in each feed. |
384
+ | `imagesDir` | `'blog-images'` | Output folder for copied post images. |
385
+ | `pageSize` | `10` | Posts per page on blog pages that don't set `pageSize`. `0` turns pages off. |
386
+ | `stickyLimit` | `0` (all) | Most pinned sticky posts on blog pages that don't set `stickyLimit`. |
387
+ | `rssLink` | `true` | A "Subscribe" RSS link on blog pages and blocks that don't set `rssLink`. |
388
+ | `labels` | English | Words the plugin writes, for translating. See below. |
355
389
 
356
390
  ```ts
357
391
  withBlog(config, {
358
392
  locale: 'de-DE',
359
393
  labels: {
360
394
  by: 'Von',
395
+ published: 'Veröffentlicht',
361
396
  updated: 'Aktualisiert',
397
+ dateTime: '{date} um {time}',
362
398
  readingTime: '{n} Min. Lesezeit',
363
399
  newer: 'Neuerer Beitrag',
364
400
  older: 'Älterer Beitrag',
@@ -104,12 +104,6 @@ function toUrlPath(routePath, cleanUrls) {
104
104
  function joinUrl(base, urlPath) {
105
105
  return `${base.replace(/\/+$/, "")}/${urlPath.replace(/^\/+/, "")}`;
106
106
  }
107
- function toIsoDate(date) {
108
- return date.toISOString().slice(0, 10);
109
- }
110
- function formatDate(date, locale, format) {
111
- return new Intl.DateTimeFormat(locale, { timeZone: "UTC", ...format }).format(date);
112
- }
113
107
 
114
108
  // src/feed.ts
115
109
  import { createHash } from "crypto";
@@ -208,7 +202,9 @@ ${lines.filter(Boolean).map((line) => ` ${line}`).join("\n")}
208
202
  // src/types.ts
209
203
  var DEFAULT_LABELS = {
210
204
  by: "By",
205
+ published: "Published",
211
206
  updated: "Updated",
207
+ dateTime: "{date} at {time}",
212
208
  readingTime: "{n} min read",
213
209
  newer: "Newer post",
214
210
  older: "Older post",
@@ -230,12 +226,118 @@ var DEFAULT_LABELS = {
230
226
 
231
227
  // src/render.ts
232
228
  import path2 from "path";
229
+
230
+ // src/dates.ts
231
+ var DATE_TIME = /^(\d{4})-(\d{2})-(\d{2})(?:[T ](\d{2}):(\d{2})(?::(\d{2})(?:\.(\d{1,3})\d*)?)?)?\s*(Z|[+-]\d{2}:?\d{2})?$/i;
232
+ function isTimeZone(timeZone) {
233
+ try {
234
+ new Intl.DateTimeFormat("en-US", { timeZone });
235
+ return true;
236
+ } catch {
237
+ return false;
238
+ }
239
+ }
240
+ var warnedZones = /* @__PURE__ */ new Set();
241
+ function resolveTimeZone(timeZone) {
242
+ if (!timeZone) return "UTC";
243
+ if (isTimeZone(timeZone)) return timeZone;
244
+ if (!warnedZones.has(timeZone)) {
245
+ warnedZones.add(timeZone);
246
+ console.warn(
247
+ `[vitepress-blog] "${timeZone}" is not a time zone; using UTC. Use an IANA name such as "America/New_York".`
248
+ );
249
+ }
250
+ return "UTC";
251
+ }
252
+ var offsetFormats = /* @__PURE__ */ new Map();
253
+ function zoneOffset(instant, timeZone) {
254
+ let format = offsetFormats.get(timeZone);
255
+ if (!format) {
256
+ format = new Intl.DateTimeFormat("en-US", {
257
+ timeZone,
258
+ hourCycle: "h23",
259
+ year: "numeric",
260
+ month: "2-digit",
261
+ day: "2-digit",
262
+ hour: "2-digit",
263
+ minute: "2-digit",
264
+ second: "2-digit"
265
+ });
266
+ offsetFormats.set(timeZone, format);
267
+ }
268
+ const parts = Object.fromEntries(format.formatToParts(instant).map(({ type, value }) => [type, Number(value)]));
269
+ const wall = Date.UTC(parts.year, parts.month - 1, parts.day, parts.hour, parts.minute, parts.second);
270
+ return wall - Math.floor(instant / 1e3) * 1e3;
271
+ }
272
+ function fromZonedTime(wall, timeZone) {
273
+ const first = wall - zoneOffset(wall, timeZone);
274
+ const second = wall - zoneOffset(first, timeZone);
275
+ return second;
276
+ }
277
+ function parsePostDate(value, timeZone = "UTC") {
278
+ if (value instanceof Date) {
279
+ if (Number.isNaN(value.getTime())) return null;
280
+ const midnight = value.getUTCHours() === 0 && value.getUTCMinutes() === 0 && value.getUTCSeconds() === 0;
281
+ return { date: value, hasTime: !midnight };
282
+ }
283
+ if (typeof value !== "string") return null;
284
+ const match = DATE_TIME.exec(value.trim());
285
+ if (!match) return null;
286
+ const [, year, month, day, hour, minute, second = "0", millisecond = "0", offset] = match;
287
+ const hasTime = hour !== void 0;
288
+ const wall = Date.UTC(
289
+ Number(year),
290
+ Number(month) - 1,
291
+ Number(day),
292
+ hasTime ? Number(hour) : 0,
293
+ hasTime ? Number(minute) : 0,
294
+ Number(second),
295
+ Number(millisecond.padEnd(3, "0"))
296
+ );
297
+ const check = new Date(wall);
298
+ if (check.getUTCMonth() !== Number(month) - 1 || check.getUTCDate() !== Number(day)) return null;
299
+ if (!offset) return { date: new Date(fromZonedTime(wall, timeZone)), hasTime };
300
+ if (offset.toUpperCase() === "Z") return { date: new Date(wall), hasTime };
301
+ const sign = offset.startsWith("-") ? -1 : 1;
302
+ const digits = offset.slice(1).replace(":", "");
303
+ const minutes = Number(digits.slice(0, 2)) * 60 + Number(digits.slice(2));
304
+ return { date: new Date(wall - sign * minutes * 6e4), hasTime };
305
+ }
306
+ function formatPostDate({ date, hasTime }, display) {
307
+ const { locale, timeZone } = display;
308
+ const day = new Intl.DateTimeFormat(locale, { timeZone, ...display.dateFormat }).format(date);
309
+ if (!hasTime) return day;
310
+ const time = new Intl.DateTimeFormat(locale, { timeZone, ...display.timeFormat }).format(date);
311
+ return display.dateTime.replace("{date}", day).replace("{time}", time);
312
+ }
313
+ function toZonedIsoDate(date, timeZone) {
314
+ const parts = Object.fromEntries(
315
+ new Intl.DateTimeFormat("en-US", { timeZone, year: "numeric", month: "2-digit", day: "2-digit" }).formatToParts(date).map(({ type, value }) => [type, value])
316
+ );
317
+ return `${parts.year}-${parts.month}-${parts.day}`;
318
+ }
319
+ function toDateTimeAttribute({ date, hasTime }, timeZone) {
320
+ return hasTime ? date.toISOString() : toZonedIsoDate(date, timeZone);
321
+ }
322
+ function yearIn(date, timeZone) {
323
+ return Number(toZonedIsoDate(date, timeZone).slice(0, 4));
324
+ }
325
+
326
+ // src/render.ts
233
327
  var BLOG_POSTS_PLACEHOLDER = "<!-- blog-posts -->";
234
328
  function createRenderContext(options, pageDirectory, feeds = !!options.siteUrl) {
329
+ const locale = options.locale ?? "en-US";
330
+ const labels = { ...DEFAULT_LABELS, ...options.labels };
235
331
  return {
236
- locale: options.locale ?? "en-US",
237
- dateFormat: options.dateFormat ?? { dateStyle: "long" },
238
- labels: { ...DEFAULT_LABELS, ...options.labels },
332
+ locale,
333
+ dates: {
334
+ locale,
335
+ timeZone: resolveTimeZone(options.timeZone),
336
+ dateFormat: options.dateFormat ?? { dateStyle: "long" },
337
+ timeFormat: options.timeFormat ?? { hour: "numeric", minute: "2-digit", timeZoneName: "short" },
338
+ dateTime: labels.dateTime
339
+ },
340
+ labels,
239
341
  pageDirectory,
240
342
  feeds
241
343
  };
@@ -257,8 +359,10 @@ function toImageSource(post, pageDirectory) {
257
359
  const relative = path2.relative(pageDirectory, absolute).split(path2.sep).join("/");
258
360
  return relative.startsWith(".") ? relative : `./${relative}`;
259
361
  }
260
- function renderTime(date, context) {
261
- return `<time datetime="${toIsoDate(date)}">${escapeTemplateText(formatDate(date, context.locale, context.dateFormat))}</time>`;
362
+ function renderTime(date, hasTime, context) {
363
+ const value = { date, hasTime };
364
+ const attribute = toDateTimeAttribute(value, context.dates.timeZone);
365
+ return `<time datetime="${attribute}">${escapeTemplateText(formatPostDate(value, context.dates))}</time>`;
262
366
  }
263
367
  function renderAuthor(author) {
264
368
  return author.url ? `[${escapeMarkdown(author.name)}](${toMarkdownUrl(author.url)})` : escapeMarkdown(author.name);
@@ -287,16 +391,19 @@ function toTagsAttribute(post) {
287
391
  return escapeHtml(post.post.tags.map((tag) => tag.toLowerCase()).join("|"));
288
392
  }
289
393
  function renderMeta(post, context, include) {
290
- const { date, updated, readingMinutes, authors, draft, sticky } = post.post;
394
+ const { date, dateHasTime, updated, updatedHasTime, readingMinutes, authors, draft, sticky } = post.post;
291
395
  const parts = [];
292
396
  if (include.badges) {
293
397
  const badges = [draft && renderBadge("draft", context), sticky && renderBadge("sticky", context)];
294
398
  const shown = badges.filter(Boolean).join(" ");
295
399
  if (shown) parts.push(shown);
296
400
  }
297
- if (date) parts.push(renderTime(date, context));
401
+ if (date) {
402
+ const time = renderTime(date, dateHasTime, context);
403
+ parts.push(include.updated ? `${escapeMarkdown(context.labels.published)} ${time}` : time);
404
+ }
298
405
  if (include.updated && updated && (!date || updated.getTime() !== date.getTime())) {
299
- parts.push(`${escapeMarkdown(context.labels.updated)} ${renderTime(updated, context)}`);
406
+ parts.push(`${escapeMarkdown(context.labels.updated)} ${renderTime(updated, updatedHasTime, context)}`);
300
407
  }
301
408
  if (readingMinutes) parts.push(escapeMarkdown(fill(context.labels.readingTime, { n: String(readingMinutes) })));
302
409
  if (include.authors) {
@@ -334,7 +441,7 @@ function renderCard(post, context, index) {
334
441
  ];
335
442
  }
336
443
  function renderCompactItem(post, context) {
337
- const date = post.post.date ? `${renderTime(post.post.date, context)} ` : "";
444
+ const date = post.post.date ? `${renderTime(post.post.date, post.post.dateHasTime, context)} ` : "";
338
445
  const badges = [post.post.draft && renderBadge("draft", context), post.post.sticky && renderBadge("sticky", context)].filter(Boolean).join(" ");
339
446
  const marker = `<span hidden data-vp-blog-item data-tags="${toTagsAttribute(post)}"></span>`;
340
447
  return `- ${marker}${date}${toPageLink(post.post.title, post.routePath)}${badges ? ` ${badges}` : ""}`;
@@ -359,7 +466,7 @@ function renderGroup(posts, index, context) {
359
466
  function renderYears(posts, index, context) {
360
467
  const years = /* @__PURE__ */ new Map();
361
468
  for (const post of posts) {
362
- const year = post.post.date ? String(post.post.date.getUTCFullYear()) : context.labels.undated;
469
+ const year = post.post.date ? String(yearIn(post.post.date, context.dates.timeZone)) : context.labels.undated;
363
470
  years.set(year, [...years.get(year) ?? [], post]);
364
471
  }
365
472
  const lines = [];
@@ -510,12 +617,6 @@ function toStringList(value) {
510
617
  const values = Array.isArray(value) ? value : typeof value === "string" ? value.split(",") : [];
511
618
  return values.map(toText).filter(Boolean);
512
619
  }
513
- function toDate(value) {
514
- if (value instanceof Date) return Number.isNaN(value.getTime()) ? null : value;
515
- if (typeof value !== "string" && typeof value !== "number") return null;
516
- const date = new Date(value);
517
- return Number.isNaN(date.getTime()) ? null : date;
518
- }
519
620
  function toAuthor(value, options) {
520
621
  if (typeof value === "string") {
521
622
  const name2 = value.trim();
@@ -580,11 +681,16 @@ function resolvePostData(data, content, fallbackTitle, options = {}) {
580
681
  const stripTags = options.stripTags ?? true;
581
682
  const clean = (text) => stripTags ? stripTagSyntax(text) : text;
582
683
  const image = toRecord(pick("image"));
684
+ const timeZone = resolveTimeZone(options.timeZone);
685
+ const date = parsePostDate(pick("date"), timeZone);
686
+ const updated = parsePostDate(pick("updated"), timeZone);
583
687
  return {
584
688
  title: clean(toText(post.title) || toText(data.title) || readHeading(content)) || fallbackTitle,
585
689
  summary: clean(toText(post.summary) || toText(data.description) || readExcerpt(content)),
586
- date: toDate(pick("date")),
587
- updated: toDate(pick("updated")),
690
+ date: date?.date ?? null,
691
+ dateHasTime: date?.hasTime ?? false,
692
+ updated: updated?.date ?? null,
693
+ updatedHasTime: updated?.hasTime ?? false,
588
694
  image: toText(image.src) || toText(pick("image")),
589
695
  imageAlt: toText(image.alt) || toText(post.imageAlt) || toText(data.imageAlt),
590
696
  imageFit: (toText(image.fit) || toText(post.imageFit)) === "contain" ? "contain" : "cover",
@@ -714,7 +820,8 @@ function toOptionsKey(options) {
714
820
  options.stripTags,
715
821
  options.pageSize,
716
822
  options.stickyLimit,
717
- options.rssLink
823
+ options.rssLink,
824
+ options.timeZone
718
825
  ]);
719
826
  }
720
827
  function indexBlogSource(sourceDirectory, options = {}, rewrites = {}) {
@@ -998,4 +1105,4 @@ export {
998
1105
  hasWidget,
999
1106
  blogMarkdown
1000
1107
  };
1001
- //# sourceMappingURL=chunk-DICBXOTS.js.map
1108
+ //# sourceMappingURL=chunk-PWNWSYI4.js.map