rusteerio 1.0.1 → 1.1.1

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 (57) 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 +107 -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 +219 -80
  22. package/native/src/attributes.rs +11 -5
  23. package/native/src/document_order.rs +711 -0
  24. package/native/src/extraction.rs +15 -3
  25. package/native/src/gc.rs +12 -3
  26. package/native/src/id_hash.rs +42 -0
  27. package/native/src/immutable.rs +256 -0
  28. package/native/src/lib.rs +1039 -171
  29. package/native/src/runtime.rs +189 -33
  30. package/native/src/selector.rs +346 -44
  31. package/native/src/sink.rs +14 -1
  32. package/native/src/snapshot.rs +527 -0
  33. package/native/src/stream.rs +2 -2
  34. package/native/src/tokenizer.rs +29 -12
  35. package/native/vendor/html5ever-0.35.0/src/tokenizer/char_ref/mod.rs +79 -0
  36. package/package.json +3 -1
  37. package/rusteerio.darwin-arm64.node +0 -0
  38. package/rusteerio.darwin-x64.node +0 -0
  39. package/rusteerio.linux-arm64-gnu.node +0 -0
  40. package/rusteerio.linux-arm64-musl.node +0 -0
  41. package/rusteerio.linux-x64-gnu.node +0 -0
  42. package/rusteerio.linux-x64-musl.node +0 -0
  43. package/rusteerio.win32-arm64-msvc.node +0 -0
  44. package/rusteerio.win32-x64-msvc.node +0 -0
  45. package/src/attributes.ts +78 -44
  46. package/src/codec.ts +22 -3
  47. package/src/extract.ts +86 -2
  48. package/src/hooks.ts +148 -2
  49. package/src/index.ts +51 -40
  50. package/src/match.ts +207 -0
  51. package/src/native.ts +9 -1
  52. package/src/nodes.ts +531 -183
  53. package/src/router.ts +792 -229
  54. package/src/selection.ts +239 -125
  55. package/src/snapshot.ts +255 -0
  56. package/src/transport.ts +51 -42
  57. package/dist/chunk-DMRD3FGC.mjs.map +0 -1
@@ -20,6 +20,10 @@ use self::State::*;
20
20
  pub(super) use self::Status::*;
21
21
 
22
22
  //§ tokenizing-character-references
