Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions .github/workflows/js.yml
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,8 @@ jobs:
run: cd crates/markkit-js && npx --no-install napi build --release --platform
- name: Verify build preserved the patched lockfile
run: git diff --exit-code -- Cargo.lock
- name: Binding tests
run: cd crates/markkit-js && node --test __test__/*.test.cjs
- name: Verify installed npm package
run: |
cd crates/markkit-js
Expand Down
5 changes: 5 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,11 @@ and this project adheres to [Semantic Versioning](https://semver.org/).
- `@everruns/markkit-wasm`: browser / edge WebAssembly package (`crates/markkit-wasm`), single-threaded,
no special headers. `Markkit` class (HTML, DOCX, XLSX/XLS/ODS with validated camelCase options) and
`MarkkitTool`. Built by `crates/markkit-wasm/scripts/build.sh`, published by `publish-wasm.yml`.
- Node (`@everruns/markkit`): every function takes an optional trailing `options` object with all
builder settings (selectors, links/images, input, archive and sheet limits) and the URL policy
(`allowPrivateNetworks`, `allowedHosts`, `maxRedirects`, `maxProcessingSecs`). New
`extractDocument` / `extractDocumentSync` return Markdown with title, format, warnings, stats and
page metadata.
- `Markkit::extract_url_document`: `extract_url` with format, title, warnings and stats.

### Security
Expand Down
27 changes: 26 additions & 1 deletion crates/markkit-js/README.md
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
# @everruns/markkit

Native Node.js bindings for Markkit HTML-to-Markdown extraction.
Native Node.js bindings for Markkit: HTML, DOCX and spreadsheets to Markdown.

This npm release supports **Linux x64 with glibc**. It does not include binaries for
macOS, Windows, ARM64 or musl Linux. Other platforms require a separate future
Expand All @@ -15,3 +15,28 @@ const { extractHtmlSync } = require('@everruns/markkit');
console.log(extractHtmlSync('<h1>Hello</h1>'));
// # Hello
```

## Options

Every function takes an optional trailing options object:

```js
const { extractHtml, extractUrl, extractDocumentSync } = require('@everruns/markkit');

await extractHtml(html, { includeSelectors: ['article'], excludeSelectors: ['.ad'], preserveImages: false });

// URLs: non-public addresses are blocked by default.
await extractUrl('https://docs.example.com/page', { allowedHosts: ['docs.example.com'], maxRedirects: 2 });

// Documents with metadata (HTML, DOCX, XLSX/XLS/ODS).
const doc = extractDocumentSync(fs.readFileSync('report.docx'), { fileName: 'report.docx' }, { maxSheetRows: 500 });
console.log(doc.title, doc.format, doc.warnings, doc.markdown);
```

Fields: `maxInputSize`, `maxProcessingSecs`, `preserveLinks`, `preserveImages`,
`includeSelectors`, `excludeSelectors` (added to the defaults), `maxArchiveEntries`,
`maxArchiveEntrySize`, `maxArchiveTotalSize`, `maxSheetRows`, `maxSheetCols`,
`maxSpreadsheetCells`, `includeHiddenSheets`, `allowPrivateNetworks` (trusted URLs only),
`allowedHosts`, `maxRedirects`.

For browsers and edge runtimes use [`@everruns/markkit-wasm`](https://www.npmjs.com/package/@everruns/markkit-wasm).
67 changes: 67 additions & 0 deletions crates/markkit-js/__test__/options.test.cjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,67 @@
// Node binding tests (run after `napi build --platform`): options parity,
// extractDocument, URL policy. All inputs inline; the URL test uses a local server.
const { test } = require("node:test");
const assert = require("node:assert/strict");
const http = require("node:http");
const m = require("..");

test("defaults unchanged without options", async () => {
assert.equal(m.extractHtmlSync("<h1>Hello</h1>"), "# Hello\n");
assert.equal(await m.extractHtml("<p>a</p>"), "a\n");
assert.equal(await m.extractBytes(Buffer.from("<p>b</p>"), "text/html"), "b\n");
});

test("selector and link options apply to every entry point", async () => {
const html = '<nav>menu</nav><article><p>Read <a href="https://x.test">this</a></p></article><aside class="ad">buy</aside>';
const options = { preserveLinks: false, includeSelectors: ["article"], excludeSelectors: [".ad"] };
assert.equal(m.extractHtmlSync(html, options), "Read this\n");
assert.equal(await m.extractHtml(html, options), "Read this\n");
assert.equal(await m.extractBytes(Buffer.from(html), "text/html", options), "Read this\n");
assert.equal(m.extractDocumentSync(Buffer.from(html), { fileName: "a.html" }, options).markdown, "Read this\n");
});

test("input size limit", () => {
assert.throws(() => m.extractHtmlSync("<p>too long</p>", { maxInputSize: 4 }), /Resource limit/);
});

test("invalid numeric options are rejected", () => {
assert.throws(() => m.extractHtmlSync("<p>x</p>", { maxArchiveTotalSize: -1 }), /invalid options/);
assert.throws(() => m.extractHtmlSync("<p>x</p>", { maxSpreadsheetCells: 1.5 }), /invalid options/);
});

test("extractDocument returns metadata", async () => {
const out = await m.extractDocument(Buffer.from("<p>bytes</p>"), { contentType: "text/html" });
assert.equal(out.markdown, "bytes\n");
assert.equal(out.format, "html");
assert.deepEqual(out.warnings, []);
assert.equal(out.title ?? null, null);
assert.throws(() => m.extractDocumentSync(Buffer.from("%PDF-1.7"), { fileName: "a.pdf" }), /Unsupported/);
});

test("extractDocument exposes page metadata", () => {
const html = '<html lang="en"><head><title>T</title><meta property="og:site_name" content="Site"><link rel="canonical" href="https://x.test/a"></head><body><p>b</p></body></html>';
const out = m.extractDocumentSync(Buffer.from(html), { contentType: "text/html" });
assert.equal(out.title, "T");
assert.equal(out.metadata.siteName, "Site");
assert.equal(out.metadata.canonicalUrl, "https://x.test/a");
assert.equal(out.metadata.language, "en");
});

function serve(body) {
return new Promise(resolve => {
const srv = http.createServer((_, res) => { res.setHeader("content-type", "text/html"); res.end(body); });
srv.listen(0, "127.0.0.1", () => resolve(srv));
});
}

test("extractUrl blocks private networks unless allowed", async () => {
const srv = await serve("<h1>Local</h1>");
const url = `http://127.0.0.1:${srv.address().port}/`;
try {
await assert.rejects(m.extractUrl(url), /URL blocked/);
assert.equal(await m.extractUrl(url, { allowPrivateNetworks: true }), "# Local\n");
await assert.rejects(m.extractUrl(url, { allowPrivateNetworks: true, allowedHosts: ["example.com"] }), /URL blocked/);
} finally {
srv.close();
}
});
Loading
Loading