meodp 0.1.0 → 0.2.0

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 (48) hide show
  1. package/README.md +28 -1
  2. package/dist/check/cli.cjs +130 -39
  3. package/dist/check/cli.mjs +130 -39
  4. package/dist/check/index.cjs +12 -8
  5. package/dist/check/index.d.cts +5 -68
  6. package/dist/check/index.d.mts +5 -68
  7. package/dist/check/index.d.ts +5 -68
  8. package/dist/check/index.mjs +4 -2
  9. package/dist/cli/main.cjs +57 -5
  10. package/dist/cli/main.mjs +57 -5
  11. package/dist/config/index.cjs +7 -0
  12. package/dist/config/index.d.cts +47 -0
  13. package/dist/config/index.d.mts +47 -0
  14. package/dist/config/index.d.ts +47 -0
  15. package/dist/config/index.mjs +5 -0
  16. package/dist/notify/cli.cjs +107 -0
  17. package/dist/notify/cli.d.cts +3 -0
  18. package/dist/notify/cli.d.mts +3 -0
  19. package/dist/notify/cli.d.ts +3 -0
  20. package/dist/notify/cli.mjs +101 -0
  21. package/dist/notify/email.cjs +38 -0
  22. package/dist/notify/email.d.cts +29 -0
  23. package/dist/notify/email.d.mts +29 -0
  24. package/dist/notify/email.d.ts +29 -0
  25. package/dist/notify/email.mjs +35 -0
  26. package/dist/notify/feishu.cjs +162 -0
  27. package/dist/notify/feishu.d.cts +74 -0
  28. package/dist/notify/feishu.d.mts +74 -0
  29. package/dist/notify/feishu.d.ts +74 -0
  30. package/dist/notify/feishu.mjs +157 -0
  31. package/dist/notify/index.cjs +64 -0
  32. package/dist/notify/index.d.cts +27 -0
  33. package/dist/notify/index.d.mts +27 -0
  34. package/dist/notify/index.d.ts +27 -0
  35. package/dist/notify/index.mjs +62 -0
  36. package/dist/shared/{meodp.a0bcc3e7.cjs → meodp.2b00b0ae.cjs} +13 -365
  37. package/dist/shared/meodp.4f1da070.d.cts +41 -0
  38. package/dist/shared/meodp.63483e20.cjs +30 -0
  39. package/dist/shared/meodp.80ff918d.mjs +465 -0
  40. package/dist/shared/meodp.8369c4c1.d.ts +41 -0
  41. package/dist/shared/meodp.93e06e00.d.cts +64 -0
  42. package/dist/shared/meodp.93e06e00.d.mts +64 -0
  43. package/dist/shared/meodp.93e06e00.d.ts +64 -0
  44. package/dist/shared/{meodp.d0916bc5.mjs → meodp.9c706389.mjs} +1 -350
  45. package/dist/shared/meodp.a704f8db.d.mts +41 -0
  46. package/dist/shared/meodp.c4c70565.cjs +477 -0
  47. package/dist/shared/meodp.f96d0510.mjs +24 -0
  48. package/package.json +48 -2
package/README.md CHANGED
@@ -52,6 +52,33 @@ await saveReport(report, historyFile)
52
52
 
53
53
  Each observation includes its HTTP status, final URL, redirect chain, duration, attempt count, failure reason, last success, consecutive failed runs, and recovery/change flags. `formatReport(report, 'html')` returns a self-contained interactive HTML report; `'json'` returns JSON; the default is Markdown. `writeReports()` writes `report.html`, `report.json`, and `report.md` and returns `{ html, json, markdown }`.
54
54
 
