@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
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Songmu
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,126 @@
1
+ # mdhq - Markdown headquarters (?)
2
+
3
+ `mdhq` saves web pages as Markdown in a [ghq](https://github.com/x-motemen/ghq)-inspired filesystem layout. It
4
+ uses [Defuddle](https://defuddle.md/) for content extraction and keeps all state in Markdown and asset
5
+ files rather than a database.
6
+
7
+ ## Requirements
8
+
9
+ - Node.js 22 or newer
10
+
11
+ ## Installation
12
+
13
+ ```sh
14
+ npm install --global @songmu/mdhq
15
+ ```
16
+
17
+ ## CLI
18
+
19
+ ```sh
20
+ mdhq get https://example.com/article
21
+ mdhq get --update https://example.com/article
22
+ mdhq get --no-assets https://example.com/article
23
+ mdhq get --json --header 'Cookie: session=value' https://example.com/article
24
+ mdhq list
25
+ mdhq list --full-path
26
+ mdhq root
27
+ ```
28
+
29
+ `mdhq get` prints the absolute Markdown path to stdout by default. Warnings
30
+ are written to stderr. `--json` returns the requested URL, final source URL,
31
+ Markdown path, status, downloaded assets, and warnings.
32
+
33
+ `mdhq list` recursively lists `.md` files below the storage root, one per
34
+ line, in sorted root-relative form. Use `-p` or `--full-path` to print absolute
35
+ paths. Directory symbolic links are not followed.
36
+
37
+ `mdhq root` prints the absolute effective storage root.
38
+
39
+ The storage root is selected in this order:
40
+
41
+ 1. `--root`
42
+ 2. `MDHQ_ROOT`
43
+ 3. `root` in the configuration file
44
+ 4. `$XDG_DATA_HOME/mdhq`, or `~/.local/share/mdhq`
45
+
46
+ The configuration file is
47
+ `$XDG_CONFIG_HOME/mdhq/config.json`, or
48
+ `~/.config/mdhq/config.json`.
49
+
50
+ ```json
51
+ {
52
+ "root": "/path/to/mdhq",
53
+ "assets": false,
54
+ "useAsync": true,
55
+ "frontmatter": {
56
+ "exclude": ["description"],
57
+ "values": {
58
+ "collection": "reading"
59
+ }
60
+ },
61
+ "hosts": {
62
+ "*.example.com": {
63
+ "entryQueryKey": "entry_id",
64
+ "paths": {
65
+ "/search/*": {
66
+ "entryQueryKey": null
67
+ }
68
+ }
69
+ }
70
+ }
71
+ }
72
+ ```
73
+
74
+ Exact host and path patterns take precedence over globs. Among matching globs,
75
+ the pattern with the longest literal portion wins. Equally specific matching
76
+ patterns are rejected.
77
+
78
+ ## Library API
79
+
80
+ ```ts
81
+ import { convertHtml, getPage } from "@songmu/mdhq";
82
+
83
+ const converted = await convertHtml({
84
+ html,
85
+ url: "https://example.com/article"
86
+ });
87
+
88
+ const saved = await getPage({
89
+ url: "https://example.com/article",
90
+ root: "/path/to/mdhq",
91
+ assets: false
92
+ });
93
+ ```
94
+
95
+ `convertHtml` performs extraction without fetching or writing files.
96
+ `getPage` fetches, converts, optionally localizes images, adds frontmatter,
97
+ and saves the document. Set `assets: false` or use `--no-assets` to keep
98
+ absolute image URLs without creating `_assets`.
99
+
100
+ Saved frontmatter uses Obsidian Web Clipper-compatible names such as `title`,
101
+ `source`, `author`, `published`, `created`, and `description`. mdhq also stores
102
+ `modified` for the last meaningful note change and safe HTTP validators for
103
+ conditional updates. It does not add `type` or `tags` by default; use
104
+ `frontmatter.values` to opt into values such as `"type": "clip"`.
105
+
106
+ An update returns `updated` when the normalized Markdown body or user-facing
107
+ frontmatter changes and `unchanged` when HTTP returns 304 or the fetched note
108
+ content is unchanged.
109
+
110
+ ## Documentation
111
+
112
+ - [Current specification](docs/specification.md)
113
+ - [Configuration reference](docs/configuration.md)
114
+ - [Library API reference](docs/library-api.md)
115
+
116
+ ## Development
117
+
118
+ ```sh
119
+ npm install
120
+ npm test
121
+ npm run typecheck
122
+ npm run build
123
+ npm run test:package
124
+ ```
125
+
126
+ CI runs the same checks on Linux, Windows, and macOS.
@@ -0,0 +1,19 @@
1
+ import { type FetchResourceOptions } from "../http/fetch.js";
2
+ import type { AssetResult, MdhqWarning } from "../types.js";
3
+ export interface LocalizeAssetsOptions {
4
+ markdown: string;
5
+ imageUrls: string[];
6
+ representativeImage?: string;
7
+ markdownPath: string;
8
+ root: string;
9
+ baseUrl: string;
10
+ http?: FetchResourceOptions;
11
+ warn: (warning: MdhqWarning) => void;
12
+ }
13
+ export interface LocalizeAssetsResult {
14
+ markdown: string;
15
+ assets: AssetResult[];
16
+ representativeImage?: string;
17
+ representativeImageSource?: string;
18
+ }
19
+ export declare function localizeAssets(options: LocalizeAssetsOptions): Promise<LocalizeAssetsResult>;
@@ -0,0 +1,364 @@
1
+ import { createHash } from "node:crypto";
2
+ import path from "node:path";
3
+ import { lstat, readFile, unlink } from "node:fs/promises";
4
+ import { fetchResource } from "../http/fetch.js";
5
+ import { rewriteImageUrls } from "../markdown/transform.js";
6
+ import { publishFileExclusive, replaceFileAtomic, withDestinationLock } from "../storage/atomic.js";
7
+ const CONTENT_TYPE_EXTENSIONS = {
8
+ "image/avif": ".avif",
9
+ "image/gif": ".gif",
10
+ "image/jpeg": ".jpg",
11
+ "image/png": ".png",
12
+ "image/svg+xml": ".svg",
13
+ "image/webp": ".webp"
14
+ };
15
+ const IMAGE_EXTENSIONS = new Set(Object.values(CONTENT_TYPE_EXTENSIONS));
16
+ IMAGE_EXTENSIONS.add(".jpeg");
17
+ IMAGE_EXTENSIONS.add(".jfif");
18
+ const ASSET_CACHE_VERSION = 1;
19
+ function assetExtension(contentType, finalUrl) {
20
+ const fromType = CONTENT_TYPE_EXTENSIONS[contentType];
21
+ if (fromType) {
22
+ return fromType;
23
+ }
24
+ const extension = path.posix.extname(new URL(finalUrl).pathname).toLowerCase();
25
+ return /^\.[a-z0-9]{1,8}$/u.test(extension) ? extension : ".bin";
26
+ }
27
+ async function saveAsset(assetPath, body, root) {
28
+ if (await publishFileExclusive(assetPath, body, root)) {
29
+ return "saved";
30
+ }
31
+ const existing = await readFile(assetPath);
32
+ if (Buffer.from(body).equals(existing)) {
33
+ return "reused";
34
+ }
35
+ throw new Error(`Asset digest collision: ${assetPath}`);
36
+ }
37
+ function cachePathForUrl(root, sourceUrl) {
38
+ const digest = createHash("sha256").update(sourceUrl).digest("hex");
39
+ return path.join(root, "_assets", ".cache", `${digest}.json`);
40
+ }
41
+ function varyNames(value) {
42
+ return [
43
+ ...new Set((value ?? "")
44
+ .split(",")
45
+ .map((name) => name.trim().toLowerCase())
46
+ .filter(Boolean))
47
+ ];
48
+ }
49
+ function hasCredentialHeaders(headers) {
50
+ return (headers ?? []).some((header) => {
51
+ const name = header.name.toLowerCase();
52
+ return name === "authorization" || name === "cookie";
53
+ });
54
+ }
55
+ function hasNoStore(value) {
56
+ return (value ?? "")
57
+ .split(",")
58
+ .some((directive) => directive.trim().toLowerCase().split("=", 1)[0] === "no-store");
59
+ }
60
+ function requestHasNoStore(headers) {
61
+ return (headers ?? []).some((header) => header.name.toLowerCase() === "cache-control" &&
62
+ hasNoStore(header.value));
63
+ }
64
+ function parseAssetCacheEntry(value) {
65
+ if (!value || typeof value !== "object") {
66
+ return undefined;
67
+ }
68
+ const entry = value;
69
+ if (entry.version !== ASSET_CACHE_VERSION ||
70
+ typeof entry.sourceUrl !== "string" ||
71
+ typeof entry.finalUrl !== "string" ||
72
+ typeof entry.assetFile !== "string" ||
73
+ !/^[a-f0-9]{64}\.[a-z0-9]{1,8}$/u.test(entry.assetFile) ||
74
+ typeof entry.contentType !== "string" ||
75
+ !Array.isArray(entry.vary) ||
76
+ !entry.vary.every((name) => typeof name === "string") ||
77
+ (entry.etag !== undefined && typeof entry.etag !== "string") ||
78
+ (entry.lastModified !== undefined && typeof entry.lastModified !== "string")) {
79
+ return undefined;
80
+ }
81
+ return {
82
+ version: ASSET_CACHE_VERSION,
83
+ sourceUrl: entry.sourceUrl,
84
+ finalUrl: entry.finalUrl,
85
+ assetFile: entry.assetFile,
86
+ contentType: entry.contentType,
87
+ ...(typeof entry.etag === "string" ? { etag: entry.etag } : {}),
88
+ ...(typeof entry.lastModified === "string"
89
+ ? { lastModified: entry.lastModified }
90
+ : {}),
91
+ vary: entry.vary
92
+ };
93
+ }
94
+ async function readAssetCache(cachePath, sourceUrl) {
95
+ try {
96
+ const metadata = await lstat(cachePath);
97
+ if (!metadata.isFile() || metadata.isSymbolicLink()) {
98
+ return {
99
+ writable: false,
100
+ warning: {
101
+ code: "ASSET_CACHE_INVALID",
102
+ message: `Invalid asset cache metadata: ${cachePath}`,
103
+ url: sourceUrl
104
+ }
105
+ };
106
+ }
107
+ }
108
+ catch (error) {
109
+ if (error.code === "ENOENT") {
110
+ return { writable: true };
111
+ }
112
+ throw error;
113
+ }
114
+ const content = await readFile(cachePath, "utf8");
115
+ try {
116
+ const entry = parseAssetCacheEntry(JSON.parse(content));
117
+ if (entry?.sourceUrl === sourceUrl) {
118
+ return { entry, writable: true };
119
+ }
120
+ }
121
+ catch {
122
+ // Report malformed cache metadata and recover with an unconditional fetch.
123
+ }
124
+ return {
125
+ writable: true,
126
+ warning: {
127
+ code: "ASSET_CACHE_INVALID",
128
+ message: `Invalid asset cache metadata: ${cachePath}`,
129
+ url: sourceUrl
130
+ }
131
+ };
132
+ }
133
+ async function existingCachedAsset(entry, root) {
134
+ const assetPath = path.join(root, "_assets", entry.assetFile);
135
+ try {
136
+ const metadata = await lstat(assetPath);
137
+ return metadata.isFile() && !metadata.isSymbolicLink()
138
+ ? assetPath
139
+ : undefined;
140
+ }
141
+ catch (error) {
142
+ if (error.code === "ENOENT") {
143
+ return undefined;
144
+ }
145
+ throw error;
146
+ }
147
+ }
148
+ function serializeAssetCache(entry) {
149
+ return `${JSON.stringify(entry, null, 2)}\n`;
150
+ }
151
+ function sameCacheEntry(left, right) {
152
+ if (!left || !right) {
153
+ return left === right;
154
+ }
155
+ return (left.sourceUrl === right.sourceUrl &&
156
+ left.finalUrl === right.finalUrl &&
157
+ left.assetFile === right.assetFile &&
158
+ left.contentType === right.contentType &&
159
+ left.etag === right.etag &&
160
+ left.lastModified === right.lastModified &&
161
+ left.vary.length === right.vary.length &&
162
+ left.vary.every((name, index) => name === right.vary[index]));
163
+ }
164
+ async function removeAssetCache(cachePath) {
165
+ try {
166
+ await unlink(cachePath);
167
+ }
168
+ catch (error) {
169
+ if (error.code !== "ENOENT") {
170
+ throw error;
171
+ }
172
+ }
173
+ }
174
+ async function processAsset(sourceUrl, options, representativeImage) {
175
+ let cacheWarning;
176
+ try {
177
+ const sameOrigin = new URL(sourceUrl).origin === new URL(options.baseUrl).origin;
178
+ const http = {
179
+ ...options.http,
180
+ ...(sameOrigin ? {} : { headers: [] })
181
+ };
182
+ const cachePath = cachePathForUrl(options.root, sourceUrl);
183
+ const cached = await withDestinationLock(cachePath, () => readAssetCache(cachePath, sourceUrl), options.root);
184
+ cacheWarning = cached.warning;
185
+ const cachedAsset = cached.entry
186
+ ? await existingCachedAsset(cached.entry, options.root)
187
+ : undefined;
188
+ const validatorsReusable = cached.entry !== undefined &&
189
+ cachedAsset !== undefined &&
190
+ cached.entry.finalUrl === sourceUrl &&
191
+ cached.entry.vary.length === 0 &&
192
+ !hasCredentialHeaders(http.headers) &&
193
+ !requestHasNoStore(http.headers);
194
+ const conditional = validatorsReusable
195
+ ? cached.entry?.etag
196
+ ? { etag: cached.entry.etag }
197
+ : cached.entry?.lastModified
198
+ ? { lastModified: cached.entry.lastModified }
199
+ : undefined
200
+ : undefined;
201
+ const response = await fetchResource(sourceUrl, {
202
+ ...http,
203
+ conditional: conditional ?? {},
204
+ ...(conditional ? { allowNotModified: true } : {})
205
+ });
206
+ if (response.notModified) {
207
+ const cachedEntry = cached.entry;
208
+ if (!cachedEntry || !cachedAsset || !conditional) {
209
+ throw new Error(`HTTP 304 without a matching cached asset: ${sourceUrl}`);
210
+ }
211
+ const vary = response.vary ? varyNames(response.vary) : cachedEntry.vary;
212
+ await withDestinationLock(cachePath, async () => {
213
+ const current = await readAssetCache(cachePath, sourceUrl);
214
+ cacheWarning ??= current.warning;
215
+ if (!sameCacheEntry(current.entry, cachedEntry)) {
216
+ return;
217
+ }
218
+ if (vary.length === 0 && !hasNoStore(response.cacheControl)) {
219
+ const refreshed = {
220
+ ...cachedEntry,
221
+ ...(response.etag ? { etag: response.etag } : {}),
222
+ ...(response.lastModified
223
+ ? { lastModified: response.lastModified }
224
+ : {}),
225
+ vary
226
+ };
227
+ await replaceFileAtomic(cachePath, serializeAssetCache(refreshed), {
228
+ root: options.root
229
+ });
230
+ }
231
+ else if (current.writable) {
232
+ await removeAssetCache(cachePath);
233
+ }
234
+ }, options.root);
235
+ const replacement = path
236
+ .relative(path.dirname(options.markdownPath), cachedAsset)
237
+ .split(path.sep)
238
+ .join("/");
239
+ return {
240
+ asset: {
241
+ sourceUrl,
242
+ finalUrl: cachedEntry.finalUrl,
243
+ path: cachedAsset,
244
+ status: "reused"
245
+ },
246
+ replacement,
247
+ representative: sourceUrl === representativeImage,
248
+ ...(cacheWarning ? { warnings: [cacheWarning] } : {})
249
+ };
250
+ }
251
+ const urlExtension = path.posix.extname(new URL(response.finalUrl).pathname).toLowerCase();
252
+ if ((response.contentType && !response.contentType.startsWith("image/")) ||
253
+ (!response.contentType && !IMAGE_EXTENSIONS.has(urlExtension))) {
254
+ throw new Error(`Unsupported asset Content-Type: ${response.contentType || "(missing)"}`);
255
+ }
256
+ const digest = createHash("sha256").update(response.body).digest("hex");
257
+ const assetFile = `${digest}${assetExtension(response.contentType, response.finalUrl)}`;
258
+ const assetPath = path.join(options.root, "_assets", assetFile);
259
+ const status = await saveAsset(assetPath, response.body, options.root);
260
+ const vary = varyNames(response.vary);
261
+ const cacheable = !response.redirected &&
262
+ vary.length === 0 &&
263
+ !hasCredentialHeaders(http.headers) &&
264
+ !requestHasNoStore(http.headers) &&
265
+ !hasNoStore(response.cacheControl) &&
266
+ Boolean(response.etag || response.lastModified);
267
+ await withDestinationLock(cachePath, async () => {
268
+ const current = await readAssetCache(cachePath, sourceUrl);
269
+ cacheWarning ??= current.warning;
270
+ if (!sameCacheEntry(current.entry, cached.entry)) {
271
+ return;
272
+ }
273
+ if (cacheable && current.writable) {
274
+ const entry = {
275
+ version: ASSET_CACHE_VERSION,
276
+ sourceUrl,
277
+ finalUrl: response.finalUrl,
278
+ assetFile,
279
+ contentType: response.contentType,
280
+ ...(response.etag ? { etag: response.etag } : {}),
281
+ ...(response.lastModified
282
+ ? { lastModified: response.lastModified }
283
+ : {}),
284
+ vary
285
+ };
286
+ await replaceFileAtomic(cachePath, serializeAssetCache(entry), {
287
+ root: options.root
288
+ });
289
+ }
290
+ else if (current.writable &&
291
+ !hasCredentialHeaders(http.headers) &&
292
+ !requestHasNoStore(http.headers)) {
293
+ await removeAssetCache(cachePath);
294
+ }
295
+ }, options.root);
296
+ const replacement = path
297
+ .relative(path.dirname(options.markdownPath), assetPath)
298
+ .split(path.sep)
299
+ .join("/");
300
+ return {
301
+ asset: {
302
+ sourceUrl,
303
+ finalUrl: response.finalUrl,
304
+ path: assetPath,
305
+ status
306
+ },
307
+ replacement,
308
+ representative: sourceUrl === representativeImage,
309
+ ...(cacheWarning ? { warnings: [cacheWarning] } : {})
310
+ };
311
+ }
312
+ catch (error) {
313
+ const message = error instanceof Error ? error.message : String(error);
314
+ return {
315
+ asset: { sourceUrl, status: "failed", error: message },
316
+ representative: sourceUrl === representativeImage,
317
+ warnings: [
318
+ ...(cacheWarning ? [cacheWarning] : []),
319
+ { code: "ASSET_FETCH_FAILED", message, url: sourceUrl }
320
+ ]
321
+ };
322
+ }
323
+ }
324
+ export async function localizeAssets(options) {
325
+ const representativeCandidate = options.representativeImage
326
+ ? new URL(options.representativeImage, options.baseUrl)
327
+ : undefined;
328
+ const representativeImage = representativeCandidate?.protocol === "http:" ||
329
+ representativeCandidate?.protocol === "https:"
330
+ ? representativeCandidate.href
331
+ : undefined;
332
+ const urls = [...new Set([...options.imageUrls, ...(representativeImage ? [representativeImage] : [])])];
333
+ const replacements = new Map();
334
+ const assets = [];
335
+ let localRepresentative;
336
+ for (let offset = 0; offset < urls.length; offset += 6) {
337
+ const batch = await Promise.all(urls
338
+ .slice(offset, offset + 6)
339
+ .map((sourceUrl) => processAsset(sourceUrl, options, representativeImage)));
340
+ for (const result of batch) {
341
+ assets.push(result.asset);
342
+ for (const warning of result.warnings ?? []) {
343
+ options.warn(warning);
344
+ }
345
+ if (result.replacement) {
346
+ replacements.set(result.asset.sourceUrl, result.replacement);
347
+ if (result.representative) {
348
+ localRepresentative = result.replacement;
349
+ }
350
+ }
351
+ }
352
+ }
353
+ const result = {
354
+ markdown: rewriteImageUrls(options.markdown, replacements),
355
+ assets
356
+ };
357
+ if (localRepresentative !== undefined) {
358
+ result.representativeImage = localRepresentative;
359
+ }
360
+ if (representativeImage !== undefined && localRepresentative !== undefined) {
361
+ result.representativeImageSource = representativeImage;
362
+ }
363
+ return result;
364
+ }
package/dist/cli.d.ts ADDED
@@ -0,0 +1,8 @@
1
+ #!/usr/bin/env node
2
+ import { Command } from "commander";
3
+ export interface CliIo {
4
+ stdout: Pick<NodeJS.WriteStream, "write">;
5
+ stderr: Pick<NodeJS.WriteStream, "write">;
6
+ }
7
+ export declare function createProgram(io?: CliIo): Command;
8
+ export declare function runCli(argv?: string[], io?: CliIo): Promise<number>;
package/dist/cli.js ADDED
@@ -0,0 +1,119 @@
1
+ #!/usr/bin/env node
2
+ import { Command, Option } from "commander";
3
+ import { realpathSync } from "node:fs";
4
+ import path from "node:path";
5
+ import { fileURLToPath } from "node:url";
6
+ import { loadConfig, resolveRoot } from "./config/config.js";
7
+ import { MdhqError } from "./errors.js";
8
+ import { getPage } from "./get-page.js";
9
+ import { listMarkdownFiles } from "./list-files.js";
10
+ import { VERSION } from "./version.js";
11
+ function collect(value, previous) {
12
+ return [...previous, value];
13
+ }
14
+ function parseHeaders(values) {
15
+ return values.map((value) => {
16
+ const separator = value.indexOf(":");
17
+ const name = value.slice(0, Math.max(separator, 0)).trim();
18
+ if (separator <= 0 ||
19
+ !/^[!#$%&'*+\-.^_`|~0-9A-Za-z]+$/u.test(name)) {
20
+ throw new MdhqError("INVALID_HEADER", `Invalid header: ${value}`);
21
+ }
22
+ return {
23
+ name,
24
+ value: value.slice(separator + 1).trim()
25
+ };
26
+ });
27
+ }
28
+ export function createProgram(io = process) {
29
+ const program = new Command()
30
+ .name("mdhq")
31
+ .version(VERSION)
32
+ .description("Save web pages as Markdown.");
33
+ program.configureOutput({
34
+ writeOut: (value) => {
35
+ io.stdout.write(value);
36
+ },
37
+ writeErr: (value) => {
38
+ io.stderr.write(value);
39
+ }
40
+ });
41
+ program
42
+ .command("get")
43
+ .description("Fetch and save one web page.")
44
+ .argument("<url>")
45
+ .option("--root <path>", "storage root")
46
+ .option("--no-assets", "do not download images")
47
+ .option("--update", "update an existing page")
48
+ .option("--user-agent <value>", "HTTP User-Agent")
49
+ .option("--header <header>", "additional HTTP header", collect, [])
50
+ .addOption(new Option("--json", "print a structured result"))
51
+ .action(async (url, options) => {
52
+ const result = await getPage({
53
+ url,
54
+ ...(options.root ? { root: options.root } : {}),
55
+ ...(options.assets === false ? { assets: false } : {}),
56
+ update: options.update ?? false,
57
+ ...(options.userAgent ? { userAgent: options.userAgent } : {}),
58
+ headers: parseHeaders(options.header),
59
+ onWarning: (warning) => io.stderr.write(`warning: ${warning.message}\n`)
60
+ });
61
+ io.stdout.write(options.json ? `${JSON.stringify(result, null, 2)}\n` : `${result.path}\n`);
62
+ });
63
+ program
64
+ .command("list")
65
+ .description("List saved Markdown files.")
66
+ .option("--root <path>", "storage root")
67
+ .option("-p, --full-path", "print full paths")
68
+ .action(async (options) => {
69
+ const files = await listMarkdownFiles({
70
+ ...(options.root ? { root: options.root } : {}),
71
+ fullPath: options.fullPath ?? false,
72
+ onWarning: (warning) => io.stderr.write(`warning: ${warning.message}\n`)
73
+ });
74
+ if (files.length > 0) {
75
+ io.stdout.write(`${files.join("\n")}\n`);
76
+ }
77
+ });
78
+ program
79
+ .command("root")
80
+ .description("Print the effective storage root.")
81
+ .option("--root <path>", "storage root")
82
+ .action(async (options) => {
83
+ const loaded = await loadConfig();
84
+ for (const warning of loaded.warnings) {
85
+ io.stderr.write(`warning: ${warning.message}\n`);
86
+ }
87
+ io.stdout.write(`${resolveRoot(options.root, loaded.config)}\n`);
88
+ });
89
+ return program;
90
+ }
91
+ export async function runCli(argv = process.argv, io = process) {
92
+ try {
93
+ await createProgram(io).parseAsync(argv);
94
+ return 0;
95
+ }
96
+ catch (error) {
97
+ const message = error instanceof MdhqError
98
+ ? `mdhq: ${error.message}`
99
+ : error instanceof Error
100
+ ? error.message
101
+ : String(error);
102
+ io.stderr.write(`${message}\n`);
103
+ return 1;
104
+ }
105
+ }
106
+ let isMain = false;
107
+ if (process.argv[1] !== undefined) {
108
+ try {
109
+ isMain =
110
+ realpathSync(fileURLToPath(import.meta.url)) ===
111
+ realpathSync(path.resolve(process.argv[1]));
112
+ }
113
+ catch {
114
+ isMain = false;
115
+ }
116
+ }
117
+ if (isMain) {
118
+ process.exitCode = await runCli();
119
+ }
@@ -0,0 +1,25 @@
1
+ import type { DefuddleOptions } from "defuddle/node";
2
+ import type { MdhqWarning } from "../types.js";
3
+ import type { HostConfig } from "./match.js";
4
+ export interface MdhqConfig {
5
+ root?: string;
6
+ userAgent?: string;
7
+ timeoutMs?: number;
8
+ maxResponseBytes?: number;
9
+ maxRedirects?: number;
10
+ assets?: boolean;
11
+ useAsync?: boolean;
12
+ defuddle?: Omit<DefuddleOptions, "fetch" | "markdown" | "separateMarkdown" | "url">;
13
+ frontmatter?: {
14
+ exclude?: string[];
15
+ values?: Record<string, string | number | boolean | null>;
16
+ };
17
+ hosts?: Record<string, HostConfig>;
18
+ }
19
+ export declare function defaultConfigPath(env?: NodeJS.ProcessEnv): string;
20
+ export declare function defaultDataRoot(env?: NodeJS.ProcessEnv): string;
21
+ export declare function resolveRoot(cliRoot: string | undefined, config: MdhqConfig, env?: NodeJS.ProcessEnv): string;
22
+ export declare function loadConfig(configPath?: string): Promise<{
23
+ config: MdhqConfig;
24
+ warnings: MdhqWarning[];
25
+ }>;