rusteerio 1.1.1 → 1.1.2

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 (45) hide show
  1. package/Cargo.lock +1 -1
  2. package/Cargo.toml +1 -1
  3. package/README.md +37 -37
  4. package/THIRD_PARTY_NOTICES.md +1 -1
  5. package/dist/{chunk-FPRP6UIX.mjs → chunk-HN63P3U7.mjs} +625 -401
  6. package/dist/chunk-HN63P3U7.mjs.map +1 -0
  7. package/dist/index.cjs +624 -400
  8. package/dist/index.cjs.map +1 -1
  9. package/dist/index.d.mts +3 -2
  10. package/dist/index.d.ts +3 -2
  11. package/dist/index.mjs +1 -1
  12. package/dist/slim.cjs +555 -407
  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-Cs9UP1Vv.d.mts → types-CJsc0PH-.d.mts} +12 -0
  18. package/dist/{types-Cs9UP1Vv.d.ts → types-CJsc0PH-.d.ts} +12 -0
  19. package/dist/utils.d.mts +1 -1
  20. package/dist/utils.d.ts +1 -1
  21. package/native/src/arena.rs +163 -7
  22. package/native/src/attributes.rs +18 -1
  23. package/native/src/codec.rs +79 -0
  24. package/native/src/lib.rs +117 -30
  25. package/native/src/snapshot.rs +117 -80
  26. package/native/src/tokenizer.rs +34 -20
  27. package/package.json +1 -1
  28. package/rusteerio.darwin-arm64.node +0 -0
  29. package/rusteerio.darwin-x64.node +0 -0
  30. package/rusteerio.linux-arm64-gnu.node +0 -0
  31. package/rusteerio.linux-arm64-musl.node +0 -0
  32. package/rusteerio.linux-x64-gnu.node +0 -0
  33. package/rusteerio.linux-x64-musl.node +0 -0
  34. package/rusteerio.win32-arm64-msvc.node +0 -0
  35. package/rusteerio.win32-x64-msvc.node +0 -0
  36. package/src/extract.ts +117 -24
  37. package/src/index.ts +27 -6
  38. package/src/match.ts +4 -4
  39. package/src/native.ts +4 -0
  40. package/src/nodes.ts +70 -10
  41. package/src/router.ts +80 -4
  42. package/src/selection.ts +18 -17
  43. package/src/snapshot.ts +37 -0
  44. package/src/transport.ts +1 -1
  45. package/dist/chunk-FPRP6UIX.mjs.map +0 -1
@@ -1,13 +1,15 @@
1
1
  //! Build htmlparser2-compatible trees from tokenizer events. Events come from
2
2
  //! the native tokenizer port or from an explicitly supplied JavaScript
3
3
  //! Tokenizer, whose recorded events directly determine the DOM.
4
- use crate::arena::{append_parsed, html_parser_void, implied_close, DomArena, DomNode};
4
+ use crate::arena::{
5
+ append_parsed, append_parsed_new, append_parsed_text, html_parser_void, implied_close,
6
+ DomArena, DomNode,
7
+ };
5
8
  use crate::markup_tokenizer::Token;
6
9
  use serde_json::{json, Map, Value};
7
10
 
8
11
  struct Pending {
9
12
  name: String,
10
- attrs: Map<String, Value>,
11
13
  start: usize,
12
14
  }
13
15
  struct Builder<'a> {
@@ -16,6 +18,9 @@ struct Builder<'a> {
16
18
  parents: Vec<u32>,
17
19
  foreign: Vec<bool>,
18
20
  pending: Option<Pending>,
21
+ /// The pending tag's attributes in source order, collected so its map is
22
+ /// allocated once at its final size.
23
+ attributes: Vec<(String, String)>,
19
24
  attribute_name: String,
20
25
  attribute_value: String,
21
26
  start: usize,
@@ -40,14 +45,21 @@ impl<'a> Builder<'a> {
40
45
  let with_end_indices = options.get("withEndIndices") == Some(&Value::Bool(true));
41
46
  let recognize_self_closing = options.get("recognizeSelfClosing").and_then(Value::as_bool);
42
47
  let mut arena = DomArena::from_records(vec![DomNode::new("root")], options);
43
- // Growing the arena one doubling at a time copies every node record again.
44
- arena.nodes.reserve(source.units.len() / 48);
48
+ // Growing the arena one doubling at a time copies every node record
49
+ // again. Every node but text starts with `<`, and text lies between them.
50
+ let markup_starts = source
51
+ .units
52
+ .iter()
53
+ .filter(|unit| **unit == u16::from(b'<'))
54
+ .count();
55
+ arena.nodes.reserve(markup_starts + 1);
45
56
  Self {
46
57
  arena,
47
58
  source,
48
59
  parents: vec![0],
49
60
  foreign: vec![xml],
50
61
  pending: None,
62
+ attributes: Vec::new(),
51
63
  attribute_name: String::new(),
52
64
  attribute_value: String::new(),
53
65
  start: 0,
@@ -109,9 +121,9 @@ impl<'a> Builder<'a> {
109
121
  self.foreign.push(false);
110
122
  }
111
123
  }
124
+ self.attributes.clear();
112
125
  self.pending = Some(Pending {
113
126
  name,
114
- attrs: Map::new(),
115
127
  start: self.start,
116
128
  });
117
129
  }
