@songmu/mdhq 0.0.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +126 -0
- package/dist/assets/localize.d.ts +19 -0
- package/dist/assets/localize.js +364 -0
- package/dist/cli.d.ts +8 -0
- package/dist/cli.js +119 -0
- package/dist/config/config.d.ts +25 -0
- package/dist/config/config.js +170 -0
- package/dist/config/match.d.ts +7 -0
- package/dist/config/match.js +101 -0
- package/dist/convert/article-date.d.ts +20 -0
- package/dist/convert/article-date.js +255 -0
- package/dist/convert/convert-html.d.ts +2 -0
- package/dist/convert/convert-html.js +89 -0
- package/dist/convert/extract-published.d.ts +12 -0
- package/dist/convert/extract-published.js +24 -0
- package/dist/convert/extract-updated.d.ts +8 -0
- package/dist/convert/extract-updated.js +20 -0
- package/dist/date.d.ts +18 -0
- package/dist/date.js +448 -0
- package/dist/errors.d.ts +8 -0
- package/dist/errors.js +10 -0
- package/dist/frontmatter/frontmatter.d.ts +40 -0
- package/dist/frontmatter/frontmatter.js +114 -0
- package/dist/get-page.d.ts +2 -0
- package/dist/get-page.js +308 -0
- package/dist/http/fetch.d.ts +46 -0
- package/dist/http/fetch.js +195 -0
- package/dist/index.d.ts +6 -0
- package/dist/index.js +3 -0
- package/dist/list-files.d.ts +8 -0
- package/dist/list-files.js +35 -0
- package/dist/markdown/transform.d.ts +6 -0
- package/dist/markdown/transform.js +129 -0
- package/dist/path/storage-path.d.ts +7 -0
- package/dist/path/storage-path.js +110 -0
- package/dist/storage/atomic.d.ts +8 -0
- package/dist/storage/atomic.js +84 -0
- package/dist/storage/path-safety.d.ts +1 -0
- package/dist/storage/path-safety.js +55 -0
- package/dist/storage/save.d.ts +23 -0
- package/dist/storage/save.js +118 -0
- package/dist/types.d.ts +62 -0
- package/dist/types.js +1 -0
- package/dist/url/identity.d.ts +12 -0
- package/dist/url/identity.js +54 -0
- package/dist/url/pathname.d.ts +4 -0
- package/dist/url/pathname.js +46 -0
- package/dist/version.d.ts +3 -0
- package/dist/version.js +6 -0
- package/docs/README.md +14 -0
- package/docs/configuration.md +242 -0
- package/docs/library-api.md +275 -0
- package/docs/specification.md +730 -0
- package/package.json +73 -0
|
@@ -0,0 +1,730 @@
|
|
|
1
|
+
# Current specification
|
|
2
|
+
|
|
3
|
+
This document describes the behavior implemented in mdhq `0.0.0`.
|
|
4
|
+
|
|
5
|
+
## Overview
|
|
6
|
+
|
|
7
|
+
mdhq fetches one web page, extracts its primary content with Defuddle,
|
|
8
|
+
converts it to Markdown, downloads supported images, adds YAML frontmatter,
|
|
9
|
+
and saves the result in a ghq-inspired directory layout.
|
|
10
|
+
|
|
11
|
+
The storage tree is self-contained. mdhq does not create a database, global
|
|
12
|
+
index, persistent lock file, or state file.
|
|
13
|
+
|
|
14
|
+
Runtime requirements:
|
|
15
|
+
|
|
16
|
+
- Node.js 22 or newer
|
|
17
|
+
- ECMAScript modules
|
|
18
|
+
|
|
19
|
+
## CLI
|
|
20
|
+
|
|
21
|
+
The executable provides three subcommands:
|
|
22
|
+
|
|
23
|
+
```text
|
|
24
|
+
mdhq get [options] <url>
|
|
25
|
+
mdhq list [options]
|
|
26
|
+
mdhq root [options]
|
|
27
|
+
```
|
|
28
|
+
|
|
29
|
+
`mdhq get` accepts exactly one URL per invocation. Parallel or multi-URL
|
|
30
|
+
processing is delegated to external tools such as `xargs`.
|
|
31
|
+
|
|
32
|
+
### `get` options
|
|
33
|
+
|
|
34
|
+
| Option | Description |
|
|
35
|
+
| --- | --- |
|
|
36
|
+
| `--root <path>` | Override the storage root. |
|
|
37
|
+
| `--update` | Fetch and replace an existing document with the same URL identity. |
|
|
38
|
+
| `--user-agent <value>` | Override the default HTTP User-Agent. |
|
|
39
|
+
| `--header <header>` | Add an HTTP header. The option is repeatable and uses `Name: value` syntax. |
|
|
40
|
+
| `--json` | Write a structured result instead of only the Markdown path. |
|
|
41
|
+
|
|
42
|
+
On success without `--json`, stdout contains exactly one absolute Markdown
|
|
43
|
+
path followed by a newline. Warnings are written to stderr.
|
|
44
|
+
|
|
45
|
+
With `--json`, stdout contains an object with this shape:
|
|
46
|
+
|
|
47
|
+
```json
|
|
48
|
+
{
|
|
49
|
+
"requestedUrl": "https://example.com/start",
|
|
50
|
+
"sourceUrl": "https://example.com/article",
|
|
51
|
+
"path": "/data/mdhq/example.com/article.md",
|
|
52
|
+
"status": "saved",
|
|
53
|
+
"assets": [],
|
|
54
|
+
"warnings": []
|
|
55
|
+
}
|
|
56
|
+
```
|
|
57
|
+
|
|
58
|
+
`status` is one of:
|
|
59
|
+
|
|
60
|
+
- `saved`: a new Markdown file was created.
|
|
61
|
+
- `updated`: an existing document's normalized Markdown body changed.
|
|
62
|
+
- `unchanged`: an update succeeded without changing the normalized Markdown
|
|
63
|
+
body, including an HTTP 304 response.
|
|
64
|
+
- `skipped`: an existing same-identity file was kept.
|
|
65
|
+
|
|
66
|
+
An error is written to stderr and causes exit status `1`.
|
|
67
|
+
|
|
68
|
+
### `list`
|
|
69
|
+
|
|
70
|
+
`mdhq list` recursively lists regular files ending in `.md` below the
|
|
71
|
+
effective storage root. Results are sorted by their root-relative paths and
|
|
72
|
+
written one per line.
|
|
73
|
+
|
|
74
|
+
By default, paths are relative to the storage root. `-p` or `--full-path`
|
|
75
|
+
prints absolute paths instead. `--root <path>` overrides the storage root
|
|
76
|
+
using the same precedence as `get`.
|
|
77
|
+
|
|
78
|
+
Directory symbolic links are not followed. Other extensions, including
|
|
79
|
+
uppercase `.MD` and names such as `.markdown`, are not listed. An empty root
|
|
80
|
+
produces no output. A missing or unreadable root is an error.
|
|
81
|
+
|
|
82
|
+
### `root`
|
|
83
|
+
|
|
84
|
+
`mdhq root` writes the absolute effective storage root followed by a newline.
|
|
85
|
+
`--root <path>` overrides the storage root using the same precedence as `get`
|
|
86
|
+
and `list`. The command loads configuration and reports configuration warnings
|
|
87
|
+
to stderr, but it does not require the resolved root to exist and does not
|
|
88
|
+
create it.
|
|
89
|
+
|
|
90
|
+
## Processing pipeline
|
|
91
|
+
|
|
92
|
+
`getPage` performs the following operations:
|
|
93
|
+
|
|
94
|
+
1. Validate that the requested URL uses HTTP or HTTPS.
|
|
95
|
+
2. Load the JSON configuration and collect unknown-key warnings.
|
|
96
|
+
3. Resolve the storage root and host/path-specific entry query key.
|
|
97
|
+
4. Check whether the requested URL already has a same-identity destination.
|
|
98
|
+
5. Fetch the page when it cannot be skipped before network access.
|
|
99
|
+
6. Resolve the final URL after redirects and recalculate configuration and
|
|
100
|
+
destination from that URL.
|
|
101
|
+
7. Check the final destination for another same-identity skip.
|
|
102
|
+
8. Convert the fetched HTML to Markdown with Defuddle.
|
|
103
|
+
9. Normalize ordinary links and discover image links.
|
|
104
|
+
10. Download supported images and rewrite successful image destinations.
|
|
105
|
+
11. Normalize the Markdown body and calculate its SHA-256 content digest.
|
|
106
|
+
12. Build YAML frontmatter.
|
|
107
|
+
13. Save the Markdown with collision-safe create or update behavior.
|
|
108
|
+
|
|
109
|
+
The final URL determines the destination and the `source` frontmatter field.
|
|
110
|
+
The original URL is retained as `requested_url` only when it differs from the
|
|
111
|
+
final URL.
|
|
112
|
+
|
|
113
|
+
## HTTP behavior
|
|
114
|
+
|
|
115
|
+
The high-level API accepts only `http:` and `https:` URLs. Local HTML or other
|
|
116
|
+
schemes must be passed to the low-level `convertHtml` API instead.
|
|
117
|
+
|
|
118
|
+
Defaults:
|
|
119
|
+
|
|
120
|
+
| Setting | Default |
|
|
121
|
+
| --- | --- |
|
|
122
|
+
| User-Agent | `mdhq/0.0.0 (+https://github.com/Songmu/mdhq)` |
|
|
123
|
+
| Accept for pages | `text/html, application/xhtml+xml` |
|
|
124
|
+
| Timeout | 30 seconds per request attempt |
|
|
125
|
+
| Maximum response size | 20 MiB per resource |
|
|
126
|
+
| Maximum redirects | 10 |
|
|
127
|
+
|
|
128
|
+
With `update`, mdhq uses validators from a recognized existing destination:
|
|
129
|
+
|
|
130
|
+
1. Confirm that the request does not include `Authorization` or `Cookie`.
|
|
131
|
+
2. Confirm that the stored `source` is the same HTTP target as the requested
|
|
132
|
+
URL, comparing scheme, authority, path, and query while ignoring fragments.
|
|
133
|
+
3. Send `If-None-Match` when frontmatter contains `etag`.
|
|
134
|
+
4. Otherwise send `If-Modified-Since` when frontmatter contains a valid
|
|
135
|
+
`last_modified`.
|
|
136
|
+
5. Otherwise perform an ordinary GET.
|
|
137
|
+
|
|
138
|
+
Storage identity is intentionally broader than HTTP validator scope. A
|
|
139
|
+
same-identity URL with a different scheme, query, or path alias is fetched
|
|
140
|
+
without stored validators.
|
|
141
|
+
|
|
142
|
+
Automatic validators are sent only on the first request and are not forwarded
|
|
143
|
+
after redirects. When a stored validator is available, it replaces any
|
|
144
|
+
caller-provided `If-None-Match` or `If-Modified-Since` value so that an HTTP
|
|
145
|
+
304 always corresponds to the saved document. A 304 received without a stored
|
|
146
|
+
validator is an error.
|
|
147
|
+
|
|
148
|
+
An HTTP 304 response preserves the existing Markdown body, skips conversion
|
|
149
|
+
and asset downloads, preserves `modified`, and returns `unchanged`. A 200
|
|
150
|
+
response replaces stored validators with the response values; validators that
|
|
151
|
+
are absent from a 200 response are removed without changing `modified` when
|
|
152
|
+
the normalized Markdown body and user-facing frontmatter are unchanged.
|
|
153
|
+
|
|
154
|
+
Responses with a non-empty `Vary` header do not persist ETag or Last-Modified
|
|
155
|
+
validators, and mdhq never stores `Vary` header names or values in Markdown.
|
|
156
|
+
Responses without `Vary` may store a reusable `etag` or `last_modified`
|
|
157
|
+
validator.
|
|
158
|
+
|
|
159
|
+
Requests containing caller-supplied `Authorization` or `Cookie` headers never
|
|
160
|
+
reuse or persist HTTP validators, even when the response omits `Vary`. This
|
|
161
|
+
prevents a later request with a different credential context from accepting a
|
|
162
|
+
304 for a representation it did not fetch.
|
|
163
|
+
|
|
164
|
+
Any other non-success response, including `404 Not Found` and `410 Gone`,
|
|
165
|
+
fails the update before conversion or storage. The existing Markdown document,
|
|
166
|
+
frontmatter timestamps, validators, and localized assets remain unchanged.
|
|
167
|
+
|
|
168
|
+
All destination writes are serialized with a per-file cross-process lock.
|
|
169
|
+
Before saving either a 304 or 200 update, mdhq verifies that the destination
|
|
170
|
+
still matches the exact document snapshot read before the request. If another
|
|
171
|
+
writer changed it while the request or conversion was in flight, mdhq leaves
|
|
172
|
+
that document untouched and restarts the complete update from the latest
|
|
173
|
+
snapshot. Retries are bounded to two restarts; repeated contention fails
|
|
174
|
+
without overwriting the competing update.
|
|
175
|
+
|
|
176
|
+
Page responses must have one of these media types:
|
|
177
|
+
|
|
178
|
+
- `text/html`
|
|
179
|
+
- `application/xhtml+xml`
|
|
180
|
+
|
|
181
|
+
Media type parameters such as `charset` are ignored during comparison. A
|
|
182
|
+
missing or unsupported page Content-Type is an error. Response bytes are
|
|
183
|
+
currently decoded as UTF-8.
|
|
184
|
+
|
|
185
|
+
Redirect statuses `301`, `302`, `303`, `307`, and `308` are followed.
|
|
186
|
+
Redirects to unsupported schemes are rejected. Non-success final HTTP
|
|
187
|
+
statuses are errors.
|
|
188
|
+
|
|
189
|
+
The response-size limit is enforced from `Content-Length` when available and
|
|
190
|
+
again while streaming the response body.
|
|
191
|
+
|
|
192
|
+
### Headers and credential isolation
|
|
193
|
+
|
|
194
|
+
Headers supplied by the caller are sent to the initial page origin and
|
|
195
|
+
same-origin redirects. All caller-supplied headers are removed after a
|
|
196
|
+
cross-origin redirect and are not restored for later requests in that page
|
|
197
|
+
operation, including direct retries made against an existing redirect
|
|
198
|
+
destination.
|
|
199
|
+
|
|
200
|
+
In this section, "caller-supplied headers" means entries from CLI `--header`
|
|
201
|
+
or library `GetPageOptions.headers`. The separately selected User-Agent is
|
|
202
|
+
sent on every redirect and asset request, including cross-origin requests.
|
|
203
|
+
|
|
204
|
+
mdhq creates its built-in `Accept` and User-Agent headers first, then appends
|
|
205
|
+
entries from `--header` or `GetPageOptions.headers`. Supplying `Accept` or
|
|
206
|
+
`User-Agent` through the generic header option therefore combines another
|
|
207
|
+
value with the built-in or separately configured value rather than replacing
|
|
208
|
+
it. Use `--user-agent`, `GetPageOptions.userAgent`, or configuration
|
|
209
|
+
`userAgent` when replacement is intended.
|
|
210
|
+
|
|
211
|
+
Page headers are supplied to same-origin assets. They are not supplied to
|
|
212
|
+
cross-origin assets. If the page itself redirected across origins, no
|
|
213
|
+
caller-supplied headers are sent to any assets, including assets on the final
|
|
214
|
+
page origin. Asset redirects apply the same cross-origin stripping rule.
|
|
215
|
+
|
|
216
|
+
### Proxies
|
|
217
|
+
|
|
218
|
+
HTTP requests use Undici's environment-aware proxy agent. Standard uppercase
|
|
219
|
+
and lowercase proxy environment variables, including `HTTP_PROXY`,
|
|
220
|
+
`HTTPS_PROXY`, `ALL_PROXY`, and `NO_PROXY`, are handled by that agent.
|
|
221
|
+
|
|
222
|
+
The proxy-aware fetch function is also passed to Defuddle for asynchronous
|
|
223
|
+
extractors.
|
|
224
|
+
|
|
225
|
+
## Defuddle conversion
|
|
226
|
+
|
|
227
|
+
mdhq uses `defuddle/node` version `0.19.3`.
|
|
228
|
+
|
|
229
|
+
The high-level pipeline forces Markdown output. Defuddle asynchronous
|
|
230
|
+
extractors are enabled by default and may contact third-party APIs when local
|
|
231
|
+
HTML does not contain usable content.
|
|
232
|
+
|
|
233
|
+
The effective `useAsync` value is selected in this order:
|
|
234
|
+
|
|
235
|
+
1. `GetPageOptions.useAsync`
|
|
236
|
+
2. `config.defuddle.useAsync`
|
|
237
|
+
3. Legacy top-level `config.useAsync`
|
|
238
|
+
4. `true`
|
|
239
|
+
|
|
240
|
+
Defuddle's asynchronous extractor requests receive mdhq's environment-aware
|
|
241
|
+
proxy dispatcher. They do not automatically receive CLI `--header` values,
|
|
242
|
+
`GetPageOptions.headers`, or the selected mdhq User-Agent. mdhq's page and
|
|
243
|
+
asset timeout, redirect, and response-size limits are also not wrapped around
|
|
244
|
+
those Defuddle-internal requests. Defuddle controls their request headers and
|
|
245
|
+
failure behavior.
|
|
246
|
+
|
|
247
|
+
An empty Defuddle result is a conversion error.
|
|
248
|
+
|
|
249
|
+
The normalized metadata model includes:
|
|
250
|
+
|
|
251
|
+
- title
|
|
252
|
+
- description
|
|
253
|
+
- author
|
|
254
|
+
- published date
|
|
255
|
+
- site
|
|
256
|
+
- domain
|
|
257
|
+
- language
|
|
258
|
+
- representative image
|
|
259
|
+
- favicon
|
|
260
|
+
- word count
|
|
261
|
+
|
|
262
|
+
Not every metadata value is necessarily written to frontmatter.
|
|
263
|
+
|
|
264
|
+
## URL identity
|
|
265
|
+
|
|
266
|
+
URL identity is used to decide whether an existing destination represents the
|
|
267
|
+
same page.
|
|
268
|
+
|
|
269
|
+
Identity includes:
|
|
270
|
+
|
|
271
|
+
- normalized host
|
|
272
|
+
- canonical pathname
|
|
273
|
+
- one configured entry query key and value, when present and non-empty
|
|
274
|
+
|
|
275
|
+
Identity ignores:
|
|
276
|
+
|
|
277
|
+
- the difference between HTTP and HTTPS
|
|
278
|
+
- fragments
|
|
279
|
+
- all query parameters except the selected entry query key
|
|
280
|
+
|
|
281
|
+
Host normalization:
|
|
282
|
+
|
|
283
|
+
- lowercases the hostname
|
|
284
|
+
- applies IDNA ASCII conversion
|
|
285
|
+
- removes trailing DNS root dots from ordinary hostnames
|
|
286
|
+
- omits standard ports
|
|
287
|
+
- retains non-standard ports
|
|
288
|
+
|
|
289
|
+
Path normalization:
|
|
290
|
+
|
|
291
|
+
- splits the path before decoding, so an encoded slash remains within one
|
|
292
|
+
path segment
|
|
293
|
+
- removes repeated and trailing path separators for identity comparison
|
|
294
|
+
- decodes percent escapes as UTF-8
|
|
295
|
+
- treats an unmatched literal percent sign as a literal percent sign
|
|
296
|
+
- applies Unicode NFC normalization
|
|
297
|
+
- treats a root URL and `/index.html` as the same identity
|
|
298
|
+
- removes a final recognized HTML extension for identity comparison
|
|
299
|
+
- applies canonical percent encoding for identity comparison
|
|
300
|
+
|
|
301
|
+
The first value returned for the configured entry query key is used. A missing
|
|
302
|
+
or empty value causes the URL to use its normal path-based identity.
|
|
303
|
+
|
|
304
|
+
## Storage paths
|
|
305
|
+
|
|
306
|
+
The general layout is:
|
|
307
|
+
|
|
308
|
+
```text
|
|
309
|
+
<root>/<host>/<path>.md
|
|
310
|
+
```
|
|
311
|
+
|
|
312
|
+
Examples:
|
|
313
|
+
|
|
314
|
+
| URL | Relative destination |
|
|
315
|
+
| --- | --- |
|
|
316
|
+
| `https://example.com/` | `example.com/index.md` |
|
|
317
|
+
| `https://example.com/entry/hoge/` | `example.com/entry/hoge.md` |
|
|
318
|
+
| `https://example.com/path/fuga` | `example.com/path/fuga.md` |
|
|
319
|
+
| `https://example.com/entry/hoge.html` | `example.com/entry/hoge.md` |
|
|
320
|
+
| `https://example.com/entry/hoge.ja.html` | `example.com/entry/hoge.ja.md` |
|
|
321
|
+
| `https://example.com/data.json` | `example.com/data.json.md` |
|
|
322
|
+
| `https://example.com/file.md` | `example.com/file.md.md` |
|
|
323
|
+
| `https://example.com:8443/path` | `example.com_8443/path.md` |
|
|
324
|
+
| `https://example.com/日本語` | `example.com/日本語.md` |
|
|
325
|
+
|
|
326
|
+
The following final extensions are replaced with `.md`, case-insensitively:
|
|
327
|
+
|
|
328
|
+
- `.html`
|
|
329
|
+
- `.htm`
|
|
330
|
+
- `.xhtml`
|
|
331
|
+
- `.php`
|
|
332
|
+
- `.asp`
|
|
333
|
+
- `.aspx`
|
|
334
|
+
- `.jsp`
|
|
335
|
+
- `.jspx`
|
|
336
|
+
|
|
337
|
+
Every other filename receives an additional `.md` suffix.
|
|
338
|
+
|
|
339
|
+
### Entry query keys
|
|
340
|
+
|
|
341
|
+
When `entryQueryKey` selects a non-empty query value, the original final path
|
|
342
|
+
element becomes a directory and the query value becomes the Markdown
|
|
343
|
+
filename:
|
|
344
|
+
|
|
345
|
+
```text
|
|
346
|
+
https://example.com/blog/blog.php?entry_id=123
|
|
347
|
+
-> example.com/blog/blog.php/123.md
|
|
348
|
+
```
|
|
349
|
+
|
|
350
|
+
Other query parameters and the fragment do not affect the destination.
|
|
351
|
+
|
|
352
|
+
The query value comes from `URLSearchParams.get`, so percent escapes are
|
|
353
|
+
decoded and `+` is interpreted as a space. The resulting value then uses the
|
|
354
|
+
same NFC normalization, unsafe-character encoding, reserved-name handling,
|
|
355
|
+
240-byte limit, and MD5 fallback as a URL path segment. A slash in the query
|
|
356
|
+
value becomes `%2F` inside the filename and never creates another directory.
|
|
357
|
+
|
|
358
|
+
### Safe path segments
|
|
359
|
+
|
|
360
|
+
Each URL path segment is decoded independently and normalized to NFC.
|
|
361
|
+
|
|
362
|
+
The following are encoded as uppercase `%HH` UTF-8 bytes:
|
|
363
|
+
|
|
364
|
+
- `/`, `\`, `:`, `*`, `?`, `"`, `<`, `>`, and `|`
|
|
365
|
+
- NUL, control characters, and DEL
|
|
366
|
+
- literal percent signs
|
|
367
|
+
- a trailing space or period
|
|
368
|
+
|
|
369
|
+
An encoded slash such as `%2F` is therefore retained as `%2F` inside one
|
|
370
|
+
filesystem segment rather than becoming a directory separator.
|
|
371
|
+
|
|
372
|
+
The complete `.` and `..` names and Windows reserved device names such as
|
|
373
|
+
`CON`, `NUL`, `COM1`, and `LPT1` are entirely percent-encoded.
|
|
374
|
+
|
|
375
|
+
IPv6 host colons are replaced with underscores while brackets preserve the
|
|
376
|
+
host boundary. A non-standard IPv6 port is appended after an underscore.
|
|
377
|
+
Special hostnames such as `.` and `..` use the same safe-segment encoding and
|
|
378
|
+
cannot escape the selected storage root. The final resolved destination is
|
|
379
|
+
also checked to ensure it remains below that root. Existing directory
|
|
380
|
+
components below the root must not be symbolic links or Windows junctions;
|
|
381
|
+
mdhq rejects them with `PATH_COLLISION` instead of following them outside
|
|
382
|
+
the storage tree.
|
|
383
|
+
|
|
384
|
+
`_assets` is reserved at the storage root. A normalized host that conflicts
|
|
385
|
+
with this name is rejected.
|
|
386
|
+
|
|
387
|
+
### Length limits
|
|
388
|
+
|
|
389
|
+
A generated host, directory, or filename segment is replaced by its MD5
|
|
390
|
+
digest when it would exceed 240 bytes, including the `.md` suffix for a
|
|
391
|
+
filename.
|
|
392
|
+
|
|
393
|
+
On Windows, the complete absolute path target is 240 UTF-16 code units. On
|
|
394
|
+
other platforms, the target is 1000 UTF-8 bytes. The longest generated
|
|
395
|
+
segments are progressively replaced with MD5 digests until the path fits. If
|
|
396
|
+
hashing every eligible segment is insufficient because the root itself is too
|
|
397
|
+
long, path generation fails with `PATH_TOO_LONG`.
|
|
398
|
+
|
|
399
|
+
The Windows limit can reject unusually deep URLs even after every useful
|
|
400
|
+
segment has been shortened. The resulting error identifies both the URL and
|
|
401
|
+
the final attempted path.
|
|
402
|
+
|
|
403
|
+
## Markdown normalization
|
|
404
|
+
|
|
405
|
+
Defuddle output is parsed as an mdast tree with GitHub Flavored Markdown
|
|
406
|
+
extensions and serialized again after transformation. Tables,
|
|
407
|
+
strikethrough, task lists, and GFM autolinks are preserved. The output follows
|
|
408
|
+
the serializer's canonical Markdown formatting rather than preserving
|
|
409
|
+
Defuddle's exact whitespace.
|
|
410
|
+
|
|
411
|
+
For ordinary links:
|
|
412
|
+
|
|
413
|
+
- relative URLs are resolved against the final page URL
|
|
414
|
+
- absolute URLs are retained
|
|
415
|
+
- fragment-only links are retained
|
|
416
|
+
- reference-style link definitions are resolved and normalized
|
|
417
|
+
|
|
418
|
+
For images:
|
|
419
|
+
|
|
420
|
+
- relative destinations are resolved against the final page URL
|
|
421
|
+
- reference-style image definitions are resolved and localized
|
|
422
|
+
- absolute image URLs are collected for asset localization
|
|
423
|
+
- duplicate source URLs are fetched once
|
|
424
|
+
- successful downloads replace image destinations with relative local paths
|
|
425
|
+
- failed downloads leave the absolute source URL unchanged
|
|
426
|
+
- non-HTTP(S) images such as `data:` URLs are left unchanged and do not
|
|
427
|
+
produce asset warnings
|
|
428
|
+
|
|
429
|
+
Asset localization is enabled by default. It can be disabled with the
|
|
430
|
+
top-level configuration field `assets: false`, library option
|
|
431
|
+
`GetPageOptions.assets: false`, or CLI option `--no-assets`. The library
|
|
432
|
+
option and CLI option take precedence over configuration. When disabled,
|
|
433
|
+
HTTP(S) image destinations are kept as absolute URLs, the result `assets`
|
|
434
|
+
array is empty, and `_assets` is not created.
|
|
435
|
+
|
|
436
|
+
mdhq does not rewrite ordinary links to other locally stored Markdown
|
|
437
|
+
files.
|
|
438
|
+
|
|
439
|
+
## Assets
|
|
440
|
+
|
|
441
|
+
Assets are stored under:
|
|
442
|
+
|
|
443
|
+
```text
|
|
444
|
+
<root>/_assets/<sha256-of-content>.<extension>
|
|
445
|
+
```
|
|
446
|
+
|
|
447
|
+
The digest component is calculated from the complete fetched response body
|
|
448
|
+
after redirects. The extension is selected from Content-Type, falling back to
|
|
449
|
+
the final URL pathname when Content-Type is missing. Identical bytes therefore
|
|
450
|
+
share the same digest, while differing media metadata can still select a
|
|
451
|
+
different extension.
|
|
452
|
+
|
|
453
|
+
Downloaded asset candidates are:
|
|
454
|
+
|
|
455
|
+
- image destinations present in Defuddle's Markdown output
|
|
456
|
+
- the representative article image from Defuddle metadata
|
|
457
|
+
|
|
458
|
+
favicon, CSS background images, video, audio, and images removed by Defuddle
|
|
459
|
+
are not discovered by mdhq.
|
|
460
|
+
|
|
461
|
+
Recognized Content-Type mappings:
|
|
462
|
+
|
|
463
|
+
| Content-Type | Extension |
|
|
464
|
+
| --- | --- |
|
|
465
|
+
| `image/avif` | `.avif` |
|
|
466
|
+
| `image/gif` | `.gif` |
|
|
467
|
+
| `image/jpeg` | `.jpg` |
|
|
468
|
+
| `image/png` | `.png` |
|
|
469
|
+
| `image/svg+xml` | `.svg` |
|
|
470
|
+
| `image/webp` | `.webp` |
|
|
471
|
+
|
|
472
|
+
Other explicit `image/*` responses use a safe final-URL extension when one is
|
|
473
|
+
available, otherwise `.bin`. A missing Content-Type is accepted only when the
|
|
474
|
+
final URL ends in one of the recognized generated extensions or the common
|
|
475
|
+
`.jpeg` and `.jfif` aliases. An explicitly non-image Content-Type is rejected
|
|
476
|
+
even when the URL looks like an image.
|
|
477
|
+
|
|
478
|
+
Up to six assets are fetched concurrently. Result ordering still follows the
|
|
479
|
+
first occurrence in the document.
|
|
480
|
+
|
|
481
|
+
Asset paths are immutable and content-addressed by the SHA-256 digest of the
|
|
482
|
+
fetched bytes. Content is first written completely to a same-directory
|
|
483
|
+
temporary file and a new destination is published without replacing an
|
|
484
|
+
existing file. When the deterministic path already exists, identical content
|
|
485
|
+
is reported as `reused`; differing content is treated as a digest collision
|
|
486
|
+
and reported as an asset failure.
|
|
487
|
+
|
|
488
|
+
Reusable HTTP validators are stored separately under:
|
|
489
|
+
|
|
490
|
+
```text
|
|
491
|
+
<root>/_assets/.cache/<sha256-of-complete-source-url>.json
|
|
492
|
+
```
|
|
493
|
+
|
|
494
|
+
The cache key includes the complete normalized source URL, including its query
|
|
495
|
+
string. Query variants and otherwise unrelated source URLs are therefore
|
|
496
|
+
validated independently, while identical response bytes still converge on the
|
|
497
|
+
same content-addressed asset path when they select the same extension.
|
|
498
|
+
|
|
499
|
+
When an exact source URL is encountered again, mdhq sends its cached ETag as
|
|
500
|
+
`If-None-Match`, or its cached Last-Modified value as `If-Modified-Since`. A
|
|
501
|
+
`304 Not Modified` response reuses the cached asset path. A `200` response is
|
|
502
|
+
hashed normally, so changed bytes produce a new immutable asset path and
|
|
503
|
+
unchanged bytes reuse the existing one.
|
|
504
|
+
|
|
505
|
+
Asset validators are retained only when the response has no `Vary` fields, the
|
|
506
|
+
request has no Authorization or Cookie header, and the request did not
|
|
507
|
+
redirect. Requests or responses with `Cache-Control: no-store` are not cached.
|
|
508
|
+
Caller-supplied conditional headers are not forwarded as asset validators.
|
|
509
|
+
Invalid cache metadata is reported as an `ASSET_CACHE_INVALID` warning. A
|
|
510
|
+
malformed regular cache file is replaced after a successful cacheable
|
|
511
|
+
response; an invalid non-file entry disables caching for that URL without
|
|
512
|
+
preventing the image from being localized.
|
|
513
|
+
|
|
514
|
+
When an article update receives `304 Not Modified`, asset localization is
|
|
515
|
+
skipped together with HTML conversion. Images are revalidated when the article
|
|
516
|
+
itself returns a new `200` response.
|
|
517
|
+
|
|
518
|
+
An individual asset failure:
|
|
519
|
+
|
|
520
|
+
- does not prevent the Markdown file from being saved
|
|
521
|
+
- produces an `ASSET_FETCH_FAILED` warning
|
|
522
|
+
- produces an asset result with `status: "failed"`
|
|
523
|
+
- leaves the original absolute image URL in Markdown
|
|
524
|
+
|
|
525
|
+
All errors raised while fetching, validating, or saving an individual asset
|
|
526
|
+
are caught by the asset-localization stage and converted to this non-fatal
|
|
527
|
+
result. This includes timeouts, redirect failures, HTTP errors, response-size
|
|
528
|
+
limits, unsupported media types, and filesystem errors for that asset.
|
|
529
|
+
|
|
530
|
+
When the representative image is saved successfully, it is downloaded into
|
|
531
|
+
`_assets` like other localized images even though it is not necessarily
|
|
532
|
+
referenced from the Markdown body. When it fails, the failure produces an
|
|
533
|
+
`ASSET_FETCH_FAILED` warning and a `"failed"` asset result like any other
|
|
534
|
+
image. mdhq does not write the representative image or its source URL to
|
|
535
|
+
frontmatter.
|
|
536
|
+
|
|
537
|
+
Updates do not delete unreferenced assets.
|
|
538
|
+
|
|
539
|
+
## Frontmatter
|
|
540
|
+
|
|
541
|
+
Documents use YAML frontmatter followed by one blank line and the normalized
|
|
542
|
+
Markdown body.
|
|
543
|
+
|
|
544
|
+
Metadata fields are emitted when non-empty:
|
|
545
|
+
|
|
546
|
+
- `title`
|
|
547
|
+
- `description`
|
|
548
|
+
- `author`
|
|
549
|
+
- `published`
|
|
550
|
+
- `updated`
|
|
551
|
+
- `language`
|
|
552
|
+
|
|
553
|
+
mdhq-controlled fields:
|
|
554
|
+
|
|
555
|
+
- `source`: final page URL
|
|
556
|
+
- `requested_url`: original URL, only when different from `source`
|
|
557
|
+
- `created`: initial local acquisition time
|
|
558
|
+
- `modified`: time of the latest meaningful Markdown note change
|
|
559
|
+
- `etag`: HTTP ETag stored verbatim, when supplied
|
|
560
|
+
- `last_modified`: HTTP Last-Modified converted to RFC 3339 UTC, when valid
|
|
561
|
+
|
|
562
|
+
`created` and `modified` are both written on initial acquisition and use
|
|
563
|
+
local-offset RFC 3339 timestamps with second-level precision. A valid existing
|
|
564
|
+
`created` string is preserved verbatim during an update, including its
|
|
565
|
+
original UTC offset. `modified` changes only when the normalized Markdown body
|
|
566
|
+
or user-facing frontmatter changes; HTTP 304 responses and validator-only
|
|
567
|
+
updates preserve it.
|
|
568
|
+
|
|
569
|
+
`published` and `updated` are source-article metadata, normalized through the
|
|
570
|
+
same rules. `updated` is extracted from Schema.org `dateModified`,
|
|
571
|
+
`article:modified_time` / `og:updated_time` meta tags, or
|
|
572
|
+
`itemprop="dateModified"` microdata. `published` is extracted the same way
|
|
573
|
+
from Schema.org `datePublished`, `article:published_time` /
|
|
574
|
+
`og:published_time` meta tags, or `itemprop="datePublished"` microdata, with
|
|
575
|
+
Defuddle's own (string-only) extraction used as a fallback when no such
|
|
576
|
+
metadata is present. Schema.org selection prefers an entity linked to the
|
|
577
|
+
current page through `url`, `@id`, `mainEntity`, or `mainEntityOfPage`;
|
|
578
|
+
concrete Article and Posting types rank ahead of generic WebPage and
|
|
579
|
+
CreativeWork fallbacks, independently of graph order.
|
|
580
|
+
The `published` field represents the initial publication date. Explicit
|
|
581
|
+
publication timestamps are preserved only when they come from
|
|
582
|
+
publication-specific metadata; modification or event timestamps are not
|
|
583
|
+
publication evidence. If Defuddle synthesizes a midnight UTC timestamp from
|
|
584
|
+
visible date-only text, unrelated same-day datetime metadata does not prevent
|
|
585
|
+
mdhq from returning `YYYY-MM-DD`.
|
|
586
|
+
|
|
587
|
+
Source-date normalization accepts the following inputs and, for each, always
|
|
588
|
+
produces exactly one of two canonical forms: `YYYY-MM-DD` when only a
|
|
589
|
+
calendar date is reliably known, or an RFC 3339 date-time with an explicit
|
|
590
|
+
`Z` or numeric offset and second-level precision (fractional seconds are
|
|
591
|
+
dropped):
|
|
592
|
+
|
|
593
|
+
- `YYYY-MM-DD`, and compact `YYYYMMDD` (recognized as a date before being
|
|
594
|
+
considered as a numeric epoch).
|
|
595
|
+
- RFC 3339 date-times with an explicit `Z` or numeric UTC offset. A space is
|
|
596
|
+
accepted instead of `T` only when an explicit offset is also present. An
|
|
597
|
+
offset without a colon (`+0900`) is accepted and normalized to the colon
|
|
598
|
+
form (`+09:00`). A supplied offset is preserved rather than converted to
|
|
599
|
+
UTC.
|
|
600
|
+
- Local date-times without any UTC offset. Since no offset can be inferred
|
|
601
|
+
reliably, these are reduced to their `YYYY-MM-DD` calendar date instead of
|
|
602
|
+
inventing an offset such as `+00:00`.
|
|
603
|
+
- Unambiguous English month-name date text, such as `August 31, 2026` or
|
|
604
|
+
`31 August 2026`, represented as `YYYY-MM-DD`.
|
|
605
|
+
- Unix epoch values, as JSON numbers or numeric strings, in seconds,
|
|
606
|
+
milliseconds, microseconds, or nanoseconds. The unit is inferred from the
|
|
607
|
+
number of significant digits (roughly 6-10 digits for seconds, 11-13 for
|
|
608
|
+
milliseconds, 14-16 for microseconds, and 17-19 for nanoseconds); values
|
|
609
|
+
outside of these ranges are rejected instead of being guessed at, so
|
|
610
|
+
arbitrary numeric identifiers are not mistaken for dates. Microsecond and
|
|
611
|
+
nanosecond values are scaled using integer arithmetic to avoid floating-
|
|
612
|
+
point precision loss. Epoch-derived output always uses `Z`.
|
|
613
|
+
- JSON-LD value objects (`{"@value": "...", "@type": "...#dateTime"}`): the
|
|
614
|
+
`@value` is normalized recursively regardless of `@type`.
|
|
615
|
+
- JSON-LD arrays: candidates are tried in their original order and the first
|
|
616
|
+
one that normalizes successfully is used.
|
|
617
|
+
|
|
618
|
+
mdhq never guesses at ambiguous input: slash-separated numeric dates (such as
|
|
619
|
+
`09/02/2026`), unrecognized timezone abbreviations, and the machine's local
|
|
620
|
+
timezone are all rejected rather than assumed. Invalid calendar dates and
|
|
621
|
+
times (such as February 30 or an hour of 24) are rejected outright instead of
|
|
622
|
+
relying on JavaScript `Date` rollover. Malformed or unsupported JSON-LD
|
|
623
|
+
shapes normalize to `undefined` rather than throwing.
|
|
624
|
+
|
|
625
|
+
This normalization is unrelated to `last_modified`, which remains an
|
|
626
|
+
HTTP-protocol-specific conversion of the `Last-Modified` response header to
|
|
627
|
+
RFC 3339 UTC.
|
|
628
|
+
|
|
629
|
+
The Markdown body uses LF line endings, has trailing whitespace removed, and
|
|
630
|
+
ends with exactly one LF. mdhq internally calculates a SHA-256 digest from the
|
|
631
|
+
UTF-8 bytes of that normalized body for comparison, concurrency, and status
|
|
632
|
+
logic. Frontmatter, delimiters, and the blank line between frontmatter and
|
|
633
|
+
body are excluded, and the digest is not stored in frontmatter.
|
|
634
|
+
|
|
635
|
+
Configured exclusions and values are applied before mdhq-controlled fields.
|
|
636
|
+
Consequently `source`, `requested_url`, `created`, `modified`, `etag`, and
|
|
637
|
+
`last_modified` cannot be removed or overridden by frontmatter configuration.
|
|
638
|
+
`content_digest` and `vary` are always removed from serialized frontmatter.
|
|
639
|
+
Other fields, including extracted metadata and `type`, can be excluded or
|
|
640
|
+
replaced. mdhq does not emit `type`, `site`, `domain`, `image`,
|
|
641
|
+
`image_source`, or `word_count` by default; any of them can be added with
|
|
642
|
+
`frontmatter.values`. Refreshing an existing file removes `site`, `domain`,
|
|
643
|
+
`image`, `image_source`, and `word_count` when they are still present from a
|
|
644
|
+
file saved by an earlier mdhq version, unless `frontmatter.values` explicitly
|
|
645
|
+
supplies them again.
|
|
646
|
+
|
|
647
|
+
## Existing files and concurrent writes
|
|
648
|
+
|
|
649
|
+
An existing Markdown file is recognized only when it starts with parseable
|
|
650
|
+
YAML frontmatter containing a string `source` field.
|
|
651
|
+
|
|
652
|
+
Without `update`:
|
|
653
|
+
|
|
654
|
+
- content is written completely to a same-directory temporary file
|
|
655
|
+
- the destination is published with an atomic hard link and
|
|
656
|
+
exclusive-create semantics
|
|
657
|
+
- a same-identity existing file returns `skipped`
|
|
658
|
+
- a different identity or an unrecognized existing file returns
|
|
659
|
+
`PATH_COLLISION`
|
|
660
|
+
- a file created by another process during the write is reread and classified
|
|
661
|
+
using the same rules
|
|
662
|
+
|
|
663
|
+
With `update`:
|
|
664
|
+
|
|
665
|
+
- all mdhq writes to the same destination are serialized by a transient
|
|
666
|
+
cross-process lock
|
|
667
|
+
- a missing destination is still created exclusively
|
|
668
|
+
- an existing same-identity document is written to a temporary file in the
|
|
669
|
+
same directory and replaced with an atomic rename
|
|
670
|
+
- the exact serialized snapshot read before fetching is checked again while
|
|
671
|
+
holding the destination lock
|
|
672
|
+
- a conflicting same-identity write restarts the complete update from the
|
|
673
|
+
latest snapshot, with at most two restarts
|
|
674
|
+
- a different-identity destination returns `PATH_COLLISION`
|
|
675
|
+
- an HTTP 304 or a 200 response with unchanged normalized Markdown body and
|
|
676
|
+
user-facing frontmatter
|
|
677
|
+
returns `unchanged`
|
|
678
|
+
- a 200 response with a changed normalized Markdown body or user-facing
|
|
679
|
+
frontmatter returns `updated`
|
|
680
|
+
- temporary files are removed after success or failure
|
|
681
|
+
|
|
682
|
+
Lock directories are removed when the write completes. Stale locks left by a
|
|
683
|
+
terminated process are recovered by the lock implementation. A writer waits
|
|
684
|
+
up to 60 seconds for a healthy writer holding the same destination lock before
|
|
685
|
+
returning a storage error.
|
|
686
|
+
|
|
687
|
+
Initial Markdown and asset publication requires filesystem hard-link support.
|
|
688
|
+
This is supported by standard Windows NTFS volumes and common Linux and macOS
|
|
689
|
+
filesystems. mdhq returns a storage or asset error rather than degrading to
|
|
690
|
+
a partially visible copy on a filesystem that rejects hard links.
|
|
691
|
+
|
|
692
|
+
Simultaneous mdhq updates of the same destination use compare-and-swap
|
|
693
|
+
semantics: a writer can commit only while the destination still matches the
|
|
694
|
+
snapshot that authorized its fetch. Localized assets are immutable and
|
|
695
|
+
content-addressed, so a losing update cannot replace bytes referenced by the
|
|
696
|
+
winning document. Programs that modify storage files directly do not
|
|
697
|
+
participate in mdhq's lock protocol and should not edit a destination while an
|
|
698
|
+
mdhq write is in progress.
|
|
699
|
+
|
|
700
|
+
## Warnings and errors
|
|
701
|
+
|
|
702
|
+
Warnings are non-fatal and are returned in `GetPageResult.warnings`. The CLI
|
|
703
|
+
also writes them to stderr.
|
|
704
|
+
|
|
705
|
+
Current warning codes:
|
|
706
|
+
|
|
707
|
+
- `UNKNOWN_CONFIG_KEY`
|
|
708
|
+
- `ASSET_FETCH_FAILED`
|
|
709
|
+
- `INVALID_IMAGE_URL`
|
|
710
|
+
- `INVALID_LAST_MODIFIED`
|
|
711
|
+
|
|
712
|
+
Fatal library errors are instances of `MdhqError`. See
|
|
713
|
+
[Library API reference](library-api.md#error-model) for the current codes.
|
|
714
|
+
|
|
715
|
+
## Current limitations
|
|
716
|
+
|
|
717
|
+
- Only HTML and XHTML page responses are accepted.
|
|
718
|
+
- Page bytes are decoded as UTF-8 without HTML charset sniffing.
|
|
719
|
+
- No headless browser is included.
|
|
720
|
+
- No Markdown, PDF, JSON, or image page import is implemented.
|
|
721
|
+
- No local-link conversion between saved Markdown documents is implemented.
|
|
722
|
+
- No asset garbage collection is implemented.
|
|
723
|
+
- No multi-URL CLI mode is implemented.
|
|
724
|
+
- Images and links embedded inside raw HTML Markdown nodes are not rewritten
|
|
725
|
+
or localized.
|
|
726
|
+
- Initial file publication is unsupported on filesystems without hard-link
|
|
727
|
+
support.
|
|
728
|
+
- Case-sensitive URL paths that differ only by letter case collide on
|
|
729
|
+
case-insensitive filesystems such as default Windows NTFS and many macOS
|
|
730
|
+
volumes. The second URL is rejected with `PATH_COLLISION`.
|