rusteerio 1.0.0 → 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.
Files changed (65) hide show
  1. package/Cargo.lock +42 -1
  2. package/Cargo.toml +8 -1
  3. package/README.md +90 -51
  4. package/THIRD_PARTY_NOTICES.md +138 -2
  5. package/dist/{chunk-DMRD3FGC.mjs → chunk-FPRP6UIX.mjs} +2233 -751
  6. package/dist/chunk-FPRP6UIX.mjs.map +1 -0
  7. package/dist/index.cjs +2232 -750
  8. package/dist/index.cjs.map +1 -1
  9. package/dist/index.d.mts +2 -2
  10. package/dist/index.d.ts +2 -2
  11. package/dist/index.mjs +1 -1
  12. package/dist/slim.cjs +2226 -745
  13. package/dist/slim.cjs.map +1 -1
  14. package/dist/slim.d.mts +2 -2
  15. package/dist/slim.d.ts +2 -2
  16. package/dist/slim.mjs +1 -1
  17. package/dist/{types-BP6TpxE5.d.mts → types-Cs9UP1Vv.d.mts} +3 -3
  18. package/dist/{types-BP6TpxE5.d.ts → types-Cs9UP1Vv.d.ts} +3 -3
  19. package/dist/utils.d.mts +1 -1
  20. package/dist/utils.d.ts +1 -1
  21. package/native/src/arena.rs +231 -602
  22. package/native/src/attributes.rs +11 -5
  23. package/native/src/codec.rs +62 -18
  24. package/native/src/document_order.rs +711 -0
  25. package/native/src/extraction.rs +15 -3
  26. package/native/src/gc.rs +12 -3
  27. package/native/src/hook_sink.rs +77 -3
  28. package/native/src/id_hash.rs +42 -0
  29. package/native/src/immutable.rs +256 -0
  30. package/native/src/lib.rs +1097 -172
  31. package/native/src/markup_tokenizer.rs +668 -0
  32. package/native/src/runtime.rs +189 -33
  33. package/native/src/selector.rs +346 -44
  34. package/native/src/sink.rs +14 -1
  35. package/native/src/snapshot.rs +509 -0
  36. package/native/src/stream.rs +2 -2
  37. package/native/src/tokenizer.rs +377 -204
  38. package/native/vendor/README.md +3 -2
  39. package/native/vendor/html5ever-0.35.0/src/tokenizer/char_ref/mod.rs +79 -0
  40. package/native/vendor/html5ever-0.35.0/src/tokenizer/mod.rs +23 -13
  41. package/package.json +3 -1
  42. package/rusteerio.darwin-arm64.node +0 -0
  43. package/rusteerio.darwin-x64.node +0 -0
  44. package/rusteerio.linux-arm64-gnu.node +0 -0
  45. package/rusteerio.linux-arm64-musl.node +0 -0
  46. package/rusteerio.linux-x64-gnu.node +0 -0
  47. package/rusteerio.linux-x64-musl.node +0 -0
  48. package/rusteerio.win32-arm64-msvc.node +0 -0
  49. package/rusteerio.win32-x64-msvc.node +0 -0
  50. package/scripts/generate-notices.mjs +11 -0
  51. package/scripts/notices/htmlparser2-MIT.txt +18 -0
  52. package/scripts/notices/sources.json +6 -0
  53. package/src/attributes.ts +78 -44
  54. package/src/codec.ts +22 -3
  55. package/src/extract.ts +86 -2
  56. package/src/hooks.ts +148 -2
  57. package/src/index.ts +51 -40
  58. package/src/match.ts +207 -0
  59. package/src/native.ts +9 -1
  60. package/src/nodes.ts +531 -183
  61. package/src/router.ts +792 -229
  62. package/src/selection.ts +239 -125
  63. package/src/snapshot.ts +255 -0
  64. package/src/transport.ts +51 -42
  65. 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 initialize = function<T = AnyNode>(selector?: any, context?: any, root?: any, override?: CheerioOptions): Cheerio<T> {
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
- api = Object.assign(initialize, {
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: getNode(document, initialRoot),
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 = typeof __dirname === 'string' ? __dirname : dirname(fileURLToPath(import.meta.url));
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'