23
+ /// Longest reference body plus semicolon that `resolve_terminated_reference`
24
+ /// looks for; the longest named reference has 32 characters.
25
+ const MAXIMUM_TERMINATED_REFERENCE_LENGTH: usize = 40;
26
+
23
27
  pub(super) struct CharRef {
24
28
  /// The resulting character(s)
25
29
  pub(super) chars: [char; 2],
@@ -136,6 +140,9 @@ impl CharRefTokenizer {
136
140
  tokenizer: &Tokenizer<Sink>,
137
141
  input: &BufferQueue,
138
142
  ) -> Status {
143
+ if let Some(status) = self.resolve_terminated_reference(tokenizer, input) {
144
+ return status;
145
+ }
139
146
  match tokenizer.peek(input) {
140
147
  Some('a'..='z' | 'A'..='Z' | '0'..='9') => {
141
148
  self.state = Named;
@@ -152,6 +159,78 @@ impl CharRefTokenizer {
152
159
  }
153
160
  }
154
161
 
162
+ /// Resolves a reference that ends with a semicolon within the next input
163
+ /// chunk in one step. Stepping character by character costs a state
164
+ /// machine round trip and a table lookup per character, which dominates
165
+ /// pages full of references such as `&nbsp;`. Any other input takes the
166
+ /// character states, which reach the same result for these references.
167
+ fn resolve_terminated_reference<Sink: TokenSink>(
168
+ &mut self,
169
+ tokenizer: &Tokenizer<Sink>,
170
+ input: &BufferQueue,
171
+ ) -> Option<Status> {
172
+ if tokenizer.reconsume.get() {
173
+ return None;
174
+ }
175
+ let mut chunk = input.peek_front_chunk_mut()?;
176
+ let bytes = chunk.as_bytes();
177
+ let semicolon = bytes
178
+ .iter()
179
+ .take(MAXIMUM_TERMINATED_REFERENCE_LENGTH)
180
+ .position(|&byte| byte == b';')?;
181
+ let body = &bytes[..semicolon];
182
+ let resolved = match body {
183
+ [b'#', b'x' | b'X', digits @ ..] => self.accumulate_number(digits, 16),
184
+ [b'#', digits @ ..] => self.accumulate_number(digits, 10),
185
+ [first, ..] if first.is_ascii_alphabetic() && body.iter().all(u8::is_ascii_alphanumeric) => {
186
+ match data::NAMED_ENTITIES.get(&chunk[..=semicolon]) {
187
+ Some(&(first, second)) if first != 0 => {
188
+ self.result = Some(CharRef {
189
+ chars: [from_u32(first).unwrap(), from_u32(second).unwrap()],
190
+ num_chars: if second == 0 { 1 } else { 2 },
191
+ });
192
+ true
193
+ },
194
+ _ => false,
195
+ }
196
+ },
197
+ _ => false,
198
+ };
199
+ if !resolved {
200
+ return None;
201
+ }
202
+ chunk.pop_front(semicolon as u32 + 1);
203
+ let is_chunk_consumed = chunk.is_empty();
204
+ drop(chunk);
205
+ if is_chunk_consumed {
206
+ input.pop_front();
207
+ }
208
+ tokenizer.ignore_lf.set(false);
209
+ if self.result.is_some() {
210
+ return Some(Done);
211
+ }
212
+ Some(self.finish_numeric(tokenizer))
213
+ }
214
+
215
+ /// Accumulates `digits` as `do_numeric` does, if they are all digits.
216
+ fn accumulate_number(&mut self, digits: &[u8], base: u32) -> bool {
217
+ let is_number = !digits.is_empty()
218
+ && digits
219
+ .iter()
220
+ .all(|&digit| char::from(digit).is_digit(base));
221
+ if !is_number {
222
+ return false;
223
+ }
224
+ for &digit in digits {
225
+ self.num = self.num.wrapping_mul(base);
226
+ if self.num > 0x10FFFF {
227
+ self.num_too_big = true;
228
+ }
229
+ self.num = self.num.wrapping_add(char::from(digit).to_digit(base).unwrap());
230
+ }
231
+ true
232
+ }
233
+
155
234
  fn do_octothorpe<Sink: TokenSink>(
156
235
  &mut self,
157
236
  tokenizer: &Tokenizer<Sink>,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "rusteerio",
3
- "version": "1.0.1",
3
+ "version": "1.1.1",
4
4
  "description": "Cheerio-compatible HTML processing with a Rust engine and parallel, non-blocking extraction",
5
5
  "license": "MIT",
6
6
  "repository": {
@@ -97,6 +97,8 @@
97
97
  "test:package": "node scripts/test-package.mjs",
98
98
  "bench:engine": "node bench/engine.mjs",
99
99
  "bench:fair": "node --expose-gc bench/fair.mjs",
100
+ "bench:operations": "node bench/operations.mjs",
101
+ "bench:gate": "node bench/gate.mjs",
100
102
  "release:check": "node scripts/check-release.mjs",
101
103
  "notices:generate": "node scripts/generate-notices.mjs",
102
104
  "notices:check": "node scripts/generate-notices.mjs --check"
Binary file
Binary file
Binary file
Binary file
Binary file
Binary file
Binary file
Binary file
package/src/attributes.ts CHANGED
@@ -1,7 +1,8 @@
1
- import type { AnyNode, Element, SerializedField } from './types.js';
1
+ import type { AnyNode, CheerioOptions, Element, SerializedField } from './types.js';
2
2
  import type { Cheerio } from './selection.js';
3
3
  import type { NativeDocument } from './native.js';
4
4
  import { getNode, hydrateLocation, importNodes, nodeRecord } from './nodes.js';
5
+ import { attributeAt, DOCUMENT, plainSnapshot, recordAt, VIEW_LOCAL, VIEW_SELF, VIEW_STATE, type DocumentState } from './router.js';
5
6
 
6
7
  export interface StyleProp {
7
8
  length: number;
@@ -44,21 +45,55 @@ export interface AttributeMethods<T = AnyNode> {
44
45
 
45
46
  type Selection = Cheerio<AnyNode>;
46
47
  type DataRecord = Record<string, unknown>;
47
- type DataCache = Map<number, WeakRef<DataRecord>>;
48
48
  type DataDocument = NativeDocument & {
49
- nodeSpace?: object;
50
49
  retainNodeData?: (id: number, data: DataRecord) => void;
51
50
  nodeData?: (id: number) => DataRecord | undefined;
52
51
  nodeDataOperation?: (id: number) => (command: string, args: unknown) => unknown;
53
52
  };
54
- const dataObjects = new WeakMap<object, DataCache>();
55
- const dataFinalizer = new FinalizationRegistry<{ cache: WeakRef<DataCache>; id: number; reference: WeakRef<DataRecord> }>(({ cache, id, reference }) => {
56
- const records = cache.deref();
57
- if (records?.get(id) === reference) records.delete(id);
58
- });
53
+
54
+ const elementTypes = new Set(['tag', 'script', 'style']);
55
+ /** Boolean attributes read as their name in HTML, matched case-insensitively as Cheerio does. */
56
+ const booleanAttribute = /^(?:autofocus|autoplay|async|checked|controls|defer|disabled|hidden|loop|multiple|open|readonly|required|scoped|selected)$/i;
57
+
58
+ const absent = Symbol('absent');
59
+ const deferred = Symbol('deferred');
60
+
61
+ /**
62
+ * Reads an attribute of the selection's first node, as Cheerio reads it from
63
+ * `this[0]`, from cached records; `deferred` leaves the read to the native
64
+ * `attr` operation and `absent` means there is no such attribute.
65
+ */
66
+ function readAttribute(selection: Selection, name: string): string | typeof absent | typeof deferred {
67
+ const node = selection[0] as unknown as Record<string | symbol, unknown> | null | undefined;
68
+ if (typeof node !== 'object' || node === null) return absent;
69
+ const state = node[VIEW_STATE] as DocumentState | undefined;
70
+ if (state === undefined || node[VIEW_SELF] !== node) return typeof node.type === 'string' ? deferred : absent;
71
+ const local = node[VIEW_LOCAL] as number;
72
+ const snapshot = plainSnapshot(state, local);
73
+ const record = snapshot ? undefined : recordAt(state, local);
74
+ const type = snapshot ? snapshot.typeOf(local) : record?.type;
75
+ if (type === undefined || !elementTypes.has(type)) return absent;
76
+ const value = attributeAt(state, local, name);
77
+ if (value === undefined) {
78
+ // Snapshot attributes are all strings; a record may hold another value, which the native read interprets.
79
+ const attributes = record?.attribs as Record<string, unknown> | undefined;
80
+ if (attributes && Object.hasOwn(attributes, name)) return deferred;
81
+ return name === 'value' ? deferred : absent;
82
+ }
83
+ const options = selection.options ?? {};
84
+ const isXml = options.xmlMode ?? (options.xml === true || (typeof options.xml === 'object' && options.xml !== null));
85
+ return !isXml && booleanAttribute.test(name) ? name : value;
86
+ }
87
+
88
+ /** Writes one attribute through the routed document's typed path when it has one. */
89
+ function setAttribute(selection: Selection, ids: number[], name: string, value: string | null): void {
90
+ const document = selection[DOCUMENT] as NativeDocument & { setAttribute?(ids: number[], name: string, value: string | null): void };
91
+ if (document.setAttribute) document.setAttribute(ids, name, value);
92
+ else selection.op('attr', { ids, name, value });
93
+ }
59
94
 
60
95
  function isElement(selection: Selection, id: number): boolean {
61
- return ['tag', 'script', 'style'].includes(nodeRecord(selection._document, id)?.type);
96
+ return ['tag', 'script', 'style'].includes(nodeRecord(selection[DOCUMENT], id)?.type);
62
97
  }
63
98
 
64
99
  function nativeValue(value: unknown): unknown {
@@ -84,20 +119,12 @@ function hydrateValue(value: any): any {
84
119
  }
85
120
 
86
121
  function dataObject(selection: Selection, id: number): Record<string, unknown> {
87
- // Routed documents share a stable handle space. Moving a native node between
88
- // documents therefore preserves data identities and callback references.
89
- const document = selection._document as DataDocument;
90
- const cacheKey = document.nodeSpace ?? document;
91
- let documents = dataObjects.get(cacheKey);
92
- if (!documents) dataObjects.set(cacheKey, documents = new Map());
93
- const existingReference = documents.get(id);
94
- let data = existingReference?.deref() ?? document.nodeData?.(id);
122
+ // The router stores data per node and carries it along when a node moves
123
+ // between documents, preserving data identities and callback references.
124
+ const document = selection[DOCUMENT] as DataDocument;
125
+ let data = document.nodeData?.(id);
95
126
  if (!data) {
96
- const documentReference = new WeakRef(document);
97
- const dispatch = document.nodeDataOperation?.(id) ?? ((command: string, args: unknown) => {
98
- const document = documentReference.deref();
99
- return document ? JSON.parse(document.op(command, JSON.stringify(args))) : undefined;
100
- });
127
+ const dispatch = document.nodeDataOperation?.(id) ?? ((command: string, args: unknown) => JSON.parse(document.op(command, JSON.stringify(args))));
101
128
  const target: Record<string, unknown> = {};
102
129
  data = new Proxy(target, {
103
130
  set(object, name, value) {
@@ -116,29 +143,27 @@ function dataObject(selection: Selection, id: number): Record<string, unknown> {
116
143
  return true;
117
144
  },
118
145
  });
119
- const node = getNode(selection._document, id);
120
- // JS data can contain callbacks, cycles, and object identities. The DOM and
121
- // parsed data values live in Rust; this handle preserves those JS values.
122
- Object.defineProperty(node, 'data', { configurable: true, enumerable: true, get: () => data, set: (value: Record<string, unknown>) => { for (const key of Object.keys(data!)) delete data![key]; for (const key of Object.keys(value ?? {})) data![key] = value[key]; } });
146
+ document.retainNodeData?.(id, data);
123
147
  }
124
- if (existingReference?.deref() !== data) {
125
- const reference = new WeakRef(data);
126
- documents.set(id, reference);
127
- dataFinalizer.register(data, { cache: new WeakRef(documents), id, reference });
148
+ const node = getNode(selection[DOCUMENT], id);
149
+ // JS data can contain callbacks, cycles, and object identities. The DOM and
150
+ // parsed data values live in Rust; the node exposes the JS values.
151
+ if (!Object.hasOwn(node, 'data')) {
152
+ const values = data;
153
+ Object.defineProperty(node, 'data', { configurable: true, enumerable: true, get: () => values, set: (value: Record<string, unknown>) => { for (const key of Object.keys(values)) delete values[key]; for (const key of Object.keys(value ?? {})) values[key] = value[key]; } });
128
154
  }
129
- document.retainNodeData?.(id, data);
130
155
  return data;
131
156
  }
132
157
 
133
158
  function property(selection: Selection, id: number, name: string, raw = false): any {
134
159
  const result = selection.op('prop', { ids: [id], name, ...(raw ? { raw: true } : {}) });
135
160
  if (name === 'sourceCodeLocation') return hydrateLocation(result);
136
- return result?.__nativeNodeProperty ? (getNode(selection._document, id) as any)[result.__nativeNodeProperty] : result === null ? undefined : result;
161
+ return result?.__nativeNodeProperty ? (getNode(selection[DOCUMENT], id) as any)[result.__nativeNodeProperty] : result === null ? undefined : result;
137
162
  }
138
163
 
139
164
  function propertyValue(selection: Selection, name: string, value: any): any {
140
- if (name === 'children' || name === 'childNodes') return importNodes(selection._document, value);
141
- if (['parent', 'parentNode', 'prev', 'previousSibling', 'next', 'nextSibling'].includes(name)) return value == null ? null : importNodes(selection._document, [value])[0] ?? null;
165
+ if (name === 'children' || name === 'childNodes') return importNodes(selection[DOCUMENT], value);
166
+ if (['parent', 'parentNode', 'prev', 'previousSibling', 'next', 'nextSibling'].includes(name)) return value == null ? null : importNodes(selection[DOCUMENT], [value])[0] ?? null;
142
167
  return value;
143
168
  }
144
169
 
@@ -148,22 +173,27 @@ function attr(this: Selection, name?: any, value?: any): any {
148
173
  if (typeof name !== 'string') throw new Error('Bad combination of arguments.');
149
174
  this.ids.forEach((id, index) => {
150
175
  if (!isElement(this, id)) return;
151
- const node = getNode(this._document, id) as Element;
176
+ const node = getNode(this[DOCUMENT], id) as Element;
152
177
  const current = node.attribs[name];
153
178
  const next = value.call(node, index, current);
154
- this.op('attr', { ids: [id], name, value: next === null ? null : String(next) });
179
+ setAttribute(this, [id], name, next === null ? null : String(next));
155
180
  });
156
181
  } else if (typeof name === 'object') {
157
- for (const key of Object.keys(name)) this.op('attr', { ids: this.ids, name: key, value: name[key] === null ? null : String(name[key]) });
182
+ for (const key of Object.keys(name)) setAttribute(this, this.ids, key, name[key] === null ? null : String(name[key]));
158
183
  } else {
159
- this.op('attr', { ids: this.ids, name, value: value === null ? null : String(value) });
184
+ setAttribute(this, this.ids, name, value === null ? null : String(value));
160
185
  }
161
186
  return this;
162
187
  }
163
188
  if (arguments.length > 1) return this;
189
+ if (typeof name === 'string') {
190
+ const cached = readAttribute(this, name);
191
+ if (cached === absent) return undefined;
192
+ if (cached !== deferred) return cached;
193
+ }
164
194
  const result = this.op('attr', { ids: this.ids, ...(typeof name === 'string' ? { name } : {}) });
165
195
  if (result === null) return undefined;
166
- return !name && this.ids.length ? (getNode(this._document, this.ids[0]) as Element).attribs : result;
196
+ return !name && this.ids.length ? (getNode(this[DOCUMENT], this.ids[0]) as Element).attribs : result;
167
197
  }
168
198
 
169
199
  function prop(this: Selection, name: any, value?: any): any {
@@ -173,7 +203,7 @@ function prop(this: Selection, name: any, value?: any): any {
173
203
  if (typeof name === 'object') throw new TypeError('Bad combination of arguments.');
174
204
  this.ids.forEach((id, index) => {
175
205
  if (!isElement(this, id)) return;
176
- const next = value.call(getNode(this._document, id), index, property(this, id, name, true));
206
+ const next = value.call(getNode(this[DOCUMENT], id), index, property(this, id, name, true));
177
207
  this.op('prop', { ids: [id], name, value: propertyValue(this, name, next) });
178
208
  });
179
209
  } else if (typeof name === 'object') {
@@ -218,7 +248,7 @@ function css(this: Selection, name?: any, value?: any): any {
218
248
  for (const id of this.ids) {
219
249
  if (!isElement(this, id)) continue;
220
250
  const current = this.op('css', { ids: [id], name: key }) ?? undefined;
221
- const next = name[key].call(getNode(this._document, id), propertyIndex, current);
251
+ const next = name[key].call(getNode(this[DOCUMENT], id), propertyIndex, current);
222
252
  this.op('css', { ids: [id], name: key, value: next == null ? null : String(next) });
223
253
  }
224
254
  } else this.op('css', { ids: this.ids, name: key, value: name[key] == null ? null : String(name[key]) });
@@ -227,7 +257,7 @@ function css(this: Selection, name?: any, value?: any): any {
227
257
  this.ids.forEach((id, index) => {
228
258
  if (!isElement(this, id)) return;
229
259
  const current = this.op('css', { ids: [id], name }) ?? undefined;
230
- const next = value.call(getNode(this._document, id), index, current);
260
+ const next = value.call(getNode(this[DOCUMENT], id), index, current);
231
261
  this.op('css', { ids: [id], name, value: next == null ? null : String(next) });
232
262
  });
233
263
  } else if (typeof name === 'string') this.op('css', { ids: this.ids, name, value: value == null ? null : String(value) });
@@ -241,7 +271,7 @@ function classMethod(command: 'addClass' | 'removeClass' | 'toggleClass') {
241
271
  if (typeof name === 'function') {
242
272
  this.ids.forEach((id, index) => {
243
273
  if (!isElement(this, id)) return;
244
- const node = getNode(this._document, id) as Element;
274
+ const node = getNode(this[DOCUMENT], id) as Element;
245
275
  const next = command === 'toggleClass'
246
276
  ? name.call(node, index, node.attribs.class ?? '', state)
247
277
  : name.call(node, index, node.attribs.class ?? '');
@@ -257,7 +287,11 @@ function classMethod(command: 'addClass' | 'removeClass' | 'toggleClass') {
257
287
  export function installAttributes(CheerioClass: { prototype: object }): void {
258
288
  Object.assign(CheerioClass.prototype, {
259
289
  attr, prop, data, css,
260
- removeAttr(this: Selection, name: string) { this.op('removeAttr', { ids: this.ids, name }); return this; },
290
+ removeAttr(this: Selection, name: string) {
291
+ const ids = this.ids;
292
+ for (const single of String(name).split(/\s+/)) if (single) setAttribute(this, ids, single, null);
293
+ return this;
294
+ },
261
295
  hasClass(this: Selection, name: string) { return this.op<boolean>('hasClass', { ids: this.ids, name }); },
262
296
  addClass: classMethod('addClass'), removeClass: classMethod('removeClass'), toggleClass: classMethod('toggleClass'),
263
297
  val(this: Selection, value?: unknown) {
package/src/codec.ts CHANGED
@@ -48,7 +48,6 @@ export function decodeString(value: string): string {
48
48
 
49
49
  // Transform JSON string tokens rather than the whole JSON text: JSON.stringify
50
50
  // escapes lone surrogates, while a literal backslash-u sequence is plain data.
51
- const stringToken = /"(?:[^"\\]|\\[\s\S])*"/g;
52
51
  const encodeJsonCandidate = /[\ud800-\ue800]|\\u(?:d[89a-f][\da-f]{2}|e[0-8][\da-f]{2})/i;
53
52
  const escapedJsonCandidate = /\\u(?:d[89a-f][\da-f]{2}|e[0-8][\da-f]{2})/i;
54
53
  const decodeJsonCandidate = /[\ue000-\ue800]|\\ue[0-8][\da-f]{2}/i;
@@ -56,11 +55,31 @@ const decodeJsonCandidate = /[\ue000-\ue800]|\\ue[0-8][\da-f]{2}/i;
56
55
  export function encodeJson(value: string): string {
57
56
  if (!encodeJsonCandidate.test(value)) return value;
58
57
  if (isWellFormed?.call(value) && !encoded.test(value) && !escapedJsonCandidate.test(value)) return value;
59
- return value.replace(stringToken, (token) => JSON.stringify(encodeString(JSON.parse(token))));
58
+ return mapJsonStrings(value, encodeString);
60
59
  }
61
60
 
62
61
  export function decodeJson(value: string): string {
63
- return decodeJsonCandidate.test(value) ? value.replace(stringToken, (token) => JSON.stringify(decodeString(JSON.parse(token)))) : value;
62
+ return decodeJsonCandidate.test(value) ? mapJsonStrings(value, decodeString) : value;
63
+ }
64
+
65
+ /**
66
+ * Replaces every string token of the JSON text `json` with `transform` of its
67
+ * value, in one left-to-right pass. A regular expression scanning for tokens
68
+ * retries from every quote of an unterminated token, which is quadratic.
69
+ */
70
+ function mapJsonStrings(json: string, transform: (value: string) => string): string {
71
+ const parts: string[] = [];
72
+ let copied = 0;
73
+ let quote = json.indexOf('"');
74
+ while (quote !== -1) {
75
+ let end = quote + 1;
76
+ while (end < json.length && json.charCodeAt(end) !== 0x22) end += json.charCodeAt(end) === 0x5c ? 2 : 1;
77
+ if (end >= json.length) break;
78
+ parts.push(json.slice(copied, quote), JSON.stringify(transform(JSON.parse(json.slice(quote, end + 1)))));
79
+ copied = end + 1;
80
+ quote = json.indexOf('"', copied);
81
+ }
82
+ return copied === 0 ? json : parts.join('') + json.slice(copied);
64
83
  }
65
84
 
66
85
  /** Private parser protocol flag, appended without deserializing large options. */
package/src/extract.ts CHANGED
@@ -1,6 +1,7 @@
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';
4
5
  import type { AnyNode, Element, ExtractMap, ExtractedMap, ExtractDescriptor } from './types.js';
5
6
 
6
7
  export interface ExtractMethods<T = AnyNode> {
@@ -51,12 +52,14 @@ export function extract<M extends ExtractMap>(this: Cheerio<AnyNode>, map: M): E
51
52
  const selector = typeof descriptor === 'string' ? descriptor : descriptor.selector;
52
53
  const value = typeof descriptor === 'string' ? 'textContent' : descriptor.value ?? 'textContent';
53
54
  const selection = this._findBySelector(selector, all ? Infinity : 1);
55
+ const rows = typeof value === 'object' ? nativeRows(this, key, all, selector, value) : undefined;
54
56
  const read = (index: number) => {
55
57
  if (typeof value === 'function') return value(selection[index] as Element, key, result);
56
58
  const scope = this._make === nativeExtractionMethods.make
57
59
  ? this._fromIds([selection.ids[index]])
58
60
  : this._make(selection[index]);
59
- return typeof value === 'string' ? scope.prop(value) : scope.extract(value);
61
+ if (typeof value === 'string') return scope.prop(value);
62
+ return rows ? completeRow(scope, value, rows, index) : scope.extract(value);
60
63
  };
61
64
  let extracted: unknown;
62
65
  if (all) {
@@ -74,6 +77,84 @@ export function extract<M extends ExtractMap>(this: Cheerio<AnyNode>, map: M): E
74
77
  return result as ExtractedMap<M>;
75
78
  }
76
79
 
80
+ /** Fields of each row that the native engine extracts, for every row in one call. */
81
+ interface NativeRows { keys: Set<string>; rows: Record<string, unknown>[]; writes: number }
82
+
83
+ /**
84
+ * For a nested map with callbacks, extracts the fields the native engine can
85
+ * for every matched element in one call, instead of one call per element;
86
+ * the remaining fields are then filled in map order, so each callback sees
87
+ * exactly the fields before it, as in Cheerio.
88
+ */
89
+ function nativeRows(scope: Cheerio<AnyNode>, key: string, all: boolean, selector: string, map: ExtractMap): NativeRows | undefined {
90
+ 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;
92
+ const subset: ExtractMap = Object.create(null);
93
+ const nativeKeys = new Set<string>();
94
+ for (const name of keys) {
95
+ const slot = Object.getOwnPropertyDescriptor(map, name);
96
+ if (!slot || !slot.enumerable || !('value' in slot)) continue;
97
+ const single: ExtractMap = Object.create(null);
98
+ Object.defineProperty(single, name, { value: slot.value, enumerable: true, configurable: true });
99
+ if (!compileExtractPlan(single, scope.options)) continue;
100
+ Object.defineProperty(subset, name, { value: slot.value, enumerable: true, configurable: true });
101
+ nativeKeys.add(name);
102
+ }
103
+ if (!nativeKeys.size || nativeKeys.size === keys.length) return undefined;
104
+ const outer: ExtractMap = Object.create(null);
105
+ 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 });
110
+ 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;
114
+ }
115
+
116
+ /**
117
+ * Builds row `index` of a nested map from its native fields and the scope's
118
+ * JavaScript fields. Native fields extracted before a callback changed any
119
+ * document are extracted again, as extracting row by row would see them.
120
+ */
121
+ function completeRow(scope: Cheerio<AnyNode>, map: ExtractMap, rows: NativeRows, index: number): Record<string, unknown> {
122
+ const row: Record<string, unknown> = {};
123
+ const native = rows.rows[index] ?? {};
124
+ for (const name in map) {
125
+ if (rows.keys.has(name) && writeCount() === rows.writes) { row[name] = native[name]; continue; }
126
+ const entry: ExtractMap = Object.create(null);
127
+ Object.defineProperty(entry, name, { value: map[name], enumerable: true, configurable: true });
128
+ fillEntries(scope, entry, row);
129
+ }
130
+ return row;
131
+ }
132
+
133
+ /** Extracts `map`'s entries into `result`, which callbacks receive as built so far. */
134
+ function fillEntries(scope: Cheerio<AnyNode>, map: ExtractMap, result: Record<string, unknown>): void {
135
+ for (const key in map) {
136
+ const entry = map[key];
137
+ const all = Array.isArray(entry);
138
+ const descriptor = (all ? entry[0] : entry) as string | ExtractDescriptor;
139
+ const selector = typeof descriptor === 'string' ? descriptor : descriptor.selector;
140
+ const value = typeof descriptor === 'string' ? 'textContent' : descriptor.value ?? 'textContent';
141
+ const selection = scope._findBySelector(selector, all ? Infinity : 1);
142
+ const read = (index: number) => {
143
+ if (typeof value === 'function') return value(selection[index] as Element, key, result);
144
+ const child = scope._make === nativeExtractionMethods.make ? scope._fromIds([selection.ids[index]]) : scope._make(selection[index]);
145
+ return typeof value === 'string' ? child.prop(value) : child.extract(value);
146
+ };
147
+ if (all) {
148
+ const values: unknown[] = [];
149
+ for (let index = 0; index < selection.length; index += 1) {
150
+ const extracted = read(index);
151
+ if (extracted != null) values.push(...(Array.isArray(extracted) ? extracted : [extracted]));
152
+ }
153
+ result[key] = values;
154
+ } else result[key] = selection.length ? read(0) : undefined;
155
+ }
156
+ }
157
+
77
158
  export function installExtract(CheerioClass: { prototype: object }): void {
78
159
  Object.assign(CheerioClass.prototype, { extract });
79
160
  }
@@ -104,6 +185,7 @@ export async function extractCooperatively<M extends ExtractMap>(selection: Chee
104
185
  const selector = typeof descriptor === 'string' ? descriptor : descriptor.selector;
105
186
  const value = typeof descriptor === 'string' ? 'textContent' : descriptor.value ?? 'textContent';
106
187
  const found = scope._findBySelector(selector, all ? Infinity : 1);
188
+ const rows = typeof value === 'object' ? nativeRows(scope, key, all, selector, value) : undefined;
107
189
  const output: unknown[] = [];
108
190
  let single: unknown;
109
191
  const count = all ? found.length : Math.min(found.length, 1);
@@ -117,7 +199,9 @@ export async function extractCooperatively<M extends ExtractMap>(selection: Chee
117
199
  const child = scope._make === nativeExtractionMethods.make
118
200
  ? scope._fromIds([found.ids[index]])
119
201
  : scope._make(found[index]);
120
- extracted = typeof value === 'string' ? child.prop(value) : child.extract === nativeExtractionMethods.extract ? (await walk(child, value)).value : child.extract(value);
202
+ extracted = typeof value === 'string' ? child.prop(value)
203
+ : rows ? completeRow(child, value, rows, index)
204
+ : child.extract === nativeExtractionMethods.extract ? (await walk(child, value)).value : child.extract(value);
121
205
  }
122
206
  if (all) { if (extracted != null) output.push(...(Array.isArray(extracted) ? extracted : [extracted])); }
123
207
  else single = extracted;