@songmu/mdhq 0.0.2
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/LICENSE +21 -0
- package/README.md +126 -0
- package/dist/assets/localize.d.ts +19 -0
- package/dist/assets/localize.js +364 -0
- package/dist/cli.d.ts +8 -0
- package/dist/cli.js +119 -0
- package/dist/config/config.d.ts +25 -0
- package/dist/config/config.js +170 -0
- package/dist/config/match.d.ts +7 -0
- package/dist/config/match.js +101 -0
- package/dist/convert/article-date.d.ts +20 -0
- package/dist/convert/article-date.js +255 -0
- package/dist/convert/convert-html.d.ts +2 -0
- package/dist/convert/convert-html.js +89 -0
- package/dist/convert/extract-published.d.ts +12 -0
- package/dist/convert/extract-published.js +24 -0
- package/dist/convert/extract-updated.d.ts +8 -0
- package/dist/convert/extract-updated.js +20 -0
- package/dist/date.d.ts +18 -0
- package/dist/date.js +448 -0
- package/dist/errors.d.ts +8 -0
- package/dist/errors.js +10 -0
- package/dist/frontmatter/frontmatter.d.ts +40 -0
- package/dist/frontmatter/frontmatter.js +114 -0
- package/dist/get-page.d.ts +2 -0
- package/dist/get-page.js +308 -0
- package/dist/http/fetch.d.ts +46 -0
- package/dist/http/fetch.js +195 -0
- package/dist/index.d.ts +6 -0
- package/dist/index.js +3 -0
- package/dist/list-files.d.ts +8 -0
- package/dist/list-files.js +35 -0
- package/dist/markdown/transform.d.ts +6 -0
- package/dist/markdown/transform.js +129 -0
- package/dist/path/storage-path.d.ts +7 -0
- package/dist/path/storage-path.js +110 -0
- package/dist/storage/atomic.d.ts +8 -0
- package/dist/storage/atomic.js +84 -0
- package/dist/storage/path-safety.d.ts +1 -0
- package/dist/storage/path-safety.js +55 -0
- package/dist/storage/save.d.ts +23 -0
- package/dist/storage/save.js +118 -0
- package/dist/types.d.ts +62 -0
- package/dist/types.js +1 -0
- package/dist/url/identity.d.ts +12 -0
- package/dist/url/identity.js +54 -0
- package/dist/url/pathname.d.ts +4 -0
- package/dist/url/pathname.js +46 -0
- package/dist/version.d.ts +3 -0
- package/dist/version.js +6 -0
- package/docs/README.md +14 -0
- package/docs/configuration.md +242 -0
- package/docs/library-api.md +275 -0
- package/docs/specification.md +730 -0
- package/package.json +73 -0
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
import { type ArticleDocument } from "./article-date.js";
|
|
2
|
+
/**
|
|
3
|
+
* Extracts and normalizes the article modification ("updated") date from
|
|
4
|
+
* Schema.org `dateModified` JSON-LD, `article:modified_time` /
|
|
5
|
+
* `og:updated_time` meta tags, or `itemprop="dateModified"` microdata.
|
|
6
|
+
*/
|
|
7
|
+
export declare function extractUpdatedDate(html: string, pageUrl?: string): string | undefined;
|
|
8
|
+
export declare function extractUpdatedDateFromDocument(document: ArticleDocument, pageUrl?: string): string | undefined;
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
import { extractArticleDate, extractArticleDateFromDocument } from "./article-date.js";
|
|
2
|
+
/**
|
|
3
|
+
* Extracts and normalizes the article modification ("updated") date from
|
|
4
|
+
* Schema.org `dateModified` JSON-LD, `article:modified_time` /
|
|
5
|
+
* `og:updated_time` meta tags, or `itemprop="dateModified"` microdata.
|
|
6
|
+
*/
|
|
7
|
+
export function extractUpdatedDate(html, pageUrl) {
|
|
8
|
+
return extractArticleDate(html, {
|
|
9
|
+
schemaProperty: "dateModified",
|
|
10
|
+
metaProperties: ["article:modified_time", "og:updated_time"],
|
|
11
|
+
itemprops: ["dateModified"]
|
|
12
|
+
}, pageUrl);
|
|
13
|
+
}
|
|
14
|
+
export function extractUpdatedDateFromDocument(document, pageUrl) {
|
|
15
|
+
return extractArticleDateFromDocument(document, {
|
|
16
|
+
schemaProperty: "dateModified",
|
|
17
|
+
metaProperties: ["article:modified_time", "og:updated_time"],
|
|
18
|
+
itemprops: ["dateModified"]
|
|
19
|
+
}, pageUrl);
|
|
20
|
+
}
|
package/dist/date.d.ts
ADDED
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
export declare function formatLocalRfc3339(date: Date): string;
|
|
2
|
+
/**
|
|
3
|
+
* Normalizes heterogeneous source-article date metadata (published/updated)
|
|
4
|
+
* into exactly one of:
|
|
5
|
+
* - `YYYY-MM-DD`, when only a calendar date is reliably known, or
|
|
6
|
+
* - an RFC 3339 date-time with an explicit `Z` or numeric offset and
|
|
7
|
+
* second-level precision.
|
|
8
|
+
*
|
|
9
|
+
* Accepts strings, finite integer numbers (interpreted as Unix epoch
|
|
10
|
+
* seconds/milliseconds/microseconds/nanoseconds based on magnitude), JSON-LD
|
|
11
|
+
* value objects (`{"@value": ..., "@type": ...}`), and arrays of candidates
|
|
12
|
+
* (the first value that normalizes successfully is used). Unsupported or
|
|
13
|
+
* ambiguous shapes return `undefined` instead of throwing or guessing.
|
|
14
|
+
*/
|
|
15
|
+
export declare function normalizeSourceDate(value: unknown): string | undefined;
|
|
16
|
+
export declare function isRfc3339DateTime(value: string | undefined): value is string;
|
|
17
|
+
export declare function httpDateToRfc3339(value: string | undefined, now?: Date): string | undefined;
|
|
18
|
+
export declare function rfc3339ToHttpDate(value: string | undefined): string | undefined;
|
package/dist/date.js
ADDED
|
@@ -0,0 +1,448 @@
|
|
|
1
|
+
const RFC3339_DATE = /^(\d{4})-(\d{2})-(\d{2})$/u;
|
|
2
|
+
const RFC3339_DATE_TIME = /^(\d{4})-(\d{2})-(\d{2})T(\d{2}):(\d{2}):(\d{2})(?:\.\d+)?(Z|([+-])(\d{2}):(\d{2}))$/iu;
|
|
3
|
+
const COMPACT_DATE = /^(\d{4})(\d{2})(\d{2})$/u;
|
|
4
|
+
const INTEGER_STRING = /^[+-]?\d+$/u;
|
|
5
|
+
// A space separator is accepted only alongside an explicit zone; a bare
|
|
6
|
+
// space-separated local date-time without a zone is handled by
|
|
7
|
+
// LOCAL_DATE_TIME below and reduced to a date-only value.
|
|
8
|
+
const ZONED_DATE_TIME = /^(\d{4})-(\d{2})-(\d{2})[T ](\d{2}):(\d{2}):(\d{2})(?:\.\d+)?(Z|[+-]\d{2}:?\d{2})$/iu;
|
|
9
|
+
const LOCAL_DATE_TIME = /^(\d{4})-(\d{2})-(\d{2})[T ](\d{2}):(\d{2}):(\d{2})(?:\.\d+)?$/u;
|
|
10
|
+
const MONTH_DAY_YEAR = /^([A-Za-z]{3,9})\.?\s+(\d{1,2}),?\s+(\d{4})$/u;
|
|
11
|
+
const DAY_MONTH_YEAR = /^(\d{1,2})\s+([A-Za-z]{3,9})\.?,?\s+(\d{4})$/u;
|
|
12
|
+
const MAX_SAFE_DATE_MS = 8640000000000000n;
|
|
13
|
+
const IMF_FIXDATE = /^(Sun|Mon|Tue|Wed|Thu|Fri|Sat), (\d{2}) (Jan|Feb|Mar|Apr|May|Jun|Jul|Aug|Sep|Oct|Nov|Dec) (\d{4}) (\d{2}):(\d{2}):(\d{2}) GMT$/u;
|
|
14
|
+
const RFC850_DATE = /^(Sunday|Monday|Tuesday|Wednesday|Thursday|Friday|Saturday), (\d{2})-(Jan|Feb|Mar|Apr|May|Jun|Jul|Aug|Sep|Oct|Nov|Dec)-(\d{2}) (\d{2}):(\d{2}):(\d{2}) GMT$/u;
|
|
15
|
+
const ASCTIME_DATE = /^(Sun|Mon|Tue|Wed|Thu|Fri|Sat) (Jan|Feb|Mar|Apr|May|Jun|Jul|Aug|Sep|Oct|Nov|Dec) {1,2}(\d{1,2}) (\d{2}):(\d{2}):(\d{2}) (\d{4})$/u;
|
|
16
|
+
const MONTHS = new Map(["Jan", "Feb", "Mar", "Apr", "May", "Jun", "Jul", "Aug", "Sep", "Oct", "Nov", "Dec"].map((month, index) => [month, index + 1]));
|
|
17
|
+
const MONTH_FULL_NAMES = [
|
|
18
|
+
"january",
|
|
19
|
+
"february",
|
|
20
|
+
"march",
|
|
21
|
+
"april",
|
|
22
|
+
"may",
|
|
23
|
+
"june",
|
|
24
|
+
"july",
|
|
25
|
+
"august",
|
|
26
|
+
"september",
|
|
27
|
+
"october",
|
|
28
|
+
"november",
|
|
29
|
+
"december"
|
|
30
|
+
];
|
|
31
|
+
const MONTH_NAME_TO_NUMBER = new Map();
|
|
32
|
+
for (const [index, name] of MONTH_FULL_NAMES.entries()) {
|
|
33
|
+
MONTH_NAME_TO_NUMBER.set(name, index + 1);
|
|
34
|
+
MONTH_NAME_TO_NUMBER.set(name.slice(0, 3), index + 1);
|
|
35
|
+
}
|
|
36
|
+
const SHORT_WEEKDAYS = ["Sun", "Mon", "Tue", "Wed", "Thu", "Fri", "Sat"];
|
|
37
|
+
const LONG_WEEKDAYS = [
|
|
38
|
+
"Sunday",
|
|
39
|
+
"Monday",
|
|
40
|
+
"Tuesday",
|
|
41
|
+
"Wednesday",
|
|
42
|
+
"Thursday",
|
|
43
|
+
"Friday",
|
|
44
|
+
"Saturday"
|
|
45
|
+
];
|
|
46
|
+
function capture(match, index) {
|
|
47
|
+
const value = match[index];
|
|
48
|
+
if (value === undefined) {
|
|
49
|
+
throw new TypeError(`Missing regular expression capture ${index}`);
|
|
50
|
+
}
|
|
51
|
+
return value;
|
|
52
|
+
}
|
|
53
|
+
function leapYear(year) {
|
|
54
|
+
return year % 4 === 0 && (year % 100 !== 0 || year % 400 === 0);
|
|
55
|
+
}
|
|
56
|
+
function validCalendarDate(year, month, day) {
|
|
57
|
+
const days = [31, leapYear(year) ? 29 : 28, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31];
|
|
58
|
+
return month >= 1 && month <= 12 && day >= 1 && day <= (days[month - 1] ?? 0);
|
|
59
|
+
}
|
|
60
|
+
function validTime(hour, minute, second) {
|
|
61
|
+
return hour >= 0 && hour <= 23 && minute >= 0 && minute <= 59 && second >= 0 && second <= 59;
|
|
62
|
+
}
|
|
63
|
+
function utcTimestamp(year, month, day, hour, minute, second) {
|
|
64
|
+
const date = new Date(0);
|
|
65
|
+
date.setUTCFullYear(year, month - 1, day);
|
|
66
|
+
date.setUTCHours(hour, minute, second, 0);
|
|
67
|
+
return date.getTime();
|
|
68
|
+
}
|
|
69
|
+
function validWeekday(timestamp, value, long) {
|
|
70
|
+
const expected = (long ? LONG_WEEKDAYS : SHORT_WEEKDAYS)[new Date(timestamp).getUTCDay()];
|
|
71
|
+
return value === expected;
|
|
72
|
+
}
|
|
73
|
+
function parseRfc3339(value) {
|
|
74
|
+
const match = RFC3339_DATE_TIME.exec(value);
|
|
75
|
+
if (!match) {
|
|
76
|
+
return undefined;
|
|
77
|
+
}
|
|
78
|
+
const yearValue = capture(match, 1);
|
|
79
|
+
const monthValue = capture(match, 2);
|
|
80
|
+
const dayValue = capture(match, 3);
|
|
81
|
+
const hourValue = capture(match, 4);
|
|
82
|
+
const minuteValue = capture(match, 5);
|
|
83
|
+
const secondValue = capture(match, 6);
|
|
84
|
+
const zone = capture(match, 7);
|
|
85
|
+
const year = Number(yearValue);
|
|
86
|
+
const month = Number(monthValue);
|
|
87
|
+
const day = Number(dayValue);
|
|
88
|
+
const hour = Number(hourValue);
|
|
89
|
+
const minute = Number(minuteValue);
|
|
90
|
+
const second = Number(secondValue);
|
|
91
|
+
if (!validCalendarDate(year, month, day) || !validTime(hour, minute, second)) {
|
|
92
|
+
return undefined;
|
|
93
|
+
}
|
|
94
|
+
let offsetMinutes = 0;
|
|
95
|
+
if (zone.toUpperCase() !== "Z") {
|
|
96
|
+
const offsetSign = capture(match, 8);
|
|
97
|
+
const offsetHour = Number(capture(match, 9));
|
|
98
|
+
const offsetMinute = Number(capture(match, 10));
|
|
99
|
+
if (offsetHour > 23 || offsetMinute > 59) {
|
|
100
|
+
return undefined;
|
|
101
|
+
}
|
|
102
|
+
offsetMinutes = (offsetHour * 60 + offsetMinute) * (offsetSign === "+" ? 1 : -1);
|
|
103
|
+
}
|
|
104
|
+
return utcTimestamp(year, month, day, hour, minute, second) - offsetMinutes * 60_000;
|
|
105
|
+
}
|
|
106
|
+
function parseHttpDate(value, now) {
|
|
107
|
+
const imf = IMF_FIXDATE.exec(value);
|
|
108
|
+
if (imf) {
|
|
109
|
+
const weekday = capture(imf, 1);
|
|
110
|
+
const dayValue = capture(imf, 2);
|
|
111
|
+
const monthName = capture(imf, 3);
|
|
112
|
+
const yearValue = capture(imf, 4);
|
|
113
|
+
const hourValue = capture(imf, 5);
|
|
114
|
+
const minuteValue = capture(imf, 6);
|
|
115
|
+
const secondValue = capture(imf, 7);
|
|
116
|
+
const month = MONTHS.get(monthName) ?? 0;
|
|
117
|
+
const values = [
|
|
118
|
+
Number(yearValue),
|
|
119
|
+
month,
|
|
120
|
+
Number(dayValue),
|
|
121
|
+
Number(hourValue),
|
|
122
|
+
Number(minuteValue),
|
|
123
|
+
Number(secondValue)
|
|
124
|
+
];
|
|
125
|
+
if (!validCalendarDate(values[0], values[1], values[2]) || !validTime(values[3], values[4], values[5])) {
|
|
126
|
+
return undefined;
|
|
127
|
+
}
|
|
128
|
+
const timestamp = utcTimestamp(...values);
|
|
129
|
+
return validWeekday(timestamp, weekday, false) ? timestamp : undefined;
|
|
130
|
+
}
|
|
131
|
+
const rfc850 = RFC850_DATE.exec(value);
|
|
132
|
+
if (rfc850) {
|
|
133
|
+
const weekday = capture(rfc850, 1);
|
|
134
|
+
const dayValue = capture(rfc850, 2);
|
|
135
|
+
const monthName = capture(rfc850, 3);
|
|
136
|
+
const shortYearValue = capture(rfc850, 4);
|
|
137
|
+
const hourValue = capture(rfc850, 5);
|
|
138
|
+
const minuteValue = capture(rfc850, 6);
|
|
139
|
+
const secondValue = capture(rfc850, 7);
|
|
140
|
+
const month = MONTHS.get(monthName) ?? 0;
|
|
141
|
+
const currentYear = now.getUTCFullYear();
|
|
142
|
+
let year = Math.floor(currentYear / 100) * 100 + Number(shortYearValue);
|
|
143
|
+
const candidateValues = [
|
|
144
|
+
year,
|
|
145
|
+
month,
|
|
146
|
+
Number(dayValue),
|
|
147
|
+
Number(hourValue),
|
|
148
|
+
Number(minuteValue),
|
|
149
|
+
Number(secondValue)
|
|
150
|
+
];
|
|
151
|
+
if (!validCalendarDate(candidateValues[0], candidateValues[1], candidateValues[2]) ||
|
|
152
|
+
!validTime(candidateValues[3], candidateValues[4], candidateValues[5])) {
|
|
153
|
+
return undefined;
|
|
154
|
+
}
|
|
155
|
+
const cutoff = new Date(now.getTime());
|
|
156
|
+
cutoff.setUTCFullYear(cutoff.getUTCFullYear() + 50);
|
|
157
|
+
if (utcTimestamp(...candidateValues) > cutoff.getTime()) {
|
|
158
|
+
year -= 100;
|
|
159
|
+
}
|
|
160
|
+
const values = [
|
|
161
|
+
year,
|
|
162
|
+
month,
|
|
163
|
+
Number(dayValue),
|
|
164
|
+
Number(hourValue),
|
|
165
|
+
Number(minuteValue),
|
|
166
|
+
Number(secondValue)
|
|
167
|
+
];
|
|
168
|
+
const timestamp = utcTimestamp(...values);
|
|
169
|
+
return validWeekday(timestamp, weekday, true) ? timestamp : undefined;
|
|
170
|
+
}
|
|
171
|
+
const asctime = ASCTIME_DATE.exec(value);
|
|
172
|
+
if (!asctime) {
|
|
173
|
+
return undefined;
|
|
174
|
+
}
|
|
175
|
+
const weekday = capture(asctime, 1);
|
|
176
|
+
const monthName = capture(asctime, 2);
|
|
177
|
+
const dayValue = capture(asctime, 3);
|
|
178
|
+
const hourValue = capture(asctime, 4);
|
|
179
|
+
const minuteValue = capture(asctime, 5);
|
|
180
|
+
const secondValue = capture(asctime, 6);
|
|
181
|
+
const yearValue = capture(asctime, 7);
|
|
182
|
+
const month = MONTHS.get(monthName) ?? 0;
|
|
183
|
+
const values = [
|
|
184
|
+
Number(yearValue),
|
|
185
|
+
month,
|
|
186
|
+
Number(dayValue),
|
|
187
|
+
Number(hourValue),
|
|
188
|
+
Number(minuteValue),
|
|
189
|
+
Number(secondValue)
|
|
190
|
+
];
|
|
191
|
+
if (!validCalendarDate(values[0], values[1], values[2]) || !validTime(values[3], values[4], values[5])) {
|
|
192
|
+
return undefined;
|
|
193
|
+
}
|
|
194
|
+
const timestamp = utcTimestamp(...values);
|
|
195
|
+
return validWeekday(timestamp, weekday, false) ? timestamp : undefined;
|
|
196
|
+
}
|
|
197
|
+
export function formatLocalRfc3339(date) {
|
|
198
|
+
const pad = (value, width = 2) => String(value).padStart(width, "0");
|
|
199
|
+
const offsetMinutes = -date.getTimezoneOffset();
|
|
200
|
+
const sign = offsetMinutes >= 0 ? "+" : "-";
|
|
201
|
+
const hours = Math.floor(Math.abs(offsetMinutes) / 60);
|
|
202
|
+
const minutes = Math.abs(offsetMinutes) % 60;
|
|
203
|
+
return `${date.getFullYear()}-${pad(date.getMonth() + 1)}-${pad(date.getDate())}T${pad(date.getHours())}:${pad(date.getMinutes())}:${pad(date.getSeconds())}${sign}${pad(hours)}:${pad(minutes)}`;
|
|
204
|
+
}
|
|
205
|
+
function monthNumber(name) {
|
|
206
|
+
return MONTH_NAME_TO_NUMBER.get(name.toLowerCase());
|
|
207
|
+
}
|
|
208
|
+
function parseZoneOffset(zone) {
|
|
209
|
+
if (zone.toUpperCase() === "Z") {
|
|
210
|
+
return { canonical: "Z" };
|
|
211
|
+
}
|
|
212
|
+
const match = /^([+-])(\d{2}):?(\d{2})$/u.exec(zone);
|
|
213
|
+
if (!match) {
|
|
214
|
+
return undefined;
|
|
215
|
+
}
|
|
216
|
+
const sign = capture(match, 1);
|
|
217
|
+
const hour = Number(capture(match, 2));
|
|
218
|
+
const minute = Number(capture(match, 3));
|
|
219
|
+
if (hour > 23 || minute > 59) {
|
|
220
|
+
return undefined;
|
|
221
|
+
}
|
|
222
|
+
return { canonical: `${sign}${capture(match, 2)}:${capture(match, 3)}` };
|
|
223
|
+
}
|
|
224
|
+
function formatZonedDateTime(match) {
|
|
225
|
+
const year = Number(capture(match, 1));
|
|
226
|
+
const month = Number(capture(match, 2));
|
|
227
|
+
const day = Number(capture(match, 3));
|
|
228
|
+
const hour = Number(capture(match, 4));
|
|
229
|
+
const minute = Number(capture(match, 5));
|
|
230
|
+
const second = Number(capture(match, 6));
|
|
231
|
+
if (!validCalendarDate(year, month, day) || !validTime(hour, minute, second)) {
|
|
232
|
+
return undefined;
|
|
233
|
+
}
|
|
234
|
+
const zone = parseZoneOffset(capture(match, 7));
|
|
235
|
+
if (!zone) {
|
|
236
|
+
return undefined;
|
|
237
|
+
}
|
|
238
|
+
return `${capture(match, 1)}-${capture(match, 2)}-${capture(match, 3)}T${capture(match, 4)}:${capture(match, 5)}:${capture(match, 6)}${zone.canonical}`;
|
|
239
|
+
}
|
|
240
|
+
function formatLocalDateOnly(match) {
|
|
241
|
+
const year = Number(capture(match, 1));
|
|
242
|
+
const month = Number(capture(match, 2));
|
|
243
|
+
const day = Number(capture(match, 3));
|
|
244
|
+
const hour = Number(capture(match, 4));
|
|
245
|
+
const minute = Number(capture(match, 5));
|
|
246
|
+
const second = Number(capture(match, 6));
|
|
247
|
+
if (!validCalendarDate(year, month, day) || !validTime(hour, minute, second)) {
|
|
248
|
+
return undefined;
|
|
249
|
+
}
|
|
250
|
+
// A local date-time without an explicit UTC offset never establishes a
|
|
251
|
+
// reliable instant: reduce it to the calendar date it unambiguously names
|
|
252
|
+
// instead of inventing an offset.
|
|
253
|
+
return `${capture(match, 1)}-${capture(match, 2)}-${capture(match, 3)}`;
|
|
254
|
+
}
|
|
255
|
+
function formatNamedMonthDate(monthName, dayValue, yearValue) {
|
|
256
|
+
const month = monthNumber(monthName);
|
|
257
|
+
if (!month) {
|
|
258
|
+
return undefined;
|
|
259
|
+
}
|
|
260
|
+
const day = Number(dayValue);
|
|
261
|
+
const year = Number(yearValue);
|
|
262
|
+
if (!validCalendarDate(year, month, day)) {
|
|
263
|
+
return undefined;
|
|
264
|
+
}
|
|
265
|
+
const pad = (value) => String(value).padStart(2, "0");
|
|
266
|
+
return `${yearValue}-${pad(month)}-${pad(day)}`;
|
|
267
|
+
}
|
|
268
|
+
function digitLength(value) {
|
|
269
|
+
return (value < 0n ? -value : value).toString().length;
|
|
270
|
+
}
|
|
271
|
+
/**
|
|
272
|
+
* Infers the epoch unit (seconds, milliseconds, microseconds, or
|
|
273
|
+
* nanoseconds) from the number of significant digits and returns the
|
|
274
|
+
* equivalent millisecond count. Values with too few digits to plausibly be a
|
|
275
|
+
* modern timestamp, or too many digits to be any of the four supported
|
|
276
|
+
* units, are rejected so arbitrary numeric identifiers are not treated as
|
|
277
|
+
* dates.
|
|
278
|
+
*/
|
|
279
|
+
function epochToMilliseconds(value) {
|
|
280
|
+
const digits = digitLength(value);
|
|
281
|
+
if (digits < 6) {
|
|
282
|
+
return undefined;
|
|
283
|
+
}
|
|
284
|
+
if (digits <= 10) {
|
|
285
|
+
return value * 1000n;
|
|
286
|
+
}
|
|
287
|
+
if (digits <= 13) {
|
|
288
|
+
return value;
|
|
289
|
+
}
|
|
290
|
+
if (digits <= 16) {
|
|
291
|
+
return floorDivide(value, 1000n);
|
|
292
|
+
}
|
|
293
|
+
if (digits <= 19) {
|
|
294
|
+
return floorDivide(value, 1000000n);
|
|
295
|
+
}
|
|
296
|
+
return undefined;
|
|
297
|
+
}
|
|
298
|
+
function floorDivide(value, divisor) {
|
|
299
|
+
const quotient = value / divisor;
|
|
300
|
+
return value < 0n && value % divisor !== 0n ? quotient - 1n : quotient;
|
|
301
|
+
}
|
|
302
|
+
function formatEpochMilliseconds(ms) {
|
|
303
|
+
if (ms < -MAX_SAFE_DATE_MS || ms > MAX_SAFE_DATE_MS) {
|
|
304
|
+
return undefined;
|
|
305
|
+
}
|
|
306
|
+
const date = new Date(Number(ms));
|
|
307
|
+
if (Number.isNaN(date.getTime()) || date.getUTCFullYear() < 0 || date.getUTCFullYear() > 9999) {
|
|
308
|
+
return undefined;
|
|
309
|
+
}
|
|
310
|
+
return date.toISOString().replace(/\.\d{3}Z$/u, "Z");
|
|
311
|
+
}
|
|
312
|
+
function normalizeEpochBigInt(value) {
|
|
313
|
+
const ms = epochToMilliseconds(value);
|
|
314
|
+
return ms === undefined ? undefined : formatEpochMilliseconds(ms);
|
|
315
|
+
}
|
|
316
|
+
function normalizeEpochNumber(value) {
|
|
317
|
+
if (!Number.isFinite(value) || !Number.isInteger(value) || !Number.isSafeInteger(value)) {
|
|
318
|
+
return undefined;
|
|
319
|
+
}
|
|
320
|
+
// Route through the string path so a compact YYYYMMDD-shaped integer (e.g.
|
|
321
|
+
// 20260831) is recognized as a date before being treated as an epoch, the
|
|
322
|
+
// same as an equivalent numeric string.
|
|
323
|
+
return normalizeSourceDateString(String(value));
|
|
324
|
+
}
|
|
325
|
+
function normalizeEpochString(trimmed) {
|
|
326
|
+
const unsigned = trimmed.startsWith("+") ? trimmed.slice(1) : trimmed;
|
|
327
|
+
try {
|
|
328
|
+
return normalizeEpochBigInt(BigInt(unsigned));
|
|
329
|
+
}
|
|
330
|
+
catch {
|
|
331
|
+
return undefined;
|
|
332
|
+
}
|
|
333
|
+
}
|
|
334
|
+
function normalizeSourceDateString(trimmed) {
|
|
335
|
+
const compactMatch = COMPACT_DATE.exec(trimmed);
|
|
336
|
+
if (compactMatch) {
|
|
337
|
+
if (validCalendarDate(Number(compactMatch[1]), Number(compactMatch[2]), Number(compactMatch[3]))) {
|
|
338
|
+
return `${compactMatch[1]}-${compactMatch[2]}-${compactMatch[3]}`;
|
|
339
|
+
}
|
|
340
|
+
return undefined;
|
|
341
|
+
}
|
|
342
|
+
if (INTEGER_STRING.test(trimmed)) {
|
|
343
|
+
return normalizeEpochString(trimmed);
|
|
344
|
+
}
|
|
345
|
+
const dateMatch = RFC3339_DATE.exec(trimmed);
|
|
346
|
+
if (dateMatch) {
|
|
347
|
+
return validCalendarDate(Number(dateMatch[1]), Number(dateMatch[2]), Number(dateMatch[3]))
|
|
348
|
+
? trimmed
|
|
349
|
+
: undefined;
|
|
350
|
+
}
|
|
351
|
+
const zonedMatch = ZONED_DATE_TIME.exec(trimmed);
|
|
352
|
+
if (zonedMatch) {
|
|
353
|
+
return formatZonedDateTime(zonedMatch);
|
|
354
|
+
}
|
|
355
|
+
const localMatch = LOCAL_DATE_TIME.exec(trimmed);
|
|
356
|
+
if (localMatch) {
|
|
357
|
+
return formatLocalDateOnly(localMatch);
|
|
358
|
+
}
|
|
359
|
+
const monthDayYear = MONTH_DAY_YEAR.exec(trimmed);
|
|
360
|
+
if (monthDayYear) {
|
|
361
|
+
return formatNamedMonthDate(capture(monthDayYear, 1), capture(monthDayYear, 2), capture(monthDayYear, 3));
|
|
362
|
+
}
|
|
363
|
+
const dayMonthYear = DAY_MONTH_YEAR.exec(trimmed);
|
|
364
|
+
if (dayMonthYear) {
|
|
365
|
+
return formatNamedMonthDate(capture(dayMonthYear, 2), capture(dayMonthYear, 1), capture(dayMonthYear, 3));
|
|
366
|
+
}
|
|
367
|
+
return undefined;
|
|
368
|
+
}
|
|
369
|
+
/**
|
|
370
|
+
* Normalizes heterogeneous source-article date metadata (published/updated)
|
|
371
|
+
* into exactly one of:
|
|
372
|
+
* - `YYYY-MM-DD`, when only a calendar date is reliably known, or
|
|
373
|
+
* - an RFC 3339 date-time with an explicit `Z` or numeric offset and
|
|
374
|
+
* second-level precision.
|
|
375
|
+
*
|
|
376
|
+
* Accepts strings, finite integer numbers (interpreted as Unix epoch
|
|
377
|
+
* seconds/milliseconds/microseconds/nanoseconds based on magnitude), JSON-LD
|
|
378
|
+
* value objects (`{"@value": ..., "@type": ...}`), and arrays of candidates
|
|
379
|
+
* (the first value that normalizes successfully is used). Unsupported or
|
|
380
|
+
* ambiguous shapes return `undefined` instead of throwing or guessing.
|
|
381
|
+
*/
|
|
382
|
+
export function normalizeSourceDate(value) {
|
|
383
|
+
const candidates = [value];
|
|
384
|
+
const seen = new Set();
|
|
385
|
+
while (candidates.length > 0) {
|
|
386
|
+
const candidate = candidates.pop();
|
|
387
|
+
if (candidate === undefined || candidate === null) {
|
|
388
|
+
continue;
|
|
389
|
+
}
|
|
390
|
+
if (Array.isArray(candidate)) {
|
|
391
|
+
if (seen.has(candidate)) {
|
|
392
|
+
continue;
|
|
393
|
+
}
|
|
394
|
+
seen.add(candidate);
|
|
395
|
+
for (let index = candidate.length - 1; index >= 0; index -= 1) {
|
|
396
|
+
candidates.push(candidate[index]);
|
|
397
|
+
}
|
|
398
|
+
continue;
|
|
399
|
+
}
|
|
400
|
+
if (typeof candidate === "number") {
|
|
401
|
+
const normalized = normalizeEpochNumber(candidate);
|
|
402
|
+
if (normalized !== undefined) {
|
|
403
|
+
return normalized;
|
|
404
|
+
}
|
|
405
|
+
continue;
|
|
406
|
+
}
|
|
407
|
+
if (typeof candidate === "string") {
|
|
408
|
+
const trimmed = candidate.trim();
|
|
409
|
+
const normalized = trimmed ? normalizeSourceDateString(trimmed) : undefined;
|
|
410
|
+
if (normalized !== undefined) {
|
|
411
|
+
return normalized;
|
|
412
|
+
}
|
|
413
|
+
continue;
|
|
414
|
+
}
|
|
415
|
+
if (typeof candidate === "object") {
|
|
416
|
+
if (seen.has(candidate)) {
|
|
417
|
+
continue;
|
|
418
|
+
}
|
|
419
|
+
seen.add(candidate);
|
|
420
|
+
const record = candidate;
|
|
421
|
+
if ("@value" in record) {
|
|
422
|
+
candidates.push(record["@value"]);
|
|
423
|
+
}
|
|
424
|
+
}
|
|
425
|
+
}
|
|
426
|
+
return undefined;
|
|
427
|
+
}
|
|
428
|
+
export function isRfc3339DateTime(value) {
|
|
429
|
+
return value !== undefined && parseRfc3339(value) !== undefined;
|
|
430
|
+
}
|
|
431
|
+
export function httpDateToRfc3339(value, now = new Date()) {
|
|
432
|
+
const trimmed = value?.trim();
|
|
433
|
+
if (!trimmed) {
|
|
434
|
+
return undefined;
|
|
435
|
+
}
|
|
436
|
+
const timestamp = parseHttpDate(trimmed, now);
|
|
437
|
+
if (timestamp === undefined) {
|
|
438
|
+
return undefined;
|
|
439
|
+
}
|
|
440
|
+
return new Date(timestamp).toISOString().replace(".000Z", "Z");
|
|
441
|
+
}
|
|
442
|
+
export function rfc3339ToHttpDate(value) {
|
|
443
|
+
if (!value) {
|
|
444
|
+
return undefined;
|
|
445
|
+
}
|
|
446
|
+
const timestamp = parseRfc3339(value);
|
|
447
|
+
return timestamp === undefined ? undefined : new Date(timestamp).toUTCString();
|
|
448
|
+
}
|
package/dist/errors.d.ts
ADDED
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
export type MdhqErrorCode = "INVALID_URL" | "INVALID_HEADER" | "UNSUPPORTED_SCHEME" | "UNSUPPORTED_CONTENT_TYPE" | "RESPONSE_TOO_LARGE" | "TOO_MANY_REDIRECTS" | "FETCH_FAILED" | "CONVERSION_FAILED" | "CONFIG_ERROR" | "PATH_COLLISION" | "PATH_TOO_LONG" | "STORAGE_ERROR";
|
|
2
|
+
export declare class MdhqError extends Error {
|
|
3
|
+
readonly code: MdhqErrorCode;
|
|
4
|
+
readonly cause?: unknown;
|
|
5
|
+
constructor(code: MdhqErrorCode, message: string, options?: {
|
|
6
|
+
cause?: unknown;
|
|
7
|
+
});
|
|
8
|
+
}
|
package/dist/errors.js
ADDED
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
import type { MdhqConfig } from "../config/config.js";
|
|
2
|
+
import type { PageMetadata } from "../types.js";
|
|
3
|
+
export interface FrontmatterOptions {
|
|
4
|
+
metadata: PageMetadata;
|
|
5
|
+
sourceUrl: string;
|
|
6
|
+
requestedUrl: string;
|
|
7
|
+
created: Date | string;
|
|
8
|
+
modified: Date;
|
|
9
|
+
contentDigest: string;
|
|
10
|
+
etag?: string;
|
|
11
|
+
lastModified?: string;
|
|
12
|
+
vary?: string[];
|
|
13
|
+
image?: string;
|
|
14
|
+
imageSource?: string;
|
|
15
|
+
config?: MdhqConfig["frontmatter"];
|
|
16
|
+
}
|
|
17
|
+
interface ControlledFrontmatterOptions {
|
|
18
|
+
fields: Record<string, unknown>;
|
|
19
|
+
sourceUrl: string;
|
|
20
|
+
requestedUrl: string;
|
|
21
|
+
created: Date | string;
|
|
22
|
+
modified: Date;
|
|
23
|
+
contentDigest: string;
|
|
24
|
+
etag?: string;
|
|
25
|
+
lastModified?: string;
|
|
26
|
+
vary?: string[];
|
|
27
|
+
config?: MdhqConfig["frontmatter"];
|
|
28
|
+
}
|
|
29
|
+
export declare function buildFrontmatter(options: FrontmatterOptions): Record<string, unknown>;
|
|
30
|
+
export declare function refreshFrontmatter(existing: Record<string, unknown>, options: Omit<ControlledFrontmatterOptions, "fields">): Record<string, unknown>;
|
|
31
|
+
export declare function normalizeMarkdownBody(markdown: string): string;
|
|
32
|
+
export declare function markdownContentDigest(markdown: string): string;
|
|
33
|
+
export declare function serializeDocument(frontmatter: Record<string, unknown>, markdown: string): string;
|
|
34
|
+
export interface ParsedDocument {
|
|
35
|
+
frontmatter: Record<string, unknown>;
|
|
36
|
+
markdown: string;
|
|
37
|
+
}
|
|
38
|
+
export declare function parseDocument(document: string): ParsedDocument | undefined;
|
|
39
|
+
export declare function parseDocumentFrontmatter(document: string): Record<string, unknown> | undefined;
|
|
40
|
+
export {};
|
|
@@ -0,0 +1,114 @@
|
|
|
1
|
+
import { createHash } from "node:crypto";
|
|
2
|
+
import { parse, stringify } from "yaml";
|
|
3
|
+
import { formatLocalRfc3339 } from "../date.js";
|
|
4
|
+
function applyControlledFields(options) {
|
|
5
|
+
const fields = { ...options.fields };
|
|
6
|
+
delete fields.type;
|
|
7
|
+
for (const key of options.config?.exclude ?? []) {
|
|
8
|
+
delete fields[key];
|
|
9
|
+
}
|
|
10
|
+
for (const [key, value] of Object.entries(options.config?.values ?? {})) {
|
|
11
|
+
fields[key] = value;
|
|
12
|
+
}
|
|
13
|
+
fields.source = options.sourceUrl;
|
|
14
|
+
if (options.requestedUrl !== options.sourceUrl) {
|
|
15
|
+
fields.requested_url = options.requestedUrl;
|
|
16
|
+
}
|
|
17
|
+
else {
|
|
18
|
+
delete fields.requested_url;
|
|
19
|
+
}
|
|
20
|
+
fields.created =
|
|
21
|
+
typeof options.created === "string"
|
|
22
|
+
? options.created
|
|
23
|
+
: formatLocalRfc3339(options.created);
|
|
24
|
+
fields.modified = formatLocalRfc3339(options.modified);
|
|
25
|
+
fields.content_digest = options.contentDigest;
|
|
26
|
+
if (options.etag) {
|
|
27
|
+
fields.etag = options.etag;
|
|
28
|
+
}
|
|
29
|
+
else {
|
|
30
|
+
delete fields.etag;
|
|
31
|
+
}
|
|
32
|
+
if (options.lastModified) {
|
|
33
|
+
fields.last_modified = options.lastModified;
|
|
34
|
+
}
|
|
35
|
+
else {
|
|
36
|
+
delete fields.last_modified;
|
|
37
|
+
}
|
|
38
|
+
if (options.vary) {
|
|
39
|
+
fields.vary = options.vary;
|
|
40
|
+
}
|
|
41
|
+
else {
|
|
42
|
+
delete fields.vary;
|
|
43
|
+
}
|
|
44
|
+
return fields;
|
|
45
|
+
}
|
|
46
|
+
export function buildFrontmatter(options) {
|
|
47
|
+
const fields = {};
|
|
48
|
+
const metadataFields = [
|
|
49
|
+
["title", options.metadata.title],
|
|
50
|
+
["description", options.metadata.description],
|
|
51
|
+
["author", options.metadata.author],
|
|
52
|
+
["published", options.metadata.published],
|
|
53
|
+
["updated", options.metadata.updated],
|
|
54
|
+
["site", options.metadata.site],
|
|
55
|
+
["domain", options.metadata.domain],
|
|
56
|
+
["language", options.metadata.language],
|
|
57
|
+
["word_count", options.metadata.wordCount]
|
|
58
|
+
];
|
|
59
|
+
for (const [key, value] of metadataFields) {
|
|
60
|
+
if (value !== undefined && value !== "") {
|
|
61
|
+
fields[key] = value;
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
if (options.image) {
|
|
65
|
+
fields.image = options.image;
|
|
66
|
+
}
|
|
67
|
+
else if (options.metadata.image) {
|
|
68
|
+
fields.image = options.metadata.image;
|
|
69
|
+
}
|
|
70
|
+
if (options.imageSource) {
|
|
71
|
+
fields.image_source = options.imageSource;
|
|
72
|
+
}
|
|
73
|
+
return applyControlledFields({ ...options, fields });
|
|
74
|
+
}
|
|
75
|
+
export function refreshFrontmatter(existing, options) {
|
|
76
|
+
return applyControlledFields({ ...options, fields: existing });
|
|
77
|
+
}
|
|
78
|
+
export function normalizeMarkdownBody(markdown) {
|
|
79
|
+
const normalized = markdown.replace(/\r\n?/gu, "\n").trimEnd();
|
|
80
|
+
return normalized ? `${normalized}\n` : "";
|
|
81
|
+
}
|
|
82
|
+
export function markdownContentDigest(markdown) {
|
|
83
|
+
return `sha256:${createHash("sha256").update(normalizeMarkdownBody(markdown), "utf8").digest("hex")}`;
|
|
84
|
+
}
|
|
85
|
+
export function serializeDocument(frontmatter, markdown) {
|
|
86
|
+
return `---\n${stringify(frontmatter, { lineWidth: 0 }).trimEnd()}\n---\n\n${normalizeMarkdownBody(markdown)}`;
|
|
87
|
+
}
|
|
88
|
+
export function parseDocument(document) {
|
|
89
|
+
const normalized = document.replace(/\r\n?/gu, "\n");
|
|
90
|
+
if (!normalized.startsWith("---\n")) {
|
|
91
|
+
return undefined;
|
|
92
|
+
}
|
|
93
|
+
const end = normalized.indexOf("\n---\n", 4);
|
|
94
|
+
if (end < 0) {
|
|
95
|
+
return undefined;
|
|
96
|
+
}
|
|
97
|
+
try {
|
|
98
|
+
const value = parse(normalized.slice(4, end));
|
|
99
|
+
if (!value || typeof value !== "object" || Array.isArray(value)) {
|
|
100
|
+
return undefined;
|
|
101
|
+
}
|
|
102
|
+
const afterFrontmatter = normalized.slice(end + 5);
|
|
103
|
+
return {
|
|
104
|
+
frontmatter: value,
|
|
105
|
+
markdown: normalizeMarkdownBody(afterFrontmatter.startsWith("\n") ? afterFrontmatter.slice(1) : afterFrontmatter)
|
|
106
|
+
};
|
|
107
|
+
}
|
|
108
|
+
catch {
|
|
109
|
+
return undefined;
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
export function parseDocumentFrontmatter(document) {
|
|
113
|
+
return parseDocument(document)?.frontmatter;
|
|
114
|
+
}
|