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.
- package/Cargo.lock +42 -1
- package/Cargo.toml +8 -1
- package/README.md +90 -51
- package/THIRD_PARTY_NOTICES.md +107 -2
- package/dist/{chunk-DMRD3FGC.mjs → chunk-FPRP6UIX.mjs} +2233 -751
- package/dist/chunk-FPRP6UIX.mjs.map +1 -0
- package/dist/index.cjs +2232 -750
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.mts +2 -2
- package/dist/index.d.ts +2 -2
- package/dist/index.mjs +1 -1
- package/dist/slim.cjs +2226 -745
- package/dist/slim.cjs.map +1 -1
- package/dist/slim.d.mts +2 -2
- package/dist/slim.d.ts +2 -2
- package/dist/slim.mjs +1 -1
- package/dist/{types-BP6TpxE5.d.mts → types-Cs9UP1Vv.d.mts} +3 -3
- package/dist/{types-BP6TpxE5.d.ts → types-Cs9UP1Vv.d.ts} +3 -3
- package/dist/utils.d.mts +1 -1
- package/dist/utils.d.ts +1 -1
- package/native/src/arena.rs +219 -80
- package/native/src/attributes.rs +11 -5
- package/native/src/document_order.rs +711 -0
- package/native/src/extraction.rs +15 -3
- package/native/src/gc.rs +12 -3
- package/native/src/id_hash.rs +42 -0
- package/native/src/immutable.rs +256 -0
- package/native/src/lib.rs +1039 -171
- package/native/src/runtime.rs +189 -33
- package/native/src/selector.rs +346 -44
- package/native/src/sink.rs +14 -1
- package/native/src/snapshot.rs +527 -0
- package/native/src/stream.rs +2 -2
- package/native/src/tokenizer.rs +29 -12
- package/native/vendor/html5ever-0.35.0/src/tokenizer/char_ref/mod.rs +79 -0
- package/package.json +3 -1
- package/rusteerio.darwin-arm64.node +0 -0
- package/rusteerio.darwin-x64.node +0 -0
- package/rusteerio.linux-arm64-gnu.node +0 -0
- package/rusteerio.linux-arm64-musl.node +0 -0
- package/rusteerio.linux-x64-gnu.node +0 -0
- package/rusteerio.linux-x64-musl.node +0 -0
- package/rusteerio.win32-arm64-msvc.node +0 -0
- package/rusteerio.win32-x64-msvc.node +0 -0
- package/src/attributes.ts +78 -44
- package/src/codec.ts +22 -3
- package/src/extract.ts +86 -2
- package/src/hooks.ts +148 -2
- package/src/index.ts +51 -40
- package/src/match.ts +207 -0
- package/src/native.ts +9 -1
- package/src/nodes.ts +531 -183
- package/src/router.ts +792 -229
- package/src/selection.ts +239 -125
- package/src/snapshot.ts +255 -0
- package/src/transport.ts +51 -42
- package/dist/chunk-DMRD3FGC.mjs.map +0 -1
|
@@ -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 ` `. 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.
|
|
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
|
-
|
|
55
|
-
const
|
|
56
|
-
|
|
57
|
-
|
|
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
|
|
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
|
-
//
|
|
88
|
-
// documents
|
|
89
|
-
const document = selection
|
|
90
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
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
|
|
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
|
|
141
|
-
if (['parent', 'parentNode', 'prev', 'previousSibling', 'next', 'nextSibling'].includes(name)) return value == null ? null : importNodes(selection
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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) {
|
|
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
|
|
58
|
+
return mapJsonStrings(value, encodeString);
|
|
60
59
|
}
|
|
61
60
|
|
|
62
61
|
export function decodeJson(value: string): string {
|
|
63
|
-
return decodeJsonCandidate.test(value) ? 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
|
-
|
|
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)
|
|
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;
|