@binarynoir/vitepress-blog 0.1.1 → 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.
@@ -16,8 +16,21 @@ interface BlogOptions {
16
16
  siteUrl?: string;
17
17
  /** Locale for dates in bylines and cards. Default: `'en-US'`. */
18
18
  locale?: string;
19
- /** How dates are written. Default: `{ dateStyle: 'long' }`, in UTC. */
19
+ /** How dates are written. Default: `{ dateStyle: 'long' }`. */
20
20
  dateFormat?: Intl.DateTimeFormatOptions;
21
+ /**
22
+ * How times are written, for dates whose frontmatter gives one
23
+ * (`2026-10-20 14:30`). Default: `{ hour: 'numeric', minute: '2-digit', timeZoneName: 'short' }`,
24
+ * as in "2:30 PM EDT".
25
+ */
26
+ timeFormat?: Intl.DateTimeFormatOptions;
27
+ /**
28
+ * The time zone frontmatter times without an offset are in, and that every
29
+ * date and time is shown in: an IANA name such as `'America/New_York'`.
30
+ * A value with an offset (`2026-10-20T14:30:00-04:00`, `…Z`) is read as
31
+ * written. Default: `'UTC'`.
32
+ */
33
+ timeZone?: string;
21
34
  /** Reading speed for the "5 min read" estimate. `0` hides it. Default: `200`. */
22
35
  wordsPerMinute?: number;
23
36
  /** Add an author, date and tags line under each post's title. Default: `true`. */
@@ -68,8 +81,12 @@ interface BlogOptions {
68
81
  interface BlogLabels {
69
82
  /** Before the author names: "By Jane Doe". */
70
83
  by: string;
84
+ /** Before a post's `date` in its byline: "Published October 20, 2026". */
85
+ published: string;
71
86
  /** Before a post's `updated` date. */
72
87
  updated: string;
88
+ /** A date and its time: "{date} at {time}". */
89
+ dateTime: string;
73
90
  /** Reading time: "{n} min read". */
74
91
  readingTime: string;
75
92
  newer: string;
@@ -138,8 +155,14 @@ interface BlogIndexConfig {
138
155
  interface BlogPostData {
139
156
  title: string;
140
157
  summary: string;
158
+ /** When the post was published, from `date`. */
141
159
  date: Date | null;
160
+ /** Whether `date` gave a time, not just a day. */
161
+ dateHasTime: boolean;
162
+ /** When the post was last changed, from `updated`. */
142
163
  updated: Date | null;
164
+ /** Whether `updated` gave a time, not just a day. */
165
+ updatedHasTime: boolean;
143
166
  /** As written: a path relative to the post, a public path (`/x.png`) or a URL. */
144
167
  image: string;
145
168
  imageAlt: string;
@@ -16,8 +16,21 @@ interface BlogOptions {
16
16
  siteUrl?: string;
17
17
  /** Locale for dates in bylines and cards. Default: `'en-US'`. */
18
18
  locale?: string;
19
- /** How dates are written. Default: `{ dateStyle: 'long' }`, in UTC. */
19
+ /** How dates are written. Default: `{ dateStyle: 'long' }`. */
20
20
  dateFormat?: Intl.DateTimeFormatOptions;
21
+ /**
22
+ * How times are written, for dates whose frontmatter gives one
23
+ * (`2026-10-20 14:30`). Default: `{ hour: 'numeric', minute: '2-digit', timeZoneName: 'short' }`,
24
+ * as in "2:30 PM EDT".
25
+ */
26
+ timeFormat?: Intl.DateTimeFormatOptions;
27
+ /**
28
+ * The time zone frontmatter times without an offset are in, and that every
29
+ * date and time is shown in: an IANA name such as `'America/New_York'`.
30
+ * A value with an offset (`2026-10-20T14:30:00-04:00`, `…Z`) is read as
31
+ * written. Default: `'UTC'`.
32
+ */
33
+ timeZone?: string;
21
34
  /** Reading speed for the "5 min read" estimate. `0` hides it. Default: `200`. */
22
35
  wordsPerMinute?: number;
23
36
  /** Add an author, date and tags line under each post's title. Default: `true`. */
@@ -68,8 +81,12 @@ interface BlogOptions {
68
81
  interface BlogLabels {
69
82
  /** Before the author names: "By Jane Doe". */
70
83
  by: string;
84
+ /** Before a post's `date` in its byline: "Published October 20, 2026". */
85
+ published: string;
71
86
  /** Before a post's `updated` date. */
72
87
  updated: string;
88
+ /** A date and its time: "{date} at {time}". */
89
+ dateTime: string;
73
90
  /** Reading time: "{n} min read". */
74
91
  readingTime: string;
75
92
  newer: string;
@@ -138,8 +155,14 @@ interface BlogIndexConfig {
138
155
  interface BlogPostData {
139
156
  title: string;
140
157
  summary: string;
158
+ /** When the post was published, from `date`. */
141
159
  date: Date | null;
160
+ /** Whether `date` gave a time, not just a day. */
161
+ dateHasTime: boolean;
162
+ /** When the post was last changed, from `updated`. */
142
163
  updated: Date | null;
164
+ /** Whether `updated` gave a time, not just a day. */
165
+ updatedHasTime: boolean;
143
166
  /** As written: a path relative to the post, a public path (`/x.png`) or a URL. */
144
167
  image: string;
145
168
  imageAlt: string;
@@ -117,6 +117,104 @@ function findBlogsListing(pages, post) {
117
117
  // src/frontmatter.ts
118
118
  var import_node_path = __toESM(require("path"), 1);
119
119
  var import_yaml = require("yaml");
120
+
121
+ // src/dates.ts
122
+ 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;
123
+ function isTimeZone(timeZone) {
124
+ try {
125
+ new Intl.DateTimeFormat("en-US", { timeZone });
126
+ return true;
127
+ } catch {
128
+ return false;
129
+ }
130
+ }
131
+ var warnedZones = /* @__PURE__ */ new Set();
132
+ function resolveTimeZone(timeZone) {
133
+ if (!timeZone) return "UTC";
134
+ if (isTimeZone(timeZone)) return timeZone;
135
+ if (!warnedZones.has(timeZone)) {
136
+ warnedZones.add(timeZone);
137
+ console.warn(
138
+ `[vitepress-blog] "${timeZone}" is not a time zone; using UTC. Use an IANA name such as "America/New_York".`
139
+ );
140
+ }
141
+ return "UTC";
142
+ }
143
+ var offsetFormats = /* @__PURE__ */ new Map();
144
+ function zoneOffset(instant, timeZone) {
145
+ let format = offsetFormats.get(timeZone);
146
+ if (!format) {
147
+ format = new Intl.DateTimeFormat("en-US", {
148
+ timeZone,
149
+ hourCycle: "h23",
150
+ year: "numeric",
151
+ month: "2-digit",
152
+ day: "2-digit",
153
+ hour: "2-digit",
154
+ minute: "2-digit",
155
+ second: "2-digit"
156
+ });
157
+ offsetFormats.set(timeZone, format);
158
+ }
159
+ const parts = Object.fromEntries(format.formatToParts(instant).map(({ type, value }) => [type, Number(value)]));
160
+ const wall = Date.UTC(parts.year, parts.month - 1, parts.day, parts.hour, parts.minute, parts.second);
161
+ return wall - Math.floor(instant / 1e3) * 1e3;
162
+ }
163
+ function fromZonedTime(wall, timeZone) {
164
+ const first = wall - zoneOffset(wall, timeZone);
165
+ const second = wall - zoneOffset(first, timeZone);
166
+ return second;
167
+ }
168
+ function parsePostDate(value, timeZone = "UTC") {
169
+ if (value instanceof Date) {
170
+ if (Number.isNaN(value.getTime())) return null;
171
+ const midnight = value.getUTCHours() === 0 && value.getUTCMinutes() === 0 && value.getUTCSeconds() === 0;
172
+ return { date: value, hasTime: !midnight };
173
+ }
174
+ if (typeof value !== "string") return null;
175
+ const match = DATE_TIME.exec(value.trim());
176
+ if (!match) return null;
177
+ const [, year, month, day, hour, minute, second = "0", millisecond = "0", offset] = match;
178
+ const hasTime = hour !== void 0;
179
+ const wall = Date.UTC(
180
+ Number(year),
181
+ Number(month) - 1,
182
+ Number(day),
183
+ hasTime ? Number(hour) : 0,
184
+ hasTime ? Number(minute) : 0,
185
+ Number(second),
186
+ Number(millisecond.padEnd(3, "0"))
187
+ );
188
+ const check = new Date(wall);
189
+ if (check.getUTCMonth() !== Number(month) - 1 || check.getUTCDate() !== Number(day)) return null;
190
+ if (!offset) return { date: new Date(fromZonedTime(wall, timeZone)), hasTime };
191
+ if (offset.toUpperCase() === "Z") return { date: new Date(wall), hasTime };
192
+ const sign = offset.startsWith("-") ? -1 : 1;
193
+ const digits = offset.slice(1).replace(":", "");
194
+ const minutes = Number(digits.slice(0, 2)) * 60 + Number(digits.slice(2));
195
+ return { date: new Date(wall - sign * minutes * 6e4), hasTime };
196
+ }
197
+ function formatPostDate({ date, hasTime }, display) {
198
+ const { locale, timeZone } = display;
199
+ const day = new Intl.DateTimeFormat(locale, { timeZone, ...display.dateFormat }).format(date);
200
+ if (!hasTime) return day;
201
+ const time = new Intl.DateTimeFormat(locale, { timeZone, ...display.timeFormat }).format(date);
202
+ return display.dateTime.replace("{date}", day).replace("{time}", time);
203
+ }
204
+ function toZonedIsoDate(date, timeZone) {
205
+ const parts = Object.fromEntries(
206
+ new Intl.DateTimeFormat("en-US", { timeZone, year: "numeric", month: "2-digit", day: "2-digit" }).formatToParts(date).map(({ type, value }) => [type, value])
207
+ );
208
+ return `${parts.year}-${parts.month}-${parts.day}`;
209
+ }
210
+ function toDateTimeAttribute({ date, hasTime }, timeZone) {
211
+ return hasTime ? date.toISOString() : toZonedIsoDate(date, timeZone);
212
+ }
213
+ function yearIn(date, timeZone) {
214
+ return Number(toZonedIsoDate(date, timeZone).slice(0, 4));
215
+ }
216
+
217
+ // src/frontmatter.ts
120
218
  var POST_FRONTMATTER_KEY = "blog";
121
219
  var INDEX_FRONTMATTER_KEY = "blog-index";
122
220
  var FRONTMATTER = /^---[ \t]*\r?\n(?:([\s\S]*?)\r?\n)?---[ \t]*(?:\r?\n|$)/;
@@ -155,12 +253,6 @@ function toStringList(value) {
155
253
  const values = Array.isArray(value) ? value : typeof value === "string" ? value.split(",") : [];
156
254
  return values.map(toText).filter(Boolean);
157
255
  }
158
- function toDate(value) {
159
- if (value instanceof Date) return Number.isNaN(value.getTime()) ? null : value;
160
- if (typeof value !== "string" && typeof value !== "number") return null;
161
- const date = new Date(value);
162
- return Number.isNaN(date.getTime()) ? null : date;
163
- }
164
256
  function toAuthor(value, options) {
165
257
  if (typeof value === "string") {
166
258
  const name2 = value.trim();
@@ -225,11 +317,16 @@ function resolvePostData(data, content, fallbackTitle, options = {}) {
225
317
  const stripTags = options.stripTags ?? true;
226
318
  const clean = (text) => stripTags ? stripTagSyntax(text) : text;
227
319
  const image = toRecord(pick("image"));
320
+ const timeZone = resolveTimeZone(options.timeZone);
321
+ const date = parsePostDate(pick("date"), timeZone);
322
+ const updated = parsePostDate(pick("updated"), timeZone);
228
323
  return {
229
324
  title: clean(toText(post.title) || toText(data.title) || readHeading(content)) || fallbackTitle,
230
325
  summary: clean(toText(post.summary) || toText(data.description) || readExcerpt(content)),
231
- date: toDate(pick("date")),
232
- updated: toDate(pick("updated")),
326
+ date: date?.date ?? null,
327
+ dateHasTime: date?.hasTime ?? false,
328
+ updated: updated?.date ?? null,
329
+ updatedHasTime: updated?.hasTime ?? false,
233
330
  image: toText(image.src) || toText(pick("image")),
234
331
  imageAlt: toText(image.alt) || toText(post.imageAlt) || toText(data.imageAlt),
235
332
  imageFit: (toText(image.fit) || toText(post.imageFit)) === "contain" ? "contain" : "cover",
@@ -333,12 +430,6 @@ function toUrlPath(routePath, cleanUrls) {
333
430
  function joinUrl(base, urlPath) {
334
431
  return `${base.replace(/\/+$/, "")}/${urlPath.replace(/^\/+/, "")}`;
335
432
  }
336
- function toIsoDate(date) {
337
- return date.toISOString().slice(0, 10);
338
- }
339
- function formatDate(date, locale, format) {
340
- return new Intl.DateTimeFormat(locale, { timeZone: "UTC", ...format }).format(date);
341
- }
342
433
 
343
434
  // src/feed.ts
344
435
  function toAbsoluteUrl(urlPath, site) {
@@ -440,7 +531,9 @@ var import_node_path3 = __toESM(require("path"), 1);
440
531
  // src/types.ts
441
532
  var DEFAULT_LABELS = {
442
533
  by: "By",
534
+ published: "Published",
443
535
  updated: "Updated",
536
+ dateTime: "{date} at {time}",
444
537
  readingTime: "{n} min read",
445
538
  newer: "Newer post",
446
539
  older: "Older post",
@@ -463,10 +556,18 @@ var DEFAULT_LABELS = {
463
556
  // src/render.ts
464
557
  var BLOG_POSTS_PLACEHOLDER = "<!-- blog-posts -->";
465
558
  function createRenderContext(options, pageDirectory, feeds = !!options.siteUrl) {
559
+ const locale = options.locale ?? "en-US";
560
+ const labels = { ...DEFAULT_LABELS, ...options.labels };
466
561
  return {
467
- locale: options.locale ?? "en-US",
468
- dateFormat: options.dateFormat ?? { dateStyle: "long" },
469
- labels: { ...DEFAULT_LABELS, ...options.labels },
562
+ locale,
563
+ dates: {
564
+ locale,
565
+ timeZone: resolveTimeZone(options.timeZone),
566
+ dateFormat: options.dateFormat ?? { dateStyle: "long" },
567
+ timeFormat: options.timeFormat ?? { hour: "numeric", minute: "2-digit", timeZoneName: "short" },
568
+ dateTime: labels.dateTime
569
+ },
570
+ labels,
470
571
  pageDirectory,
471
572
  feeds
472
573
  };
@@ -488,8 +589,10 @@ function toImageSource(post, pageDirectory) {
488
589
  const relative = import_node_path3.default.relative(pageDirectory, absolute).split(import_node_path3.default.sep).join("/");
489
590
  return relative.startsWith(".") ? relative : `./${relative}`;
490
591
  }
491
- function renderTime(date, context) {
492
- return `<time datetime="${toIsoDate(date)}">${escapeTemplateText(formatDate(date, context.locale, context.dateFormat))}</time>`;
592
+ function renderTime(date, hasTime, context) {
593
+ const value = { date, hasTime };
594
+ const attribute = toDateTimeAttribute(value, context.dates.timeZone);
595
+ return `<time datetime="${attribute}">${escapeTemplateText(formatPostDate(value, context.dates))}</time>`;
493
596
  }
494
597
  function renderAuthor(author) {
495
598
  return author.url ? `[${escapeMarkdown(author.name)}](${toMarkdownUrl(author.url)})` : escapeMarkdown(author.name);
@@ -518,16 +621,19 @@ function toTagsAttribute(post) {
518
621
  return escapeHtml(post.post.tags.map((tag) => tag.toLowerCase()).join("|"));
519
622
  }
520
623
  function renderMeta(post, context, include) {
521
- const { date, updated, readingMinutes, authors, draft, sticky } = post.post;
624
+ const { date, dateHasTime, updated, updatedHasTime, readingMinutes, authors, draft, sticky } = post.post;
522
625
  const parts = [];
523
626
  if (include.badges) {
524
627
  const badges = [draft && renderBadge("draft", context), sticky && renderBadge("sticky", context)];
525
628
  const shown = badges.filter(Boolean).join(" ");
526
629
  if (shown) parts.push(shown);
527
630
  }
528
- if (date) parts.push(renderTime(date, context));
631
+ if (date) {
632
+ const time = renderTime(date, dateHasTime, context);
633
+ parts.push(include.updated ? `${escapeMarkdown(context.labels.published)} ${time}` : time);
634
+ }
529
635
  if (include.updated && updated && (!date || updated.getTime() !== date.getTime())) {
530
- parts.push(`${escapeMarkdown(context.labels.updated)} ${renderTime(updated, context)}`);
636
+ parts.push(`${escapeMarkdown(context.labels.updated)} ${renderTime(updated, updatedHasTime, context)}`);
531
637
  }
532
638
  if (readingMinutes) parts.push(escapeMarkdown(fill(context.labels.readingTime, { n: String(readingMinutes) })));
533
639
  if (include.authors) {
@@ -565,7 +671,7 @@ function renderCard(post, context, index) {
565
671
  ];
566
672
  }
567
673
  function renderCompactItem(post, context) {
568
- const date = post.post.date ? `${renderTime(post.post.date, context)} ` : "";
674
+ const date = post.post.date ? `${renderTime(post.post.date, post.post.dateHasTime, context)} ` : "";
569
675
  const badges = [post.post.draft && renderBadge("draft", context), post.post.sticky && renderBadge("sticky", context)].filter(Boolean).join(" ");
570
676
  const marker = `<span hidden data-vp-blog-item data-tags="${toTagsAttribute(post)}"></span>`;
571
677
  return `- ${marker}${date}${toPageLink(post.post.title, post.routePath)}${badges ? ` ${badges}` : ""}`;
@@ -590,7 +696,7 @@ function renderGroup(posts, index, context) {
590
696
  function renderYears(posts, index, context) {
591
697
  const years = /* @__PURE__ */ new Map();
592
698
  for (const post of posts) {
593
- const year = post.post.date ? String(post.post.date.getUTCFullYear()) : context.labels.undated;
699
+ const year = post.post.date ? String(yearIn(post.post.date, context.dates.timeZone)) : context.labels.undated;
594
700
  years.set(year, [...years.get(year) ?? [], post]);
595
701
  }
596
702
  const lines = [];
@@ -765,7 +871,8 @@ function toOptionsKey(options) {
765
871
  options.stripTags,
766
872
  options.pageSize,
767
873
  options.stickyLimit,
768
- options.rssLink
874
+ options.rssLink,
875
+ options.timeZone
769
876
  ]);
770
877
  }
771
878
  function indexBlogSource(sourceDirectory, options = {}, rewrites = {}) {