@v1nvn/readability-mcp 0.16.0 → 0.18.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.
- package/README.md +52 -3
- package/dist/assets/{cli-CCcnfTRC.js → cli-B3ybWgFv.js} +4 -2
- package/dist/assets/{cli-CCcnfTRC.js.map → cli-B3ybWgFv.js.map} +1 -1
- package/dist/assets/{extract-C55iDWx1.js → extract-Cd5nnM0z.js} +1935 -1822
- package/dist/assets/extract-Cd5nnM0z.js.map +1 -0
- package/dist/index.js +869 -16
- package/dist/index.js.map +1 -1
- package/package.json +2 -2
- package/dist/assets/extract-C55iDWx1.js.map +0 -1
package/README.md
CHANGED
|
@@ -71,7 +71,7 @@ Add to your MCP client config (Claude Code, Claude Desktop, etc.):
|
|
|
71
71
|
|
|
72
72
|
## Tools
|
|
73
73
|
|
|
74
|
-
All eleven always-on tools return MCP **structured content** (`schemaVersion` plus a tool-specific payload of `metadata` / `diagnostics` / `items` / …) validated by a zod `outputSchema`, plus a human/LLM-readable payload in `content[0].text`. A sampling-capable host also sees a twelfth — `summarize` — registered after the `initialize` handshake when the client advertises the MCP `sampling` capability. Nothing throws across the wire — failures become `{ "isError": true }` results. Every input and output field carries a description in the tool's JSON schema, so clients can introspect each option without reading these docs.
|
|
74
|
+
All eleven always-on tools return MCP **structured content** (`schemaVersion` plus a tool-specific payload of `metadata` / `diagnostics` / `items` / …) validated by a zod `outputSchema`, plus a human/LLM-readable payload in `content[0].text`. A sampling-capable host also sees a twelfth and a thirteenth — `summarize` and `suggest_preset` — registered after the `initialize` handshake when the client advertises the MCP `sampling` capability. Nothing throws across the wire — failures become `{ "isError": true }` results. Every input and output field carries a description in the tool's JSON schema, so clients can introspect each option without reading these docs.
|
|
75
75
|
|
|
76
76
|
**`localPath` — the only HTML input.** Every HTML-input tool takes a `localPath` pointing at a file holding the already-rendered (post-JavaScript) HTML. The server reads the bytes itself, so the page never enters the model context — the model emits only a path string. The motivating hop is chrome-devtools → readability: `evaluate_script` writes `document.documentElement.outerHTML` to a file via its `filePath` arg, then the tool reads that path. Resolved relative to the server process working directory; prefer absolute paths so the chrome-devtools capture and this read agree on location.
|
|
77
77
|
|
|
@@ -106,6 +106,30 @@ Extracts the main article from rendered HTML and returns Markdown + metadata + d
|
|
|
106
106
|
|
|
107
107
|
**Fallback.** If Readability's `parse()` returns no article (e.g. an app shell or image-only page), a selector cascade salvages the first usable root — `article` → `main` → `[role=main]` → largest text-dense block → `body` — and reports `diagnostics.fallbackUsed: true` with `extractedNode` naming the root that was used.
|
|
108
108
|
|
|
109
|
+
**Site presets.** A preset pairs a site with layout fingerprints (`detectors`) and a `selectors`-shaped scope. When `baseUrl` names a site holding a preset, `extract` applies the scope through the same path as the `selectors` option — no second mechanism — after every detector still matches the document; a preset whose fingerprints no longer hit (site redesign) is discarded and the normal cascade runs, so a stale preset can never win. An explicit `selectors` argument beats the preset. `diagnostics.preset = {site, applied, reason?}` reports the outcome; the field is absent when the site has none. Other tools never resolve presets.
|
|
110
|
+
|
|
111
|
+
Presets load at server start from a local cache directory — one `<site>.json` file per site, read into the preset store before the first tool call:
|
|
112
|
+
|
|
113
|
+
```json
|
|
114
|
+
{
|
|
115
|
+
"site": "www.dailymail.com",
|
|
116
|
+
"detectors": ["#js-article-text", ".artSplitter"],
|
|
117
|
+
"scope": {
|
|
118
|
+
"include": "div[itemprop=\"articleBody\"]",
|
|
119
|
+
"exclude": [".mol-video", ".vjs-video-container", ".artSplitter"]
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
```
|
|
123
|
+
|
|
124
|
+
| Aspect | Behavior |
|
|
125
|
+
| --- | --- |
|
|
126
|
+
| Directory | `$READABILITY_MCP_PRESETS_DIR`; else `$XDG_CACHE_HOME/readability-mcp/presets`; else `~/Library/Caches/readability-mcp/presets` (macOS) / `~/.cache/readability-mcp/presets`. Setting the variable to an empty string disables preset loading. |
|
|
127
|
+
| Bound | 64 files; beyond that the oldest by mtime are deleted at load — enforced when loading and again when `suggest_preset` writes. |
|
|
128
|
+
| Invalid files | Unreadable JSON, a wrong shape (no `detectors`, empty `scope`), or a non-hostname `site` is skipped with a warning; the remaining files still load. |
|
|
129
|
+
| Staleness | No clock-based expiry — detectors are re-checked against every page, so a preset survives until the site redesigns. |
|
|
130
|
+
| Writing | `suggest_preset` is the writer: an accepted, verified preset is stored in memory immediately and persisted as `<site>.json` after verification comes back clean — on the proposal page, plus the `secondPath` capture when one was given. A disabled directory (empty env value) skips the write and reports `persisted: false` — the in-memory preset still applies for the session. Presets can also land by hand in this exact shape. |
|
|
131
|
+
| Scope | Local to the user's machine; the server never fetches or phones home. |
|
|
132
|
+
|
|
109
133
|
**Metadata cascade.** Each metadata field is resolved by priority: **JSON-LD → OpenGraph → Twitter → `<meta>`/`<time>` → Readability → `<title>`** (first non-empty value wins). When the page carries schema.org JSON-LD, `metadata.structured` exposes the parsed primary object (Recipe/Product/Event/HowTo/Article…) with `@context` stripped and `@type` normalized, so non-article content rides on `extract` without a separate tool. Alongside the bibliographic fields, `metadata` carries `wordCount`, `readingTimeMin`, and `tokenEstimate` (with `estimator: "chars/4"` naming the heuristic) — an advisory count for context budgeting; the host re-counts before sending, so a model-specific tokenizer isn't worth the weight.
|
|
110
134
|
|
|
111
135
|
### `html_to_markdown` — fragment path
|
|
@@ -238,9 +262,30 @@ Delegates summarization to the **host's** model via MCP `sampling/createMessage`
|
|
|
238
262
|
|
|
239
263
|
Output shape: a single `content[0].text` entry holding the host's summary. No `structuredContent` — the server returns whatever the host model produces. A non-text response from the host (e.g. an image) surfaces as `{ "isError": true }`.
|
|
240
264
|
|
|
265
|
+
### `suggest_preset` — the site-preset suggest loop (sampling-gated)
|
|
266
|
+
|
|
267
|
+
For a page whose extraction was lost, asks the host's model to propose a site preset, validates it deterministically, applies it through the real pipeline, and — on convergence — stores it in memory and persists it to the preset cache directory. Call it after `extract` reports lost signals: gated content, `fallbackUsed`, a near-empty result, or visible debris (e.g. video-player control text). On a healthy page it refuses without any sampling call.
|
|
268
|
+
|
|
269
|
+
| Option | Default | Description |
|
|
270
|
+
| --- | --- | --- |
|
|
271
|
+
| `localPath` *(required)* | — | Path to a file holding the rendered HTML. |
|
|
272
|
+
| `baseUrl` *(required)* | — | A URL of the site; its host names the preset. Refused when the page's `<link rel="canonical">` names a different host. |
|
|
273
|
+
| `secondPath` | — | A second capture of the same site. The converged preset must verify on it too before anything is persisted; without it the preset is verified on the proposal page alone. |
|
|
274
|
+
| `maxSamplingCalls` | `4` | Budget for host-model calls. Round one (include + detectors) and round two (excludes) each consume one; rejected proposals consume retries. A budget of 1 skips round two. |
|
|
275
|
+
|
|
276
|
+
The loop, in order:
|
|
277
|
+
|
|
278
|
+
1. **Trigger.** A baseline `extract` runs with caching off; the lost verdict is `fallbackUsed`, a word count under 120, or a debris probe over the extracted text (`Loaded: N%` / `Duration Time m:ss` player controls, metered-barrier phrases). A gating signal alone never fires the loop — vendor SDK classes report on free articles. The baseline's canonical URL must name the same host as `baseUrl`, or the run refuses — a capture keyed to the wrong site can never mint a preset.
|
|
279
|
+
2. **Round one.** The model sees a copy-safe text-chain outline (ancestor chains of every node with ≥200 chars of own text, real attributes, hash classes already stripped) and returns `{"detectors": […], "scope": {"include": "…", "exclude": […]}}`.
|
|
280
|
+
3. **Validation.** Static: no positional pseudos, no `:contains`, no generated hash identifiers. DOM: detectors and `include` must match, proposed excludes must match, and no exclude may shadow the include root. Rejections are fed back verbatim for a retry.
|
|
281
|
+
4. **Round two.** The accepted `include` is applied for real; the model then sees the included subtree's remaining text blocks (grouped by chain, with samples) and proposes excludes for the debris groups.
|
|
282
|
+
5. **Verification.** The candidate preset is added to the store and a verification `extract` runs through the real preset path. Converged = the preset applied **and** the extraction comes back clean. With `secondPath`, the same verification runs on the second capture — detectors must match there and the extraction must come back clean — and a capture whose canonical names another site refuses the run. Only then is `<site>.json` persisted; non-convergence removes the preset and leaves no file.
|
|
283
|
+
|
|
284
|
+
Output: `trigger` (the lost-signal verdict), `preset` (when accepted), `verification` (`applied`, `converged`, `verifiedPages`, word counts before/after), `secondVerification` (the second capture's verdict, when `secondPath` was given), `persistence` (file written, or why not), and `sampling` (calls/budget/exhausted). Every model proposal is validated — a proposal the pipeline would silently misapply never reaches it.
|
|
285
|
+
|
|
241
286
|
## Diagnostics
|
|
242
287
|
|
|
243
|
-
`structuredContent.diagnostics` exposes: `readerable`, `extractedNode`, `fallbackUsed`, `removedNodes` (element delta vs. the document), `chromeRemoved` and `imagesResolved` (pre-conversion cleanup counts), `boilerplateRemoved` (related-posts / newsletter-signup / read-next blocks stripped before conversion, footprint-guarded so article content is never deleted), `sanitization.{scripts,iframes}` (counted across the **whole** pipeline), `pagination` (`{type:"paginated"|"infinite", nextUrl?, selector?}` — detection only; the host drives loading, this server never fetches), `gated` (`{likely, reason}` — detection only; signals a likely paywall/metered gate so the host knows the extraction may be partial — this server never fetches or authenticates), `truncated`, and `trace` (per-stage `{stage, ms}` timings — **debug-only**, emitted only when `debug:true` is passed to `extract`/`html_to_markdown`; absent otherwise). Stages are non-overlapping and ordered: `normalize`, `readability`, `sanitize`, `turndown`, `metadata` on the article path (`html_to_markdown` omits `readability`); on the fallback path a single `fallback` stage covers sanitize + turndown so the timings still sum to the pipeline's wall-clock.
|
|
288
|
+
`structuredContent.diagnostics` exposes: `readerable`, `extractedNode`, `fallbackUsed`, `removedNodes` (element delta vs. the document), `chromeRemoved` and `imagesResolved` (pre-conversion cleanup counts), `boilerplateRemoved` (related-posts / newsletter-signup / read-next blocks stripped before conversion, footprint-guarded so article content is never deleted), `sanitization.{scripts,iframes}` (counted across the **whole** pipeline), `pagination` (`{type:"paginated"|"infinite", nextUrl?, selector?}` — detection only; the host drives loading, this server never fetches), `gated` (`{likely, reason}` — detection only; signals a likely paywall/metered gate so the host knows the extraction may be partial — this server never fetches or authenticates), `preset` (`{site, applied, reason?}` — `extract` only, present only when the document's site holds a preset: `reason:"overridden"` when an explicit `selectors` argument won, `"detectors-missed"` when a fingerprint no longer matched and the normal cascade ran), `truncated`, and `trace` (per-stage `{stage, ms}` timings — **debug-only**, emitted only when `debug:true` is passed to `extract`/`html_to_markdown`; absent otherwise). Stages are non-overlapping and ordered: `normalize`, `readability`, `sanitize`, `turndown`, `metadata` on the article path (`html_to_markdown` omits `readability`); on the fallback path a single `fallback` stage covers sanitize + turndown so the timings still sum to the pipeline's wall-clock.
|
|
244
289
|
|
|
245
290
|
## Rich content
|
|
246
291
|
|
|
@@ -303,7 +348,11 @@ yarn bench # print metrics + content deltas + PR/timing tables
|
|
|
303
348
|
BENCH_UPDATE=1 yarn bench # refresh baselines (do deliberately, like UPDATE_GOLDENS)
|
|
304
349
|
```
|
|
305
350
|
|
|
306
|
-
Per-fixture fields: `inputNodes` (parsed element count), `markdownChars`/`tokens` (output size, chars/4), `compressionRatio` (output chars per input node), `removedNodes` (element delta across the pipeline), and `images`/`tables`/`links` (preserved content counts). PR fields: `precision` (fraction of extracted word tokens inside the labeled main content), `recall` (fraction of labeled tokens recovered), `f1` (harmonic mean), `extractedTokens`/`labeledTokens` (multiset sizes). Fixtures with no prose (the image-only `fallback` gallery) score N/A and are excluded from the aggregate.
|
|
351
|
+
Per-fixture fields: `inputNodes` (parsed element count), `markdownChars`/`tokens` (output size, chars/4), `compressionRatio` (output chars per input node), `removedNodes` (element delta across the pipeline), and `images`/`tables`/`links` (preserved content counts). PR fields: `precision` (fraction of extracted word tokens inside the labeled main content), `recall` (fraction of labeled tokens recovered), `f1` (harmonic mean), `extractedTokens`/`labeledTokens` (multiset sizes). Fixtures with no prose (the image-only `fallback` gallery) score N/A and are excluded from the aggregate. Site-preset scenarios (`test/bench/fixtures.ts` `PRESET_SCENARIOS`) are scored against the same human labels and land in `scores.json` as `<fixtureId>@preset`; they print as their own table with their own aggregate, never folded into the default-pipeline aggregate. Suggest-loop scenarios (`test/bench/suggest-scenarios.ts`) replay the proposals the loop accepted through its own validators (static lint, preset match contract, material budget) and land as `<fixtureId>@suggest` in a third table — the suggest-loop baseline.
|
|
352
|
+
|
|
353
|
+
## Corpus fixtures
|
|
354
|
+
|
|
355
|
+
Captured real pages live beside the hand-built ones under `test/fixtures/<site>-<slug>/` (`saved.html` + `saved.test.ts`). New captures are slimmed with `node scripts/trim-capture.mjs <capture.html> test/fixtures/<dir>/saved.html` — executable scripts, style payloads, and base64 data URIs are dropped; every element and attribute is kept, so DOM-level behavior is unchanged. Each capture's `saved.test.ts` asserts the failure class it proves (current debris, gating signals, applied-selector behavior). Captured pages are composite — article plus feed furniture — so the list-detector's article-fixture guard excludes them and their detection behavior is asserted in their own test.
|
|
307
356
|
|
|
308
357
|
## License
|
|
309
358
|
|
|
@@ -1,5 +1,7 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import { j as readHtmlFile, t as extractArticleFromHtml } from "./extract-Cd5nnM0z.js";
|
|
2
2
|
import "node:fs";
|
|
3
|
+
import "node:os";
|
|
4
|
+
import "node:path";
|
|
3
5
|
import { Command, InvalidArgumentError, Option } from "commander";
|
|
4
6
|
//#region ../core/dist/index.js
|
|
5
7
|
function parseQuietly(program, args) {
|
|
@@ -82,4 +84,4 @@ async function runCli(argv) {
|
|
|
82
84
|
//#endregion
|
|
83
85
|
export { runCli };
|
|
84
86
|
|
|
85
|
-
//# sourceMappingURL=cli-
|
|
87
|
+
//# sourceMappingURL=cli-B3ybWgFv.js.map
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"cli-CCcnfTRC.js","names":[],"sources":["../../../core/dist/index.js","../../src/cli.ts"],"sourcesContent":["import { existsSync, readFileSync, readdirSync, statSync } from \"node:fs\";\nimport { homedir } from \"node:os\";\nimport { join } from \"node:path\";\n//#region src/hook.ts\n/**\n* Read the Claude Code hook event JSON from stdin; empty or unparseable input\n* yields an empty event, never a thrown error — a hook must always answer.\n*/\nfunction readHookEvent(stream = process.stdin) {\n\treturn new Promise((resolve) => {\n\t\tlet data = \"\";\n\t\tstream.setEncoding(\"utf8\");\n\t\tstream.on(\"data\", (chunk) => {\n\t\t\tdata += chunk;\n\t\t});\n\t\tstream.on(\"end\", () => {\n\t\t\ttry {\n\t\t\t\tresolve(JSON.parse(data));\n\t\t\t} catch {\n\t\t\t\tresolve({});\n\t\t\t}\n\t\t});\n\t\tstream.on(\"error\", () => {\n\t\t\tresolve({});\n\t\t});\n\t});\n}\n/**\n* Which transcript lastReply should read, per the hook event: transcript_path\n* when it names a real file, else session_id, else nothing (lastReply then\n* falls back to the newest session for the current project).\n*/\nfunction replyTarget(event) {\n\tif (event.transcript_path !== void 0 && isFile$1(event.transcript_path)) return event.transcript_path;\n\treturn event.session_id || void 0;\n}\nfunction isFile$1(path) {\n\ttry {\n\t\treturn statSync(path).isFile();\n\t} catch {\n\t\treturn false;\n\t}\n}\n/**\n* Print the UserPromptExpansion decision for a zero-token command: `block`\n* keeps the command from reaching the model, with the output as the `reason`.\n*/\nfunction emitHookBlock(reason) {\n\tprocess.stdout.write(`${JSON.stringify({\n\t\tdecision: \"block\",\n\t\treason\n\t})}\\n`);\n}\n//#endregion\n//#region src/cli.ts\nfunction parseQuietly(program, args) {\n\ttry {\n\t\tprogram.allowExcessArguments(false).exitOverride().configureOutput({\n\t\t\twriteOut: () => void 0,\n\t\t\twriteErr: () => void 0\n\t\t}).parse([...args], { from: \"user\" });\n\t\treturn program;\n\t} catch {\n\t\treturn;\n\t}\n}\nfunction printUsageAndExit(program) {\n\tconsole.error(program.helpInformation());\n\tprocess.exit(1);\n}\nasync function hookOrPrint(hook, failure, run, stdin = process.stdin) {\n\tif (hook) {\n\t\tlet reason;\n\t\ttry {\n\t\t\treason = await run(await readHookEvent(stdin));\n\t\t} catch (e) {\n\t\t\treason = `${failure}: ${e.message}`;\n\t\t}\n\t\temitHookBlock(reason);\n\t\treturn;\n\t}\n\ttry {\n\t\tconsole.log(await run(void 0));\n\t} catch (e) {\n\t\tconsole.error(e.message);\n\t\tprocess.exit(1);\n\t}\n}\n//#endregion\n//#region src/last-reply.ts\nfunction isFile(path) {\n\ttry {\n\t\treturn statSync(path).isFile();\n\t} catch {\n\t\treturn false;\n\t}\n}\nfunction textBlocks(entry) {\n\tconst content = entry.message?.content;\n\tif (entry.type !== \"assistant\" || !Array.isArray(content)) return;\n\tconst blocks = content.filter((b) => typeof b === \"object\" && b !== null && b.type === \"text\");\n\treturn blocks.length > 0 ? blocks : void 0;\n}\n/**\n* The last assistant text reply from a Claude Code session, reproducing Claude\n* Code's `/copy` byte-for-byte: the last assistant entry that contains a\n* `text` block, only its `text` block(s) (tool_use / thinking dropped), blocks\n* joined with a blank line, and no trailing newline.\n*\n* @param arg a transcript file, a session UUID under the project dir, or\n* nothing to use the newest session for the current project.\n*/\nfunction lastReply(arg) {\n\tconst claudeDir = process.env.CLAUDE_DIR ?? join(homedir(), \".claude\");\n\tconst proj = (process.env.CLAUDE_PROJECT_DIR ?? process.cwd()).replaceAll(\"/\", \"-\");\n\tconst projDir = join(claudeDir, \"projects\", proj);\n\tlet file;\n\tif (arg !== void 0) file = isFile(arg) ? arg : join(projDir, `${arg}.jsonl`);\n\telse if (existsSync(projDir)) {\n\t\tconst newest = readdirSync(projDir).filter((f) => f.endsWith(\".jsonl\")).map((f) => ({\n\t\t\tf,\n\t\t\tmtimeMs: statSync(join(projDir, f)).mtimeMs\n\t\t})).sort((a, b) => b.mtimeMs - a.mtimeMs).at(0);\n\t\tif (newest) file = join(projDir, newest.f);\n\t}\n\tif (file === void 0 || !isFile(file)) throw new Error(`no session transcript found in ${projDir}`);\n\tlet last;\n\tfor (const line of readFileSync(file, \"utf8\").split(\"\\n\")) {\n\t\tif (!line.includes(\"\\\"assistant\\\"\")) continue;\n\t\tlet entry;\n\t\ttry {\n\t\t\tentry = JSON.parse(line);\n\t\t} catch {\n\t\t\tcontinue;\n\t\t}\n\t\tconst blocks = textBlocks(entry);\n\t\tif (blocks) last = blocks;\n\t}\n\treturn (last ?? []).map((b) => b.text ?? \"\").join(\"\\n\\n\");\n}\n//#endregion\n//#region src/text-format.ts\n/**\n* Plain-text rendering primitives shared by the zai and tokens report\n* renderers. Output targets a monospace terminal / hook-block `reason`, so\n* everything here is fixed-width: padding, block-glyph bars, and compact\n* number formatting.\n*/\nvar MONTHS = [\n\t\"Jan\",\n\t\"Feb\",\n\t\"Mar\",\n\t\"Apr\",\n\t\"May\",\n\t\"Jun\",\n\t\"Jul\",\n\t\"Aug\",\n\t\"Sep\",\n\t\"Oct\",\n\t\"Nov\",\n\t\"Dec\"\n];\nvar EIGHTHS = [\n\t\"\",\n\t\"▏\",\n\t\"▎\",\n\t\"▍\",\n\t\"▌\",\n\t\"▋\",\n\t\"▊\",\n\t\"▉\"\n];\nfunction fmtTokens(n) {\n\tif (n == null || Number.isNaN(n)) return \"—\";\n\tif (n >= 1e9) return (n / 1e9).toFixed(1) + \"B\";\n\tif (n >= 1e6) return (n / 1e6).toFixed(1) + \"M\";\n\tif (n >= 1e3) return (n / 1e3).toFixed(1) + \"K\";\n\treturn String(n);\n}\nfunction fmtNum(n) {\n\treturn (n || 0).toLocaleString(\"en-US\");\n}\nfunction padR(s, n) {\n\treturn s.length >= n ? s : s + \" \".repeat(n - s.length);\n}\nfunction padL(s, n) {\n\treturn s.length >= n ? s : \" \".repeat(n - s.length) + s;\n}\n/** Fixed-width bar field (width cols): █ blocks + an eighth-fraction + trailing spaces. */\nfunction barField(v, max, width) {\n\tif (!v || v <= 0 || max <= 0) return \" \".repeat(width);\n\tconst scaled = v / max * width;\n\tlet full = Math.floor(scaled);\n\tlet fi = Math.round((scaled - full) * 8);\n\tif (fi === 8) {\n\t\tfull += 1;\n\t\tfi = 0;\n\t}\n\tif (full === 0 && fi === 0) fi = 1;\n\tlet s = \"█\".repeat(Math.min(full, width));\n\tif (full < width && fi > 0) s += EIGHTHS[fi];\n\tif (s.length < width) s += \" \".repeat(width - s.length);\n\treturn s.slice(0, width);\n}\n/** Filled/empty meter: █ for used, ░ for remaining. */\nfunction meter(pct, width) {\n\tlet filled = Math.round((pct || 0) / 100 * width);\n\tfilled = Math.max(0, Math.min(width, filled));\n\treturn \"█\".repeat(filled) + \"░\".repeat(width - filled);\n}\n//#endregion\nexport { MONTHS, barField, emitHookBlock, fmtNum, fmtTokens, hookOrPrint, lastReply, meter, padL, padR, parseQuietly, printUsageAndExit, readHookEvent, replyTarget };\n\n//# sourceMappingURL=index.js.map","import { parseQuietly } from '@v1nvn/agentic-core';\nimport { Command, InvalidArgumentError, Option } from 'commander';\n\nimport { extractArticleFromHtml } from './tools/extract.js';\nimport { readHtmlFile } from './tools/html-source.js';\n\nimport type { CallToolResult } from '@modelcontextprotocol/sdk/types.js';\nimport type { Readable } from 'node:stream';\n\ntype CliFormat = 'html' | 'json' | 'md';\n\nexport interface ParsedArgs {\n readonly file: string | undefined;\n readonly format: CliFormat;\n readonly maxChars: number | undefined;\n}\n\nconst FORMATS: readonly CliFormat[] = ['html', 'json', 'md'];\n\nfunction parseMaxChars(value: string): number {\n const n = Number(value);\n if (!Number.isInteger(n)) {\n throw new InvalidArgumentError('must be an integer');\n }\n return n;\n}\n\nexport function buildProgram(): Command {\n return new Command('readability-mcp extract')\n .argument('[file]', 'HTML file; stdin when omitted')\n .addOption(\n new Option('--format <fmt>', 'output format')\n .choices(FORMATS)\n .default('md'),\n )\n .option('--max-chars <n>', 'truncate the output', parseMaxChars);\n}\n\nexport function parseArgs(argv: readonly string[]): ParsedArgs | undefined {\n const program = parseQuietly(buildProgram(), argv.slice(1));\n if (program === undefined) {\n return undefined;\n }\n const { format, maxChars } = program.opts<{\n format: CliFormat;\n maxChars: number | undefined;\n }>();\n return { file: program.args.at(0), format, maxChars };\n}\n\n// The stream is injected rather than reading process.stdin directly so the\n// path is testable. Chunks may be Buffer (process.stdin) or string\n// (Readable.from), so both are handled.\nexport async function readHtml(\n file: string | undefined,\n stream: Readable,\n): Promise<string> {\n if (file !== undefined) {\n return readHtmlFile(file);\n }\n const chunks: string[] = [];\n for await (const chunk of stream) {\n if (typeof chunk === 'string') {\n chunks.push(chunk);\n } else {\n chunks.push(Buffer.from(chunk as Uint8Array).toString('utf8'));\n }\n }\n return chunks.join('');\n}\n\nfunction payloadText(result: CallToolResult): string {\n const first = result.content.at(0);\n return first !== undefined && 'text' in first ? first.text : '';\n}\n\nexport async function runCli(argv: readonly string[]): Promise<number> {\n if (argv[0] !== 'extract') {\n process.stderr.write(buildProgram().helpInformation());\n return 2;\n }\n\n const parsed = parseArgs(argv);\n if (parsed === undefined) {\n process.stderr.write(buildProgram().helpInformation());\n return 2;\n }\n\n try {\n const html = await readHtml(parsed.file, process.stdin);\n // json reuses the markdown pipeline; the structured object is serialized below.\n const pipelineFormat = parsed.format === 'html' ? 'html' : 'markdown';\n const result = extractArticleFromHtml({\n html,\n format: pipelineFormat,\n ...(parsed.maxChars !== undefined ? { maxChars: parsed.maxChars } : {}),\n });\n\n if (result.isError) {\n process.stderr.write(`${payloadText(result)}\\n`);\n return 1;\n }\n\n if (parsed.format === 'json') {\n process.stdout.write(\n `${JSON.stringify(result.structuredContent, null, 2)}\\n`,\n );\n } else {\n process.stdout.write(`${payloadText(result)}\\n`);\n }\n return 0;\n } catch (err) {\n process.stderr.write(\n `${err instanceof Error ? err.message : String(err)}\\n`,\n );\n return 1;\n }\n}\n"],"mappings":";;;;AAuDA,SAAS,aAAa,SAAS,MAAM;CACpC,IAAI;EACH,QAAQ,qBAAqB,KAAK,CAAC,CAAC,aAAa,CAAC,CAAC,gBAAgB;GAClE,gBAAgB,KAAK;GACrB,gBAAgB,KAAK;EACtB,CAAC,CAAC,CAAC,MAAM,CAAC,GAAG,IAAI,GAAG,EAAE,MAAM,OAAO,CAAC;EACpC,OAAO;CACR,QAAQ;EACP;CACD;AACD;;;AChDA,IAAM,UAAgC;CAAC;CAAQ;CAAQ;AAAI;AAE3D,SAAS,cAAc,OAAuB;CAC5C,MAAM,IAAI,OAAO,KAAK;CACtB,IAAI,CAAC,OAAO,UAAU,CAAC,GACrB,MAAM,IAAI,qBAAqB,oBAAoB;CAErD,OAAO;AACT;AAEA,SAAgB,eAAwB;CACtC,OAAO,IAAI,QAAQ,yBAAyB,CAAC,CAC1C,SAAS,UAAU,+BAA+B,CAAC,CACnD,UACC,IAAI,OAAO,kBAAkB,eAAe,CAAC,CAC1C,QAAQ,OAAO,CAAC,CAChB,QAAQ,IAAI,CACjB,CAAC,CACA,OAAO,mBAAmB,uBAAuB,aAAa;AACnE;AAEA,SAAgB,UAAU,MAAiD;CACzE,MAAM,UAAU,aAAa,aAAa,GAAG,KAAK,MAAM,CAAC,CAAC;CAC1D,IAAI,YAAY,KAAA,GACd;CAEF,MAAM,EAAE,QAAQ,aAAa,QAAQ,KAGlC;CACH,OAAO;EAAE,MAAM,QAAQ,KAAK,GAAG,CAAC;EAAG;EAAQ;CAAS;AACtD;AAKA,eAAsB,SACpB,MACA,QACiB;CACjB,IAAI,SAAS,KAAA,GACX,OAAO,aAAa,IAAI;CAE1B,MAAM,SAAmB,CAAC;CAC1B,WAAW,MAAM,SAAS,QACxB,IAAI,OAAO,UAAU,UACnB,OAAO,KAAK,KAAK;MAEjB,OAAO,KAAK,OAAO,KAAK,KAAmB,CAAC,CAAC,SAAS,MAAM,CAAC;CAGjE,OAAO,OAAO,KAAK,EAAE;AACvB;AAEA,SAAS,YAAY,QAAgC;CACnD,MAAM,QAAQ,OAAO,QAAQ,GAAG,CAAC;CACjC,OAAO,UAAU,KAAA,KAAa,UAAU,QAAQ,MAAM,OAAO;AAC/D;AAEA,eAAsB,OAAO,MAA0C;CACrE,IAAI,KAAK,OAAO,WAAW;EACzB,QAAQ,OAAO,MAAM,aAAa,CAAC,CAAC,gBAAgB,CAAC;EACrD,OAAO;CACT;CAEA,MAAM,SAAS,UAAU,IAAI;CAC7B,IAAI,WAAW,KAAA,GAAW;EACxB,QAAQ,OAAO,MAAM,aAAa,CAAC,CAAC,gBAAgB,CAAC;EACrD,OAAO;CACT;CAEA,IAAI;EACF,MAAM,OAAO,MAAM,SAAS,OAAO,MAAM,QAAQ,KAAK;EAEtD,MAAM,iBAAiB,OAAO,WAAW,SAAS,SAAS;EAC3D,MAAM,SAAS,uBAAuB;GACpC;GACA,QAAQ;GACR,GAAI,OAAO,aAAa,KAAA,IAAY,EAAE,UAAU,OAAO,SAAS,IAAI,CAAC;EACvE,CAAC;EAED,IAAI,OAAO,SAAS;GAClB,QAAQ,OAAO,MAAM,GAAG,YAAY,MAAM,EAAE,GAAG;GAC/C,OAAO;EACT;EAEA,IAAI,OAAO,WAAW,QACpB,QAAQ,OAAO,MACb,GAAG,KAAK,UAAU,OAAO,mBAAmB,MAAM,CAAC,EAAE,GACvD;OAEA,QAAQ,OAAO,MAAM,GAAG,YAAY,MAAM,EAAE,GAAG;EAEjD,OAAO;CACT,SAAS,KAAK;EACZ,QAAQ,OAAO,MACb,GAAG,eAAe,QAAQ,IAAI,UAAU,OAAO,GAAG,EAAE,GACtD;EACA,OAAO;CACT;AACF"}
|
|
1
|
+
{"version":3,"file":"cli-B3ybWgFv.js","names":[],"sources":["../../../core/dist/index.js","../../src/cli.ts"],"sourcesContent":["import { existsSync, readFileSync, readdirSync, statSync } from \"node:fs\";\nimport { homedir } from \"node:os\";\nimport { join } from \"node:path\";\n//#region src/hook.ts\n/**\n* Read the Claude Code hook event JSON from stdin; empty or unparseable input\n* yields an empty event, never a thrown error — a hook must always answer.\n*/\nfunction readHookEvent(stream = process.stdin) {\n\treturn new Promise((resolve) => {\n\t\tlet data = \"\";\n\t\tstream.setEncoding(\"utf8\");\n\t\tstream.on(\"data\", (chunk) => {\n\t\t\tdata += chunk;\n\t\t});\n\t\tstream.on(\"end\", () => {\n\t\t\ttry {\n\t\t\t\tresolve(JSON.parse(data));\n\t\t\t} catch {\n\t\t\t\tresolve({});\n\t\t\t}\n\t\t});\n\t\tstream.on(\"error\", () => {\n\t\t\tresolve({});\n\t\t});\n\t});\n}\n/**\n* Which transcript lastReply should read, per the hook event: transcript_path\n* when it names a real file, else session_id, else nothing (lastReply then\n* falls back to the newest session for the current project).\n*/\nfunction replyTarget(event) {\n\tif (event.transcript_path !== void 0 && isFile$1(event.transcript_path)) return event.transcript_path;\n\treturn event.session_id || void 0;\n}\nfunction isFile$1(path) {\n\ttry {\n\t\treturn statSync(path).isFile();\n\t} catch {\n\t\treturn false;\n\t}\n}\n/**\n* Print the UserPromptExpansion decision for a zero-token command: `block`\n* keeps the command from reaching the model, with the output as the `reason`.\n*/\nfunction emitHookBlock(reason) {\n\tprocess.stdout.write(`${JSON.stringify({\n\t\tdecision: \"block\",\n\t\treason\n\t})}\\n`);\n}\n//#endregion\n//#region src/cli.ts\nfunction parseQuietly(program, args) {\n\ttry {\n\t\tprogram.allowExcessArguments(false).exitOverride().configureOutput({\n\t\t\twriteOut: () => void 0,\n\t\t\twriteErr: () => void 0\n\t\t}).parse([...args], { from: \"user\" });\n\t\treturn program;\n\t} catch {\n\t\treturn;\n\t}\n}\nfunction printUsageAndExit(program) {\n\tconsole.error(program.helpInformation());\n\tprocess.exit(1);\n}\nasync function hookOrPrint(hook, failure, run, stdin = process.stdin) {\n\tif (hook) {\n\t\tlet reason;\n\t\ttry {\n\t\t\treason = await run(await readHookEvent(stdin));\n\t\t} catch (e) {\n\t\t\treason = `${failure}: ${e.message}`;\n\t\t}\n\t\temitHookBlock(reason);\n\t\treturn;\n\t}\n\ttry {\n\t\tconsole.log(await run(void 0));\n\t} catch (e) {\n\t\tconsole.error(e.message);\n\t\tprocess.exit(1);\n\t}\n}\n//#endregion\n//#region src/last-reply.ts\nfunction isFile(path) {\n\ttry {\n\t\treturn statSync(path).isFile();\n\t} catch {\n\t\treturn false;\n\t}\n}\nfunction textBlocks(entry) {\n\tconst content = entry.message?.content;\n\tif (entry.type !== \"assistant\" || !Array.isArray(content)) return;\n\tconst blocks = content.filter((b) => typeof b === \"object\" && b !== null && b.type === \"text\");\n\treturn blocks.length > 0 ? blocks : void 0;\n}\n/**\n* The last assistant text reply from a Claude Code session, reproducing Claude\n* Code's `/copy` byte-for-byte: the last assistant entry that contains a\n* `text` block, only its `text` block(s) (tool_use / thinking dropped), blocks\n* joined with a blank line, and no trailing newline.\n*\n* @param arg a transcript file, a session UUID under the project dir, or\n* nothing to use the newest session for the current project.\n*/\nfunction lastReply(arg) {\n\tconst claudeDir = process.env.CLAUDE_DIR ?? join(homedir(), \".claude\");\n\tconst proj = (process.env.CLAUDE_PROJECT_DIR ?? process.cwd()).replaceAll(\"/\", \"-\");\n\tconst projDir = join(claudeDir, \"projects\", proj);\n\tlet file;\n\tif (arg !== void 0) file = isFile(arg) ? arg : join(projDir, `${arg}.jsonl`);\n\telse if (existsSync(projDir)) {\n\t\tconst newest = readdirSync(projDir).filter((f) => f.endsWith(\".jsonl\")).map((f) => ({\n\t\t\tf,\n\t\t\tmtimeMs: statSync(join(projDir, f)).mtimeMs\n\t\t})).sort((a, b) => b.mtimeMs - a.mtimeMs).at(0);\n\t\tif (newest) file = join(projDir, newest.f);\n\t}\n\tif (file === void 0 || !isFile(file)) throw new Error(`no session transcript found in ${projDir}`);\n\tlet last;\n\tfor (const line of readFileSync(file, \"utf8\").split(\"\\n\")) {\n\t\tif (!line.includes(\"\\\"assistant\\\"\")) continue;\n\t\tlet entry;\n\t\ttry {\n\t\t\tentry = JSON.parse(line);\n\t\t} catch {\n\t\t\tcontinue;\n\t\t}\n\t\tconst blocks = textBlocks(entry);\n\t\tif (blocks) last = blocks;\n\t}\n\treturn (last ?? []).map((b) => b.text ?? \"\").join(\"\\n\\n\");\n}\n//#endregion\n//#region src/text-format.ts\n/**\n* Plain-text rendering primitives shared by the zai and tokens report\n* renderers. Output targets a monospace terminal / hook-block `reason`, so\n* everything here is fixed-width: padding, block-glyph bars, and compact\n* number formatting.\n*/\nvar MONTHS = [\n\t\"Jan\",\n\t\"Feb\",\n\t\"Mar\",\n\t\"Apr\",\n\t\"May\",\n\t\"Jun\",\n\t\"Jul\",\n\t\"Aug\",\n\t\"Sep\",\n\t\"Oct\",\n\t\"Nov\",\n\t\"Dec\"\n];\nvar EIGHTHS = [\n\t\"\",\n\t\"▏\",\n\t\"▎\",\n\t\"▍\",\n\t\"▌\",\n\t\"▋\",\n\t\"▊\",\n\t\"▉\"\n];\nfunction fmtTokens(n) {\n\tif (n == null || Number.isNaN(n)) return \"—\";\n\tif (n >= 1e9) return (n / 1e9).toFixed(1) + \"B\";\n\tif (n >= 1e6) return (n / 1e6).toFixed(1) + \"M\";\n\tif (n >= 1e3) return (n / 1e3).toFixed(1) + \"K\";\n\treturn String(n);\n}\nfunction fmtNum(n) {\n\treturn (n || 0).toLocaleString(\"en-US\");\n}\nfunction padR(s, n) {\n\treturn s.length >= n ? s : s + \" \".repeat(n - s.length);\n}\nfunction padL(s, n) {\n\treturn s.length >= n ? s : \" \".repeat(n - s.length) + s;\n}\n/** Fixed-width bar field (width cols): █ blocks + an eighth-fraction + trailing spaces. */\nfunction barField(v, max, width) {\n\tif (!v || v <= 0 || max <= 0) return \" \".repeat(width);\n\tconst scaled = v / max * width;\n\tlet full = Math.floor(scaled);\n\tlet fi = Math.round((scaled - full) * 8);\n\tif (fi === 8) {\n\t\tfull += 1;\n\t\tfi = 0;\n\t}\n\tif (full === 0 && fi === 0) fi = 1;\n\tlet s = \"█\".repeat(Math.min(full, width));\n\tif (full < width && fi > 0) s += EIGHTHS[fi];\n\tif (s.length < width) s += \" \".repeat(width - s.length);\n\treturn s.slice(0, width);\n}\n/** Filled/empty meter: █ for used, ░ for remaining. */\nfunction meter(pct, width) {\n\tlet filled = Math.round((pct || 0) / 100 * width);\n\tfilled = Math.max(0, Math.min(width, filled));\n\treturn \"█\".repeat(filled) + \"░\".repeat(width - filled);\n}\n//#endregion\nexport { MONTHS, barField, emitHookBlock, fmtNum, fmtTokens, hookOrPrint, lastReply, meter, padL, padR, parseQuietly, printUsageAndExit, readHookEvent, replyTarget };\n\n//# sourceMappingURL=index.js.map","import { parseQuietly } from '@v1nvn/agentic-core';\nimport { Command, InvalidArgumentError, Option } from 'commander';\n\nimport { extractArticleFromHtml } from './tools/extract.js';\nimport { readHtmlFile } from './tools/html-source.js';\n\nimport type { CallToolResult } from '@modelcontextprotocol/sdk/types.js';\nimport type { Readable } from 'node:stream';\n\ntype CliFormat = 'html' | 'json' | 'md';\n\nexport interface ParsedArgs {\n readonly file: string | undefined;\n readonly format: CliFormat;\n readonly maxChars: number | undefined;\n}\n\nconst FORMATS: readonly CliFormat[] = ['html', 'json', 'md'];\n\nfunction parseMaxChars(value: string): number {\n const n = Number(value);\n if (!Number.isInteger(n)) {\n throw new InvalidArgumentError('must be an integer');\n }\n return n;\n}\n\nexport function buildProgram(): Command {\n return new Command('readability-mcp extract')\n .argument('[file]', 'HTML file; stdin when omitted')\n .addOption(\n new Option('--format <fmt>', 'output format')\n .choices(FORMATS)\n .default('md'),\n )\n .option('--max-chars <n>', 'truncate the output', parseMaxChars);\n}\n\nexport function parseArgs(argv: readonly string[]): ParsedArgs | undefined {\n const program = parseQuietly(buildProgram(), argv.slice(1));\n if (program === undefined) {\n return undefined;\n }\n const { format, maxChars } = program.opts<{\n format: CliFormat;\n maxChars: number | undefined;\n }>();\n return { file: program.args.at(0), format, maxChars };\n}\n\n// The stream is injected rather than reading process.stdin directly so the\n// path is testable. Chunks may be Buffer (process.stdin) or string\n// (Readable.from), so both are handled.\nexport async function readHtml(\n file: string | undefined,\n stream: Readable,\n): Promise<string> {\n if (file !== undefined) {\n return readHtmlFile(file);\n }\n const chunks: string[] = [];\n for await (const chunk of stream) {\n if (typeof chunk === 'string') {\n chunks.push(chunk);\n } else {\n chunks.push(Buffer.from(chunk as Uint8Array).toString('utf8'));\n }\n }\n return chunks.join('');\n}\n\nfunction payloadText(result: CallToolResult): string {\n const first = result.content.at(0);\n return first !== undefined && 'text' in first ? first.text : '';\n}\n\nexport async function runCli(argv: readonly string[]): Promise<number> {\n if (argv[0] !== 'extract') {\n process.stderr.write(buildProgram().helpInformation());\n return 2;\n }\n\n const parsed = parseArgs(argv);\n if (parsed === undefined) {\n process.stderr.write(buildProgram().helpInformation());\n return 2;\n }\n\n try {\n const html = await readHtml(parsed.file, process.stdin);\n // json reuses the markdown pipeline; the structured object is serialized below.\n const pipelineFormat = parsed.format === 'html' ? 'html' : 'markdown';\n const result = extractArticleFromHtml({\n html,\n format: pipelineFormat,\n ...(parsed.maxChars !== undefined ? { maxChars: parsed.maxChars } : {}),\n });\n\n if (result.isError) {\n process.stderr.write(`${payloadText(result)}\\n`);\n return 1;\n }\n\n if (parsed.format === 'json') {\n process.stdout.write(\n `${JSON.stringify(result.structuredContent, null, 2)}\\n`,\n );\n } else {\n process.stdout.write(`${payloadText(result)}\\n`);\n }\n return 0;\n } catch (err) {\n process.stderr.write(\n `${err instanceof Error ? err.message : String(err)}\\n`,\n );\n return 1;\n }\n}\n"],"mappings":";;;;;;AAuDA,SAAS,aAAa,SAAS,MAAM;CACpC,IAAI;EACH,QAAQ,qBAAqB,KAAK,CAAC,CAAC,aAAa,CAAC,CAAC,gBAAgB;GAClE,gBAAgB,KAAK;GACrB,gBAAgB,KAAK;EACtB,CAAC,CAAC,CAAC,MAAM,CAAC,GAAG,IAAI,GAAG,EAAE,MAAM,OAAO,CAAC;EACpC,OAAO;CACR,QAAQ;EACP;CACD;AACD;;;AChDA,IAAM,UAAgC;CAAC;CAAQ;CAAQ;AAAI;AAE3D,SAAS,cAAc,OAAuB;CAC5C,MAAM,IAAI,OAAO,KAAK;CACtB,IAAI,CAAC,OAAO,UAAU,CAAC,GACrB,MAAM,IAAI,qBAAqB,oBAAoB;CAErD,OAAO;AACT;AAEA,SAAgB,eAAwB;CACtC,OAAO,IAAI,QAAQ,yBAAyB,CAAC,CAC1C,SAAS,UAAU,+BAA+B,CAAC,CACnD,UACC,IAAI,OAAO,kBAAkB,eAAe,CAAC,CAC1C,QAAQ,OAAO,CAAC,CAChB,QAAQ,IAAI,CACjB,CAAC,CACA,OAAO,mBAAmB,uBAAuB,aAAa;AACnE;AAEA,SAAgB,UAAU,MAAiD;CACzE,MAAM,UAAU,aAAa,aAAa,GAAG,KAAK,MAAM,CAAC,CAAC;CAC1D,IAAI,YAAY,KAAA,GACd;CAEF,MAAM,EAAE,QAAQ,aAAa,QAAQ,KAGlC;CACH,OAAO;EAAE,MAAM,QAAQ,KAAK,GAAG,CAAC;EAAG;EAAQ;CAAS;AACtD;AAKA,eAAsB,SACpB,MACA,QACiB;CACjB,IAAI,SAAS,KAAA,GACX,OAAO,aAAa,IAAI;CAE1B,MAAM,SAAmB,CAAC;CAC1B,WAAW,MAAM,SAAS,QACxB,IAAI,OAAO,UAAU,UACnB,OAAO,KAAK,KAAK;MAEjB,OAAO,KAAK,OAAO,KAAK,KAAmB,CAAC,CAAC,SAAS,MAAM,CAAC;CAGjE,OAAO,OAAO,KAAK,EAAE;AACvB;AAEA,SAAS,YAAY,QAAgC;CACnD,MAAM,QAAQ,OAAO,QAAQ,GAAG,CAAC;CACjC,OAAO,UAAU,KAAA,KAAa,UAAU,QAAQ,MAAM,OAAO;AAC/D;AAEA,eAAsB,OAAO,MAA0C;CACrE,IAAI,KAAK,OAAO,WAAW;EACzB,QAAQ,OAAO,MAAM,aAAa,CAAC,CAAC,gBAAgB,CAAC;EACrD,OAAO;CACT;CAEA,MAAM,SAAS,UAAU,IAAI;CAC7B,IAAI,WAAW,KAAA,GAAW;EACxB,QAAQ,OAAO,MAAM,aAAa,CAAC,CAAC,gBAAgB,CAAC;EACrD,OAAO;CACT;CAEA,IAAI;EACF,MAAM,OAAO,MAAM,SAAS,OAAO,MAAM,QAAQ,KAAK;EAEtD,MAAM,iBAAiB,OAAO,WAAW,SAAS,SAAS;EAC3D,MAAM,SAAS,uBAAuB;GACpC;GACA,QAAQ;GACR,GAAI,OAAO,aAAa,KAAA,IAAY,EAAE,UAAU,OAAO,SAAS,IAAI,CAAC;EACvE,CAAC;EAED,IAAI,OAAO,SAAS;GAClB,QAAQ,OAAO,MAAM,GAAG,YAAY,MAAM,EAAE,GAAG;GAC/C,OAAO;EACT;EAEA,IAAI,OAAO,WAAW,QACpB,QAAQ,OAAO,MACb,GAAG,KAAK,UAAU,OAAO,mBAAmB,MAAM,CAAC,EAAE,GACvD;OAEA,QAAQ,OAAO,MAAM,GAAG,YAAY,MAAM,EAAE,GAAG;EAEjD,OAAO;CACT,SAAS,KAAK;EACZ,QAAQ,OAAO,MACb,GAAG,eAAe,QAAQ,IAAI,UAAU,OAAO,GAAG,EAAE,GACtD;EACA,OAAO;CACT;AACF"}
|