rusteerio 1.0.1 → 1.1.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/Cargo.lock +42 -1
- package/Cargo.toml +8 -1
- package/README.md +90 -51
- package/THIRD_PARTY_NOTICES.md +107 -2
- package/dist/{chunk-DMRD3FGC.mjs → chunk-FPRP6UIX.mjs} +2233 -751
- package/dist/chunk-FPRP6UIX.mjs.map +1 -0
- package/dist/index.cjs +2232 -750
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.mts +2 -2
- package/dist/index.d.ts +2 -2
- package/dist/index.mjs +1 -1
- package/dist/slim.cjs +2226 -745
- package/dist/slim.cjs.map +1 -1
- package/dist/slim.d.mts +2 -2
- package/dist/slim.d.ts +2 -2
- package/dist/slim.mjs +1 -1
- package/dist/{types-BP6TpxE5.d.mts → types-Cs9UP1Vv.d.mts} +3 -3
- package/dist/{types-BP6TpxE5.d.ts → types-Cs9UP1Vv.d.ts} +3 -3
- package/dist/utils.d.mts +1 -1
- package/dist/utils.d.ts +1 -1
- package/native/src/arena.rs +219 -80
- package/native/src/attributes.rs +11 -5
- package/native/src/document_order.rs +711 -0
- package/native/src/extraction.rs +15 -3
- package/native/src/gc.rs +12 -3
- package/native/src/id_hash.rs +42 -0
- package/native/src/immutable.rs +256 -0
- package/native/src/lib.rs +1039 -171
- package/native/src/runtime.rs +189 -33
- package/native/src/selector.rs +346 -44
- package/native/src/sink.rs +14 -1
- package/native/src/snapshot.rs +509 -0
- package/native/src/stream.rs +2 -2
- package/native/src/tokenizer.rs +29 -12
- package/native/vendor/html5ever-0.35.0/src/tokenizer/char_ref/mod.rs +79 -0
- package/package.json +3 -1
- package/rusteerio.darwin-arm64.node +0 -0
- package/rusteerio.darwin-x64.node +0 -0
- package/rusteerio.linux-arm64-gnu.node +0 -0
- package/rusteerio.linux-arm64-musl.node +0 -0
- package/rusteerio.linux-x64-gnu.node +0 -0
- package/rusteerio.linux-x64-musl.node +0 -0
- package/rusteerio.win32-arm64-msvc.node +0 -0
- package/rusteerio.win32-x64-msvc.node +0 -0
- package/src/attributes.ts +78 -44
- package/src/codec.ts +22 -3
- package/src/extract.ts +86 -2
- package/src/hooks.ts +148 -2
- package/src/index.ts +51 -40
- package/src/match.ts +207 -0
- package/src/native.ts +9 -1
- package/src/nodes.ts +531 -183
- package/src/router.ts +792 -229
- package/src/selection.ts +239 -125
- package/src/snapshot.ts +255 -0
- package/src/transport.ts +51 -42
- package/dist/chunk-DMRD3FGC.mjs.map +0 -1
package/src/hooks.ts
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { requireNative, type NativeDocument } from './native.js';
|
|
2
|
-
import { getNode, hydrateLocation } from './nodes.js';
|
|
3
|
-
import { routeDocument } from './router.js';
|
|
2
|
+
import { attachParsedNodes, getNode, hydrateLocation, operation } from './nodes.js';
|
|
3
|
+
import { plainSnapshot, recordAt, routeDocument, type DocumentState, type RoutedDocument } from './router.js';
|
|
4
4
|
import type { CheerioOptions } from './types.js';
|
|
5
5
|
import type { TokenizerCallbacks } from './parser-types.js';
|
|
6
6
|
|
|
@@ -182,6 +182,11 @@ export function createTreeAdapterSession(options: CheerioOptions, isDocument: bo
|
|
|
182
182
|
export function parseNativeDocument(source: string, options: CheerioOptions, isDocument: boolean): NativeDocument {
|
|
183
183
|
const binding = requireNative();
|
|
184
184
|
const nativeOptions = parserOptions(source, options);
|
|
185
|
+
if (!options._useHtmlParser2 && options.treeAdapter && canReplay(options)) {
|
|
186
|
+
const document = routeDocument(new binding.NativeDocument(source, isDocument, JSON.stringify(nativeOptions)), options) as RoutedDocument;
|
|
187
|
+
const nodes = replayIntoAdapter(document, options.treeAdapter, isDocument);
|
|
188
|
+
if (nodes) { attachParsedNodes(document.state, nodes); return document; }
|
|
189
|
+
}
|
|
185
190
|
if (!options._useHtmlParser2 && options.treeAdapter) {
|
|
186
191
|
const parse = (binding as typeof binding & { parseWithTreeAdapter?(source: string, isDocument: boolean, options: string, bridge: (request: string) => string): NativeDocument }).parseWithTreeAdapter;
|
|
187
192
|
if (!parse) throw new Error('This native addon does not support treeAdapter hooks; rebuild the Rust addon.');
|
|
@@ -218,3 +223,144 @@ export function createDiagnosticSession(options: CheerioOptions, isDocument = tr
|
|
|
218
223
|
abort() { aborted = true; parser.abort(); },
|
|
219
224
|
};
|
|
220
225
|
}
|
|
226
|
+
|
|
227
|
+
/**
|
|
228
|
+
* Whether an adapter can be given the finished tree instead of every parser
|
|
229
|
+
* step: it observes neither the open-element stack nor source locations, and
|
|
230
|
+
* no parse-error callback can change it while parsing.
|
|
231
|
+
*/
|
|
232
|
+
function canReplay(options: CheerioOptions): boolean {
|
|
233
|
+
const adapter = options.treeAdapter as unknown as Record<string, unknown>;
|
|
234
|
+
return !options.onParseError && !options.sourceCodeLocationInfo && adapter.onItemPush === undefined && adapter.onItemPop === undefined;
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
/**
|
|
238
|
+
* Builds the adapter's tree from a native parse by calling its construction
|
|
239
|
+
* methods in document order, then checks every node against the parse. An
|
|
240
|
+
* adapter whose nodes differ from what it was given (renamed elements,
|
|
241
|
+
* rewritten text, filtered children) could have steered parsing differently,
|
|
242
|
+
* so it returns `undefined` and the caller runs the parser with the adapter
|
|
243
|
+
* live instead.
|
|
244
|
+
*/
|
|
245
|
+
function replayIntoAdapter(document: RoutedDocument, adapter: any, isDocument: boolean): (object | undefined)[] | undefined {
|
|
246
|
+
const state = document.state;
|
|
247
|
+
const root = document.rootId() - state.base;
|
|
248
|
+
const size = state.baseline;
|
|
249
|
+
const nodes: any[] = new Array(size);
|
|
250
|
+
// Reading every record once builds the snapshot; plain nodes are then read from its arrays.
|
|
251
|
+
for (let local = 0; local < Math.min(size, 65); local++) recordAt(state, local);
|
|
252
|
+
for (let local = 0; local < size; local++) {
|
|
253
|
+
const snapshot = plainSnapshot(state, local);
|
|
254
|
+
const record = snapshot ? undefined : recordAt(state, local);
|
|
255
|
+
if (!snapshot && !record) continue;
|
|
256
|
+
const type = snapshot ? snapshot.typeOf(local) : record!.type;
|
|
257
|
+
if (local === root) {
|
|
258
|
+
nodes[local] = isDocument ? adapter.createDocument() : adapter.createDocumentFragment();
|
|
259
|
+
const mode = record?.['x-mode'];
|
|
260
|
+
if (isDocument && mode !== undefined) adapter.setDocumentMode(nodes[local], mode);
|
|
261
|
+
continue;
|
|
262
|
+
}
|
|
263
|
+
const parentLocal = snapshot ? snapshot.linkOf(local, 1) : record!.parent;
|
|
264
|
+
const parent = parentLocal == null ? undefined : nodes[parentLocal];
|
|
265
|
+
if (parent === undefined) return undefined;
|
|
266
|
+
switch (type) {
|
|
267
|
+
case 'tag': case 'script': case 'style': {
|
|
268
|
+
const element = snapshot
|
|
269
|
+
? adapter.createElement(snapshot.nameOf(local), snapshot.namespaceOf(local), snapshot.attributeObjects(local))
|
|
270
|
+
: adapter.createElement(record!.name, record!.namespace, attributeList(record));
|
|
271
|
+
adapter.appendChild(parent, element);
|
|
272
|
+
nodes[local] = element;
|
|
273
|
+
break;
|
|
274
|
+
}
|
|
275
|
+
case 'root': {
|
|
276
|
+
const content = adapter.createDocumentFragment();
|
|
277
|
+
adapter.setTemplateContent(parent, content);
|
|
278
|
+
nodes[local] = content;
|
|
279
|
+
break;
|
|
280
|
+
}
|
|
281
|
+
case 'text': {
|
|
282
|
+
adapter.insertText(parent, (snapshot ? snapshot.dataOf(local) : record!.data) ?? '');
|
|
283
|
+
const children = adapter.getChildNodes(parent);
|
|
284
|
+
nodes[local] = children[children.length - 1];
|
|
285
|
+
break;
|
|
286
|
+
}
|
|
287
|
+
case 'comment': {
|
|
288
|
+
const comment = adapter.createCommentNode((snapshot ? snapshot.dataOf(local) : record!.data) ?? '');
|
|
289
|
+
adapter.appendChild(parent, comment);
|
|
290
|
+
nodes[local] = comment;
|
|
291
|
+
break;
|
|
292
|
+
}
|
|
293
|
+
case 'directive': {
|
|
294
|
+
if (!record || record.name !== '!doctype' || !isDocument) return undefined;
|
|
295
|
+
adapter.setDocumentType(parent, record['x-name'] ?? '', record['x-publicId'] ?? '', record['x-systemId'] ?? '');
|
|
296
|
+
nodes[local] = Array.from(adapter.getChildNodes(parent) ?? []).find((child) => adapter.isDocumentTypeNode(child));
|
|
297
|
+
break;
|
|
298
|
+
}
|
|
299
|
+
default: return undefined;
|
|
300
|
+
}
|
|
301
|
+
}
|
|
302
|
+
const changes = differencesFromParse(adapter, nodes, state, root);
|
|
303
|
+
if (!changes) return undefined;
|
|
304
|
+
// Attribute and text rewrites do not steer parsing; the document takes the adapter's values.
|
|
305
|
+
for (const change of changes) operation(document, 'setNode', { id: state.base + change.local, field: change.field, value: change.value });
|
|
306
|
+
return nodes;
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
function attributeList(record: any): { name: string; value: string; namespace?: string; prefix?: string }[] {
|
|
310
|
+
const attributes = record.attribs ?? {};
|
|
311
|
+
return Object.keys(attributes).map((name) => {
|
|
312
|
+
const namespace = record.attribsNamespace?.[name];
|
|
313
|
+
const prefix = record.attribsPrefix?.[name];
|
|
314
|
+
return { name, value: attributes[name], ...(namespace === undefined ? {} : { namespace }), ...(prefix === undefined ? {} : { prefix }) };
|
|
315
|
+
});
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
interface DataChange { local: number; field: 'attribs' | 'attribsNamespace' | 'attribsPrefix' | 'data'; value: unknown }
|
|
319
|
+
|
|
320
|
+
/**
|
|
321
|
+
* The attribute and text values in which the adapter's nodes differ from the
|
|
322
|
+
* parse, or `undefined` when a name, namespace or link differs, since those
|
|
323
|
+
* could have steered parsing.
|
|
324
|
+
*/
|
|
325
|
+
function differencesFromParse(adapter: any, nodes: any[], state: DocumentState, root: number): DataChange[] | undefined {
|
|
326
|
+
const changes: DataChange[] = [];
|
|
327
|
+
for (let local = 0; local < nodes.length; local++) {
|
|
328
|
+
const node = nodes[local];
|
|
329
|
+
const snapshot = plainSnapshot(state, local);
|
|
330
|
+
const record = snapshot ? undefined : recordAt(state, local);
|
|
331
|
+
if (!snapshot && !record) continue;
|
|
332
|
+
if (!node) return undefined;
|
|
333
|
+
const type = snapshot ? snapshot.typeOf(local) : record!.type;
|
|
334
|
+
if (local !== root && adapter.getParentNode(node) !== nodes[(snapshot ? snapshot.linkOf(local, 1) : record!.parent)!]) return undefined;
|
|
335
|
+
if (type !== 'text' && type !== 'comment' && type !== 'directive') {
|
|
336
|
+
const children = adapter.getChildNodes(node) ?? [];
|
|
337
|
+
const count = snapshot ? snapshot.childCount(local) : record!.children.length;
|
|
338
|
+
if (children.length !== count) return undefined;
|
|
339
|
+
for (let index = 0; index < count; index++) if (children[index] !== nodes[snapshot ? snapshot.childAt(local, index) : record!.children[index]]) return undefined;
|
|
340
|
+
}
|
|
341
|
+
if (type === 'tag' || type === 'script' || type === 'style') {
|
|
342
|
+
const name = snapshot ? snapshot.nameOf(local) : record!.name;
|
|
343
|
+
const namespace = snapshot ? snapshot.namespaceOf(local) : record!.namespace;
|
|
344
|
+
if (!adapter.isElementNode(node) || adapter.getTagName(node) !== name || adapter.getNamespaceURI(node) !== namespace) return undefined;
|
|
345
|
+
const attributes: { name: string; value: string; namespace?: string; prefix?: string }[] = adapter.getAttrList(node) ?? [];
|
|
346
|
+
const expected = snapshot ? snapshot.attributeObjects(local) : attributeList(record);
|
|
347
|
+
const isSame = attributes.length === expected.length && attributes.every((attribute, index) => attribute.name === expected[index].name && attribute.value === expected[index].value);
|
|
348
|
+
if (!isSame) {
|
|
349
|
+
const values: Record<string, string> = Object.create(null);
|
|
350
|
+
const namespaces: Record<string, string> = Object.create(null);
|
|
351
|
+
const prefixes: Record<string, string> = Object.create(null);
|
|
352
|
+
for (const attribute of attributes) {
|
|
353
|
+
values[attribute.name] = attribute.value;
|
|
354
|
+
if (attribute.namespace !== undefined) namespaces[attribute.name] = attribute.namespace;
|
|
355
|
+
if (attribute.prefix !== undefined) prefixes[attribute.name] = attribute.prefix;
|
|
356
|
+
}
|
|
357
|
+
changes.push({ local, field: 'attribs', value: values }, { local, field: 'attribsNamespace', value: namespaces }, { local, field: 'attribsPrefix', value: prefixes });
|
|
358
|
+
}
|
|
359
|
+
} else if (type === 'text' || type === 'comment') {
|
|
360
|
+
if (type === 'text' ? !adapter.isTextNode(node) : !adapter.isCommentNode(node)) return undefined;
|
|
361
|
+
const data = type === 'text' ? adapter.getTextNodeContent(node) : adapter.getCommentNodeContent(node);
|
|
362
|
+
if (data !== (snapshot ? snapshot.dataOf(local) : record!.data)) changes.push({ local, field: 'data', value: data });
|
|
363
|
+
}
|
|
364
|
+
}
|
|
365
|
+
return changes;
|
|
366
|
+
}
|
package/src/index.ts
CHANGED
|
@@ -6,9 +6,9 @@ import { setImmediate as yieldToEventLoop } from 'node:timers/promises';
|
|
|
6
6
|
import { availableParallelism } from 'node:os';
|
|
7
7
|
import { requireNative, nativeStatus, type NativeDocument, type NativeQuery } from './native.js';
|
|
8
8
|
import { Cheerio } from './selection.js';
|
|
9
|
-
import { getNode, importNodes, nodeBinding, operation } from './nodes.js';
|
|
9
|
+
import { getNode, importNodes, nodeBinding, operation, rootNode, viewIdIn } from './nodes.js';
|
|
10
10
|
import { isHtml } from './utils.js';
|
|
11
|
-
import { routeDocument, sameNodeSpace } from './router.js';
|
|
11
|
+
import { DOCUMENT, exposeDocumentTo, resumeCollection, routeDocument, sameNodeSpace } from './router.js';
|
|
12
12
|
import type { AnyNode, Document, CheerioAPI, CheerioOptions, LoadContent, ParallelOptions, ExtractMap, ExtractedMap } from './types.js';
|
|
13
13
|
|
|
14
14
|
export type * from './types.js';
|
|
@@ -38,15 +38,22 @@ export function createAPI(document: NativeDocument, options?: CheerioOptions | n
|
|
|
38
38
|
const internal = flattenOptions(options);
|
|
39
39
|
class LoadedCheerio<T = AnyNode> extends Cheerio<T> {}
|
|
40
40
|
let api: CheerioAPI;
|
|
41
|
-
const
|
|
42
|
-
if (selector?.cheerio) return selector;
|
|
43
|
-
const opts = flattenOptions(override, internal);
|
|
44
|
-
const rootIds = root === undefined ? [initialRoot]
|
|
45
|
-
: typeof root === 'string' ? [makeRoot(document, operation(document, 'create', { html: root, options: opts }))]
|
|
46
|
-
: root?.cheerio ? importNodes(document, root.toArray()) : importNodes(document, root);
|
|
41
|
+
const createRoot = (rootIds: number[], opts: CheerioOptions): Cheerio<Document> => {
|
|
47
42
|
const rootInstance = new LoadedCheerio<Document>(api, document, rootIds, null, opts);
|
|
48
43
|
rootInstance._root = rootInstance;
|
|
44
|
+
return rootInstance;
|
|
45
|
+
};
|
|
46
|
+
const initialize = function<T = AnyNode>(selector?: any, context?: any, root?: any, override?: CheerioOptions): Cheerio<T> {
|
|
47
|
+
resumeCollection(document);
|
|
48
|
+
if (selector?.cheerio) { exposeDocumentTo(document, selector[DOCUMENT]); return selector; }
|
|
49
|
+
const opts = flattenOptions(override, internal);
|
|
50
|
+
// Each call gets its own root selection, as in Cheerio: callers may change the one `end()` returns.
|
|
51
|
+
const rootInstance = createRoot(root === undefined ? [initialRoot]
|
|
52
|
+
: typeof root === 'string' ? [makeRoot(document, operation(document, 'create', { html: root, options: opts }))]
|
|
53
|
+
: root?.cheerio ? importNodes(document, root.toArray()) : importNodes(document, root), opts);
|
|
49
54
|
if (!selector) return new LoadedCheerio<T>(api, document, [], rootInstance, opts);
|
|
55
|
+
const id = viewIdIn(document, selector);
|
|
56
|
+
if (id !== undefined) return new LoadedCheerio<T>(api, document, [id], rootInstance, opts);
|
|
50
57
|
if (typeof selector === 'string' && isHtml(selector)) return new LoadedCheerio<T>(api, document, operation(document, 'create', { html: selector, options: opts }), rootInstance, opts);
|
|
51
58
|
if (typeof selector === 'object') {
|
|
52
59
|
const values = 'length' in selector && !selector.type ? Array.from(selector as ArrayLike<T>) : [selector];
|
|
@@ -65,46 +72,50 @@ export function createAPI(document: NativeDocument, options?: CheerioOptions | n
|
|
|
65
72
|
}
|
|
66
73
|
return searchContext.find(search) as unknown as Cheerio<T>;
|
|
67
74
|
};
|
|
68
|
-
|
|
75
|
+
// As in Cheerio, static methods are shared by every loaded API and read the document through `this`.
|
|
76
|
+
api = Object.assign(initialize, staticMethods, {
|
|
69
77
|
load,
|
|
70
|
-
_root:
|
|
78
|
+
_root: rootNode(document, initialRoot),
|
|
71
79
|
_options: internal,
|
|
72
80
|
fn: LoadedCheerio.prototype,
|
|
73
81
|
prototype: LoadedCheerio.prototype,
|
|
74
|
-
root(this: CheerioAPI): Cheerio<Document> { return this(this._root) as Cheerio<Document>; },
|
|
75
|
-
html(this: CheerioAPI | undefined, dom?: any, renderOptions?: CheerioOptions): string {
|
|
76
|
-
if (!this) return '';
|
|
77
|
-
if (dom && typeof dom === 'object' && !('length' in dom) && !('type' in dom)) { renderOptions = dom; dom = undefined; }
|
|
78
|
-
const nodes = dom === undefined ? [this._root] : this(dom).toArray();
|
|
79
|
-
const binding = nodeBinding(this._root)!;
|
|
80
|
-
return operation(binding.document, 'render', { ids: importNodes(binding.document, nodes), options: flattenOptions(renderOptions, this._options) });
|
|
81
|
-
},
|
|
82
|
-
xml(this: CheerioAPI, dom?: any): string {
|
|
83
|
-
const binding = nodeBinding(this._root)!;
|
|
84
|
-
return operation(binding.document, 'render', { ids: importNodes(binding.document, dom === undefined ? [this._root] : this(dom).toArray()), options: { ...this._options, xmlMode: true, _useHtmlParser2: true } });
|
|
85
|
-
},
|
|
86
|
-
text(this: CheerioAPI | undefined, elements?: ArrayLike<AnyNode>): string {
|
|
87
|
-
if (!elements && !this) return '';
|
|
88
|
-
const nodes = elements ?? [this!._root];
|
|
89
|
-
const binding = this ? nodeBinding(this._root) : nodeBinding(nodes[0]);
|
|
90
|
-
if (binding) return binding.document.text(importNodes(binding.document, nodes));
|
|
91
|
-
const doc = routeDocument(new (requireNative().NativeDocument)('', false));
|
|
92
|
-
return doc.text(importNodes(doc, nodes));
|
|
93
|
-
},
|
|
94
|
-
parseHTML(this: CheerioAPI, data?: string | null, context?: unknown, keepScripts = typeof context === 'boolean' ? context : false): AnyNode[] | null {
|
|
95
|
-
if (!data || typeof data !== 'string') return null;
|
|
96
|
-
if (typeof context === 'boolean') keepScripts = context;
|
|
97
|
-
const parsed = this.load(data, this._options, false);
|
|
98
|
-
if (!keepScripts) parsed('script').remove();
|
|
99
|
-
return parsed._root.children.slice();
|
|
100
|
-
},
|
|
101
|
-
extract<M extends ExtractMap>(this: CheerioAPI, map: M): ExtractedMap<M> { return this.root().extract(map); },
|
|
102
|
-
contains,
|
|
103
|
-
merge,
|
|
104
82
|
}) as unknown as CheerioAPI;
|
|
105
83
|
return api;
|
|
106
84
|
}
|
|
107
85
|
|
|
86
|
+
const staticMethods = {
|
|
87
|
+
root(this: CheerioAPI): Cheerio<Document> { return this(this._root) as Cheerio<Document>; },
|
|
88
|
+
html(this: CheerioAPI | undefined, dom?: any, renderOptions?: CheerioOptions): string {
|
|
89
|
+
if (!this) return '';
|
|
90
|
+
if (dom && typeof dom === 'object' && !('length' in dom) && !('type' in dom)) { renderOptions = dom; dom = undefined; }
|
|
91
|
+
const nodes = dom === undefined ? [this._root] : this(dom).toArray();
|
|
92
|
+
const binding = nodeBinding(this._root)!;
|
|
93
|
+
return operation(binding.document, 'render', { ids: importNodes(binding.document, nodes), options: flattenOptions(renderOptions, this._options) });
|
|
94
|
+
},
|
|
95
|
+
xml(this: CheerioAPI, dom?: any): string {
|
|
96
|
+
const binding = nodeBinding(this._root)!;
|
|
97
|
+
return operation(binding.document, 'render', { ids: importNodes(binding.document, dom === undefined ? [this._root] : this(dom).toArray()), options: { ...this._options, xmlMode: true, _useHtmlParser2: true } });
|
|
98
|
+
},
|
|
99
|
+
text(this: CheerioAPI | undefined, elements?: ArrayLike<AnyNode>): string {
|
|
100
|
+
if (!elements && !this) return '';
|
|
101
|
+
const nodes = elements ?? [this!._root];
|
|
102
|
+
const binding = this ? nodeBinding(this._root) : nodeBinding(nodes[0]);
|
|
103
|
+
if (binding) return binding.document.text(importNodes(binding.document, nodes));
|
|
104
|
+
const doc = routeDocument(new (requireNative().NativeDocument)('', false));
|
|
105
|
+
return doc.text(importNodes(doc, nodes));
|
|
106
|
+
},
|
|
107
|
+
parseHTML(this: CheerioAPI, data?: string | null, context?: unknown, keepScripts = typeof context === 'boolean' ? context : false): AnyNode[] | null {
|
|
108
|
+
if (!data || typeof data !== 'string') return null;
|
|
109
|
+
if (typeof context === 'boolean') keepScripts = context;
|
|
110
|
+
const parsed = this.load(data, this._options, false);
|
|
111
|
+
if (!keepScripts) parsed('script').remove();
|
|
112
|
+
return parsed._root.children.slice();
|
|
113
|
+
},
|
|
114
|
+
extract<M extends ExtractMap>(this: CheerioAPI, map: M): ExtractedMap<M> { return this.root().extract(map); },
|
|
115
|
+
contains,
|
|
116
|
+
merge,
|
|
117
|
+
};
|
|
118
|
+
|
|
108
119
|
function makeRoot(document: NativeDocument, children: number[]): number {
|
|
109
120
|
const id = operation<number>(document, 'createNode', { type: 'root' });
|
|
110
121
|
operation(document, 'setNode', { id, field: 'children', value: children });
|
package/src/match.ts
ADDED
|
@@ -0,0 +1,207 @@
|
|
|
1
|
+
import type { NativeDocument } from './native.js';
|
|
2
|
+
import type { CheerioOptions } from './types.js';
|
|
3
|
+
import { attributeAt, isRouted, plainSnapshot, recordAt, stateIn, type DocumentState } from './router.js';
|
|
4
|
+
import { NodeSnapshot } from './snapshot.js';
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* One compound selector: an optional type plus id, class and attribute-presence
|
|
8
|
+
* tests. Lowercased names are kept for documents that lowercase names.
|
|
9
|
+
*/
|
|
10
|
+
interface Compound { tag: string | undefined; lowerTag: string | undefined; ids: string[]; classes: string[]; attributes: string[]; lowerAttributes: string[] }
|
|
11
|
+
|
|
12
|
+
const compiled = new Map<string, Compound[] | null>();
|
|
13
|
+
const compiledLimit = 512;
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* Matches `ids` against selectors simple enough to evaluate on cached records,
|
|
17
|
+
* with the native engine's semantics; `undefined` means the caller must ask
|
|
18
|
+
* the native engine (any other syntax, foreign documents or parser options).
|
|
19
|
+
*/
|
|
20
|
+
export function matchSimpleSelector(document: NativeDocument, ids: readonly number[], selector: string, options: CheerioOptions): number[] | undefined {
|
|
21
|
+
if (!isRouted(document)) return undefined;
|
|
22
|
+
const compounds = compile(selector);
|
|
23
|
+
if (!compounds) return undefined;
|
|
24
|
+
const isXml = isXmlMode(options);
|
|
25
|
+
const isLowerCaseTags = options.lowerCaseTags ?? !isXml;
|
|
26
|
+
const isLowerCaseAttributes = options.lowerCaseAttributeNames ?? !isXml;
|
|
27
|
+
const result: number[] = [];
|
|
28
|
+
for (const id of ids) {
|
|
29
|
+
const state = stateIn(document, id);
|
|
30
|
+
if (!sameMatchOptions(state.options, isXml, isLowerCaseTags, isLowerCaseAttributes)) return undefined;
|
|
31
|
+
const local = id - state.base;
|
|
32
|
+
for (const compound of compounds) {
|
|
33
|
+
if (matchesCompound(state, local, compound, isLowerCaseTags, isLowerCaseAttributes)) { result.push(id); break; }
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
return result;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/** Nodes a single `find()` walks in JavaScript before leaving larger subtrees to the native engine. */
|
|
40
|
+
const walkBudget = 4096;
|
|
41
|
+
/** Documents small enough that walking all of them in JavaScript beats a native call. */
|
|
42
|
+
const smallDocumentNodes = 512;
|
|
43
|
+
/** Documents small enough that taking a snapshot to walk them still beats a native call. */
|
|
44
|
+
const tinyDocumentNodes = 64;
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* Selects the descendants of one element, or of the root of a small
|
|
48
|
+
* document, that match a simple selector, in document order, as the native
|
|
49
|
+
* `select` does; `undefined` defers to it.
|
|
50
|
+
*/
|
|
51
|
+
export function selectSimpleWithin(document: NativeDocument, contexts: readonly number[], selector: string, options: CheerioOptions): number[] | undefined {
|
|
52
|
+
if (contexts.length !== 1 || !isRouted(document)) return undefined;
|
|
53
|
+
const compounds = compile(selector);
|
|
54
|
+
if (!compounds) return undefined;
|
|
55
|
+
const isXml = isXmlMode(options);
|
|
56
|
+
const isLowerCaseTags = options.lowerCaseTags ?? !isXml;
|
|
57
|
+
const isLowerCaseAttributes = options.lowerCaseAttributeNames ?? !isXml;
|
|
58
|
+
const state = stateIn(document, contexts[0]);
|
|
59
|
+
if (!sameMatchOptions(state.options, isXml, isLowerCaseTags, isLowerCaseAttributes)) return undefined;
|
|
60
|
+
const context = contexts[0] - state.base;
|
|
61
|
+
const size = Math.max(state.baseline, state.snapshotSize);
|
|
62
|
+
const isDocumentRoot = context === (state.rootLocal ??= state.raw.rootId());
|
|
63
|
+
const contextType = isDocumentRoot ? 'root' : typeAt(state, context);
|
|
64
|
+
// After a write, walking would cost a fresh snapshot; the native engine reads the arena directly.
|
|
65
|
+
// A walk reads every node: worthwhile with a current snapshot, or when a tiny unchanged document needs one.
|
|
66
|
+
const isReadable = state.bulk instanceof NodeSnapshot ? size <= smallDocumentNodes : state.generation === 0 && size <= tinyDocumentNodes;
|
|
67
|
+
const isSmallRoot = contextType === 'root' && isReadable;
|
|
68
|
+
if (!isElementType(contextType) && !isSmallRoot) return undefined;
|
|
69
|
+
const result: number[] = [];
|
|
70
|
+
const stack: number[] = [];
|
|
71
|
+
pushChildren(state, context, stack);
|
|
72
|
+
for (let visited = 0; stack.length; visited++) {
|
|
73
|
+
if (visited === walkBudget) return undefined;
|
|
74
|
+
const local = stack.pop()!;
|
|
75
|
+
const type = typeAt(state, local);
|
|
76
|
+
// Template contents: outside an element's descendants, but selected from the document root.
|
|
77
|
+
if (type === 'root') { if (isSmallRoot) return undefined; continue; }
|
|
78
|
+
if (isElementType(type)) {
|
|
79
|
+
for (const compound of compounds) {
|
|
80
|
+
if (matchesCompound(state, local, compound, isLowerCaseTags, isLowerCaseAttributes)) { result.push(state.base + local); break; }
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
pushChildren(state, local, stack);
|
|
84
|
+
}
|
|
85
|
+
return result;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
const isElementType = (type: string | undefined): boolean => type === 'tag' || type === 'script' || type === 'style';
|
|
89
|
+
|
|
90
|
+
function typeAt(state: DocumentState, local: number): string | undefined {
|
|
91
|
+
const snapshot = plainSnapshot(state, local);
|
|
92
|
+
return snapshot ? snapshot.typeOf(local) : recordAt(state, local)?.type;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/** Pushes children last to first, so they pop in document order. */
|
|
96
|
+
function pushChildren(state: DocumentState, local: number, stack: number[]): void {
|
|
97
|
+
const snapshot = plainSnapshot(state, local);
|
|
98
|
+
if (snapshot) {
|
|
99
|
+
for (let index = snapshot.childCount(local) - 1; index >= 0; index--) stack.push(snapshot.childAt(local, index));
|
|
100
|
+
return;
|
|
101
|
+
}
|
|
102
|
+
const children = recordAt(state, local)?.children ?? [];
|
|
103
|
+
for (let index = children.length - 1; index >= 0; index--) stack.push(children[index]);
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
function isXmlMode(options: CheerioOptions): boolean {
|
|
107
|
+
return options.xmlMode ?? (options.xml === true || typeof options.xml === 'object' && options.xml !== null);
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
/** Records were parsed with the document's options; matching must use the same name casing. */
|
|
111
|
+
function sameMatchOptions(documentOptions: CheerioOptions, isXml: boolean, isLowerCaseTags: boolean, isLowerCaseAttributes: boolean): boolean {
|
|
112
|
+
const isDocumentXml = isXmlMode(documentOptions);
|
|
113
|
+
return isDocumentXml === isXml
|
|
114
|
+
&& (documentOptions.lowerCaseTags ?? !isDocumentXml) === isLowerCaseTags
|
|
115
|
+
&& (documentOptions.lowerCaseAttributeNames ?? !isDocumentXml) === isLowerCaseAttributes;
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
function matchesCompound(state: DocumentState, local: number, compound: Compound, isLowerCaseTags: boolean, isLowerCaseAttributes: boolean): boolean {
|
|
119
|
+
const snapshot = plainSnapshot(state, local);
|
|
120
|
+
const record = snapshot ? undefined : recordAt(state, local);
|
|
121
|
+
if (!snapshot && !record) return false;
|
|
122
|
+
if (!isElementType(snapshot ? snapshot.typeOf(local) : record!.type)) return false;
|
|
123
|
+
if (compound.tag !== undefined && (snapshot ? snapshot.nameOf(local) : record!.name) !== (isLowerCaseTags ? compound.lowerTag : compound.tag)) return false;
|
|
124
|
+
for (const id of compound.ids) if (attributeAt(state, local, 'id') !== id) return false;
|
|
125
|
+
if (compound.classes.length) {
|
|
126
|
+
const classes = attributeAt(state, local, 'class');
|
|
127
|
+
if (classes === undefined) return false;
|
|
128
|
+
for (const name of compound.classes) if (!hasClass(classes, name)) return false;
|
|
129
|
+
}
|
|
130
|
+
for (const name of isLowerCaseAttributes ? compound.lowerAttributes : compound.attributes) if (attributeAt(state, local, name) === undefined) return false;
|
|
131
|
+
return true;
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
const isAsciiWhitespace = (code: number): boolean => code === 32 || code === 9 || code === 10 || code === 12 || code === 13;
|
|
135
|
+
|
|
136
|
+
/** Whether `name` is one of the ASCII-whitespace-separated tokens of `classes`. */
|
|
137
|
+
function hasClass(classes: string, name: string): boolean {
|
|
138
|
+
for (let start = classes.indexOf(name); start !== -1; start = classes.indexOf(name, start + 1)) {
|
|
139
|
+
const end = start + name.length;
|
|
140
|
+
if ((start === 0 || isAsciiWhitespace(classes.charCodeAt(start - 1))) && (end === classes.length || isAsciiWhitespace(classes.charCodeAt(end)))) return true;
|
|
141
|
+
}
|
|
142
|
+
return false;
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
function asciiLowerCase(value: string): string {
|
|
146
|
+
return /[A-Z]/.test(value) ? value.replace(/[A-Z]/g, (letter) => letter.toLowerCase()) : value;
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
function compile(selector: string): Compound[] | null {
|
|
150
|
+
const cached = compiled.get(selector);
|
|
151
|
+
if (cached !== undefined) return cached;
|
|
152
|
+
const result = parse(selector);
|
|
153
|
+
if (compiled.size >= compiledLimit) compiled.clear();
|
|
154
|
+
compiled.set(selector, result);
|
|
155
|
+
return result;
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
const identifierStart = /[A-Za-z_]/;
|
|
159
|
+
const identifierPart = /[A-Za-z0-9_-]/;
|
|
160
|
+
|
|
161
|
+
/** Parses the supported subset; anything else (escapes, combinators, pseudos, values) yields `null`. */
|
|
162
|
+
function parse(selector: string): Compound[] | null {
|
|
163
|
+
const compounds: Compound[] = [];
|
|
164
|
+
let index = 0;
|
|
165
|
+
const skipSpace = (): void => { while (index < selector.length && isAsciiWhitespace(selector.charCodeAt(index))) index++; };
|
|
166
|
+
const identifier = (): string | null => {
|
|
167
|
+
const start = index;
|
|
168
|
+
if (selector[index] === '-') index++;
|
|
169
|
+
if (!identifierStart.test(selector[index] ?? '')) return null;
|
|
170
|
+
while (index < selector.length && identifierPart.test(selector[index])) index++;
|
|
171
|
+
return selector.slice(start, index);
|
|
172
|
+
};
|
|
173
|
+
for (;;) {
|
|
174
|
+
skipSpace();
|
|
175
|
+
const compound: Compound = { tag: undefined, lowerTag: undefined, ids: [], classes: [], attributes: [], lowerAttributes: [] };
|
|
176
|
+
let hasPart = false;
|
|
177
|
+
if (selector[index] === '*') { index++; hasPart = true; }
|
|
178
|
+
else if (/[A-Za-z]/.test(selector[index] ?? '')) { compound.tag = identifier()!; hasPart = true; }
|
|
179
|
+
for (;;) {
|
|
180
|
+
const character = selector[index];
|
|
181
|
+
if (character === '#' || character === '.') {
|
|
182
|
+
index++;
|
|
183
|
+
const name = identifier();
|
|
184
|
+
if (name === null) return null;
|
|
185
|
+
(character === '#' ? compound.ids : compound.classes).push(name);
|
|
186
|
+
} else if (character === '[') {
|
|
187
|
+
index++;
|
|
188
|
+
skipSpace();
|
|
189
|
+
const name = identifier();
|
|
190
|
+
if (name === null) return null;
|
|
191
|
+
skipSpace();
|
|
192
|
+
if (selector[index] !== ']') return null;
|
|
193
|
+
index++;
|
|
194
|
+
compound.attributes.push(name);
|
|
195
|
+
} else break;
|
|
196
|
+
hasPart = true;
|
|
197
|
+
}
|
|
198
|
+
if (!hasPart) return null;
|
|
199
|
+
compound.lowerTag = compound.tag === undefined ? undefined : asciiLowerCase(compound.tag);
|
|
200
|
+
compound.lowerAttributes = compound.attributes.map(asciiLowerCase);
|
|
201
|
+
compounds.push(compound);
|
|
202
|
+
skipSpace();
|
|
203
|
+
if (index === selector.length) return compounds;
|
|
204
|
+
if (selector[index] !== ',') return null;
|
|
205
|
+
index++;
|
|
206
|
+
}
|
|
207
|
+
}
|
package/src/native.ts
CHANGED
|
@@ -90,6 +90,14 @@ let cached: NativeBinding | undefined;
|
|
|
90
90
|
let attempted = false;
|
|
91
91
|
let bindingError: unknown;
|
|
92
92
|
|
|
93
|
+
/**
|
|
94
|
+
* The directory of this bundle, shared by the package's CommonJS and ESM
|
|
95
|
+
* entry points and distinct for every installed copy of the package.
|
|
96
|
+
*/
|
|
97
|
+
export function packageDirectory(): string {
|
|
98
|
+
return typeof __dirname === 'string' ? __dirname : dirname(fileURLToPath(import.meta.url));
|
|
99
|
+
}
|
|
100
|
+
|
|
93
101
|
/** Resolve the native addon bundled with the npm package. */
|
|
94
102
|
export function nativeBinding(): NativeBinding | undefined {
|
|
95
103
|
if (attempted) return cached;
|
|
@@ -97,7 +105,7 @@ export function nativeBinding(): NativeBinding | undefined {
|
|
|
97
105
|
|
|
98
106
|
// This module is bundled into both CommonJS and ESM entry points. A synthetic
|
|
99
107
|
// absolute filename lets createRequire work in both module formats.
|
|
100
|
-
const moduleDirectory =
|
|
108
|
+
const moduleDirectory = packageDirectory();
|
|
101
109
|
const require = createRequire(join(moduleDirectory, 'rusteerio-loader.cjs'));
|
|
102
110
|
const directories = [moduleDirectory, dirname(moduleDirectory)];
|
|
103
111
|
const libc = process.platform === 'linux'
|