@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
package/dist/get-page.js
ADDED
|
@@ -0,0 +1,308 @@
|
|
|
1
|
+
import { loadConfig, resolveRoot } from "./config/config.js";
|
|
2
|
+
import { resolveHostConfig } from "./config/match.js";
|
|
3
|
+
import { convertHtml } from "./convert/convert-html.js";
|
|
4
|
+
import { localizeAssets } from "./assets/localize.js";
|
|
5
|
+
import { httpDateToRfc3339, isRfc3339DateTime, rfc3339ToHttpDate } from "./date.js";
|
|
6
|
+
import { MdhqError } from "./errors.js";
|
|
7
|
+
import { buildFrontmatter, markdownContentDigest, refreshFrontmatter, serializeDocument } from "./frontmatter/frontmatter.js";
|
|
8
|
+
import { fetchHtml, fetchWithEnvProxy } from "./http/fetch.js";
|
|
9
|
+
import { transformMarkdown } from "./markdown/transform.js";
|
|
10
|
+
import { storagePathForUrl } from "./path/storage-path.js";
|
|
11
|
+
import { inspectDestination, saveDocument } from "./storage/save.js";
|
|
12
|
+
import { normalizeHost, parseHttpUrl, sameHttpTarget } from "./url/identity.js";
|
|
13
|
+
function varyNames(value) {
|
|
14
|
+
return [
|
|
15
|
+
...new Set((value ?? "")
|
|
16
|
+
.split(",")
|
|
17
|
+
.map((name) => name.trim().toLowerCase())
|
|
18
|
+
.filter(Boolean))
|
|
19
|
+
];
|
|
20
|
+
}
|
|
21
|
+
function hasCredentialHeaders(headers) {
|
|
22
|
+
return (headers ?? []).some((header) => {
|
|
23
|
+
const name = header.name.toLowerCase();
|
|
24
|
+
return name === "authorization" || name === "cookie";
|
|
25
|
+
});
|
|
26
|
+
}
|
|
27
|
+
export async function getPage(options) {
|
|
28
|
+
const requestedUrl = parseHttpUrl(options.url).href;
|
|
29
|
+
const loaded = await loadConfig(options.configPath);
|
|
30
|
+
const warnings = [];
|
|
31
|
+
const warn = (warning) => {
|
|
32
|
+
warnings.push(warning);
|
|
33
|
+
options.onWarning?.(warning);
|
|
34
|
+
};
|
|
35
|
+
for (const warning of loaded.warnings) {
|
|
36
|
+
warn(warning);
|
|
37
|
+
}
|
|
38
|
+
const root = resolveRoot(options.root, loaded.config);
|
|
39
|
+
return getPageAttempt({ options, requestedUrl, loaded, warnings, warn, root }, requestedUrl, 2, true);
|
|
40
|
+
}
|
|
41
|
+
async function getPageAttempt(context, fetchUrl, retriesRemaining, callerHeadersAllowed) {
|
|
42
|
+
const { options, requestedUrl, loaded, warnings, warn, root } = context;
|
|
43
|
+
const requested = new URL(fetchUrl);
|
|
44
|
+
const requestedConfig = resolveHostConfig(normalizeHost(requested), requested.pathname, loaded.config.hosts ?? {});
|
|
45
|
+
const requestedEntryKey = requestedConfig?.entryQueryKey ?? undefined;
|
|
46
|
+
const requestedPath = storagePathForUrl({
|
|
47
|
+
root,
|
|
48
|
+
url: requested,
|
|
49
|
+
...(requestedEntryKey ? { entryQueryKey: requestedEntryKey } : {})
|
|
50
|
+
});
|
|
51
|
+
const requestedExisting = await inspectDestination(requestedPath, fetchUrl, requestedEntryKey, root);
|
|
52
|
+
if (requestedExisting && !options.update) {
|
|
53
|
+
return {
|
|
54
|
+
requestedUrl,
|
|
55
|
+
sourceUrl: requestedExisting.sourceUrl,
|
|
56
|
+
path: requestedPath,
|
|
57
|
+
status: "skipped",
|
|
58
|
+
assets: [],
|
|
59
|
+
warnings
|
|
60
|
+
};
|
|
61
|
+
}
|
|
62
|
+
const headers = callerHeadersAllowed ? options.headers : [];
|
|
63
|
+
const userAgent = options.userAgent ?? loaded.config.userAgent;
|
|
64
|
+
const timeoutMs = options.timeoutMs ?? loaded.config.timeoutMs;
|
|
65
|
+
const maxResponseBytes = options.maxResponseBytes ?? loaded.config.maxResponseBytes;
|
|
66
|
+
const maxRedirects = options.maxRedirects ?? loaded.config.maxRedirects;
|
|
67
|
+
const http = {
|
|
68
|
+
...(headers ? { headers } : {}),
|
|
69
|
+
...(userAgent ? { userAgent } : {}),
|
|
70
|
+
...(timeoutMs !== undefined ? { timeoutMs } : {}),
|
|
71
|
+
...(maxResponseBytes !== undefined ? { maxResponseBytes } : {}),
|
|
72
|
+
...(maxRedirects !== undefined ? { maxRedirects } : {})
|
|
73
|
+
};
|
|
74
|
+
let conditional;
|
|
75
|
+
if (options.update &&
|
|
76
|
+
requestedExisting &&
|
|
77
|
+
requestedExisting.vary?.length === 0 &&
|
|
78
|
+
!hasCredentialHeaders(headers) &&
|
|
79
|
+
sameHttpTarget(requestedExisting.sourceUrl, fetchUrl)) {
|
|
80
|
+
if (requestedExisting.etag) {
|
|
81
|
+
conditional = { etag: requestedExisting.etag };
|
|
82
|
+
}
|
|
83
|
+
else {
|
|
84
|
+
const lastModified = rfc3339ToHttpDate(requestedExisting.lastModified);
|
|
85
|
+
if (lastModified) {
|
|
86
|
+
conditional = { lastModified };
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
const fetched = await fetchHtml(fetchUrl, {
|
|
91
|
+
...http,
|
|
92
|
+
...(conditional ? { conditional } : {})
|
|
93
|
+
});
|
|
94
|
+
const responseHeadersAllowed = callerHeadersAllowed && fetched.customHeadersAllowed;
|
|
95
|
+
const now = options.now?.() ?? new Date();
|
|
96
|
+
const normalizeLastModified = (value, fallback) => {
|
|
97
|
+
const validFallback = rfc3339ToHttpDate(fallback) ? fallback : undefined;
|
|
98
|
+
if (!value) {
|
|
99
|
+
return validFallback;
|
|
100
|
+
}
|
|
101
|
+
const normalized = httpDateToRfc3339(value);
|
|
102
|
+
if (!normalized) {
|
|
103
|
+
warn({
|
|
104
|
+
code: "INVALID_LAST_MODIFIED",
|
|
105
|
+
message: `Invalid Last-Modified response header: ${value}`,
|
|
106
|
+
url: fetched.finalUrl
|
|
107
|
+
});
|
|
108
|
+
return undefined;
|
|
109
|
+
}
|
|
110
|
+
return normalized;
|
|
111
|
+
};
|
|
112
|
+
if (fetched.notModified) {
|
|
113
|
+
if (!requestedExisting || !conditional) {
|
|
114
|
+
throw new MdhqError("FETCH_FAILED", `HTTP 304 for ${fetchUrl} without a matching stored validator`);
|
|
115
|
+
}
|
|
116
|
+
const lastModified = normalizeLastModified(fetched.lastModified, requestedExisting.lastModified);
|
|
117
|
+
const vary = varyNames(fetched.vary);
|
|
118
|
+
const validatorsReusable = vary.length === 0 && !hasCredentialHeaders(headers);
|
|
119
|
+
const etag = validatorsReusable
|
|
120
|
+
? fetched.etag ?? requestedExisting.etag
|
|
121
|
+
: undefined;
|
|
122
|
+
const reusableLastModified = validatorsReusable ? lastModified : undefined;
|
|
123
|
+
const frontmatter = refreshFrontmatter(requestedExisting.frontmatter, {
|
|
124
|
+
sourceUrl: requestedExisting.sourceUrl,
|
|
125
|
+
requestedUrl,
|
|
126
|
+
created: isRfc3339DateTime(requestedExisting.created)
|
|
127
|
+
? requestedExisting.created
|
|
128
|
+
: now,
|
|
129
|
+
modified: now,
|
|
130
|
+
contentDigest: requestedExisting.contentDigest,
|
|
131
|
+
...(etag ? { etag } : {}),
|
|
132
|
+
...(reusableLastModified
|
|
133
|
+
? { lastModified: reusableLastModified }
|
|
134
|
+
: {}),
|
|
135
|
+
...(vary.length > 0
|
|
136
|
+
? { vary }
|
|
137
|
+
: etag || reusableLastModified
|
|
138
|
+
? { vary: [] }
|
|
139
|
+
: {}),
|
|
140
|
+
...(loaded.config.frontmatter ? { config: loaded.config.frontmatter } : {})
|
|
141
|
+
});
|
|
142
|
+
const content = serializeDocument(frontmatter, requestedExisting.markdown);
|
|
143
|
+
const storageStatus = await saveDocument({
|
|
144
|
+
path: requestedPath,
|
|
145
|
+
content,
|
|
146
|
+
sourceUrl: requestedExisting.sourceUrl,
|
|
147
|
+
update: true,
|
|
148
|
+
expectedContent: requestedExisting.content,
|
|
149
|
+
root,
|
|
150
|
+
...(requestedEntryKey ? { entryQueryKey: requestedEntryKey } : {})
|
|
151
|
+
});
|
|
152
|
+
if (storageStatus === "conflicted") {
|
|
153
|
+
if (retriesRemaining === 0) {
|
|
154
|
+
throw new MdhqError("STORAGE_ERROR", `Destination changed repeatedly while updating ${requestedPath}`);
|
|
155
|
+
}
|
|
156
|
+
return getPageAttempt(context, fetchUrl, retriesRemaining - 1, responseHeadersAllowed);
|
|
157
|
+
}
|
|
158
|
+
return {
|
|
159
|
+
requestedUrl,
|
|
160
|
+
sourceUrl: requestedExisting.sourceUrl,
|
|
161
|
+
path: requestedPath,
|
|
162
|
+
status: storageStatus === "updated" ? "unchanged" : storageStatus,
|
|
163
|
+
assets: [],
|
|
164
|
+
warnings
|
|
165
|
+
};
|
|
166
|
+
}
|
|
167
|
+
const finalUrl = new URL(fetched.finalUrl);
|
|
168
|
+
const matchedConfig = resolveHostConfig(normalizeHost(finalUrl), finalUrl.pathname, loaded.config.hosts ?? {});
|
|
169
|
+
const entryQueryKey = matchedConfig?.entryQueryKey ?? undefined;
|
|
170
|
+
const markdownPath = storagePathForUrl({
|
|
171
|
+
root,
|
|
172
|
+
url: finalUrl,
|
|
173
|
+
...(entryQueryKey ? { entryQueryKey } : {})
|
|
174
|
+
});
|
|
175
|
+
const existing = await inspectDestination(markdownPath, finalUrl.href, entryQueryKey, root);
|
|
176
|
+
if (options.update &&
|
|
177
|
+
existing &&
|
|
178
|
+
markdownPath !== requestedPath) {
|
|
179
|
+
if (retriesRemaining === 0) {
|
|
180
|
+
throw new MdhqError("STORAGE_ERROR", `Redirect destination changed repeatedly while updating ${markdownPath}`);
|
|
181
|
+
}
|
|
182
|
+
return getPageAttempt(context, finalUrl.href, retriesRemaining - 1, responseHeadersAllowed);
|
|
183
|
+
}
|
|
184
|
+
if (existing && !options.update) {
|
|
185
|
+
return {
|
|
186
|
+
requestedUrl,
|
|
187
|
+
sourceUrl: finalUrl.href,
|
|
188
|
+
path: markdownPath,
|
|
189
|
+
status: "skipped",
|
|
190
|
+
assets: [],
|
|
191
|
+
warnings
|
|
192
|
+
};
|
|
193
|
+
}
|
|
194
|
+
const expectedExisting = markdownPath === requestedPath ? requestedExisting : existing;
|
|
195
|
+
const responseCredentialed = responseHeadersAllowed && hasCredentialHeaders(http.headers);
|
|
196
|
+
const converted = await convertHtml({
|
|
197
|
+
html: fetched.html,
|
|
198
|
+
url: finalUrl,
|
|
199
|
+
defuddle: {
|
|
200
|
+
...loaded.config.defuddle,
|
|
201
|
+
fetch: fetchWithEnvProxy,
|
|
202
|
+
useAsync: options.useAsync ??
|
|
203
|
+
loaded.config.defuddle?.useAsync ??
|
|
204
|
+
loaded.config.useAsync ??
|
|
205
|
+
true
|
|
206
|
+
}
|
|
207
|
+
});
|
|
208
|
+
let metadata = converted.metadata;
|
|
209
|
+
if (converted.metadata.image) {
|
|
210
|
+
try {
|
|
211
|
+
const image = new URL(converted.metadata.image, finalUrl);
|
|
212
|
+
if (image.protocol !== "http:" && image.protocol !== "https:") {
|
|
213
|
+
throw new TypeError(`Unsupported image URL scheme: ${image.protocol}`);
|
|
214
|
+
}
|
|
215
|
+
metadata = {
|
|
216
|
+
...converted.metadata,
|
|
217
|
+
image: image.href
|
|
218
|
+
};
|
|
219
|
+
}
|
|
220
|
+
catch {
|
|
221
|
+
const { image: _invalidImage, ...metadataWithoutImage } = converted.metadata;
|
|
222
|
+
metadata = metadataWithoutImage;
|
|
223
|
+
warn({
|
|
224
|
+
code: "INVALID_IMAGE_URL",
|
|
225
|
+
message: `Invalid representative image URL: ${converted.metadata.image}`,
|
|
226
|
+
url: finalUrl.href
|
|
227
|
+
});
|
|
228
|
+
}
|
|
229
|
+
}
|
|
230
|
+
const transformed = transformMarkdown(converted.markdown, finalUrl.href);
|
|
231
|
+
const assetsEnabled = options.assets ?? loaded.config.assets ?? true;
|
|
232
|
+
const localized = assetsEnabled
|
|
233
|
+
? await localizeAssets({
|
|
234
|
+
markdown: transformed.markdown,
|
|
235
|
+
imageUrls: transformed.imageUrls,
|
|
236
|
+
...(metadata.image ? { representativeImage: metadata.image } : {}),
|
|
237
|
+
markdownPath,
|
|
238
|
+
root,
|
|
239
|
+
baseUrl: finalUrl.href,
|
|
240
|
+
http: responseHeadersAllowed ? http : { ...http, headers: [] },
|
|
241
|
+
warn
|
|
242
|
+
})
|
|
243
|
+
: {
|
|
244
|
+
markdown: transformed.markdown,
|
|
245
|
+
assets: [],
|
|
246
|
+
...(metadata.image ? { representativeImage: metadata.image } : {})
|
|
247
|
+
};
|
|
248
|
+
const created = isRfc3339DateTime(expectedExisting?.created)
|
|
249
|
+
? expectedExisting.created
|
|
250
|
+
: now;
|
|
251
|
+
const contentDigest = markdownContentDigest(localized.markdown);
|
|
252
|
+
const lastModified = normalizeLastModified(fetched.lastModified);
|
|
253
|
+
const vary = varyNames(fetched.vary);
|
|
254
|
+
const validatorsReusable = vary.length === 0 && !responseCredentialed;
|
|
255
|
+
const etag = validatorsReusable ? fetched.etag : undefined;
|
|
256
|
+
const reusableLastModified = validatorsReusable ? lastModified : undefined;
|
|
257
|
+
const frontmatter = buildFrontmatter({
|
|
258
|
+
metadata,
|
|
259
|
+
sourceUrl: finalUrl.href,
|
|
260
|
+
requestedUrl,
|
|
261
|
+
created,
|
|
262
|
+
modified: now,
|
|
263
|
+
contentDigest,
|
|
264
|
+
...(etag ? { etag } : {}),
|
|
265
|
+
...(reusableLastModified ? { lastModified: reusableLastModified } : {}),
|
|
266
|
+
...(vary.length > 0
|
|
267
|
+
? { vary }
|
|
268
|
+
: etag || reusableLastModified
|
|
269
|
+
? { vary: [] }
|
|
270
|
+
: {}),
|
|
271
|
+
...(localized.representativeImage
|
|
272
|
+
? { image: localized.representativeImage }
|
|
273
|
+
: {}),
|
|
274
|
+
...(localized.representativeImageSource
|
|
275
|
+
? { imageSource: localized.representativeImageSource }
|
|
276
|
+
: {}),
|
|
277
|
+
...(loaded.config.frontmatter ? { config: loaded.config.frontmatter } : {})
|
|
278
|
+
});
|
|
279
|
+
const content = serializeDocument(frontmatter, localized.markdown);
|
|
280
|
+
const storageStatus = await saveDocument({
|
|
281
|
+
path: markdownPath,
|
|
282
|
+
content,
|
|
283
|
+
sourceUrl: finalUrl.href,
|
|
284
|
+
update: options.update ?? false,
|
|
285
|
+
...(options.update
|
|
286
|
+
? { expectedContent: expectedExisting?.content ?? null }
|
|
287
|
+
: {}),
|
|
288
|
+
root,
|
|
289
|
+
...(entryQueryKey ? { entryQueryKey } : {})
|
|
290
|
+
});
|
|
291
|
+
if (storageStatus === "conflicted") {
|
|
292
|
+
if (retriesRemaining === 0) {
|
|
293
|
+
throw new MdhqError("STORAGE_ERROR", `Destination changed repeatedly while updating ${markdownPath}`);
|
|
294
|
+
}
|
|
295
|
+
return getPageAttempt(context, fetchUrl, retriesRemaining - 1, responseHeadersAllowed);
|
|
296
|
+
}
|
|
297
|
+
return {
|
|
298
|
+
requestedUrl,
|
|
299
|
+
sourceUrl: finalUrl.href,
|
|
300
|
+
path: markdownPath,
|
|
301
|
+
status: storageStatus === "updated" &&
|
|
302
|
+
expectedExisting?.contentDigest === contentDigest
|
|
303
|
+
? "unchanged"
|
|
304
|
+
: storageStatus,
|
|
305
|
+
assets: localized.assets,
|
|
306
|
+
warnings
|
|
307
|
+
};
|
|
308
|
+
}
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
import type { HeaderValue } from "../types.js";
|
|
2
|
+
export interface FetchResourceOptions {
|
|
3
|
+
headers?: HeaderValue[];
|
|
4
|
+
userAgent?: string;
|
|
5
|
+
timeoutMs?: number;
|
|
6
|
+
maxResponseBytes?: number;
|
|
7
|
+
maxRedirects?: number;
|
|
8
|
+
acceptedContentTypes?: string[];
|
|
9
|
+
allowNotModified?: boolean;
|
|
10
|
+
conditional?: {
|
|
11
|
+
etag?: string;
|
|
12
|
+
lastModified?: string;
|
|
13
|
+
};
|
|
14
|
+
}
|
|
15
|
+
export interface FetchedResource {
|
|
16
|
+
body: Uint8Array;
|
|
17
|
+
finalUrl: string;
|
|
18
|
+
contentType: string;
|
|
19
|
+
status: number;
|
|
20
|
+
customHeadersAllowed: boolean;
|
|
21
|
+
redirected: boolean;
|
|
22
|
+
notModified: boolean;
|
|
23
|
+
etag?: string;
|
|
24
|
+
lastModified?: string;
|
|
25
|
+
vary?: string;
|
|
26
|
+
cacheControl?: string;
|
|
27
|
+
}
|
|
28
|
+
export declare const fetchWithEnvProxy: typeof globalThis.fetch;
|
|
29
|
+
export declare function fetchResource(input: string | URL, options?: FetchResourceOptions): Promise<FetchedResource>;
|
|
30
|
+
export type FetchedHtml = {
|
|
31
|
+
notModified: true;
|
|
32
|
+
finalUrl: string;
|
|
33
|
+
customHeadersAllowed: boolean;
|
|
34
|
+
etag?: string;
|
|
35
|
+
lastModified?: string;
|
|
36
|
+
vary?: string;
|
|
37
|
+
} | {
|
|
38
|
+
notModified: false;
|
|
39
|
+
html: string;
|
|
40
|
+
finalUrl: string;
|
|
41
|
+
customHeadersAllowed: boolean;
|
|
42
|
+
etag?: string;
|
|
43
|
+
lastModified?: string;
|
|
44
|
+
vary?: string;
|
|
45
|
+
};
|
|
46
|
+
export declare function fetchHtml(input: string | URL, options?: FetchResourceOptions): Promise<FetchedHtml>;
|
|
@@ -0,0 +1,195 @@
|
|
|
1
|
+
import { EnvHttpProxyAgent, Headers, fetch as undiciFetch } from "undici";
|
|
2
|
+
import { MdhqError } from "../errors.js";
|
|
3
|
+
import { DEFAULT_USER_AGENT } from "../version.js";
|
|
4
|
+
import { parseHttpUrl } from "../url/identity.js";
|
|
5
|
+
const proxyAgent = new EnvHttpProxyAgent();
|
|
6
|
+
const undiciProxyFetch = undiciFetch;
|
|
7
|
+
export const fetchWithEnvProxy = (input, init) => undiciProxyFetch(input, { ...init, dispatcher: proxyAgent });
|
|
8
|
+
function requestHeaders(options, includeCustomHeaders, includeStoredConditional) {
|
|
9
|
+
const headers = new Headers({
|
|
10
|
+
accept: options.acceptedContentTypes?.join(", ") ?? "*/*",
|
|
11
|
+
"user-agent": options.userAgent ?? DEFAULT_USER_AGENT
|
|
12
|
+
});
|
|
13
|
+
if (includeCustomHeaders) {
|
|
14
|
+
for (const header of options.headers ?? []) {
|
|
15
|
+
headers.append(header.name, header.value);
|
|
16
|
+
}
|
|
17
|
+
}
|
|
18
|
+
if (options.conditional) {
|
|
19
|
+
headers.delete("if-none-match");
|
|
20
|
+
headers.delete("if-modified-since");
|
|
21
|
+
}
|
|
22
|
+
if (includeStoredConditional && options.conditional) {
|
|
23
|
+
if (options.conditional.etag) {
|
|
24
|
+
headers.set("if-none-match", options.conditional.etag);
|
|
25
|
+
}
|
|
26
|
+
else if (options.conditional.lastModified) {
|
|
27
|
+
headers.set("if-modified-since", options.conditional.lastModified);
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
return headers;
|
|
31
|
+
}
|
|
32
|
+
function contentType(value) {
|
|
33
|
+
return value?.split(";", 1)[0]?.trim().toLowerCase() ?? "";
|
|
34
|
+
}
|
|
35
|
+
async function readLimited(response, limit) {
|
|
36
|
+
const length = Number(response.headers.get("content-length"));
|
|
37
|
+
if (Number.isFinite(length) && length > limit) {
|
|
38
|
+
await response.body?.cancel().catch(() => undefined);
|
|
39
|
+
throw new MdhqError("RESPONSE_TOO_LARGE", `Response exceeds ${limit} bytes`);
|
|
40
|
+
}
|
|
41
|
+
if (!response.body) {
|
|
42
|
+
return new Uint8Array();
|
|
43
|
+
}
|
|
44
|
+
const reader = response.body.getReader();
|
|
45
|
+
const chunks = [];
|
|
46
|
+
let total = 0;
|
|
47
|
+
while (true) {
|
|
48
|
+
const { done, value } = await reader.read();
|
|
49
|
+
if (done) {
|
|
50
|
+
break;
|
|
51
|
+
}
|
|
52
|
+
total += value.byteLength;
|
|
53
|
+
if (total > limit) {
|
|
54
|
+
await reader.cancel();
|
|
55
|
+
throw new MdhqError("RESPONSE_TOO_LARGE", `Response exceeds ${limit} bytes`);
|
|
56
|
+
}
|
|
57
|
+
chunks.push(value);
|
|
58
|
+
}
|
|
59
|
+
const body = new Uint8Array(total);
|
|
60
|
+
let offset = 0;
|
|
61
|
+
for (const chunk of chunks) {
|
|
62
|
+
body.set(chunk, offset);
|
|
63
|
+
offset += chunk.byteLength;
|
|
64
|
+
}
|
|
65
|
+
return body;
|
|
66
|
+
}
|
|
67
|
+
export async function fetchResource(input, options = {}) {
|
|
68
|
+
const timeoutMs = options.timeoutMs ?? 30_000;
|
|
69
|
+
const maxResponseBytes = options.maxResponseBytes ?? 20 * 1024 * 1024;
|
|
70
|
+
const maxRedirects = options.maxRedirects ?? 10;
|
|
71
|
+
let url = parseHttpUrl(input);
|
|
72
|
+
let customHeadersAllowed = true;
|
|
73
|
+
for (let redirects = 0;; redirects += 1) {
|
|
74
|
+
let response;
|
|
75
|
+
try {
|
|
76
|
+
response = (await undiciFetch(url, {
|
|
77
|
+
dispatcher: proxyAgent,
|
|
78
|
+
headers: requestHeaders(options, customHeadersAllowed, redirects === 0),
|
|
79
|
+
redirect: "manual",
|
|
80
|
+
signal: AbortSignal.timeout(timeoutMs)
|
|
81
|
+
}));
|
|
82
|
+
}
|
|
83
|
+
catch (error) {
|
|
84
|
+
throw new MdhqError("FETCH_FAILED", `Failed to fetch ${url.href}`, { cause: error });
|
|
85
|
+
}
|
|
86
|
+
if ([301, 302, 303, 307, 308].includes(response.status)) {
|
|
87
|
+
const location = response.headers.get("location");
|
|
88
|
+
if (!location) {
|
|
89
|
+
await response.body?.cancel().catch(() => undefined);
|
|
90
|
+
throw new MdhqError("FETCH_FAILED", `Redirect response has no Location: ${url.href}`);
|
|
91
|
+
}
|
|
92
|
+
if (redirects >= maxRedirects) {
|
|
93
|
+
await response.body?.cancel().catch(() => undefined);
|
|
94
|
+
throw new MdhqError("TOO_MANY_REDIRECTS", `Too many redirects: ${String(input)}`);
|
|
95
|
+
}
|
|
96
|
+
let nextUrl;
|
|
97
|
+
try {
|
|
98
|
+
nextUrl = parseHttpUrl(new URL(location, url));
|
|
99
|
+
}
|
|
100
|
+
catch (error) {
|
|
101
|
+
await response.body?.cancel().catch(() => undefined);
|
|
102
|
+
if (error instanceof MdhqError && error.code === "UNSUPPORTED_SCHEME") {
|
|
103
|
+
throw error;
|
|
104
|
+
}
|
|
105
|
+
throw new MdhqError("FETCH_FAILED", `Invalid redirect Location for ${url.href}: ${location}`, { cause: error });
|
|
106
|
+
}
|
|
107
|
+
if (nextUrl.origin !== url.origin) {
|
|
108
|
+
customHeadersAllowed = false;
|
|
109
|
+
}
|
|
110
|
+
await response.body?.cancel().catch(() => undefined);
|
|
111
|
+
url = nextUrl;
|
|
112
|
+
continue;
|
|
113
|
+
}
|
|
114
|
+
const etag = response.headers.get("etag")?.trim() || undefined;
|
|
115
|
+
const lastModified = response.headers.get("last-modified")?.trim() || undefined;
|
|
116
|
+
const vary = response.headers.get("vary")?.trim() || undefined;
|
|
117
|
+
const cacheControl = response.headers.get("cache-control")?.trim() || undefined;
|
|
118
|
+
if (response.status === 304 && options.allowNotModified && redirects === 0) {
|
|
119
|
+
await response.body?.cancel().catch(() => undefined);
|
|
120
|
+
return {
|
|
121
|
+
body: new Uint8Array(),
|
|
122
|
+
finalUrl: url.href,
|
|
123
|
+
contentType: "",
|
|
124
|
+
status: response.status,
|
|
125
|
+
customHeadersAllowed,
|
|
126
|
+
redirected: redirects > 0,
|
|
127
|
+
notModified: true,
|
|
128
|
+
...(etag ? { etag } : {}),
|
|
129
|
+
...(lastModified ? { lastModified } : {}),
|
|
130
|
+
...(vary ? { vary } : {}),
|
|
131
|
+
...(cacheControl ? { cacheControl } : {})
|
|
132
|
+
};
|
|
133
|
+
}
|
|
134
|
+
if (!response.ok) {
|
|
135
|
+
await response.body?.cancel().catch(() => undefined);
|
|
136
|
+
throw new MdhqError("FETCH_FAILED", `HTTP ${response.status} for ${url.href}`);
|
|
137
|
+
}
|
|
138
|
+
const type = contentType(response.headers.get("content-type"));
|
|
139
|
+
if (options.acceptedContentTypes && !options.acceptedContentTypes.includes(type)) {
|
|
140
|
+
await response.body?.cancel().catch(() => undefined);
|
|
141
|
+
throw new MdhqError("UNSUPPORTED_CONTENT_TYPE", `Unsupported Content-Type ${type || "(missing)"} for ${url.href}`);
|
|
142
|
+
}
|
|
143
|
+
let body;
|
|
144
|
+
try {
|
|
145
|
+
body = await readLimited(response, maxResponseBytes);
|
|
146
|
+
}
|
|
147
|
+
catch (error) {
|
|
148
|
+
if (error instanceof MdhqError) {
|
|
149
|
+
throw error;
|
|
150
|
+
}
|
|
151
|
+
throw new MdhqError("FETCH_FAILED", `Failed to read response body from ${url.href}`, {
|
|
152
|
+
cause: error
|
|
153
|
+
});
|
|
154
|
+
}
|
|
155
|
+
return {
|
|
156
|
+
body,
|
|
157
|
+
finalUrl: url.href,
|
|
158
|
+
contentType: type,
|
|
159
|
+
status: response.status,
|
|
160
|
+
customHeadersAllowed,
|
|
161
|
+
redirected: redirects > 0,
|
|
162
|
+
notModified: false,
|
|
163
|
+
...(etag ? { etag } : {}),
|
|
164
|
+
...(lastModified ? { lastModified } : {}),
|
|
165
|
+
...(vary ? { vary } : {}),
|
|
166
|
+
...(cacheControl ? { cacheControl } : {})
|
|
167
|
+
};
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
export async function fetchHtml(input, options = {}) {
|
|
171
|
+
const resource = await fetchResource(input, {
|
|
172
|
+
...options,
|
|
173
|
+
allowNotModified: true,
|
|
174
|
+
acceptedContentTypes: ["text/html", "application/xhtml+xml"]
|
|
175
|
+
});
|
|
176
|
+
if (resource.notModified) {
|
|
177
|
+
return {
|
|
178
|
+
notModified: true,
|
|
179
|
+
finalUrl: resource.finalUrl,
|
|
180
|
+
customHeadersAllowed: resource.customHeadersAllowed,
|
|
181
|
+
...(resource.etag ? { etag: resource.etag } : {}),
|
|
182
|
+
...(resource.lastModified ? { lastModified: resource.lastModified } : {}),
|
|
183
|
+
...(resource.vary ? { vary: resource.vary } : {})
|
|
184
|
+
};
|
|
185
|
+
}
|
|
186
|
+
return {
|
|
187
|
+
notModified: false,
|
|
188
|
+
html: new TextDecoder().decode(resource.body),
|
|
189
|
+
finalUrl: resource.finalUrl,
|
|
190
|
+
customHeadersAllowed: resource.customHeadersAllowed,
|
|
191
|
+
...(resource.etag ? { etag: resource.etag } : {}),
|
|
192
|
+
...(resource.lastModified ? { lastModified: resource.lastModified } : {}),
|
|
193
|
+
...(resource.vary ? { vary: resource.vary } : {})
|
|
194
|
+
};
|
|
195
|
+
}
|
package/dist/index.d.ts
ADDED
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
export { convertHtml } from "./convert/convert-html.js";
|
|
2
|
+
export { getPage } from "./get-page.js";
|
|
3
|
+
export { MdhqError } from "./errors.js";
|
|
4
|
+
export type { MdhqErrorCode } from "./errors.js";
|
|
5
|
+
export type { MdhqConfig } from "./config/config.js";
|
|
6
|
+
export type { AssetResult, ConvertedPage, ConvertHtmlOptions, GetPageOptions, GetPageResult, HeaderValue, MdhqWarning, PageMetadata } from "./types.js";
|
package/dist/index.js
ADDED
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
import type { MdhqWarning } from "./types.js";
|
|
2
|
+
export interface ListMarkdownFilesOptions {
|
|
3
|
+
root?: string;
|
|
4
|
+
configPath?: string;
|
|
5
|
+
fullPath?: boolean;
|
|
6
|
+
onWarning?: (warning: MdhqWarning) => void;
|
|
7
|
+
}
|
|
8
|
+
export declare function listMarkdownFiles(options?: ListMarkdownFilesOptions): Promise<string[]>;
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
import { readdir } from "node:fs/promises";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
import { loadConfig, resolveRoot } from "./config/config.js";
|
|
4
|
+
import { MdhqError } from "./errors.js";
|
|
5
|
+
async function collectMarkdownFiles(root, relativeDirectory, files) {
|
|
6
|
+
const directory = path.join(root, relativeDirectory);
|
|
7
|
+
const entries = await readdir(directory, { withFileTypes: true });
|
|
8
|
+
for (const entry of entries) {
|
|
9
|
+
const relativePath = path.join(relativeDirectory, entry.name);
|
|
10
|
+
if (entry.isDirectory()) {
|
|
11
|
+
await collectMarkdownFiles(root, relativePath, files);
|
|
12
|
+
}
|
|
13
|
+
else if (entry.isFile() && entry.name.endsWith(".md")) {
|
|
14
|
+
files.push(relativePath);
|
|
15
|
+
}
|
|
16
|
+
}
|
|
17
|
+
}
|
|
18
|
+
export async function listMarkdownFiles(options = {}) {
|
|
19
|
+
const loaded = await loadConfig(options.configPath);
|
|
20
|
+
for (const warning of loaded.warnings) {
|
|
21
|
+
options.onWarning?.(warning);
|
|
22
|
+
}
|
|
23
|
+
const root = resolveRoot(options.root, loaded.config);
|
|
24
|
+
const files = [];
|
|
25
|
+
try {
|
|
26
|
+
await collectMarkdownFiles(root, "", files);
|
|
27
|
+
}
|
|
28
|
+
catch (error) {
|
|
29
|
+
throw new MdhqError("STORAGE_ERROR", `Failed to list storage root: ${root}`, {
|
|
30
|
+
cause: error
|
|
31
|
+
});
|
|
32
|
+
}
|
|
33
|
+
files.sort();
|
|
34
|
+
return options.fullPath ? files.map((file) => path.join(root, file)) : files;
|
|
35
|
+
}
|
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
export interface MarkdownTransformResult {
|
|
2
|
+
markdown: string;
|
|
3
|
+
imageUrls: string[];
|
|
4
|
+
}
|
|
5
|
+
export declare function transformMarkdown(markdown: string, baseUrl: string): MarkdownTransformResult;
|
|
6
|
+
export declare function rewriteImageUrls(markdown: string, replacements: ReadonlyMap<string, string>): string;
|