@@ -123,18 +135,25 @@ impl<'a> Builder<'a> {
123
135
  "style" if !self.xml => "style",
124
136
  _ => "tag",
125
137
  };
126
- let mut node = DomNode::new(kind);
127
138
  let lowercase_name = (pending.name.bytes().any(|byte| byte.is_ascii_uppercase()))
128
139
  .then(|| pending.name.to_ascii_lowercase());
129
- node.name = Some(pending.name);
130
- node.attribs = pending.attrs;
131
- let id = append_parsed(
140
+ let id = append_parsed_new(
132
141
  &mut self.arena,
133
142
  *self.parents.last().unwrap(),
134
- node,
143
+ kind,
135
144
  pending.start,
136
145
  self.end,
137
146
  );
147
+ let node = &mut self.arena.nodes[id as usize];
148
+ node.name = Some(pending.name);
149
+ let mut attribs = Map::with_capacity(self.attributes.len());
150
+ for (name, value) in self.attributes.drain(..) {
151
+ // A repeated attribute keeps its first value, as htmlparser2 does.
152
+ if let serde_json::map::Entry::Vacant(entry) = attribs.entry(name) {
153
+ entry.insert(Value::String(value));
154
+ }
155
+ }
156
+ node.attribs = attribs.into();
138
157
  let name = lowercase_name
139
158
  .as_deref()
140
159
  .or(self.arena.nodes[id as usize].name.as_deref())
@@ -153,12 +172,10 @@ impl<'a> Builder<'a> {
153
172
  }
154
173
  fn text(&mut self, data: String, end: usize) {
155
174
  self.end = end.saturating_sub(1);
156
- let mut node = DomNode::new("text");
157
- node.data = Some(data);
158
- append_parsed(
175
+ append_parsed_text(
159
176
  &mut self.arena,
160
177
  *self.parents.last().unwrap(),
161
- node,
178
+ data,
162
179
  self.start,
163
180
  self.end,
164
181
  );
@@ -422,12 +439,9 @@ impl Builder<'_> {
422
439
  fn on_attribute_end(&mut self, end: usize) {
423
440
  self.end = end;
424
441
  let value = std::mem::take(&mut self.attribute_value);
425
- if let Some(pending) = self.pending.as_mut() {
426
- if !pending.attrs.contains_key(&self.attribute_name) {
427
- pending
428
- .attrs
429
- .insert(self.attribute_name.clone(), Value::String(value));
430
- }
442
+ if self.pending.is_some() {
443
+ self.attributes
444
+ .push((std::mem::take(&mut self.attribute_name), value));
431
445
  }
432
446
  }
433
447
  fn on_close_tag(&mut self, start: usize, end: usize) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "rusteerio",
3
- "version": "1.1.1",
3
+ "version": "1.1.2",
4
4
  "description": "Cheerio-compatible HTML processing with a Rust engine and parallel, non-blocking extraction",
5
5
  "license": "MIT",
6
6
  "repository": {
Binary file
Binary file
Binary file
Binary file
Binary file
Binary file
Binary file
Binary file
package/src/extract.ts CHANGED
@@ -1,8 +1,11 @@
1
1
  import { Cheerio, nativeExtractionMethods } from './selection.js';
2
2
  import { types } from 'node:util';
3
3
  import { compileExtractPlan, hydrateExtractResult } from './extract-plan.js';
4
- import { writeCount } from './router.js';
5
- import type { AnyNode, Element, ExtractMap, ExtractedMap, ExtractDescriptor } from './types.js';
4
+ import { decodeJson } from './codec.js';
5
+ import { DOCUMENT, writeCount } from './router.js';
6
+ import type { AnyNode, CheerioOptions, Element, ExtractMap, ExtractedMap, ExtractDescriptor } from './types.js';
7
+
8
+ type ExtractPlan = NonNullable<ReturnType<typeof compileExtractPlan>>;
6
9
 
7
10
  export interface ExtractMethods<T = AnyNode> {
8
11
  extract<M extends ExtractMap>(map: M): ExtractedMap<M>;
@@ -12,26 +15,32 @@ function plainKeys(map: ExtractMap): string[] | undefined {
12
15
  return !types.isProxy(map) && (Object.getPrototypeOf(map) === Object.prototype || Object.getPrototypeOf(map) === null) ? Object.keys(map) : undefined;
13
16
  }
14
17
  function nativePrefix(scope: Cheerio<AnyNode>, map: ExtractMap, keys: string[] | undefined, key: string): { keys: string[]; values: Record<string, unknown> } | undefined {
15
- if (!keys || scope._make !== nativeExtractionMethods.make || scope._findBySelector !== nativeExtractionMethods.find || scope.prop !== nativeExtractionMethods.prop || scope.extract !== nativeExtractionMethods.extract) return undefined;
18
+ if (!keys || !hasNativeExtraction(scope)) return undefined;
19
+ const prefix = prefixPlan(map, keys, key, scope.options);
20
+ // A single scalar entry costs one selector call and one property call; the
21
+ // native plan also avoids constructing an intermediate selection for each.
22
+ if (!prefix) return undefined;
23
+ return { keys: prefix.names, values: runPlan(scope, prefix.plan) };
24
+ }
25
+
26
+ /** The native plan for the longest run of entries from `key` on that compile to one. */
27
+ function prefixPlan(map: ExtractMap, keys: string[], key: string, options: CheerioOptions): { names: string[]; plan: ExtractPlan } | undefined {
16
28
  const prefix: ExtractMap = Object.create(null);
17
- let count = 0;
18
- let plan: ReturnType<typeof compileExtractPlan>;
19
- const index = keys.indexOf(key);
20
- for (let position = index; position < keys.length; position++) {
29
+ let plan: ExtractPlan | undefined;
30
+ for (let position = keys.indexOf(key); position < keys.length; position++) {
21
31
  const name = keys[position];
22
32
  const slot = Object.getOwnPropertyDescriptor(map, name);
23
33
  if (!slot || !slot.enumerable || !('value' in slot)) break;
24
34
  Object.defineProperty(prefix, name, { value: slot.value, enumerable: true, configurable: true });
25
- const compiled = compileExtractPlan(prefix, scope.options);
35
+ const compiled = compileExtractPlan(prefix, options);
26
36
  if (!compiled) { delete prefix[name]; break; }
27
- plan = compiled; count++;
37
+ plan = compiled;
28
38
  }
29
- // A single scalar entry costs one selector call and one property call; the
30
- // native plan also avoids constructing an intermediate selection for each.
31
- if (!plan || !count) return undefined;
32
- const values = scope.op<Record<string, unknown>>('extractPlan', { ids: scope.ids, plan });
33
- hydrateExtractResult(values, plan.entries);
34
- return { keys: Object.keys(prefix), values };
39
+ return plan ? { names: Object.keys(prefix), plan } : undefined;
40
+ }
41
+
42
+ function hasNativeExtraction(scope: Cheerio<AnyNode>): boolean {
43
+ return scope._make === nativeExtractionMethods.make && scope._findBySelector === nativeExtractionMethods.find && scope.prop === nativeExtractionMethods.prop && scope.extract === nativeExtractionMethods.extract;
35
44
  }
36
45
 
37
46
  /** Descriptor callbacks stay in JavaScript; matching and properties use Rust. */
@@ -87,8 +96,19 @@ interface NativeRows { keys: Set<string>; rows: Record<string, unknown>[]; write
87
96
  * exactly the fields before it, as in Cheerio.
88
97
  */
89
98
  function nativeRows(scope: Cheerio<AnyNode>, key: string, all: boolean, selector: string, map: ExtractMap): NativeRows | undefined {
99
+ if (!hasNativeExtraction(scope)) return undefined;
100
+ const planned = rowsPlan(key, all, selector, map, scope.options);
101
+ if (!planned) return undefined;
102
+ const writes = writeCount();
103
+ const extracted = runPlan(scope, planned.plan)[key];
104
+ const rows = (all ? extracted : extracted === undefined ? [] : [extracted]) as Record<string, unknown>[];
105
+ return Array.isArray(rows) ? { keys: planned.nativeKeys, rows, writes } : undefined;
106
+ }
107
+
108
+ /** The native plan for the fields of `map` that compile to one, when only some do. */
109
+ function rowsPlan(key: string, all: boolean, selector: string, map: ExtractMap, options: CheerioOptions): { nativeKeys: Set<string>; plan: ExtractPlan } | undefined {
90
110
  const keys = plainKeys(map);
91
- if (!keys || scope._make !== nativeExtractionMethods.make || scope._findBySelector !== nativeExtractionMethods.find || scope.prop !== nativeExtractionMethods.prop || scope.extract !== nativeExtractionMethods.extract) return undefined;
111
+ if (!keys) return undefined;
92
112
  const subset: ExtractMap = Object.create(null);
93
113
  const nativeKeys = new Set<string>();
94
114
  for (const name of keys) {
@@ -96,21 +116,94 @@ function nativeRows(scope: Cheerio<AnyNode>, key: string, all: boolean, selector
96
116
  if (!slot || !slot.enumerable || !('value' in slot)) continue;
97
117
  const single: ExtractMap = Object.create(null);
98
118
  Object.defineProperty(single, name, { value: slot.value, enumerable: true, configurable: true });
99
- if (!compileExtractPlan(single, scope.options)) continue;
119
+ if (!compileExtractPlan(single, options)) continue;
100
120
  Object.defineProperty(subset, name, { value: slot.value, enumerable: true, configurable: true });
101
121
  nativeKeys.add(name);
102
122
  }
103
123
  if (!nativeKeys.size || nativeKeys.size === keys.length) return undefined;
104
124
  const outer: ExtractMap = Object.create(null);
105
125
  Object.defineProperty(outer, key, { value: all ? [{ selector, value: subset }] : { selector, value: subset }, enumerable: true, configurable: true });
106
- const plan = compileExtractPlan(outer, scope.options);
107
- if (!plan) return undefined;
108
- const writes = writeCount();
109
- const values = scope.op<Record<string, unknown>>('extractPlan', { ids: scope.ids, plan });
126
+ const plan = compileExtractPlan(outer, options);
127
+ return plan ? { nativeKeys, plan } : undefined;
128
+ }
129
+
130
+ /**
131
+ * The plans a walk of `map` from a document's root runs first, in order, so
132
+ * a batch can run them on its parsing threads. Callbacks may change the
133
+ * document, so later plans of the walk stay on the calling thread.
134
+ */
135
+ export function rootExtractPlans(map: ExtractMap, options: CheerioOptions): ExtractPlan[] {
136
+ // Reading a map ahead of the walk must not run code the walk would run in
137
+ // a different order, and a malformed map fails later with the walk's own error.
138
+ if (!isPlainData(map, 0) || hasInheritedEntryFields()) return [];
139
+ try { return collectRootExtractPlans(map, options); } catch { return []; }
140
+ }
141
+
142
+ /** Whether reading `value` and everything it holds runs no code: no proxies, accessors or class instances. */
143
+ function isPlainData(value: unknown, depth: number): boolean {
144
+ if (typeof value !== 'object' || value === null) return true;
145
+ if (depth > 32 || types.isProxy(value)) return false;
146
+ const prototype = Object.getPrototypeOf(value);
147
+ if (prototype !== Object.prototype && prototype !== Array.prototype && prototype !== null) return false;
148
+ for (const key of Reflect.ownKeys(value)) {
149
+ const slot = Object.getOwnPropertyDescriptor(value, key)!;
150
+ if (!('value' in slot) || !isPlainData(slot.value, depth + 1)) return false;
151
+ }
152
+ return true;
153
+ }
154
+
155
+ function hasInheritedEntryFields(): boolean {
156
+ return 'selector' in Object.prototype || 'value' in Object.prototype || '0' in Array.prototype;
157
+ }
158
+
159
+ function collectRootExtractPlans(map: ExtractMap, options: CheerioOptions): ExtractPlan[] {
160
+ const plans: ExtractPlan[] = [];
161
+ const keys = plainKeys(map);
162
+ const processed = new Set<string>();
163
+ for (const key in map) {
164
+ if (processed.has(key)) continue;
165
+ const prefix = keys ? prefixPlan(map, keys, key, options) : undefined;
166
+ if (prefix) {
167
+ for (const name of prefix.names) processed.add(name);
168
+ plans.push(prefix.plan);
169
+ continue;
170
+ }
171
+ const entry = map[key];
172
+ const all = Array.isArray(entry);
173
+ const descriptor = (all ? entry[0] : entry) as string | ExtractDescriptor;
174
+ const value = typeof descriptor === 'string' ? 'textContent' : descriptor.value ?? 'textContent';
175
+ if (typeof value !== 'object' || value === null) continue;
176
+ const planned = rowsPlan(key, all, typeof descriptor === 'string' ? descriptor : descriptor.selector, value, options);
177
+ if (planned) plans.push(planned.plan);
178
+ }
179
+ return plans.filter((plan) => !JSON.stringify(plan).includes('innerHTML'));
180
+ }
181
+
182
+ /** Results of root plans a batch ran while parsing, valid while no document changes. */
183
+ const preparedPlans = new WeakMap<object, { writes: number; results: Map<string, string> }>();
184
+
185
+ export function prepareExtractPlans(document: object, plans: ExtractPlan[], results: (string | null)[] | null | undefined): void {
186
+ if (!results) return;
187
+ const prepared = new Map<string, string>();
188
+ for (let index = 0; index < plans.length; index++) {
189
+ const result = results[index];
190
+ if (typeof result === 'string') prepared.set(JSON.stringify(plans[index]), decodeJson(result));
191
+ }
192
+ preparedPlans.set(document, { writes: writeCount(), results: prepared });
193
+ }
194
+
195
+ export function releaseExtractPlans(document: object): void {
196
+ preparedPlans.delete(document);
197
+ }
198
+
199
+ function runPlan(scope: Cheerio<AnyNode>, plan: ExtractPlan): Record<string, unknown> {
200
+ const document = scope[DOCUMENT] as { rootId(): number };
201
+ const prepared = preparedPlans.get(document);
202
+ const isRoot = prepared !== undefined && prepared.writes === writeCount() && scope.ids.length === 1 && scope.ids[0] === document.rootId();
203
+ const result = isRoot ? prepared.results.get(JSON.stringify(plan)) : undefined;
204
+ const values = result !== undefined ? JSON.parse(result) as Record<string, unknown> : scope.op<Record<string, unknown>>('extractPlan', { ids: scope.ids, plan });
110
205
  hydrateExtractResult(values, plan.entries);
111
- const extracted = values[key];
112
- const rows = (all ? extracted : extracted === undefined ? [] : [extracted]) as Record<string, unknown>[];
113
- return Array.isArray(rows) ? { keys: nativeKeys, rows, writes } : undefined;
206
+ return values;
114
207
  }
115
208
 
116
209
  /**
package/src/index.ts CHANGED
@@ -1,14 +1,14 @@
1
1
  import * as nativeDOM from './dom.js';
2
2
  import { parseNativeDocument, parserOptions, deliverParseErrors, hasParserHooks, cleanParserOptions, createTreeAdapterSession } from './hooks.js';
3
3
  import { compileExtractPlan, hydrateExtractResult } from './extract-plan.js';
4
- import { extractCooperatively } from './extract.js';
4
+ import { extractCooperatively, prepareExtractPlans, releaseExtractPlans, rootExtractPlans } from './extract.js';
5
5
  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
9
  import { getNode, importNodes, nodeBinding, operation, rootNode, viewIdIn } from './nodes.js';
10
10
  import { isHtml } from './utils.js';
11
- import { DOCUMENT, exposeDocumentTo, resumeCollection, routeDocument, sameNodeSpace } from './router.js';
11
+ import { DOCUMENT, exposeDocumentTo, resumeCollection, routeDocument, sameNodeSpace, usePreparedSnapshot } 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';
@@ -26,7 +26,11 @@ export { configureWorkers, workerPoolInfo } from './workers.js';
26
26
  export type { WorkerOptions, WorkerPoolInfo } from './workers.js';
27
27
  export { loadBuffer, stringStream, decodeStream, fromURL } from './io.js';
28
28
 
29
+ const defaultOptions: CheerioOptions = { _useHtmlParser2: false };
30
+
31
+ /** Cheerio's option merge: without overrides, calls share the base options object. */
29
32
  export function flattenOptions(options?: CheerioOptions | null, base?: CheerioOptions): CheerioOptions {
33
+ if (!options) return base ?? defaultOptions;
30
34
  const result: CheerioOptions = { _useHtmlParser2: false, ...base, ...options };
31
35
  if (options?.xml) { result._useHtmlParser2 = true; result.xmlMode = true; if (options.xml !== true) Object.assign(result, options.xml); }
32
36
  else if (options?.xmlMode) result._useHtmlParser2 = true;
@@ -181,13 +185,29 @@ function parallelOptions(options?: ParallelOptions | null): { options: CheerioOp
181
185
  }
182
186
 
183
187
  export async function loadAll(content: readonly string[], options?: ParallelOptions | null, isDocument = true): Promise<CheerioAPI[]> {
188
+ return loadAllWithPlans(content, options, isDocument);
189
+ }
190
+
191
+ /**
192
+ * Loads documents in parallel. With `plans`, the parsing threads also run
193
+ * them on each root and build each document's snapshot, which extraction
194
+ * with callbacks would otherwise do on the calling thread when it needs them.
195
+ */
196
+ async function loadAllWithPlans(content: readonly string[], options: ParallelOptions | null | undefined, isDocument: boolean, plans?: ReturnType<typeof rootExtractPlans>): Promise<CheerioAPI[]> {
184
197
  const { options: opts, threads } = parallelOptions(options);
185
198
  if (!Array.isArray(content) || content.some((item) => typeof item !== 'string')) throw new TypeError('loadAll expects an array of HTML strings');
186
199
  if (!content.length) return [];
187
200
  const binding = requireNative();
188
201
  if (!binding.parseManyAsync || hasParserHooks(opts)) return Promise.all(content.map((html) => loadAsync(html, opts, isDocument)));
189
- const documents = await binding.parseManyAsync([...content], isDocument, threads, JSON.stringify(opts));
190
- return documents.map((document) => createAPI(routeDocument(document, opts), opts));
202
+ const documents = await binding.parseManyAsync([...content], isDocument, threads, JSON.stringify(opts), plans && JSON.stringify(plans));
203
+ return documents.map((document) => {
204
+ const routed = routeDocument(document, opts);
205
+ if (plans) {
206
+ usePreparedSnapshot(routed);
207
+ prepareExtractPlans(routed, plans, document.takePreparedPlans?.());
208
+ }
209
+ return createAPI(routed, opts);
210
+ });
191
211
  }
192
212
 
193
213
  function nativeQueries(map: ExtractMap, options: CheerioOptions): NativeQuery[] | undefined {
@@ -223,11 +243,12 @@ export async function extractAll<M extends ExtractMap>(content: readonly string[
223
243
  }
224
244
  return results as ExtractedMap<M>[];
225
245
  }
226
- const documents = await loadAll(content, { ...opts, threads });
246
+ const documents = await loadAllWithPlans(content, { ...opts, threads }, true, hasParserHooks(opts) ? undefined : rootExtractPlans(map, opts));
227
247
  const results: ExtractedMap<M>[] = [];
228
248
  let yieldedAt = performance.now();
229
249
  for (const document of documents) {
230
- results.push((await extractCooperatively(document.root(), map)).value);
250
+ const root = document.root();
251
+ try { results.push((await extractCooperatively(root, map)).value); } finally { releaseExtractPlans(root[DOCUMENT]); }
231
252
  if (performance.now() - yieldedAt >= 4) { await yieldToEventLoop(); yieldedAt = performance.now(); }
232
253
  }
233
254
  return results;
package/src/match.ts CHANGED
@@ -1,6 +1,6 @@
1
1
  import type { NativeDocument } from './native.js';
2
2
  import type { CheerioOptions } from './types.js';
3
- import { attributeAt, isRouted, plainSnapshot, recordAt, stateIn, type DocumentState } from './router.js';
3
+ import { attributeAt, isRouted, recordAt, stateIn, structureSnapshot, type DocumentState } from './router.js';
4
4
  import { NodeSnapshot } from './snapshot.js';
5
5
 
6
6
  /**
@@ -88,13 +88,13 @@ export function selectSimpleWithin(document: NativeDocument, contexts: readonly
88
88
  const isElementType = (type: string | undefined): boolean => type === 'tag' || type === 'script' || type === 'style';
89
89
 
90
90
  function typeAt(state: DocumentState, local: number): string | undefined {
91
- const snapshot = plainSnapshot(state, local);
91
+ const snapshot = structureSnapshot(state, local);
92
92
  return snapshot ? snapshot.typeOf(local) : recordAt(state, local)?.type;
93
93
  }
94
94
 
95
95
  /** Pushes children last to first, so they pop in document order. */
96
96
  function pushChildren(state: DocumentState, local: number, stack: number[]): void {
97
- const snapshot = plainSnapshot(state, local);
97
+ const snapshot = structureSnapshot(state, local);
98
98
  if (snapshot) {
99
99
  for (let index = snapshot.childCount(local) - 1; index >= 0; index--) stack.push(snapshot.childAt(local, index));
100
100
  return;
@@ -116,7 +116,7 @@ function sameMatchOptions(documentOptions: CheerioOptions, isXml: boolean, isLow
116
116
  }
117
117
 
118
118
  function matchesCompound(state: DocumentState, local: number, compound: Compound, isLowerCaseTags: boolean, isLowerCaseAttributes: boolean): boolean {
119
- const snapshot = plainSnapshot(state, local);
119
+ const snapshot = structureSnapshot(state, local);
120
120
  const record = snapshot ? undefined : recordAt(state, local);
121
121
  if (!snapshot && !record) return false;
122
122
  if (!isElementType(snapshot ? snapshot.typeOf(local) : record!.type)) return false;
package/src/native.ts CHANGED
@@ -3,6 +3,7 @@ import { existsSync } from 'node:fs';
3
3
  import { dirname, join } from 'node:path';
4
4
  import { fileURLToPath } from 'node:url';
5
5
  import { wrapNativeBinding } from './transport.js';
6
+ import type { NativeSnapshot } from './snapshot.js';
6
7
 
7
8
  export interface NativeNodeRecord {
8
9
  id: number;
@@ -41,6 +42,8 @@ export interface NativeDocument {
41
42
  tagName(id: number): string | undefined | null;
42
43
  exportTree(): string;
43
44
  rootId(): number;
45
+ takePreparedPlans?(): (string | null)[] | null;
46
+ takePreparedSnapshot?(isWanted: boolean): NativeSnapshot | null;
44
47
  parserDiagnostics?(): string;
45
48
  }
46
49
 
@@ -76,6 +79,7 @@ export interface NativeBinding {
76
79
  isDocument: boolean,
77
80
  threads: number,
78
81
  optionsJson?: string,
82
+ plansJson?: string,
79
83
  ) => Promise<NativeDocument[]>;
80
84
  extractManyAsync?: (
81
85
  html: string[],
package/src/nodes.ts CHANGED
@@ -1,8 +1,8 @@
1
1
  import type { NativeDocument, NativeNodeRecord } from './native.js';
2
- import type { NodeSnapshot } from './snapshot.js';
2
+ import { NodeSnapshot } from './snapshot.js';
3
3
  import type { AnyNode } from './types.js';
4
4
  import { nodePrototype } from './dom.js';
5
- import { cachedView, cacheView, exposeTo, isLocalState, isRouted, onViewRefresh, plainSnapshot, recordAt, routedFor, stateIn, VIEW_ATTRIBUTES, VIEW_CHILD_TARGET, VIEW_LOCAL, VIEW_SELF, VIEW_STATE, type DocumentState } from './router.js';
5
+ import { cachedView, cacheView, exposeTo, isLocalState, isRouted, onViewRefresh, plainSnapshot, recordAt, routedFor, stateIn, structureSnapshot, VIEW_ATTRIBUTES, VIEW_CHILD_TARGET, VIEW_LOCAL, VIEW_SELF, VIEW_STATE, type DocumentState } from './router.js';
6
6
 
7
7
  export interface NodeBinding { document: NativeDocument; id: number }
8
8
 
@@ -85,6 +85,10 @@ export function traverseRecords(document: NativeDocument, ids: number[], kind: s
85
85
  for (const id of ids) {
86
86
  const state = stateIn(document, id);
87
87
  const { base } = state;
88
+ // Attribute writes leave a snapshot's links and types current, and other
89
+ // writes discard it, so its arrays answer without records.
90
+ const snapshot = state.bulk instanceof NodeSnapshot ? state.bulk : undefined;
91
+ if (snapshot?.isPresent(id - base) && traverseSnapshot(snapshot, base, id - base, kind, result)) continue;
88
92
  const node = recordAt(state, id - base);
89
93
  if (!node) continue;
90
94
  const isElement = (local: number): boolean => isElementType(recordAt(state, local)?.type);
@@ -126,15 +130,62 @@ export function traverseRecords(document: NativeDocument, ids: number[], kind: s
126
130
  return result;
127
131
  }
128
132
 
133
+ /** `traverseRecords` for one node of a current snapshot; false for kinds it leaves to records. */
134
+ function traverseSnapshot(snapshot: NodeSnapshot, base: number, local: number, kind: string, result: number[]): boolean {
135
+ const pushChildren = (parent: number, isElementsOnly: boolean, except: number): void => {
136
+ const count = snapshot.childCount(parent);
137
+ for (let index = 0; index < count; index++) {
138
+ const child = snapshot.childAt(parent, index);
139
+ if (child !== except && (!isElementsOnly || snapshot.isElementAt(child))) result.push(base + child);
140
+ }
141
+ };
142
+ switch (kind) {
143
+ case 'children': pushChildren(local, true, -1); return true;
144
+ case 'contents': pushChildren(local, false, -1); return true;
145
+ case 'siblings': {
146
+ const parent = snapshot.linkOf(local, 1);
147
+ if (parent !== null && snapshot.isPresent(parent)) pushChildren(parent, true, local);
148
+ return true;
149
+ }
150
+ case 'parent': {
151
+ const parent = snapshot.linkOf(local, 1);
152
+ if (parent !== null && snapshot.isPresent(parent) && snapshot.isElementAt(parent)) result.push(base + parent);
153
+ return true;
154
+ }
155
+ case 'parents': case 'ancestors': {
156
+ let parent = snapshot.linkOf(local, 1);
157
+ for (let steps = 0; parent !== null && steps < snapshot.size && snapshot.isPresent(parent) && !snapshot.isRootAt(parent); steps++) {
158
+ if (snapshot.isElementAt(parent)) result.push(base + parent);
159
+ parent = snapshot.linkOf(parent, 1);
160
+ }
161
+ return true;
162
+ }
163
+ case 'next': case 'prev': case 'nextAll': case 'prevAll': {
164
+ const offset = kind === 'next' || kind === 'nextAll' ? 3 : 2;
165
+ const isSingle = kind === 'next' || kind === 'prev';
166
+ let sibling = snapshot.linkOf(local, offset);
167
+ for (let steps = 0; sibling !== null && steps < snapshot.size && snapshot.isPresent(sibling); steps++) {
168
+ if (snapshot.isElementAt(sibling)) {
169
+ result.push(base + sibling);
170
+ if (isSingle) break;
171
+ }
172
+ sibling = snapshot.linkOf(sibling, offset);
173
+ }
174
+ return true;
175
+ }
176
+ default: return false;
177
+ }
178
+ }
179
+
129
180
  /** The node object for an arena slot, created on first use and then shared. */
130
181
  export function viewAt(state: DocumentState, local: number): AnyNode | undefined {
131
182
  const existing = state.views[local] ?? cachedView(state, local);
132
183
  if (existing) return existing as AnyNode;
133
184
  const parsed = state.adapterNodes?.[local];
134
185
  if (parsed) { state.adapterNodes![local] = undefined; return adoptObject(state, local, parsed); }
135
- const snapshot = plainSnapshot(state, local);
186
+ const snapshot = structureSnapshot(state, local);
136
187
  let constructor: ViewConstructor;
137
- if (snapshot) constructor = viewConstructor(snapshot.typeOf(local), false);
188
+ if (snapshot && !snapshot.hasExtraFields(local)) constructor = viewConstructor(snapshot.typeOf(local), false);
138
189
  else {
139
190
  const record = recordAt(state, local);
140
191
  if (!record) return undefined;
@@ -342,14 +393,23 @@ function fillChildren(view: object): void {
342
393
  if (!target) return;
343
394
  const state = (view as View)[VIEW_STATE];
344
395
  const local = (view as View)[VIEW_LOCAL];
345
- const snapshot = plainSnapshot(state, local);
396
+ const snapshot = structureSnapshot(state, local);
346
397
  if (snapshot && state.stale.size === 0) {
347
398
  fillFromSnapshot(state, snapshot, local, target);
348
399
  return;
349
400
  }
350
401
  const locals = viewRecord(view as View).children ?? [];
351
- target.length = locals.length;
352
402
  for (let index = 0; index < locals.length; index++) target[index] = viewAt(state, locals[index])!;
403
+ truncate(target, locals.length);
404
+ }
405
+
406
+ /**
407
+ * Children are written in order and the length is trimmed afterwards:
408
+ * presetting the length of a fresh array makes it holey and costs more than
409
+ * filling it.
410
+ */
411
+ function truncate(target: AnyNode[], length: number): void {
412
+ if (target.length !== length) target.length = length;
353
413
  }
354
414
 
355
415
  /**
@@ -364,17 +424,17 @@ function fillFromSnapshot(state: DocumentState, snapshot: NodeSnapshot, local: n
364
424
  const views = state.views;
365
425
  const isStrong = !state.adapterNodes;
366
426
  const constructors = snapshotConstructors(snapshot);
367
- target.length = count;
368
427
  for (let index = 0; index < count; index++) {
369
428
  const child = childIds[first + index];
370
429
  let node = (views[child] ?? (state.weakViews.size ? cachedView(state, child) : undefined)) as AnyNode | undefined;
371
430
  if (node === undefined) {
372
- const constructor = isStrong && child < state.baseline && snapshot.isPlain(child) ? constructors(snapshot.typeReference(child)) : undefined;
431
+ const constructor = isStrong && child < state.baseline && snapshot.isPresent(child) && !snapshot.hasExtraFields(child) ? constructors(snapshot.typeReference(child)) : undefined;
373
432
  if (constructor) views[child] = node = new constructor(state, child) as unknown as AnyNode;
374
433
  else node = viewAt(state, child)!;
375
434
  }
376
435
  target[index] = node;
377
436
  }
437
+ truncate(target, count);
378
438
  }
379
439
 
380
440
  function snapshotConstructors(snapshot: NodeSnapshot): (reference: number) => ViewConstructor {
@@ -397,7 +457,7 @@ const snapshotField = (field: 'type' | 'name' | 'data'): Accessor => ({
397
457
  get() {
398
458
  const state = this[VIEW_STATE];
399
459
  const local = this[VIEW_LOCAL];
400
- const snapshot = plainSnapshot(state, local);
460
+ const snapshot = field === 'data' ? plainSnapshot(state, local) : structureSnapshot(state, local);
401
461
  if (snapshot) return field === 'type' ? snapshot.typeOf(local) : field === 'name' ? snapshot.nameOf(local) : snapshot.dataOf(local);
402
462
  return (viewRecord(this) as any)[field];
403
463
  },
@@ -408,7 +468,7 @@ const pointerField = (field: 'parent' | 'prev' | 'next'): Accessor => ({
408
468
  get() {
409
469
  const state = this[VIEW_STATE];
410
470
  const local = this[VIEW_LOCAL];
411
- const snapshot = plainSnapshot(state, local);
471
+ const snapshot = structureSnapshot(state, local);
412
472
  return linkedView(this, snapshot ? snapshot.linkOf(local, linkOffsets[field]) : viewRecord(this)[field]);
413
473
  },
414
474
  set(value) { setPointer(this, field, value); },