55
+ ## Project configuration
56
+
57
+ Starting with 0.2.0, HTTP commands read `meodp.config.ts`:
58
+
59
+ ```ts
60
+ import { defineConfig } from 'meodp/config'
61
+
62
+ export default defineConfig({
63
+ check: {
64
+ reporter: ['json', 'markdown', ['html', { outputFolder: 'reports/site' }]],
65
+ input: 'public/links.yml',
66
+ output: 'reports/links',
67
+ history: '.cache/links.json',
68
+ failOn: 'none',
69
+ },
70
+ report: { input: 'reports/links/report.json', reporter: 'html', output: 'dist/status' },
71
+ })
72
+ ```
73
+
74
+ Run `meodp check` to collect observations or `meodp report` to render saved data. `check.reporter` selects check outputs; `report.input` is the saved JSON to read, and `report.reporter` selects export formats. Use top-level `reporter` only for shared defaults. CLI options override config values. Config file paths are relative to the config; CLI paths are relative to your working directory.
75
+
76
+ The same config supports history seeding, deployment verification (`meodp report --verify`), and optional Feishu / SMTP notifications (`meodp notify`). Reuse the pure policy from `meodp/notify` and delivery adapters from `meodp/notify/feishu` or `meodp/notify/email`. Email requires the optional Nodemailer peer only for sending. Notification channels default to off; use `--mode changes|weekly` to enable and `--dry-run` to preview.
77
+
78
+ See the [configuration guide](https://yunyoujun.github.io/meodp/guide/configuration) ([中文](https://yunyoujun.github.io/meodp/zh/guide/configuration)). The older browser `defineConfig` at the package root remains unchanged; HTTP projects import from `meodp/config`.
79
+
80
+ Override formats with `--reporter=json,markdown,html` or repeated `--reporter` flags. JSON / Markdown tuples accept `outputFile`; HTML accepts `outputFolder` or a standalone `outputFile`. See [reporter formats and path precedence](https://yunyoujun.github.io/meodp/guide/reports#reporters).
81
+
55
82
  ## Sitemap page checks
56
83
 
57
84
  Check all pages listed in an XML sitemap, reusing the same HTTP observations, history, and reports:
@@ -102,7 +129,7 @@ Use explicit subcommands to distinguish a fresh network check from rendering sav
102
129
  | Command | Input | Behavior |
103
130
  | -------------- | -------------------------------- | -------------------------------------------------------------- |
104
131
  | `meodp check` | URL array in JSON/YAML | Request sites and write fresh HTML, Markdown, and JSON reports |
105
- | `meodp report` | Saved `report.json`, or no input | Export a static report viewer; no site-check requests |
132
+ | `meodp report` | Saved `report.json`, or no input | Render JSON, Markdown or HTML; no site-check requests |
106
133
  | `meodp scan` | Legacy config directory | Run the experimental Playwright scanner |
107
134
 
108
135
  `meodp`, `meodp --help`, and `meodp help` display the command overview. Use `meodp check -h` or `meodp help report` for details; `meodp --version` / `-v` prints the version. Unknown commands exit with code `2` instead of starting a scan. Help and version do not require Playwright.
@@ -1,18 +1,21 @@
1
1
  'use strict';
2
2
 
3
3
  const promises = require('node:fs/promises');
4
+ const node_os = require('node:os');
4
5
  const path = require('node:path');
5
6
  const process = require('node:process');
6
7
  const node_util = require('node:util');
7
8
  const jsYaml = require('js-yaml');
8
- const check_index = require('../shared/meodp.a0bcc3e7.cjs');
9
+ const load = require('../shared/meodp.63483e20.cjs');
10
+ const check_index = require('../shared/meodp.c4c70565.cjs');
11
+ const report = require('../shared/meodp.2b00b0ae.cjs');
12
+ require('c12');
9
13
  require('node:timers/promises');
10
14
  require('linkinator');
11
- require('node:os');
12
- require('node:crypto');
13
15
  require('node:buffer');
14
16
  require('node:zlib');
15
17
  require('fast-xml-parser');
18
+ require('node:crypto');
16
19
 
17
20
  function _interopDefaultCompat (e) { return e && typeof e === 'object' && 'default' in e ? e.default : e; }
18
21
 
@@ -24,7 +27,10 @@ Check only the listed HTTP(S) URLs, with no browser or recursive resource scan.
24
27
  Input: an array of URL strings or objects with url and optional name.
25
28
  This makes network requests. To render saved data instead, use meodp report.
26
29
 
27
- --output <directory> Write report.json, report.md, report.html (default: reports/meodp)
30
+ --config <file> Load a project config (default: meodp.config.ts)
31
+ --output <directory> Default report directory (default: reports/meodp)
32
+ --reporter <names> json, markdown, html; comma-separated or repeated
33
+ Without a selection, write report.json, report.md, report.html
28
34
  --history <file> Read previous observations and save the completed report
29
35
  --observer <name> Identify this network/environment (default: hostname)
30
36
  --concurrency <n> Concurrent sites (default: 5)
@@ -49,14 +55,16 @@ async function runScanCli(mode, args) {
49
55
  args,
50
56
  allowPositionals: true,
51
57
  options: {
52
- "output": { type: "string", default: "reports/meodp" },
58
+ "config": { type: "string" },
59
+ "reporter": { type: "string", multiple: true },
60
+ "output": { type: "string" },
53
61
  "history": { type: "string" },
54
62
  "observer": { type: "string" },
55
63
  "concurrency": { type: "string" },
56
64
  "timeout": { type: "string" },
57
65
  "retries": { type: "string" },
58
66
  "max-redirects": { type: "string" },
59
- "fail-on": { type: "string", default: "unavailable" },
67
+ "fail-on": { type: "string" },
60
68
  "help": { type: "boolean", short: "h" },
61
69
  ...mode === "sitemap" ? {
62
70
  "discover": { type: "boolean" },
@@ -75,42 +83,64 @@ Read an XML sitemap or nested index, then check each listed page via HTTP.
75
83
  --max-sitemaps <n> Limit sitemap documents (default: 100)
76
84
  --max-sitemap-bytes <n> Limit each downloaded/decompressed document (default: 10485760)
77
85
 
78
- ${help.slice(help.indexOf(" --output"))}`);
86
+ ${help.slice(help.indexOf(" --config"))}`);
79
87
  return 0;
80
88
  }
81
- if (positionals.length !== 1)
89
+ const project = await load.loadProjectConfig(values.config);
90
+ const configured = (mode === "links" ? project.config.check : project.config.sitemap) ?? {};
91
+ const input = positionals[0] ?? (configured.input ? mode === "links" ? project.path(configured.input) : configured.input : void 0);
92
+ if (!input || positionals.length > 1)
82
93
  throw new Error(`Provide exactly one ${mode === "links" ? "JSON or YAML input file" : "HTTP(S) URL"}. Use --help for examples.`);
83
- if (!["none", "unavailable", "review"].includes(values["fail-on"]))
94
+ const selection = selectReporters(values.reporter, configured.reporter ?? project.config.reporter);
95
+ const outputDir = path.resolve(values.output ?? (configured.output ? project.path(configured.output) : "reports/meodp"));
96
+ const reporterOptions = { outputDir, cwd: project.path(".") };
97
+ const reporter = typeof selection === "string" ? [selection] : [...selection ?? ["json", "markdown", ["html", { outputFile: path.join(outputDir, "report.html") }]]];
98
+ if (configured.site)
99
+ reporter.push(["html", { outputFolder: project.path(configured.site) }]);
100
+ const plan = check_index.planReporters(reporter, reporterOptions);
101
+ const failOn = values["fail-on"] ?? configured.failOn ?? "unavailable";
102
+ if (!["none", "unavailable", "review"].includes(failOn))
84
103
  throw new Error("--fail-on must be none, unavailable, or review");
85
104
  const numeric = (value) => typeof value === "string" ? Number(value) : void 0;
105
+ const history = values.history ?? (configured.history ? project.path(configured.history) : void 0);
106
+ const seed = configured.historySeed ? project.path(configured.historySeed) : void 0;
107
+ await protectInputs(plan, [
108
+ ...mode === "links" ? [{ file: input, jsonOnly: false }] : [],
109
+ ...[history, seed].filter((file) => !!file).map((file) => ({ file, jsonOnly: true }))
110
+ ], history);
111
+ let previousReport = history ? await report.readReport(history) : void 0;
112
+ if (!previousReport && seed)
113
+ previousReport = await report.readReport(seed);
114
+ const observer = values.observer ?? configured.observer;
115
+ if (previousReport && configured.observerMismatch === "reset" && previousReport.observer !== (observer ?? node_os.hostname()))
116
+ previousReport = void 0;
86
117
  const options = {
87
- observer: values.observer,
88
- previousReport: values.history ? await check_index.readReport(values.history) : void 0,
89
- concurrency: numeric(values.concurrency),
90
- timeoutMs: numeric(values.timeout),
91
- retries: numeric(values.retries),
92
- maxRedirects: numeric(values["max-redirects"]),
118
+ observer,
119
+ previousReport,
120
+ concurrency: numeric(values.concurrency) ?? configured.concurrency,
121
+ timeoutMs: numeric(values.timeout) ?? configured.timeoutMs,
122
+ retries: numeric(values.retries) ?? configured.retries,
123
+ maxRedirects: numeric(values["max-redirects"]) ?? configured.maxRedirects,
93
124
  onResult(result) {
94
125
  console.log(`[${result.status}] ${result.httpStatus ?? result.reason} ${result.url}`);
95
126
  }
96
127
  };
97
- const input = positionals[0];
98
- const report = mode === "sitemap" ? await check_index.checkSitemap(input, {
128
+ const report$1 = mode === "sitemap" ? await check_index.checkSitemap(input, {
99
129
  ...options,
100
- discover: values.discover === true,
101
- maxUrls: numeric(values["max-urls"]),
102
- maxSitemaps: numeric(values["max-sitemaps"]),
103
- maxSitemapBytes: numeric(values["max-sitemap-bytes"])
130
+ discover: values.discover === void 0 ? configured.discover : values.discover === true,
131
+ maxUrls: numeric(values["max-urls"]) ?? configured.maxUrls,
132
+ maxSitemaps: numeric(values["max-sitemaps"]) ?? configured.maxSitemaps,
133
+ maxSitemapBytes: numeric(values["max-sitemap-bytes"]) ?? configured.maxSitemapBytes
104
134
  }) : await check_index.checkLinks(await readTargets(input), options);
105
- const paths = await check_index.writeReports(report, values.output);
106
- if (values.history)
107
- await check_index.saveReport(report, values.history);
108
- console.log(`${report.summary.total} URLs: ${report.summary.reachable} reachable, ${report.summary.restricted} restricted, ${report.summary.unavailable} unavailable`);
109
- console.log(`Interactive report: ${path.resolve(paths.html)}`);
110
- console.log(`Reports: ${path.resolve(paths.markdown)} and ${path.resolve(paths.json)}`);
111
- if (values["fail-on"] === "none")
135
+ const outputs = await check_index.writeReporters(report$1, reporter, reporterOptions);
136
+ for (const output of outputs)
137
+ console.log(`${output.reporter}: ${output.files.join(", ")}`);
138
+ if (history)
139
+ await report.saveReport(report$1, history);
140
+ console.log(`${report$1.summary.total} URLs: ${report$1.summary.reachable} reachable, ${report$1.summary.restricted} restricted, ${report$1.summary.unavailable} unavailable`);
141
+ if (failOn === "none")
112
142
  return 0;
113
- const findings = report.summary.unavailable > 0 || values["fail-on"] === "review" && (report.summary.restricted > 0 || report.summary.redirected > 0);
143
+ const findings = report$1.summary.unavailable > 0 || failOn === "review" && (report$1.summary.restricted > 0 || report$1.summary.redirected > 0);
114
144
  return findings ? 1 : 0;
115
145
  } catch (error) {
116
146
  console.error(`meodp ${mode === "links" ? "check" : "sitemap"}: ${error instanceof Error ? error.message : String(error)}`);
@@ -130,18 +160,25 @@ async function runReportCli(args = process__default.argv.slice(3)) {
130
160
  args,
131
161
  allowPositionals: true,
132
162
  options: {
133
- "output": { type: "string", default: "reports/site" },
163
+ "config": { type: "string" },
164
+ "reporter": { type: "string", multiple: true },
165
+ "verify": { type: "boolean" },
166
+ "output": { type: "string" },
134
167
  "data-url": { type: "string" },
135
168
  "help": { type: "boolean", short: "h" }
136
169
  }
137
170
  });
138
171
  if (values.help) {
139
- console.log(`Usage: meodp report [report.json] [--output reports/site] [--data-url ./report.json]
172
+ console.log(`Usage: meodp report [report.json] [--reporter json,markdown,html] [--output reports/site]
140
173
 
141
- Export a static interactive viewer without making any site-check requests.
142
- With an input report, write index.html and report.json. The hosted viewer loads
174
+ Render saved data through selected reporters without making any site-check requests.
175
+ --reporter accepts json, markdown, html; repeat it or separate names with commas.
176
+ With no configured selection, export the HTML static viewer.
177
+ HTML folders with input write index.html and report.json. The hosted viewer loads
143
178
  report.json on each visit; opening index.html as a local file uses its embedded snapshot.
144
- Without input, export an empty viewer supporting file upload and JSON URL loading.
179
+ Without input, only HTML is available: an empty viewer with file upload and URL loading.
180
+ --config loads a project config; CLI arguments override config values.
181
+ --verify waits for report.verify.url to serve this report and the matching viewer.
145
182
  --data-url overrides the data source loaded by the hosted viewer (cross-origin needs CORS).
146
183
  -h, --help shows this help. To collect fresh observations, use meodp check.
147
184
  Exit codes: 0 = exported; 2 = invalid input or execution error.`);
@@ -149,17 +186,71 @@ Exit codes: 0 = exported; 2 = invalid input or execution error.`);
149
186
  }
150
187
  if (positionals.length > 1)
151
188
  throw new Error("Provide at most one report.json file. Use --help for examples.");
152
- const report = positionals[0] ? await check_index.readReport(positionals[0]) : void 0;
153
- if (positionals[0] && !report)
154
- throw new Error(`Report not found: ${positionals[0]}`);
155
- const paths = await check_index.writeReportSite(report, values.output, values["data-url"] ? { dataUrl: values["data-url"] } : {});
156
- console.log(`Static report site: ${path.resolve(paths.index)}`);
189
+ const project = await load.loadProjectConfig(values.config);
190
+ const configured = project.config.report ?? {};
191
+ const input = positionals[0] ?? (configured.input ? project.path(configured.input) : void 0);
192
+ const report$1 = input ? await report.readReport(input) : void 0;
193
+ if (input && !report$1)
194
+ throw new Error(`Report not found: ${input}`);
195
+ const reporter = selectReporters(values.reporter, configured.reporter ?? project.config.reporter) ?? "html";
196
+ if (values.verify) {
197
+ if (!report$1 || !configured.verify)
198
+ throw new Error("Verification requires a saved report and report.verify.url in the config.");
199
+ await check_index.waitForReport(report$1, configured.verify);
200
+ console.log("The public status page is serving the expected report.");
201
+ return 0;
202
+ }
203
+ const selected = values["data-url"] === void 0 ? reporter : (typeof reporter === "string" ? [reporter] : reporter).map((entry) => {
204
+ const [name, options] = typeof entry === "string" ? [entry] : entry;
205
+ return name === "html" ? ["html", { ...options, dataUrl: values["data-url"] }] : entry;
206
+ });
207
+ const reporterOptions = {
208
+ cwd: project.path("."),
209
+ outputDir: path.resolve(values.output ?? (configured.output ? project.path(configured.output) : "reports/site")),
210
+ dataUrl: values["data-url"] ?? configured.dataUrl
211
+ };
212
+ await protectInputs(check_index.planReporters(selected, reporterOptions), input ? [{ file: input, jsonOnly: true }] : []);
213
+ const outputs = await check_index.writeReporters(report$1, selected, reporterOptions);
214
+ for (const output of outputs)
215
+ console.log(`${output.reporter}: ${output.files.join(", ")}`);
157
216
  return 0;
158
217
  } catch (error) {
159
218
  console.error(`meodp report: ${error instanceof Error ? error.message : String(error)}`);
160
219
  return 2;
161
220
  }
162
221
  }
222
+ function selectReporters(cli, configured) {
223
+ if (cli === void 0)
224
+ return configured;
225
+ const names = [...new Set(cli.flatMap((value) => value.split(",").map((name) => name.trim())))];
226
+ if (names.some((name) => !["json", "markdown", "html"].includes(name)))
227
+ throw new TypeError("--reporter must select json, markdown, or html.");
228
+ return names;
229
+ }
230
+ async function protectInputs(plan, inputs, history) {
231
+ const identity = async (file) => {
232
+ try {
233
+ return await promises.realpath(file);
234
+ } catch (error) {
235
+ if (error && typeof error === "object" && "code" in error && error.code === "ENOENT")
236
+ return path.resolve(file);
237
+ throw error;
238
+ }
239
+ };
240
+ const writes = plan.flatMap((item) => [
241
+ { file: item.file, reporter: item.reporter },
242
+ ...item.json ? [{ file: item.json, reporter: "json" }] : []
243
+ ]);
244
+ if (history)
245
+ writes.push({ file: history, reporter: "json" });
246
+ for (const input of inputs) {
247
+ const source = await identity(input.file);
248
+ for (const output of writes) {
249
+ if (await identity(output.file) === source && (!input.jsonOnly || output.reporter !== "json"))
250
+ throw new Error(`Reporter/history output would overwrite input ${input.file}; choose another output path.`);
251
+ }
252
+ }
253
+ }
163
254
 
164
255
  exports.runCheckCli = runCheckCli;
165
256
  exports.runReportCli = runReportCli;
@@ -1,16 +1,19 @@
1
- import { readFile } from 'node:fs/promises';
2
- import { resolve, extname } from 'node:path';
1
+ import { readFile, realpath } from 'node:fs/promises';
2
+ import { hostname } from 'node:os';
3
+ import { resolve, join, extname } from 'node:path';
3
4
  import process from 'node:process';
4
5
  import { parseArgs } from 'node:util';
5
6
  import { load } from 'js-yaml';
6
- import { r as readReport, c as checkSitemap, a as checkLinks, w as writeReports, s as saveReport, n as normalizeTargets, b as writeReportSite } from '../shared/meodp.d0916bc5.mjs';
7
+ import { l as loadProjectConfig } from '../shared/meodp.f96d0510.mjs';
8
+ import { p as planReporters, c as checkSitemap, a as checkLinks, w as writeReporters, n as normalizeTargets, b as waitForReport } from '../shared/meodp.80ff918d.mjs';
9
+ import { r as readReport, s as saveReport } from '../shared/meodp.9c706389.mjs';
10
+ import 'c12';
7
11
  import 'node:timers/promises';
8
12
  import 'linkinator';
9
- import 'node:os';
10
- import 'node:crypto';
11
13
  import 'node:buffer';
12
14
  import 'node:zlib';
13
15
  import 'fast-xml-parser';
16
+ import 'node:crypto';
14
17
 
15
18
  const help = `Usage: meodp check <links.json|links.yml> [options]
16
19
 
@@ -18,7 +21,10 @@ Check only the listed HTTP(S) URLs, with no browser or recursive resource scan.
18
21
  Input: an array of URL strings or objects with url and optional name.
19
22
  This makes network requests. To render saved data instead, use meodp report.
20
23
 
21
- --output <directory> Write report.json, report.md, report.html (default: reports/meodp)
24
+ --config <file> Load a project config (default: meodp.config.ts)
25
+ --output <directory> Default report directory (default: reports/meodp)
26
+ --reporter <names> json, markdown, html; comma-separated or repeated
27
+ Without a selection, write report.json, report.md, report.html
22
28
  --history <file> Read previous observations and save the completed report
23
29
  --observer <name> Identify this network/environment (default: hostname)
24
30
  --concurrency <n> Concurrent sites (default: 5)
@@ -43,14 +49,16 @@ async function runScanCli(mode, args) {
43
49
  args,
44
50
  allowPositionals: true,
45
51
  options: {
46
- "output": { type: "string", default: "reports/meodp" },
52
+ "config": { type: "string" },
53
+ "reporter": { type: "string", multiple: true },
54
+ "output": { type: "string" },
47
55
  "history": { type: "string" },
48
56
  "observer": { type: "string" },
49
57
  "concurrency": { type: "string" },
50
58
  "timeout": { type: "string" },
51
59
  "retries": { type: "string" },
52
60
  "max-redirects": { type: "string" },
53
- "fail-on": { type: "string", default: "unavailable" },
61
+ "fail-on": { type: "string" },
54
62
  "help": { type: "boolean", short: "h" },
55
63
  ...mode === "sitemap" ? {
56
64
  "discover": { type: "boolean" },
@@ -69,42 +77,64 @@ Read an XML sitemap or nested index, then check each listed page via HTTP.
69
77
  --max-sitemaps <n> Limit sitemap documents (default: 100)
70
78
  --max-sitemap-bytes <n> Limit each downloaded/decompressed document (default: 10485760)
71
79
 
72
- ${help.slice(help.indexOf(" --output"))}`);
80
+ ${help.slice(help.indexOf(" --config"))}`);
73
81
  return 0;
74
82
  }
75
- if (positionals.length !== 1)
83
+ const project = await loadProjectConfig(values.config);
84
+ const configured = (mode === "links" ? project.config.check : project.config.sitemap) ?? {};
85
+ const input = positionals[0] ?? (configured.input ? mode === "links" ? project.path(configured.input) : configured.input : void 0);
86
+ if (!input || positionals.length > 1)
76
87
  throw new Error(`Provide exactly one ${mode === "links" ? "JSON or YAML input file" : "HTTP(S) URL"}. Use --help for examples.`);
77
- if (!["none", "unavailable", "review"].includes(values["fail-on"]))
88
+ const selection = selectReporters(values.reporter, configured.reporter ?? project.config.reporter);
89
+ const outputDir = resolve(values.output ?? (configured.output ? project.path(configured.output) : "reports/meodp"));
90
+ const reporterOptions = { outputDir, cwd: project.path(".") };
91
+ const reporter = typeof selection === "string" ? [selection] : [...selection ?? ["json", "markdown", ["html", { outputFile: join(outputDir, "report.html") }]]];
92
+ if (configured.site)
93
+ reporter.push(["html", { outputFolder: project.path(configured.site) }]);
94
+ const plan = planReporters(reporter, reporterOptions);
95
+ const failOn = values["fail-on"] ?? configured.failOn ?? "unavailable";
96
+ if (!["none", "unavailable", "review"].includes(failOn))
78
97
  throw new Error("--fail-on must be none, unavailable, or review");
79
98
  const numeric = (value) => typeof value === "string" ? Number(value) : void 0;
99
+ const history = values.history ?? (configured.history ? project.path(configured.history) : void 0);
100
+ const seed = configured.historySeed ? project.path(configured.historySeed) : void 0;
101
+ await protectInputs(plan, [
102
+ ...mode === "links" ? [{ file: input, jsonOnly: false }] : [],
103
+ ...[history, seed].filter((file) => !!file).map((file) => ({ file, jsonOnly: true }))
104
+ ], history);
105
+ let previousReport = history ? await readReport(history) : void 0;
106
+ if (!previousReport && seed)
107
+ previousReport = await readReport(seed);
108
+ const observer = values.observer ?? configured.observer;
109
+ if (previousReport && configured.observerMismatch === "reset" && previousReport.observer !== (observer ?? hostname()))
110
+ previousReport = void 0;
80
111
  const options = {
81
- observer: values.observer,
82
- previousReport: values.history ? await readReport(values.history) : void 0,
83
- concurrency: numeric(values.concurrency),
84
- timeoutMs: numeric(values.timeout),
85
- retries: numeric(values.retries),
86
- maxRedirects: numeric(values["max-redirects"]),
112
+ observer,
113
+ previousReport,
114
+ concurrency: numeric(values.concurrency) ?? configured.concurrency,
115
+ timeoutMs: numeric(values.timeout) ?? configured.timeoutMs,
116
+ retries: numeric(values.retries) ?? configured.retries,
117
+ maxRedirects: numeric(values["max-redirects"]) ?? configured.maxRedirects,
87
118
  onResult(result) {
88
119
  console.log(`[${result.status}] ${result.httpStatus ?? result.reason} ${result.url}`);
89
120
  }
90
121
  };
91
- const input = positionals[0];
92
122
  const report = mode === "sitemap" ? await checkSitemap(input, {
93
123
  ...options,
94
- discover: values.discover === true,
95
- maxUrls: numeric(values["max-urls"]),
96
- maxSitemaps: numeric(values["max-sitemaps"]),
97
- maxSitemapBytes: numeric(values["max-sitemap-bytes"])
124
+ discover: values.discover === void 0 ? configured.discover : values.discover === true,
125
+ maxUrls: numeric(values["max-urls"]) ?? configured.maxUrls,
126
+ maxSitemaps: numeric(values["max-sitemaps"]) ?? configured.maxSitemaps,
127
+ maxSitemapBytes: numeric(values["max-sitemap-bytes"]) ?? configured.maxSitemapBytes
98
128
  }) : await checkLinks(await readTargets(input), options);
99
- const paths = await writeReports(report, values.output);
100
- if (values.history)
101
- await saveReport(report, values.history);
129
+ const outputs = await writeReporters(report, reporter, reporterOptions);
130
+ for (const output of outputs)
131
+ console.log(`${output.reporter}: ${output.files.join(", ")}`);
132
+ if (history)
133
+ await saveReport(report, history);
102
134
  console.log(`${report.summary.total} URLs: ${report.summary.reachable} reachable, ${report.summary.restricted} restricted, ${report.summary.unavailable} unavailable`);
103
- console.log(`Interactive report: ${resolve(paths.html)}`);
104
- console.log(`Reports: ${resolve(paths.markdown)} and ${resolve(paths.json)}`);
105
- if (values["fail-on"] === "none")
135
+ if (failOn === "none")
106
136
  return 0;
107
- const findings = report.summary.unavailable > 0 || values["fail-on"] === "review" && (report.summary.restricted > 0 || report.summary.redirected > 0);
137
+ const findings = report.summary.unavailable > 0 || failOn === "review" && (report.summary.restricted > 0 || report.summary.redirected > 0);
108
138
  return findings ? 1 : 0;
109
139
  } catch (error) {
110
140
  console.error(`meodp ${mode === "links" ? "check" : "sitemap"}: ${error instanceof Error ? error.message : String(error)}`);
@@ -124,18 +154,25 @@ async function runReportCli(args = process.argv.slice(3)) {
124
154
  args,
125
155
  allowPositionals: true,
126
156
  options: {
127
- "output": { type: "string", default: "reports/site" },
157
+ "config": { type: "string" },
158
+ "reporter": { type: "string", multiple: true },
159
+ "verify": { type: "boolean" },
160
+ "output": { type: "string" },
128
161
  "data-url": { type: "string" },
129
162
  "help": { type: "boolean", short: "h" }
130
163
  }
131
164
  });
132
165
  if (values.help) {
133
- console.log(`Usage: meodp report [report.json] [--output reports/site] [--data-url ./report.json]
166
+ console.log(`Usage: meodp report [report.json] [--reporter json,markdown,html] [--output reports/site]
134
167
 
135
- Export a static interactive viewer without making any site-check requests.
136
- With an input report, write index.html and report.json. The hosted viewer loads
168
+ Render saved data through selected reporters without making any site-check requests.
169
+ --reporter accepts json, markdown, html; repeat it or separate names with commas.
170
+ With no configured selection, export the HTML static viewer.
171
+ HTML folders with input write index.html and report.json. The hosted viewer loads
137
172
  report.json on each visit; opening index.html as a local file uses its embedded snapshot.
138
- Without input, export an empty viewer supporting file upload and JSON URL loading.
173
+ Without input, only HTML is available: an empty viewer with file upload and URL loading.
174
+ --config loads a project config; CLI arguments override config values.
175
+ --verify waits for report.verify.url to serve this report and the matching viewer.
139
176
  --data-url overrides the data source loaded by the hosted viewer (cross-origin needs CORS).
140
177
  -h, --help shows this help. To collect fresh observations, use meodp check.
141
178
  Exit codes: 0 = exported; 2 = invalid input or execution error.`);
@@ -143,16 +180,70 @@ Exit codes: 0 = exported; 2 = invalid input or execution error.`);
143
180
  }
144
181
  if (positionals.length > 1)
145
182
  throw new Error("Provide at most one report.json file. Use --help for examples.");
146
- const report = positionals[0] ? await readReport(positionals[0]) : void 0;
147
- if (positionals[0] && !report)
148
- throw new Error(`Report not found: ${positionals[0]}`);
149
- const paths = await writeReportSite(report, values.output, values["data-url"] ? { dataUrl: values["data-url"] } : {});
150
- console.log(`Static report site: ${resolve(paths.index)}`);
183
+ const project = await loadProjectConfig(values.config);
184
+ const configured = project.config.report ?? {};
185
+ const input = positionals[0] ?? (configured.input ? project.path(configured.input) : void 0);
186
+ const report = input ? await readReport(input) : void 0;
187
+ if (input && !report)
188
+ throw new Error(`Report not found: ${input}`);
189
+ const reporter = selectReporters(values.reporter, configured.reporter ?? project.config.reporter) ?? "html";
190
+ if (values.verify) {
191
+ if (!report || !configured.verify)
192
+ throw new Error("Verification requires a saved report and report.verify.url in the config.");
193
+ await waitForReport(report, configured.verify);
194
+ console.log("The public status page is serving the expected report.");
195
+ return 0;
196
+ }
197
+ const selected = values["data-url"] === void 0 ? reporter : (typeof reporter === "string" ? [reporter] : reporter).map((entry) => {
198
+ const [name, options] = typeof entry === "string" ? [entry] : entry;
199
+ return name === "html" ? ["html", { ...options, dataUrl: values["data-url"] }] : entry;
200
+ });
201
+ const reporterOptions = {
202
+ cwd: project.path("."),
203
+ outputDir: resolve(values.output ?? (configured.output ? project.path(configured.output) : "reports/site")),
204
+ dataUrl: values["data-url"] ?? configured.dataUrl
205
+ };
206
+ await protectInputs(planReporters(selected, reporterOptions), input ? [{ file: input, jsonOnly: true }] : []);
207
+ const outputs = await writeReporters(report, selected, reporterOptions);
208
+ for (const output of outputs)
209
+ console.log(`${output.reporter}: ${output.files.join(", ")}`);
151
210
  return 0;
152
211
  } catch (error) {
153
212
  console.error(`meodp report: ${error instanceof Error ? error.message : String(error)}`);
154
213
  return 2;
155
214
  }
156
215
  }
216
+ function selectReporters(cli, configured) {
217
+ if (cli === void 0)
218
+ return configured;
219
+ const names = [...new Set(cli.flatMap((value) => value.split(",").map((name) => name.trim())))];
220
+ if (names.some((name) => !["json", "markdown", "html"].includes(name)))
221
+ throw new TypeError("--reporter must select json, markdown, or html.");
222
+ return names;
223
+ }
224
+ async function protectInputs(plan, inputs, history) {
225
+ const identity = async (file) => {
226
+ try {
227
+ return await realpath(file);
228
+ } catch (error) {
229
+ if (error && typeof error === "object" && "code" in error && error.code === "ENOENT")
230
+ return resolve(file);
231
+ throw error;
232
+ }
233
+ };
234
+ const writes = plan.flatMap((item) => [
235
+ { file: item.file, reporter: item.reporter },
236
+ ...item.json ? [{ file: item.json, reporter: "json" }] : []
237
+ ]);
238
+ if (history)
239
+ writes.push({ file: history, reporter: "json" });
240
+ for (const input of inputs) {
241
+ const source = await identity(input.file);
242
+ for (const output of writes) {
243
+ if (await identity(output.file) === source && (!input.jsonOnly || output.reporter !== "json"))
244
+ throw new Error(`Reporter/history output would overwrite input ${input.file}; choose another output path.`);
245
+ }
246
+ }
247
+ }
157
248
 
158
249
  export { runCheckCli, runReportCli, runSitemapCli };
@@ -1,25 +1,29 @@
1
1
  'use strict';
2
2
 
3
- const check_index = require('../shared/meodp.a0bcc3e7.cjs');
3
+ const check_index = require('../shared/meodp.c4c70565.cjs');
4
+ const report = require('../shared/meodp.2b00b0ae.cjs');
4
5
  require('node:timers/promises');
5
6
  require('linkinator');
6
7
  require('node:os');
7
- require('node:crypto');
8
8
  require('node:fs/promises');
9
9
  require('node:path');
10
+ require('node:process');
10
11
  require('node:buffer');
11
12
  require('node:util');
12
13
  require('node:zlib');
13
14
  require('fast-xml-parser');
15
+ require('node:crypto');
14
16
 
15
17
 
16
18
 
17
19
  exports.checkLinks = check_index.checkLinks;
18
20
  exports.checkSitemap = check_index.checkSitemap;
19
- exports.formatReport = check_index.formatReport;
20
- exports.parseReport = check_index.parseReport;
21
- exports.readReport = check_index.readReport;
22
21
  exports.readSitemapUrls = check_index.readSitemapUrls;
23
- exports.saveReport = check_index.saveReport;
24
- exports.writeReportSite = check_index.writeReportSite;
25
- exports.writeReports = check_index.writeReports;
22
+ exports.waitForReport = check_index.waitForReport;
23
+ exports.writeReporters = check_index.writeReporters;
24
+ exports.formatReport = report.formatReport;
25
+ exports.parseReport = report.parseReport;
26
+ exports.readReport = report.readReport;
27
+ exports.saveReport = report.saveReport;
28
+ exports.writeReportSite = report.writeReportSite;
29
+ exports.writeReports = report.writeReports;