@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.
Files changed (55) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +126 -0
  3. package/dist/assets/localize.d.ts +19 -0
  4. package/dist/assets/localize.js +364 -0
  5. package/dist/cli.d.ts +8 -0
  6. package/dist/cli.js +119 -0
  7. package/dist/config/config.d.ts +25 -0
  8. package/dist/config/config.js +170 -0
  9. package/dist/config/match.d.ts +7 -0
  10. package/dist/config/match.js +101 -0
  11. package/dist/convert/article-date.d.ts +20 -0
  12. package/dist/convert/article-date.js +255 -0
  13. package/dist/convert/convert-html.d.ts +2 -0
  14. package/dist/convert/convert-html.js +89 -0
  15. package/dist/convert/extract-published.d.ts +12 -0
  16. package/dist/convert/extract-published.js +24 -0
  17. package/dist/convert/extract-updated.d.ts +8 -0
  18. package/dist/convert/extract-updated.js +20 -0
  19. package/dist/date.d.ts +18 -0
  20. package/dist/date.js +448 -0
  21. package/dist/errors.d.ts +8 -0
  22. package/dist/errors.js +10 -0
  23. package/dist/frontmatter/frontmatter.d.ts +40 -0
  24. package/dist/frontmatter/frontmatter.js +114 -0
  25. package/dist/get-page.d.ts +2 -0
  26. package/dist/get-page.js +308 -0
  27. package/dist/http/fetch.d.ts +46 -0
  28. package/dist/http/fetch.js +195 -0
  29. package/dist/index.d.ts +6 -0
  30. package/dist/index.js +3 -0
  31. package/dist/list-files.d.ts +8 -0
  32. package/dist/list-files.js +35 -0
  33. package/dist/markdown/transform.d.ts +6 -0
  34. package/dist/markdown/transform.js +129 -0
  35. package/dist/path/storage-path.d.ts +7 -0
  36. package/dist/path/storage-path.js +110 -0
  37. package/dist/storage/atomic.d.ts +8 -0
  38. package/dist/storage/atomic.js +84 -0
  39. package/dist/storage/path-safety.d.ts +1 -0
  40. package/dist/storage/path-safety.js +55 -0
  41. package/dist/storage/save.d.ts +23 -0
  42. package/dist/storage/save.js +118 -0
  43. package/dist/types.d.ts +62 -0
  44. package/dist/types.js +1 -0
  45. package/dist/url/identity.d.ts +12 -0
  46. package/dist/url/identity.js +54 -0
  47. package/dist/url/pathname.d.ts +4 -0
  48. package/dist/url/pathname.js +46 -0
  49. package/dist/version.d.ts +3 -0
  50. package/dist/version.js +6 -0
  51. package/docs/README.md +14 -0
  52. package/docs/configuration.md +242 -0
  53. package/docs/library-api.md +275 -0
  54. package/docs/specification.md +730 -0
  55. package/package.json +73 -0
@@ -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
+ }
@@ -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,3 @@
1
+ export { convertHtml } from "./convert/convert-html.js";
2
+ export { getPage } from "./get-page.js";
3
+ export { MdhqError } from "./errors.js";
@@ -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;