@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/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
|
+
}>;
|