@songmu/mdhq 0.0.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (55) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +126 -0
  3. package/dist/assets/localize.d.ts +19 -0
  4. package/dist/assets/localize.js +364 -0
  5. package/dist/cli.d.ts +8 -0
  6. package/dist/cli.js +119 -0
  7. package/dist/config/config.d.ts +25 -0
  8. package/dist/config/config.js +170 -0
  9. package/dist/config/match.d.ts +7 -0
  10. package/dist/config/match.js +101 -0
  11. package/dist/convert/article-date.d.ts +20 -0
  12. package/dist/convert/article-date.js +255 -0
  13. package/dist/convert/convert-html.d.ts +2 -0
  14. package/dist/convert/convert-html.js +89 -0
  15. package/dist/convert/extract-published.d.ts +12 -0
  16. package/dist/convert/extract-published.js +24 -0
  17. package/dist/convert/extract-updated.d.ts +8 -0
  18. package/dist/convert/extract-updated.js +20 -0
  19. package/dist/date.d.ts +18 -0
  20. package/dist/date.js +448 -0
  21. package/dist/errors.d.ts +8 -0
  22. package/dist/errors.js +10 -0
  23. package/dist/frontmatter/frontmatter.d.ts +40 -0
  24. package/dist/frontmatter/frontmatter.js +114 -0
  25. package/dist/get-page.d.ts +2 -0
  26. package/dist/get-page.js +308 -0
  27. package/dist/http/fetch.d.ts +46 -0
  28. package/dist/http/fetch.js +195 -0
  29. package/dist/index.d.ts +6 -0
  30. package/dist/index.js +3 -0
  31. package/dist/list-files.d.ts +8 -0
  32. package/dist/list-files.js +35 -0
  33. package/dist/markdown/transform.d.ts +6 -0
  34. package/dist/markdown/transform.js +129 -0
  35. package/dist/path/storage-path.d.ts +7 -0
  36. package/dist/path/storage-path.js +110 -0
  37. package/dist/storage/atomic.d.ts +8 -0
  38. package/dist/storage/atomic.js +84 -0
  39. package/dist/storage/path-safety.d.ts +1 -0
  40. package/dist/storage/path-safety.js +55 -0
  41. package/dist/storage/save.d.ts +23 -0
  42. package/dist/storage/save.js +118 -0
  43. package/dist/types.d.ts +62 -0
  44. package/dist/types.js +1 -0
  45. package/dist/url/identity.d.ts +12 -0
  46. package/dist/url/identity.js +54 -0
  47. package/dist/url/pathname.d.ts +4 -0
  48. package/dist/url/pathname.js +46 -0
  49. package/dist/version.d.ts +3 -0
  50. package/dist/version.js +6 -0
  51. package/docs/README.md +14 -0
  52. package/docs/configuration.md +242 -0
  53. package/docs/library-api.md +275 -0
  54. package/docs/specification.md +730 -0
  55. package/package.json +73 -0
@@ -0,0 +1,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`.