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
package/src/router.ts
CHANGED
|
@@ -1,328 +1,891 @@
|
|
|
1
|
-
import { requireNative, type NativeDocument, type NativeNodeRecord } from './native.js';
|
|
1
|
+
import { packageDirectory, requireNative, type NativeDocument, type NativeNodeRecord } from './native.js';
|
|
2
2
|
import { hasParserHooks, parseNativeDocument } from './hooks.js';
|
|
3
3
|
import type { CheerioOptions } from './types.js';
|
|
4
|
+
import { NodeSnapshot, plainRecord, type NativeSnapshot } from './snapshot.js';
|
|
4
5
|
|
|
5
6
|
interface NativeMemoryStats { live: number; slots: number; retained: number; reclaimed: number; immutableNodes: number; externalBytes: number }
|
|
7
|
+
interface PinnedCollection extends NativeMemoryStats { free: Uint32Array }
|
|
6
8
|
interface ManagedNativeDocument extends NativeDocument {
|
|
7
|
-
|
|
8
|
-
|
|
9
|
+
setAttribute?(ids: number[], name: string, value: string | null): void;
|
|
10
|
+
slotCount?(): number;
|
|
11
|
+
routingIndex?(): number;
|
|
12
|
+
detachedSlots?(root: number): Uint32Array;
|
|
13
|
+
render?(ids: number[], optionsJson?: string): string;
|
|
14
|
+
snapshot?(): NativeSnapshot;
|
|
15
|
+
takeTouched?(): Uint32Array | null;
|
|
16
|
+
collectPinned?(pins: Uint32Array, keepBelow?: number): PinnedCollection;
|
|
9
17
|
collectGarbage?(): NativeMemoryStats;
|
|
18
|
+
isCollectionDue?(): boolean;
|
|
10
19
|
}
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* A node id is `state.base + local`, where `local` indexes the node in its
|
|
23
|
+
* native arena. Ids from different documents never collide, so every routed
|
|
24
|
+
* document shares one id space without a lookup table per node.
|
|
25
|
+
*/
|
|
26
|
+
const STRIDE = 2 ** 32;
|
|
27
|
+
/** Ids must stay exact doubles, which bounds how many documents may be alive at once. */
|
|
28
|
+
const maximumDocuments = 2 ** 21;
|
|
29
|
+
/** A document's root is its first slot; collections keep it and everything attached to it. */
|
|
30
|
+
const rootSlots = 1;
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* Ids held by a JavaScript owner. Collection keeps them; moves rewrite them in
|
|
34
|
+
* place. The finalization registry holds only the token, which references
|
|
35
|
+
* nothing: its cleanup runs only once the event loop turns, and anything it
|
|
36
|
+
* held would keep documents of a synchronous loop alive until then.
|
|
37
|
+
*/
|
|
38
|
+
export interface Lease { ids: number[]; token: LeaseToken; states: DocumentState[] }
|
|
39
|
+
interface LeaseToken { isReleased: boolean }
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* JavaScript `.data()` storage for one node; moves carry it to the node's new
|
|
43
|
+
* arena. A collection that frees the node releases it, so data objects still
|
|
44
|
+
* held no longer write to whatever node reuses the slot.
|
|
45
|
+
*/
|
|
46
|
+
export interface DataEntry { state: DocumentState; local: number; data?: object; isReleased?: boolean }
|
|
47
|
+
|
|
48
|
+
/** Bookkeeping for one native document. */
|
|
49
|
+
export interface DocumentState {
|
|
50
|
+
readonly raw: ManagedNativeDocument;
|
|
51
|
+
readonly base: number;
|
|
52
|
+
/** The id space the state's base belongs to: another copy of the package numbers its documents separately. */
|
|
53
|
+
readonly space: object;
|
|
54
|
+
readonly options: CheerioOptions;
|
|
55
|
+
rootLocal: number | undefined;
|
|
56
|
+
routed: RoutedDocument | undefined;
|
|
57
|
+
generation: number;
|
|
58
|
+
records: (NativeNodeRecord | null | undefined)[];
|
|
59
|
+
bulk: NodeSnapshot | number;
|
|
60
|
+
snapshotSize: number;
|
|
61
|
+
/** Nodes whose snapshot entry is outdated by a targeted write. */
|
|
62
|
+
stale: Set<number>;
|
|
63
|
+
/**
|
|
64
|
+
* Node objects for slots that existed at parse time, until a collection
|
|
65
|
+
* finds their nodes detached; an array read is far cheaper than a map
|
|
66
|
+
* lookup.
|
|
67
|
+
*/
|
|
68
|
+
views: (object | undefined)[];
|
|
69
|
+
/** Node objects for slots created later, until a collection finds their nodes detached. */
|
|
70
|
+
looseViews: Map<number, object>;
|
|
71
|
+
/** Size of `looseViews` at which a collection is scheduled. */
|
|
72
|
+
looseViewLimit: number;
|
|
73
|
+
/** Node objects for detached nodes, kept only while something else holds them. */
|
|
74
|
+
weakViews: Map<number, WeakRef<object>>;
|
|
75
|
+
/** Slots that existed when the document was parsed. */
|
|
76
|
+
baseline: number;
|
|
77
|
+
/** Later collections to run while weakened node objects may still become unreachable. */
|
|
78
|
+
collectionRetries: number;
|
|
79
|
+
/** Whether a collection was due but the document left the pending list; its next use schedules it again. */
|
|
80
|
+
isCollectionEvicted: boolean;
|
|
81
|
+
leases: Set<Lease>;
|
|
82
|
+
/** Lease count at which released leases are swept. */
|
|
83
|
+
leaseSweepSize: number;
|
|
84
|
+
data: Map<number, DataEntry>;
|
|
85
|
+
/**
|
|
86
|
+
* Whether the id table resolves this document. Only ids that reach another
|
|
87
|
+
* document need it, and the `WeakRef` it takes keeps the document alive
|
|
88
|
+
* until the current job ends.
|
|
89
|
+
*/
|
|
90
|
+
isExposed: boolean;
|
|
91
|
+
/**
|
|
92
|
+
* Documents whose ids this document's selections or leases may hold. A
|
|
93
|
+
* document's index is reused once it is collected, so holders keep it.
|
|
94
|
+
*/
|
|
95
|
+
/** Some node object holds a child array that must follow structural writes. */
|
|
96
|
+
hasChildLists: boolean;
|
|
97
|
+
/** A tree adapter's nodes not yet returned to the caller, by slot. */
|
|
98
|
+
adapterNodes?: (object | undefined)[];
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
/** Fields on node objects; registered symbols so ESM and CJS copies share nodes. */
|
|
102
|
+
export const VIEW_STATE = Symbol.for('rusteerio.node.state');
|
|
103
|
+
export const VIEW_LOCAL = Symbol.for('rusteerio.node.local');
|
|
104
|
+
export const VIEW_ATTRIBUTES = Symbol.for('rusteerio.node.attributes');
|
|
105
|
+
/** Points at the node itself, so a spread copy of a node is not mistaken for it. */
|
|
106
|
+
export const VIEW_SELF = Symbol.for('rusteerio.node.self');
|
|
107
|
+
// Internals are own properties under symbols: reads stay plain property
|
|
108
|
+
// loads, which prototype accessors are not on selections of many documents'
|
|
109
|
+
// classes, and selections enumerate like Cheerio's. Registered symbols let
|
|
110
|
+
// the ESM and CommonJS builds read each other's selections.
|
|
111
|
+
export const DOCUMENT: unique symbol = Symbol.for('rusteerio.selection.document');
|
|
112
|
+
export const API: unique symbol = Symbol.for('rusteerio.selection.api');
|
|
113
|
+
export const IDS: unique symbol = Symbol.for('rusteerio.selection.ids');
|
|
114
|
+
export const VALUES: unique symbol = Symbol.for('rusteerio.selection.values');
|
|
115
|
+
export const LEASE: unique symbol = Symbol.for('rusteerio.selection.lease');
|
|
116
|
+
export const IS_PLAIN: unique symbol = Symbol.for('rusteerio.selection.is-plain');
|
|
117
|
+
/** A node object's materialized `children` array, refilled when the node's children change. */
|
|
118
|
+
export const VIEW_CHILD_TARGET = Symbol.for('rusteerio.node.child-target');
|
|
119
|
+
|
|
14
120
|
interface Registry {
|
|
15
|
-
next: number;
|
|
16
121
|
space: object;
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
122
|
+
/** Writes to any document, so callers can tell whether values they read earlier may be stale. */
|
|
123
|
+
writes: number;
|
|
124
|
+
states: (WeakRef<DocumentState> | undefined)[];
|
|
125
|
+
pending: Set<DocumentState>;
|
|
126
|
+
isScheduled: boolean;
|
|
127
|
+
leaseFinalizer: FinalizationRegistry<LeaseToken>;
|
|
21
128
|
}
|
|
22
|
-
|
|
129
|
+
// Each installed copy of the package loads its own addon, which numbers its
|
|
130
|
+
// documents independently, so only entry points of one copy share ids.
|
|
131
|
+
const registryKey = Symbol.for(`rusteerio.native-documents.v5:${packageDirectory()}`);
|
|
132
|
+
/** A native document's state lives on the document itself, so they share one lifetime. */
|
|
133
|
+
const stateKey = Symbol.for('rusteerio.native-documents.state');
|
|
23
134
|
const globals = globalThis as typeof globalThis & { [key: symbol]: Registry | undefined };
|
|
24
135
|
const registry: Registry = globals[registryKey] ??= (() => {
|
|
25
|
-
const
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
const batches = new Map<ManagedNativeDocument, number[]>();
|
|
33
|
-
for (const pin of pending.splice(0)) {
|
|
34
|
-
const document = pin.document.deref();
|
|
35
|
-
if (!document) continue;
|
|
36
|
-
const entries = byDocument.get(document);
|
|
37
|
-
if (entries?.get(pin.local)?.pin === pin) entries.delete(pin.local);
|
|
38
|
-
const ids = batches.get(document) ?? [];
|
|
39
|
-
ids.push(pin.local); batches.set(document, ids);
|
|
40
|
-
}
|
|
41
|
-
for (const [document, ids] of batches) {
|
|
42
|
-
snapshots.delete(document);
|
|
43
|
-
document.release?.(ids);
|
|
44
|
-
document.collectGarbage?.();
|
|
45
|
-
}
|
|
136
|
+
const created: Registry = {
|
|
137
|
+
space: {},
|
|
138
|
+
writes: 0,
|
|
139
|
+
states: [],
|
|
140
|
+
pending: new Set(),
|
|
141
|
+
isScheduled: false,
|
|
142
|
+
leaseFinalizer: new FinalizationRegistry((token) => { token.isReleased = true; }),
|
|
46
143
|
};
|
|
47
|
-
return
|
|
48
|
-
if (!handles.get(pin.global)?.deref()) handles.delete(pin.global);
|
|
49
|
-
pending.push(pin);
|
|
50
|
-
if (!scheduled) { scheduled = true; queueMicrotask(flush); }
|
|
51
|
-
}) };
|
|
144
|
+
return created;
|
|
52
145
|
})();
|
|
53
146
|
|
|
147
|
+
/** The states owning `ids`, resolved through `document`. */
|
|
148
|
+
function statesOf(document: NativeDocument, ids: readonly number[]): DocumentState[] {
|
|
149
|
+
const states: DocumentState[] = [];
|
|
150
|
+
let previous: DocumentState | undefined;
|
|
151
|
+
for (const id of ids) {
|
|
152
|
+
// Selections may hold holes after direct index writes.
|
|
153
|
+
if (id === undefined) continue;
|
|
154
|
+
if (previous && id >= previous.base && id < previous.base + STRIDE) continue;
|
|
155
|
+
const state = stateIn(document, id);
|
|
156
|
+
if (!states.includes(state)) states.push(state);
|
|
157
|
+
previous = state;
|
|
158
|
+
}
|
|
159
|
+
return states;
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
/**
|
|
163
|
+
* Collects once a structural write leaves the arena grown by half with nodes
|
|
164
|
+
* removed; collecting sooner costs more than the garbage it would free.
|
|
165
|
+
*/
|
|
166
|
+
function scheduleCollectionIfDue(state: DocumentState): void {
|
|
167
|
+
if (state.raw.isCollectionDue?.()) scheduleCollection(state);
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
/**
|
|
171
|
+
* Collections run on a later turn of the event loop: no library call is then
|
|
172
|
+
* between native writes holding ids of nodes it has just detached, and node
|
|
173
|
+
* objects weakened on an earlier turn may have been reclaimed.
|
|
174
|
+
*/
|
|
175
|
+
function scheduleCollection(state: DocumentState): void {
|
|
176
|
+
registry.pending.add(state);
|
|
177
|
+
// Pending documents are held strongly until the event loop turns; in a
|
|
178
|
+
// synchronous loop only the latest few stay pinned. Holding the others
|
|
179
|
+
// weakly would pin them too: a WeakRef keeps its target until the
|
|
180
|
+
// synchronous job ends. An evicted document is scheduled again on its next
|
|
181
|
+
// use; one never used again is either collected whole or idle.
|
|
182
|
+
if (registry.pending.size > maximumPendingCollections) {
|
|
183
|
+
const evicted = registry.pending.values().next().value!;
|
|
184
|
+
registry.pending.delete(evicted);
|
|
185
|
+
evicted.isCollectionEvicted = true;
|
|
186
|
+
}
|
|
187
|
+
if (registry.isScheduled) return;
|
|
188
|
+
registry.isScheduled = true;
|
|
189
|
+
setImmediate(() => {
|
|
190
|
+
registry.isScheduled = false;
|
|
191
|
+
const states = [...registry.pending];
|
|
192
|
+
registry.pending.clear();
|
|
193
|
+
for (const pending of states) collectState(pending);
|
|
194
|
+
}).unref();
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
const maximumPendingCollections = 4;
|
|
198
|
+
|
|
199
|
+
/** Increases with every write to any document. */
|
|
200
|
+
export function writeCount(): number {
|
|
201
|
+
return registry.writes;
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
/**
|
|
205
|
+
* The state of a native document, created on first use. The addon hands out
|
|
206
|
+
* routing indexes and takes one back when its document is finalized, which
|
|
207
|
+
* happens during garbage collection, so indexes recycle even within a
|
|
208
|
+
* synchronous loop.
|
|
209
|
+
*/
|
|
210
|
+
export function stateFor(raw: NativeDocument, options: CheerioOptions = {}): DocumentState {
|
|
211
|
+
const holder = raw as unknown as { [stateKey]?: DocumentState };
|
|
212
|
+
const existing = holder[stateKey];
|
|
213
|
+
if (existing) return existing;
|
|
214
|
+
const index = (raw as ManagedNativeDocument).routingIndex!();
|
|
215
|
+
if (index >= maximumDocuments) throw new RangeError('Too many documents are alive at once.');
|
|
216
|
+
const baseline = (raw as ManagedNativeDocument).slotCount?.() ?? 0;
|
|
217
|
+
const state: DocumentState = {
|
|
218
|
+
raw: raw as ManagedNativeDocument, base: index * STRIDE, space: registry.space, options, rootLocal: undefined, routed: undefined,
|
|
219
|
+
// Sized up front: node objects are cached out of order, and regrowing a holey array is costly.
|
|
220
|
+
generation: 0, records: [], bulk: 0, snapshotSize: 0, stale: new Set(), views: new Array(baseline), looseViews: new Map(), looseViewLimit: minimumLooseViewLimit, weakViews: new Map(), baseline,
|
|
221
|
+
leases: new Set(), leaseSweepSize: 64, data: new Map(), isExposed: false, hasChildLists: false, collectionRetries: 0, isCollectionEvicted: false,
|
|
222
|
+
};
|
|
223
|
+
holder[stateKey] = state;
|
|
224
|
+
return state;
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
/** Lets ids of `state` be resolved from documents other than its own. */
|
|
228
|
+
function exposeState(state: DocumentState): void {
|
|
229
|
+
if (state.isExposed) return;
|
|
230
|
+
state.isExposed = true;
|
|
231
|
+
registry.states[state.base / STRIDE] = new WeakRef(state);
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
/**
|
|
235
|
+
* Called before ids of `state` enter operations or selections of `document`.
|
|
236
|
+
* Leases keep the documents of the ids a selection holds; node objects keep
|
|
237
|
+
* their own.
|
|
238
|
+
*/
|
|
239
|
+
export function exposeTo(document: NativeDocument, state: DocumentState): void {
|
|
240
|
+
if ((document as Partial<RoutedDocument>).state === state) return;
|
|
241
|
+
exposeState(state);
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
/** Called before a selection's ids enter operations of `document`. */
|
|
245
|
+
export function exposeDocumentTo(document: NativeDocument, source: NativeDocument | undefined): void {
|
|
246
|
+
const state = (source as Partial<RoutedDocument> | undefined)?.state;
|
|
247
|
+
if (state) exposeTo(document, state);
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
/** The node object cached for a slot, if any. */
|
|
251
|
+
export function cachedView(state: DocumentState, local: number): object | undefined {
|
|
252
|
+
const view = local < state.baseline ? state.views[local] : state.looseViews.get(local);
|
|
253
|
+
return view ?? (state.weakViews.size ? state.weakViews.get(local)?.deref() : undefined);
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
/** Created nodes held as objects only become collectable through a collection. */
|
|
257
|
+
const minimumLooseViewLimit = 1024;
|
|
258
|
+
|
|
259
|
+
export function cacheView(state: DocumentState, local: number, view: object): void {
|
|
260
|
+
if (local < state.baseline) { state.views[local] = view; return; }
|
|
261
|
+
state.looseViews.set(local, view);
|
|
262
|
+
if (state.looseViews.size >= state.looseViewLimit) {
|
|
263
|
+
state.looseViewLimit = state.looseViews.size * 2;
|
|
264
|
+
scheduleCollection(state);
|
|
265
|
+
}
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
function uncacheView(state: DocumentState, local: number): object | undefined {
|
|
269
|
+
const view = cachedView(state, local);
|
|
270
|
+
if (local < state.baseline) state.views[local] = undefined;
|
|
271
|
+
state.looseViews.delete(local);
|
|
272
|
+
state.weakViews.delete(local);
|
|
273
|
+
return view;
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
/** A collection drops released slots at the arena's end; later nodes reuse their indexes. */
|
|
277
|
+
function releaseSlotsFrom(state: DocumentState, end: number): void {
|
|
278
|
+
for (const local of state.data.keys()) if (local >= end) releaseSlot(state, local);
|
|
279
|
+
for (const local of state.weakViews.keys()) if (local >= end) state.weakViews.delete(local);
|
|
280
|
+
if (state.adapterNodes && state.adapterNodes.length > end) {
|
|
281
|
+
for (let local = end; local < state.adapterNodes.length; local++) releaseAdapterNode(state, local);
|
|
282
|
+
state.adapterNodes.length = end;
|
|
283
|
+
}
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
/** Forgets everything bound to a slot a collection freed. */
|
|
287
|
+
function releaseSlot(state: DocumentState, local: number): void {
|
|
288
|
+
uncacheView(state, local);
|
|
289
|
+
const entry = state.data.get(local);
|
|
290
|
+
if (entry) { entry.isReleased = true; state.data.delete(local); }
|
|
291
|
+
releaseAdapterNode(state, local);
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
/** Hands a freed slot's tree adapter object back to the caller: passed in again, it is imported like any other node. */
|
|
295
|
+
function releaseAdapterNode(state: DocumentState, local: number): void {
|
|
296
|
+
const adapterNode = state.adapterNodes?.[local] as Record<symbol, unknown> | undefined;
|
|
297
|
+
if (!adapterNode) return;
|
|
298
|
+
adapterNode[VIEW_SELF] = undefined;
|
|
299
|
+
state.adapterNodes![local] = undefined;
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
function hasViews(state: DocumentState): boolean {
|
|
303
|
+
return state.views.length > 0 || state.looseViews.size > 0 || state.weakViews.size > 0;
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
function forEachView(state: DocumentState, visit: (view: object, local: number) => void): void {
|
|
307
|
+
state.views.forEach((view, local) => { if (view) visit(view, local); });
|
|
308
|
+
for (const [local, view] of state.looseViews) visit(view, local);
|
|
309
|
+
for (const [local, reference] of state.weakViews) { const view = reference.deref(); if (view) visit(view, local); }
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
/**
|
|
313
|
+
* Node objects of detached nodes stay cached only while something else holds
|
|
314
|
+
* them, so a later collection can reclaim their nodes. The `WeakRef`s are
|
|
315
|
+
* taken during a collection rather than on removal: taking one keeps its
|
|
316
|
+
* target, and so its document, alive until the current job ends. Returns
|
|
317
|
+
* whether any node object was weakened.
|
|
318
|
+
*/
|
|
319
|
+
function weakenDetachedViews(state: DocumentState): boolean {
|
|
320
|
+
if (!state.raw.detachedSlots) return false;
|
|
321
|
+
let isWeakened = false;
|
|
322
|
+
for (const local of state.raw.detachedSlots(state.rootLocal ??= state.raw.rootId())) {
|
|
323
|
+
const view = local < state.baseline ? state.views[local] : state.looseViews.get(local);
|
|
324
|
+
if (!view) continue;
|
|
325
|
+
if (local < state.baseline) state.views[local] = undefined;
|
|
326
|
+
else state.looseViews.delete(local);
|
|
327
|
+
state.weakViews.set(local, new WeakRef(view));
|
|
328
|
+
isWeakened = true;
|
|
329
|
+
}
|
|
330
|
+
return isWeakened;
|
|
331
|
+
}
|
|
332
|
+
|
|
333
|
+
/** The state owning `id` of a document whose ids have reached other documents. */
|
|
334
|
+
function exposedStateOf(id: number): DocumentState {
|
|
335
|
+
const state = registry.states[Math.floor(id / STRIDE)]?.deref();
|
|
336
|
+
if (!state || !(id >= 0)) throw new RangeError(`Unknown native node handle ${id}`);
|
|
337
|
+
return state;
|
|
338
|
+
}
|
|
339
|
+
|
|
340
|
+
/** The state owning `id`, resolved without the weak table when `document` owns it. */
|
|
341
|
+
export function stateIn(document: NativeDocument, id: number): DocumentState {
|
|
342
|
+
const own = (document as Partial<RoutedDocument>).state;
|
|
343
|
+
if (own === undefined || id < own.base || id >= own.base + STRIDE) return exposedStateOf(id);
|
|
344
|
+
if (own.isCollectionEvicted) resumeCollection(document);
|
|
345
|
+
return own;
|
|
346
|
+
}
|
|
347
|
+
|
|
348
|
+
/** Schedules again a collection that left the pending list before it ran. */
|
|
349
|
+
export function resumeCollection(document: NativeDocument): void {
|
|
350
|
+
const state = (document as Partial<RoutedDocument>).state;
|
|
351
|
+
if (!state?.isCollectionEvicted) return;
|
|
352
|
+
state.isCollectionEvicted = false;
|
|
353
|
+
scheduleCollection(state);
|
|
354
|
+
}
|
|
355
|
+
|
|
356
|
+
/** The node's record for the current generation, with ids local to `state`. */
|
|
357
|
+
export function recordAt(state: DocumentState, local: number): NativeNodeRecord | null {
|
|
358
|
+
const cached = state.records[local];
|
|
359
|
+
if (cached !== undefined) return cached;
|
|
360
|
+
const record = readRecord(state, local);
|
|
361
|
+
state.records[local] = record;
|
|
362
|
+
return record;
|
|
363
|
+
}
|
|
364
|
+
|
|
365
|
+
const minimumReadsBeforeSnapshot = 64;
|
|
366
|
+
|
|
367
|
+
/**
|
|
368
|
+
* Once individual reads in a generation cost about as much as copying every
|
|
369
|
+
* record, take one snapshot and decode the rest locally. A snapshot costs
|
|
370
|
+
* about two reads plus one read per hundred nodes, and a document read once
|
|
371
|
+
* is usually read again, so an unchanged document of under a hundred nodes
|
|
372
|
+
* takes one on its first read. Mutations start a new generation.
|
|
373
|
+
*/
|
|
374
|
+
function readRecord(state: DocumentState, local: number): NativeNodeRecord | null {
|
|
375
|
+
const bulk = state.bulk;
|
|
376
|
+
if (bulk instanceof NodeSnapshot) {
|
|
377
|
+
const record = state.stale.size && state.stale.has(local) ? undefined : bulk.record(local);
|
|
378
|
+
if (record !== undefined) return record;
|
|
379
|
+
} else if (state.raw.snapshot) {
|
|
380
|
+
const reads = bulk + 1;
|
|
381
|
+
const size = Math.max(state.snapshotSize, state.baseline);
|
|
382
|
+
// An unchanged small document is read throughout; after a write, often only a few nodes are.
|
|
383
|
+
const firstSnapshotRead = state.generation === 0 ? size / 100 : 1 + size / 100;
|
|
384
|
+
if (reads >= Math.min(minimumReadsBeforeSnapshot, firstSnapshotRead)) {
|
|
385
|
+
const snapshot = new NodeSnapshot(state.raw.snapshot());
|
|
386
|
+
state.snapshotSize = snapshot.size;
|
|
387
|
+
state.bulk = snapshot;
|
|
388
|
+
const record = snapshot.record(local);
|
|
389
|
+
if (record !== undefined) return record;
|
|
390
|
+
} else state.bulk = reads;
|
|
391
|
+
}
|
|
392
|
+
return rawOperation(state.raw, 'node', { id: local });
|
|
393
|
+
}
|
|
394
|
+
|
|
395
|
+
/** The current snapshot when it holds `local` exactly, so its fields can be read without a record. */
|
|
396
|
+
export function plainSnapshot(state: DocumentState, local: number): NodeSnapshot | undefined {
|
|
397
|
+
const bulk = state.bulk;
|
|
398
|
+
return bulk instanceof NodeSnapshot && !(state.stale.size && state.stale.has(local)) && bulk.isPlain(local) ? bulk : undefined;
|
|
399
|
+
}
|
|
400
|
+
|
|
401
|
+
/** A string attribute of an element, read like the native `attr` lookup. */
|
|
402
|
+
export function attributeAt(state: DocumentState, local: number, name: string): string | undefined {
|
|
403
|
+
const snapshot = plainSnapshot(state, local);
|
|
404
|
+
if (snapshot) return snapshot.attributeValue(local, name);
|
|
405
|
+
const attributes = recordAt(state, local)?.attribs as Record<string, unknown> | undefined;
|
|
406
|
+
const value = attributes && Object.hasOwn(attributes, name) ? attributes[name] : undefined;
|
|
407
|
+
return typeof value === 'string' ? value : undefined;
|
|
408
|
+
}
|
|
409
|
+
|
|
410
|
+
/** Starts a new generation: cached records may no longer match the arena. */
|
|
411
|
+
export function invalidate(state: DocumentState): void {
|
|
412
|
+
state.generation++;
|
|
413
|
+
state.records = [];
|
|
414
|
+
state.bulk = 0;
|
|
415
|
+
state.stale.clear();
|
|
416
|
+
}
|
|
417
|
+
|
|
418
|
+
/** Discards the records of nodes a write changed without touching the tree's shape. */
|
|
419
|
+
function invalidateNodes(state: DocumentState, locals: number[]): void {
|
|
420
|
+
const isSnapshot = state.bulk instanceof NodeSnapshot;
|
|
421
|
+
for (const local of locals) {
|
|
422
|
+
state.records[local] = undefined;
|
|
423
|
+
if (isSnapshot) state.stale.add(local);
|
|
424
|
+
}
|
|
425
|
+
}
|
|
426
|
+
|
|
427
|
+
/** Keeps the ids of `document` that `owner` holds alive, and rewrites them when their nodes move. */
|
|
428
|
+
export function leaseNodes(document: NativeDocument, owner: object, ids: number[]): Lease {
|
|
429
|
+
const token: LeaseToken = { isReleased: false };
|
|
430
|
+
const states = statesOf(document, ids);
|
|
431
|
+
const lease: Lease = { ids, token, states };
|
|
432
|
+
for (const state of states) addLease(state, lease);
|
|
433
|
+
registry.leaseFinalizer.register(owner, token);
|
|
434
|
+
return lease;
|
|
435
|
+
}
|
|
436
|
+
|
|
437
|
+
/** Points a lease at a replacement id list. */
|
|
438
|
+
export function updateLease(document: NativeDocument, lease: Lease, ids: number[]): void {
|
|
439
|
+
lease.ids = ids;
|
|
440
|
+
lease.states = statesOf(document, ids);
|
|
441
|
+
for (const state of lease.states) addLease(state, lease);
|
|
442
|
+
}
|
|
443
|
+
|
|
444
|
+
function addLease(state: DocumentState, lease: Lease): void {
|
|
445
|
+
state.leases.add(lease);
|
|
446
|
+
if (state.leases.size < state.leaseSweepSize) return;
|
|
447
|
+
for (const held of state.leases) if (held.token.isReleased) state.leases.delete(held);
|
|
448
|
+
state.leaseSweepSize = state.leases.size * 2 + 64;
|
|
449
|
+
}
|
|
450
|
+
|
|
451
|
+
/** Weakened node objects become unreachable only after a later garbage collection. */
|
|
452
|
+
const collectionRetries = 3;
|
|
453
|
+
|
|
454
|
+
/** Reclaims removed nodes that no JavaScript owner references. */
|
|
455
|
+
export function collectState(state: DocumentState): NativeMemoryStats | undefined {
|
|
456
|
+
registry.pending.delete(state);
|
|
457
|
+
state.isCollectionEvicted = false;
|
|
458
|
+
if (!state.raw.collectPinned) return state.raw.collectGarbage?.();
|
|
459
|
+
if (weakenDetachedViews(state)) state.collectionRetries = collectionRetries;
|
|
460
|
+
const pins: number[] = [];
|
|
461
|
+
const { base } = state;
|
|
462
|
+
const end = base + STRIDE;
|
|
463
|
+
for (const lease of state.leases) {
|
|
464
|
+
if (lease.token.isReleased) { state.leases.delete(lease); continue; }
|
|
465
|
+
for (const id of lease.ids) if (id >= base && id < end) pins.push(id - base);
|
|
466
|
+
}
|
|
467
|
+
state.views.forEach((view, local) => { if (view) pins.push(local); });
|
|
468
|
+
for (const local of state.looseViews.keys()) pins.push(local);
|
|
469
|
+
for (const [local, reference] of state.weakViews) {
|
|
470
|
+
if (reference.deref()) pins.push(local);
|
|
471
|
+
else state.weakViews.delete(local);
|
|
472
|
+
}
|
|
473
|
+
const { free, ...stats } = state.raw.collectPinned(Uint32Array.from(pins), rootSlots);
|
|
474
|
+
invalidate(state);
|
|
475
|
+
for (const local of free) releaseSlot(state, local);
|
|
476
|
+
releaseSlotsFrom(state, stats.slots);
|
|
477
|
+
state.looseViewLimit = Math.max(minimumLooseViewLimit, state.looseViews.size * 2);
|
|
478
|
+
if (state.collectionRetries > 0 && state.weakViews.size > 0) {
|
|
479
|
+
state.collectionRetries--;
|
|
480
|
+
scheduleCollection(state);
|
|
481
|
+
}
|
|
482
|
+
return stats;
|
|
483
|
+
}
|
|
484
|
+
|
|
54
485
|
function rawOperation<T = any>(document: NativeDocument, command: string, args: any): T {
|
|
55
486
|
const result = document.op(command, JSON.stringify(args));
|
|
56
487
|
return result === '' || result === undefined ? undefined as T : JSON.parse(result);
|
|
57
488
|
}
|
|
58
|
-
function localMap(document: NativeDocument): Map<number, HandleEntry> {
|
|
59
|
-
let map = registry.byDocument.get(document);
|
|
60
|
-
if (!map) registry.byDocument.set(document, map = new Map());
|
|
61
|
-
return map;
|
|
62
|
-
}
|
|
63
|
-
function handleFor(document: ManagedNativeDocument, local: number): Handle {
|
|
64
|
-
const map = localMap(document);
|
|
65
|
-
let handle = map.get(local)?.reference.deref();
|
|
66
|
-
if (!handle || handle.document !== document || handle.local !== local) {
|
|
67
|
-
const pin: PinRecord = { global: registry.next++, document: new WeakRef(document), local };
|
|
68
|
-
handle = { global: pin.global, document, local, pin };
|
|
69
|
-
document.retain?.([local]);
|
|
70
|
-
const reference = new WeakRef(handle);
|
|
71
|
-
map.set(local, { reference, pin }); registry.handles.set(handle.global, reference);
|
|
72
|
-
registry.finalizer.register(handle, pin);
|
|
73
|
-
}
|
|
74
|
-
return handle;
|
|
75
|
-
}
|
|
76
489
|
|
|
77
|
-
/**
|
|
78
|
-
|
|
79
|
-
|
|
490
|
+
/** Converts a record's local links to ids. */
|
|
491
|
+
function globalRecord(state: DocumentState, record: NativeNodeRecord | null): NativeNodeRecord | null {
|
|
492
|
+
if (!record) return null;
|
|
493
|
+
const { base } = state;
|
|
494
|
+
const link = (local: number | null | undefined): number | null => local == null ? null : base + local;
|
|
495
|
+
return { ...plainRecord(record), id: base + record.id, children: (record.children ?? []).map((child) => base + child), parent: link(record.parent), prev: link(record.prev), next: link(record.next) } as NativeNodeRecord;
|
|
80
496
|
}
|
|
81
497
|
|
|
498
|
+
const parentTypes = new Set(['root', 'tag', 'script', 'style', 'cdata']);
|
|
499
|
+
const pointerFields = new Set(['parent', 'parentNode', 'prev', 'previousSibling', 'next', 'nextSibling']);
|
|
500
|
+
const idResultCommands = new Set(['select', 'matches', 'uniqueSort', 'traverse', 'clone']);
|
|
501
|
+
const scratchCommands = new Set(['select', 'matches', 'uniqueSort', 'serializeArray', 'serialize', 'extractPlan']);
|
|
502
|
+
const writeCommands = new Set(['create', 'createNode', 'setNode', 'import', 'insert', 'clone', 'remove', 'removeAttr', 'removeData', 'addClass', 'removeClass', 'toggleClass']);
|
|
503
|
+
const attributeFields = new Set(['attribs', 'x-attribsNamespace', 'x-attribsPrefix']);
|
|
504
|
+
const targetedAttributeWrites = new Set(['attr', 'removeAttr', 'addClass', 'removeClass', 'toggleClass', 'css']);
|
|
505
|
+
/** Writes that only change the records of their own ids. */
|
|
506
|
+
const recordOnlyWrites = new Set([...targetedAttributeWrites, 'data', 'removeData']);
|
|
507
|
+
const structuralWrites = new Set(['create', 'createNode', 'import', 'insert', 'clone', 'remove', 'html', 'text', 'data', 'removeData']);
|
|
508
|
+
|
|
82
509
|
/**
|
|
83
|
-
*
|
|
84
|
-
*
|
|
85
|
-
*
|
|
86
|
-
* ownership records they use, including records moved to another arena.
|
|
510
|
+
* Routes commands for ids from any document to the arena that owns them.
|
|
511
|
+
* Native adoption moves nodes between arenas; `moveInto` rewrites the ids that
|
|
512
|
+
* live owners hold so node identities survive the move.
|
|
87
513
|
*/
|
|
88
514
|
export class RoutedDocument implements NativeDocument {
|
|
89
515
|
readonly nodeSpace = registry.space;
|
|
90
516
|
readonly options: CheerioOptions;
|
|
91
|
-
|
|
92
|
-
get raw(): ManagedNativeDocument { return this.
|
|
517
|
+
readonly state: DocumentState;
|
|
518
|
+
get raw(): ManagedNativeDocument { return this.state.raw; }
|
|
93
519
|
|
|
94
|
-
constructor(document: NativeDocument, options: CheerioOptions = {}
|
|
520
|
+
constructor(document: NativeDocument, options: CheerioOptions = {}) {
|
|
95
521
|
this.options = options;
|
|
96
|
-
this.
|
|
522
|
+
this.state = stateFor(document, options);
|
|
97
523
|
}
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
524
|
+
|
|
525
|
+
/**
|
|
526
|
+
* Sets (or with `null` removes) an attribute, as the `attr` command does,
|
|
527
|
+
* through a typed native call. Attribute objects already handed out are
|
|
528
|
+
* updated in place instead of being read back.
|
|
529
|
+
*/
|
|
530
|
+
setAttribute(ids: number[], name: string, value: string | null): void {
|
|
531
|
+
registry.writes++;
|
|
532
|
+
for (const { state, locals } of this.groups(ids)) {
|
|
533
|
+
if (!state.raw.setAttribute) { this.call(state, 'attr', { ids: locals, name, value }); continue; }
|
|
534
|
+
invalidateNodes(state, locals);
|
|
535
|
+
state.raw.setAttribute(locals, name, value);
|
|
536
|
+
for (const local of locals) {
|
|
537
|
+
const target = ((cachedView(state, local) as Record<symbol, { target?: Record<string, string> } | undefined> | undefined)?.[VIEW_ATTRIBUTES])?.target;
|
|
538
|
+
if (!target) continue;
|
|
539
|
+
if (value === null) delete target[name];
|
|
540
|
+
else if (name === '__proto__') Object.defineProperty(target, name, { value, writable: true, enumerable: true, configurable: true });
|
|
541
|
+
else target[name] = value;
|
|
542
|
+
}
|
|
543
|
+
}
|
|
103
544
|
}
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
collectGarbage(): NativeMemoryStats | undefined { registry.snapshots.delete(this.raw); return this.raw.collectGarbage?.(); }
|
|
110
|
-
retainNodeData(id: number, data: object): void { this.lookup(id).data = data; }
|
|
111
|
-
nodeData(id: number): object | undefined { return this.lookup(id).data; }
|
|
545
|
+
|
|
546
|
+
retainNode(id: number): void { this.owner(id); }
|
|
547
|
+
collectGarbage(): NativeMemoryStats | undefined { return collectState(this.state); }
|
|
548
|
+
retainNodeData(id: number, data: object): void { dataEntry(this.owner(id), id).data = data; }
|
|
549
|
+
nodeData(id: number): object | undefined { const state = this.owner(id); return state.data.get(id - state.base)?.data; }
|
|
112
550
|
nodeDataOperation(id: number): (command: string, args: any) => any {
|
|
113
|
-
const
|
|
551
|
+
const entry = dataEntry(this.owner(id), id);
|
|
114
552
|
const options = this.options;
|
|
115
|
-
|
|
553
|
+
// Resolve the entry's current arena on each call: the node may have moved.
|
|
554
|
+
return (command, args) => entry.isReleased ? undefined : routedFor(entry.state).operate(command, { ...args, ids: [entry.state.base + entry.local], options });
|
|
116
555
|
}
|
|
117
|
-
localNodeId(
|
|
118
|
-
rootId(): number { return this.
|
|
119
|
-
|
|
120
|
-
private
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
Object.defineProperty(view, 'children', { enumerable: true, get: () => this.globals(document, record.children ?? []) });
|
|
126
|
-
for (const key of ['parent', 'prev', 'next']) Object.defineProperty(view, key, { enumerable: true, get: () => record[key] == null ? null : this.global(document, record[key]) });
|
|
127
|
-
return view;
|
|
556
|
+
localNodeId(local: number): number { return this.state.base + local; }
|
|
557
|
+
rootId(): number { return this.state.base + (this.state.rootLocal ??= this.raw.rootId()); }
|
|
558
|
+
isOwnNode(id: number): boolean { return id >= this.state.base && id < this.state.base + STRIDE; }
|
|
559
|
+
private owner(id: number): DocumentState { return stateIn(this, id); }
|
|
560
|
+
|
|
561
|
+
private globals(state: DocumentState, locals: number[]): number[] {
|
|
562
|
+
const { base } = state;
|
|
563
|
+
return locals.map((local) => base + local);
|
|
128
564
|
}
|
|
129
|
-
|
|
130
|
-
|
|
565
|
+
|
|
566
|
+
private groups(ids: number[]): { state: DocumentState; locals: number[] }[] {
|
|
567
|
+
const groups: { state: DocumentState; locals: number[] }[] = [];
|
|
568
|
+
const own = this.state;
|
|
569
|
+
let last: { state: DocumentState; locals: number[] } | undefined;
|
|
131
570
|
for (const id of ids) {
|
|
132
|
-
const
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
else groups.push({ document: handle.document, ids: [handle.local], global: [id] });
|
|
571
|
+
const state = id >= own.base && id < own.base + STRIDE ? own : exposedStateOf(id);
|
|
572
|
+
if (last?.state === state) last.locals.push(id - state.base);
|
|
573
|
+
else groups.push(last = { state, locals: [id - state.base] });
|
|
136
574
|
}
|
|
137
575
|
return groups;
|
|
138
576
|
}
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
if (
|
|
142
|
-
|
|
577
|
+
|
|
578
|
+
private call<T = any>(state: DocumentState, command: string, args: any): T {
|
|
579
|
+
if (command === 'extractPlan' && JSON.stringify(args.plan).includes('innerHTML')) invalidate(state);
|
|
580
|
+
const isWrite = Object.hasOwn(args, 'value') || writeCommands.has(command);
|
|
581
|
+
if (isWrite) {
|
|
582
|
+
registry.writes++;
|
|
583
|
+
const changed = recordOnlyWrites.has(command) ? args.ids ?? [] : command === 'setNode' && attributeFields.has(args.field) ? [args.id] : undefined;
|
|
584
|
+
if (changed) invalidateNodes(state, changed);
|
|
585
|
+
else invalidate(state);
|
|
586
|
+
}
|
|
587
|
+
const result = rawOperation(state.raw, command, { ...args, options: args.options ?? this.options });
|
|
588
|
+
if (isWrite) { refreshWrittenAttributes(state, command, args); refreshChildLists(state); scheduleCollectionIfDue(state); }
|
|
589
|
+
return result;
|
|
143
590
|
}
|
|
144
591
|
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
for (const
|
|
149
|
-
|
|
150
|
-
if (
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
if (!entry) continue;
|
|
157
|
-
const handle = entry.reference.deref();
|
|
158
|
-
if (handle && (handle.document !== source || handle.local !== oldLocal)) continue;
|
|
159
|
-
localMap(source).delete(oldLocal);
|
|
160
|
-
entry.pin.document = new WeakRef(target); entry.pin.local = newLocal;
|
|
161
|
-
if (handle) { handle.document = target; handle.local = newLocal; }
|
|
162
|
-
localMap(target).set(newLocal, entry);
|
|
163
|
-
}
|
|
592
|
+
/** Moves or copies nodes into `target`; returns their local ids there. */
|
|
593
|
+
private moveInto(target: DocumentState, ids: number[], isCopy = false): number[] {
|
|
594
|
+
if (!isCopy) {
|
|
595
|
+
for (const group of this.groups(ids)) {
|
|
596
|
+
if (group.state === target) continue;
|
|
597
|
+
if (!target.raw.adopt) throw new Error('The native addon does not support cross-document adoption. Rebuild the Rust addon.');
|
|
598
|
+
const adopted: { mapping: [number, number][] } = JSON.parse(target.raw.adopt(group.state.raw, group.locals, true));
|
|
599
|
+
rehome(group.state, target, adopted.mapping, ids);
|
|
600
|
+
refreshChildLists(group.state);
|
|
601
|
+
refreshChildLists(target);
|
|
602
|
+
scheduleCollectionIfDue(group.state);
|
|
164
603
|
}
|
|
165
|
-
return ids.map((id) =>
|
|
604
|
+
return ids.map((id) => id - target.base);
|
|
166
605
|
}
|
|
167
606
|
const result: number[] = [];
|
|
168
607
|
for (const group of this.groups(ids)) {
|
|
169
|
-
if (
|
|
170
|
-
result.push(...this.call<number[]>(target, 'clone', { ids: group.
|
|
608
|
+
if (group.state === target) {
|
|
609
|
+
result.push(...this.call<number[]>(target, 'clone', { ids: group.locals, deep: true }));
|
|
171
610
|
continue;
|
|
172
611
|
}
|
|
173
|
-
if (!target.adopt) throw new Error('The native addon does not support cross-document adoption. Rebuild the Rust addon.');
|
|
174
|
-
|
|
175
|
-
const adopted: { roots: number[]
|
|
612
|
+
if (!target.raw.adopt) throw new Error('The native addon does not support cross-document adoption. Rebuild the Rust addon.');
|
|
613
|
+
invalidate(target);
|
|
614
|
+
const adopted: { roots: number[] } = JSON.parse(target.raw.adopt(group.state.raw, group.locals, false));
|
|
176
615
|
result.push(...adopted.roots);
|
|
177
616
|
}
|
|
178
617
|
return result;
|
|
179
618
|
}
|
|
180
619
|
|
|
181
620
|
private scratch(command: string, args: any, ids: number[]): any {
|
|
182
|
-
const
|
|
621
|
+
const states = [...new Set(ids.map((id) => this.owner(id)))];
|
|
183
622
|
const scratch = new (requireNative().NativeDocument)('', false, JSON.stringify(args.options ?? this.options));
|
|
184
623
|
const toScratch = new Map<number, number>();
|
|
185
624
|
const fromScratch = new Map<number, number>();
|
|
186
|
-
for (const
|
|
187
|
-
const records: NativeNodeRecord[] = JSON.parse(
|
|
625
|
+
for (const state of states) {
|
|
626
|
+
const records: NativeNodeRecord[] = JSON.parse(state.raw.exportTree());
|
|
188
627
|
const roots = records.filter((record) => record.parent == null).map((record) => record.id);
|
|
189
628
|
if (!scratch.adopt) throw new Error('The native addon does not support native document copies.');
|
|
190
|
-
const copied: { mapping: [number, number][] } = JSON.parse(scratch.adopt(
|
|
629
|
+
const copied: { mapping: [number, number][] } = JSON.parse(scratch.adopt(state.raw, roots, false));
|
|
191
630
|
for (const [local, copy] of copied.mapping) {
|
|
192
|
-
const global =
|
|
631
|
+
const global = state.base + local;
|
|
193
632
|
toScratch.set(global, copy); fromScratch.set(copy, global);
|
|
194
633
|
}
|
|
195
634
|
}
|
|
196
635
|
const translated = { ...args };
|
|
197
636
|
for (const key of ['ids', 'contexts']) if (Array.isArray(args[key])) translated[key] = args[key].map((id: number) => toScratch.get(id)).filter((id: number | undefined) => id !== undefined);
|
|
198
637
|
if (args.pseudoMatches) translated.pseudoMatches = Object.fromEntries(Object.entries(args.pseudoMatches).map(([key, values]) => [key, (values as number[]).map((id) => toScratch.get(id)).filter((id) => id !== undefined)]));
|
|
199
|
-
const result =
|
|
638
|
+
const result = rawOperation(scratch, command, { ...translated, options: translated.options ?? this.options });
|
|
200
639
|
return ['select', 'matches', 'uniqueSort'].includes(command) ? (result as number[]).map((id) => fromScratch.get(id)!).filter((id) => id !== undefined) : result;
|
|
201
640
|
}
|
|
202
641
|
|
|
642
|
+
private isMixed(groups: { state: DocumentState }[]): boolean {
|
|
643
|
+
return groups.length > 1 && groups.some((group) => group.state !== groups[0].state);
|
|
644
|
+
}
|
|
645
|
+
|
|
203
646
|
select(selector: string, contexts?: number[]): number[] {
|
|
204
647
|
const ids = contexts ?? [this.rootId()];
|
|
205
|
-
if (!ids.length) return this.call(this.
|
|
648
|
+
if (!ids.length) return this.call(this.state, 'select', { selector, contexts: [] });
|
|
206
649
|
const groups = this.groups(ids);
|
|
207
|
-
if (
|
|
650
|
+
if (this.isMixed(groups)) return this.scratch('select', { selector, contexts: ids }, ids);
|
|
208
651
|
const group = groups[0];
|
|
209
|
-
const found = group.
|
|
210
|
-
return this.globals(group.
|
|
652
|
+
const found = group.state === this.state ? group.state.raw.select(selector, group.locals) : this.call<number[]>(group.state, 'select', { selector, contexts: group.locals });
|
|
653
|
+
return this.globals(group.state, found);
|
|
211
654
|
}
|
|
212
|
-
text(ids: number[]): string { return this.groups(ids).map((group) => group.
|
|
213
|
-
innerHtml(id: number): string {
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
655
|
+
text(ids: number[]): string { return this.groups(ids).map((group) => group.state.raw.text(group.locals)).join(''); }
|
|
656
|
+
innerHtml(id: number): string {
|
|
657
|
+
const state = this.owner(id);
|
|
658
|
+
const local = id - state.base;
|
|
659
|
+
// Serializing template content splices its fragment into the element.
|
|
660
|
+
const isSplicing = recordAt(state, local)?.children?.some((child) => recordAt(state, child)?.type === 'root') ?? false;
|
|
661
|
+
if (isSplicing) invalidate(state);
|
|
662
|
+
const html = state === this.state ? state.raw.innerHtml(local) : this.call<string>(state, 'html', { ids: [local] });
|
|
663
|
+
if (isSplicing && state.hasChildLists && childRefresher) forEachView(state, childRefresher);
|
|
664
|
+
return html;
|
|
665
|
+
}
|
|
666
|
+
outerHtml(ids: number[]): string { return this.groups(ids).map((group) => group.state === this.state ? group.state.raw.outerHtml(group.locals) : this.call<string>(group.state, 'render', { ids: group.locals })).join(''); }
|
|
667
|
+
attr(id: number, name: string): string | undefined { const state = this.owner(id); return this.call(state, 'attr', { ids: [id - state.base], name }) ?? undefined; }
|
|
668
|
+
tagName(id: number): string | undefined { const state = this.owner(id); return recordAt(state, id - state.base)?.name; }
|
|
669
|
+
exportTree(): string { return JSON.stringify((JSON.parse(this.raw.exportTree()) as NativeNodeRecord[]).map((record) => globalRecord(this.state, record))); }
|
|
670
|
+
create(html: string, contextId?: number): number[] { return this.operate('create', { html, contextId }); }
|
|
219
671
|
|
|
220
672
|
op(command: string, json: string): string { return JSON.stringify(this.operate(command, JSON.parse(json)) ?? null); }
|
|
221
673
|
|
|
222
674
|
operate(command: string, args: any): any {
|
|
223
675
|
const options = args.options ?? this.options;
|
|
224
|
-
|
|
225
|
-
if (command === 'node') {
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
else if (command === 'create') {
|
|
233
|
-
const context = args.contextId == null ? undefined : this.lookup(args.contextId);
|
|
234
|
-
const document = context?.document ?? this.raw;
|
|
676
|
+
const hasValue = Object.hasOwn(args, 'value');
|
|
677
|
+
if (command === 'node') { const state = this.owner(args.id); return globalRecord(state, recordAt(state, args.id - state.base)); }
|
|
678
|
+
if (command === 'createNode') return this.state.base + this.call<number>(this.state, command, args);
|
|
679
|
+
if (command === 'import') return this.globals(this.state, this.call(this.state, command, args));
|
|
680
|
+
if (command === 'create') {
|
|
681
|
+
const context = args.contextId == null ? undefined : this.owner(args.contextId);
|
|
682
|
+
const state = context ?? this.state;
|
|
683
|
+
const contextLocal = context ? args.contextId - context.base : undefined;
|
|
235
684
|
if (hasParserHooks(options)) {
|
|
236
|
-
const contextRecord = context ?
|
|
237
|
-
const
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
let
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
if (
|
|
250
|
-
|
|
685
|
+
const contextRecord = context ? recordAt(context, contextLocal!) : undefined;
|
|
686
|
+
const children = parsedChildren(args.html, { ...options, ...(contextRecord ? { __contextName: contextRecord.name, __contextNamespace: contextRecord.namespace } : {}) });
|
|
687
|
+
return this.globals(state, this.moveInto(state, children));
|
|
688
|
+
}
|
|
689
|
+
return this.globals(state, this.call(state, command, { ...args, contextId: contextLocal }));
|
|
690
|
+
}
|
|
691
|
+
if (command === 'setNode' || command === 'prop' && hasValue && (args.name === 'children' || args.name === 'childNodes' || pointerFields.has(args.name))) {
|
|
692
|
+
const field = command === 'setNode' ? args.field : args.name;
|
|
693
|
+
let result = null;
|
|
694
|
+
for (const id of command === 'setNode' ? [args.id] : args.ids ?? []) {
|
|
695
|
+
let state = this.owner(id);
|
|
696
|
+
let nodeId = id;
|
|
697
|
+
let value = args.value;
|
|
698
|
+
if (field === 'children' || field === 'childNodes') value = this.moveInto(state, value ?? []);
|
|
699
|
+
else if (pointerFields.has(field) && value != null) {
|
|
700
|
+
const pointer = this.owner(value);
|
|
701
|
+
if (pointer !== state) { const moved = [id]; this.moveInto(pointer, moved); nodeId = moved[0]; state = pointer; }
|
|
702
|
+
value = args.value - pointer.base;
|
|
703
|
+
}
|
|
704
|
+
const local = nodeId - state.base;
|
|
705
|
+
result = this.call(state, command, command === 'setNode' ? { ...args, id: local, value } : { ...args, ids: [local], value });
|
|
251
706
|
}
|
|
252
|
-
result
|
|
253
|
-
}
|
|
707
|
+
return result;
|
|
708
|
+
}
|
|
709
|
+
if (command === 'insert') {
|
|
254
710
|
const targets: number[] = args.targetIds ?? [];
|
|
255
711
|
const nodes: number[] = args.nodeIds ?? [];
|
|
256
|
-
|
|
712
|
+
const sameDocument = targets.length > 0 && this.groups([...targets, ...nodes]);
|
|
713
|
+
if (sameDocument && sameDocument.length === 1) {
|
|
714
|
+
// One native call clones for every target but the last, as the loop below does per target.
|
|
715
|
+
const { state } = sameDocument[0];
|
|
716
|
+
const inserted = this.call<number[]>(state, command, { ...args, targetIds: targets.map((id) => id - state.base), nodeIds: nodes.map((id) => id - state.base) });
|
|
717
|
+
return this.globals(state, inserted);
|
|
718
|
+
}
|
|
719
|
+
const result: number[] = [];
|
|
257
720
|
for (let index = 0; index < targets.length; index++) {
|
|
258
|
-
const target = this.
|
|
259
|
-
const
|
|
260
|
-
const
|
|
261
|
-
|
|
721
|
+
const target = this.owner(targets[index]);
|
|
722
|
+
const targetLocal = targets[index] - target.base;
|
|
723
|
+
const local = this.moveInto(target, nodes, index + 1 < targets.length);
|
|
724
|
+
const inserted = this.call<number[]>(target, command, { ...args, targetIds: [targetLocal], nodeIds: local });
|
|
725
|
+
result.push(...this.globals(target, inserted));
|
|
262
726
|
}
|
|
263
|
-
|
|
264
|
-
|
|
727
|
+
return result;
|
|
728
|
+
}
|
|
729
|
+
if (command === 'html' && hasValue && hasParserHooks(options)) {
|
|
730
|
+
let result = null;
|
|
265
731
|
for (const id of args.ids ?? []) {
|
|
266
|
-
const
|
|
267
|
-
const
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
const children =
|
|
271
|
-
|
|
272
|
-
result = this.call(target.document, 'setNode', { id: target.local, field: 'children', value: local, options });
|
|
732
|
+
const state = this.owner(id);
|
|
733
|
+
const local = id - state.base;
|
|
734
|
+
const record = recordAt(state, local);
|
|
735
|
+
if (!record || !parentTypes.has(record.type)) continue;
|
|
736
|
+
const children = parsedChildren(String(args.value), { ...options, __contextName: record.name, __contextNamespace: record.namespace });
|
|
737
|
+
result = this.call(state, 'setNode', { id: local, field: 'children', value: this.moveInto(state, children), options });
|
|
273
738
|
}
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
if (mixed && ['select', 'matches', 'uniqueSort', 'serializeArray', 'serialize', 'extractPlan'].includes(command)) result = this.scratch(command, { ...args, options }, ids);
|
|
296
|
-
else if (command === 'extractPlan') {
|
|
297
|
-
const group = groups[0];
|
|
298
|
-
result = this.call(group?.document ?? this.raw, command, { ...args, options, ids: group?.ids ?? [] });
|
|
299
|
-
}
|
|
300
|
-
else if (['select', 'matches', 'uniqueSort', 'traverse', 'clone'].includes(command)) {
|
|
301
|
-
result = [];
|
|
302
|
-
if (!groups.length && (command === 'select' || command === 'matches')) result = this.call(this.raw, command, { ...args, options, ids: [], ...(command === 'select' ? { contexts: [] } : {}) });
|
|
303
|
-
for (const group of groups) {
|
|
304
|
-
const values = this.call<number[]>(group.document, command, { ...args, options, ids: group.ids, ...(command === 'select' ? { contexts: group.ids } : {}), ...(args.pseudoMatches ? { pseudoMatches: Object.fromEntries(Object.entries(args.pseudoMatches).map(([key, values]) => [key, (values as number[]).map((id) => this.lookup(id)).filter((handle) => handle.document === group.document).map((handle) => handle.local)])) } : {}) });
|
|
305
|
-
result.push(...this.globals(group.document, values));
|
|
306
|
-
}
|
|
307
|
-
} else if (['render', 'outerHtml', 'text'].includes(command) && !Object.hasOwn(args, 'value')) result = groups.map((group) => this.call<string>(group.document, command, { ...args, options, ids: group.ids })).join('');
|
|
308
|
-
else if (command === 'hasClass') result = groups.some((group) => this.call<boolean>(group.document, command, { ...args, options, ids: group.ids }));
|
|
309
|
-
else if (['serializeArray', 'serialize'].includes(command)) result = groups.length ? this.call(groups[0].document, command, { ...args, options, ids: groups[0].ids }) : command === 'serializeArray' ? [] : '';
|
|
310
|
-
else {
|
|
311
|
-
if (command === 'html') for (const group of groups) registry.snapshots.delete(group.document);
|
|
312
|
-
const writes = Object.hasOwn(args, 'value') || ['remove', 'removeAttr', 'removeData', 'addClass', 'removeClass', 'toggleClass'].includes(command);
|
|
313
|
-
const active = writes ? groups : groups.slice(0, 1);
|
|
314
|
-
result = null;
|
|
315
|
-
for (const group of active) result = this.call(group.document, command, { ...args, options, ids: group.ids });
|
|
739
|
+
return result;
|
|
740
|
+
}
|
|
741
|
+
if (command === 'contains') {
|
|
742
|
+
const container = args.container ?? args.parent;
|
|
743
|
+
const contained = args.contained ?? args.child;
|
|
744
|
+
const parent = this.owner(container);
|
|
745
|
+
const isSameDocument = contained >= parent.base && contained < parent.base + STRIDE;
|
|
746
|
+
return isSameDocument && this.call(parent, command, { container: container - parent.base, contained: contained - parent.base });
|
|
747
|
+
}
|
|
748
|
+
const ids: number[] = command === 'select' ? args.contexts ?? [this.rootId()] : args.ids ?? [];
|
|
749
|
+
const groups = this.groups(ids);
|
|
750
|
+
if (this.isMixed(groups) && scratchCommands.has(command)) return this.scratch(command, { ...args, options }, ids) ?? null;
|
|
751
|
+
if (command === 'extractPlan') { const group = groups[0]; return this.call(group?.state ?? this.state, command, { ...args, options, ids: group?.locals ?? [] }) ?? null; }
|
|
752
|
+
if (idResultCommands.has(command)) {
|
|
753
|
+
const isSelect = command === 'select';
|
|
754
|
+
if (!groups.length && (isSelect || command === 'matches')) return this.call(this.state, command, { ...args, options, ids: [], ...(isSelect ? { contexts: [] } : {}) });
|
|
755
|
+
const result: number[] = [];
|
|
756
|
+
for (const group of groups) {
|
|
757
|
+
const pseudoMatches = args.pseudoMatches ? Object.fromEntries(Object.entries(args.pseudoMatches as Record<string, number[]>).map(([key, values]) => [key, values.filter((id) => id >= group.state.base && id < group.state.base + STRIDE).map((id) => id - group.state.base)])) : undefined;
|
|
758
|
+
const values = this.call<number[]>(group.state, command, { ...args, options, ids: group.locals, ...(isSelect ? { contexts: group.locals } : {}), ...(pseudoMatches ? { pseudoMatches } : {}) });
|
|
759
|
+
result.push(...this.globals(group.state, values));
|
|
316
760
|
}
|
|
761
|
+
return result;
|
|
762
|
+
}
|
|
763
|
+
if (command === 'render' && !hasValue) {
|
|
764
|
+
const optionsJson = JSON.stringify(options);
|
|
765
|
+
return groups.map((group) => group.state.raw.render ? group.state.raw.render(group.locals, optionsJson) : this.call<string>(group.state, command, { ...args, options, ids: group.locals })).join('');
|
|
317
766
|
}
|
|
767
|
+
if ((command === 'outerHtml' || command === 'text') && !hasValue) return groups.map((group) => this.call<string>(group.state, command, { ...args, options, ids: group.locals })).join('');
|
|
768
|
+
if (command === 'hasClass') return groups.some((group) => this.call<boolean>(group.state, command, { ...args, options, ids: group.locals }));
|
|
769
|
+
if (command === 'serializeArray' || command === 'serialize') return groups.length ? this.call(groups[0].state, command, { ...args, options, ids: groups[0].locals }) : command === 'serializeArray' ? [] : '';
|
|
770
|
+
if (command === 'html') for (const group of groups) invalidate(group.state);
|
|
771
|
+
const isWrite = hasValue || writeCommands.has(command);
|
|
772
|
+
let result = null;
|
|
773
|
+
for (const group of isWrite ? groups : groups.slice(0, 1)) result = this.call(group.state, command, { ...args, options, ids: group.locals });
|
|
318
774
|
return result ?? null;
|
|
319
775
|
}
|
|
320
776
|
}
|
|
321
777
|
|
|
778
|
+
/** Parses `html` into a document of its own, whose children the caller then moves. */
|
|
779
|
+
function parsedChildren(html: string, options: CheerioOptions): number[] {
|
|
780
|
+
const parsed = parseNativeDocument(html, options, false) as RoutedDocument;
|
|
781
|
+
exposeState(parsed.state);
|
|
782
|
+
return (parsed.operate('node', { id: parsed.rootId() }) as NativeNodeRecord).children;
|
|
783
|
+
}
|
|
784
|
+
|
|
785
|
+
function dataEntry(state: DocumentState, id: number): DataEntry {
|
|
786
|
+
const local = id - state.base;
|
|
787
|
+
let entry = state.data.get(local);
|
|
788
|
+
if (!entry) state.data.set(local, entry = { state, local });
|
|
789
|
+
return entry;
|
|
790
|
+
}
|
|
791
|
+
|
|
792
|
+
/**
|
|
793
|
+
* Moves the bookkeeping of adopted nodes to their new arena. Live owners'
|
|
794
|
+
* id lists, node objects and data follow the nodes; `ids` is updated too.
|
|
795
|
+
*/
|
|
796
|
+
function rehome(source: DocumentState, target: DocumentState, mapping: [number, number][], ids: number[]): void {
|
|
797
|
+
registry.writes++;
|
|
798
|
+
invalidate(source); invalidate(target);
|
|
799
|
+
// Owners of the moved ids, including selections of the source's API, now hold ids of the target.
|
|
800
|
+
exposeState(target);
|
|
801
|
+
const moved = new Map<number, number>();
|
|
802
|
+
for (const [oldLocal, newLocal] of mapping) {
|
|
803
|
+
moved.set(source.base + oldLocal, target.base + newLocal);
|
|
804
|
+
const node = uncacheView(source, oldLocal) as Record<symbol, unknown> | undefined;
|
|
805
|
+
if (node) {
|
|
806
|
+
node[VIEW_STATE] = target; node[VIEW_LOCAL] = newLocal; cacheView(target, newLocal, node);
|
|
807
|
+
if (node[VIEW_CHILD_TARGET]) target.hasChildLists = true;
|
|
808
|
+
}
|
|
809
|
+
const entry = source.data.get(oldLocal);
|
|
810
|
+
if (entry) { source.data.delete(oldLocal); entry.state = target; entry.local = newLocal; target.data.set(newLocal, entry); }
|
|
811
|
+
const adapterNode = source.adapterNodes?.[oldLocal] as Record<symbol, unknown> | undefined;
|
|
812
|
+
if (adapterNode) {
|
|
813
|
+
source.adapterNodes![oldLocal] = undefined;
|
|
814
|
+
(target.adapterNodes ??= [])[newLocal] = adapterNode;
|
|
815
|
+
adapterNode[VIEW_STATE] = target; adapterNode[VIEW_LOCAL] = newLocal;
|
|
816
|
+
}
|
|
817
|
+
}
|
|
818
|
+
const rewrite = (list: number[]): boolean => {
|
|
819
|
+
let isTouched = false;
|
|
820
|
+
for (let index = 0; index < list.length; index++) {
|
|
821
|
+
const next = moved.get(list[index]);
|
|
822
|
+
if (next !== undefined) { list[index] = next; isTouched = true; }
|
|
823
|
+
}
|
|
824
|
+
return isTouched;
|
|
825
|
+
};
|
|
826
|
+
// Leases first: `ids` may be a lease's own list, which must still hold the
|
|
827
|
+
// moved ids for the lease to be found.
|
|
828
|
+
for (const lease of [...source.leases]) {
|
|
829
|
+
if (!rewrite(lease.ids)) continue;
|
|
830
|
+
target.leases.add(lease);
|
|
831
|
+
if (!lease.states.includes(target)) lease.states.push(target);
|
|
832
|
+
if (!lease.ids.some((id) => id >= source.base && id < source.base + STRIDE)) {
|
|
833
|
+
source.leases.delete(lease);
|
|
834
|
+
lease.states = lease.states.filter((state) => state !== source);
|
|
835
|
+
}
|
|
836
|
+
}
|
|
837
|
+
rewrite(ids);
|
|
838
|
+
}
|
|
839
|
+
|
|
840
|
+
/** Keeps attribute objects that callers hold in step with native writes. */
|
|
841
|
+
function refreshWrittenAttributes(state: DocumentState, command: string, args: any): void {
|
|
842
|
+
if (!hasViews(state)) return;
|
|
843
|
+
let locals: number[] | undefined;
|
|
844
|
+
if (targetedAttributeWrites.has(command)) locals = args.ids ?? [];
|
|
845
|
+
else if (command === 'setNode') { if (!attributeFields.has(args.field)) return; locals = [args.id]; }
|
|
846
|
+
else if (structuralWrites.has(command)) return;
|
|
847
|
+
const refresh = (view: object | undefined): void => { if (view && (view as Record<symbol, unknown>)[VIEW_ATTRIBUTES]) attributeRefresher?.(view); };
|
|
848
|
+
if (locals) for (const local of locals) refresh(cachedView(state, local));
|
|
849
|
+
else forEachView(state, refresh);
|
|
850
|
+
}
|
|
851
|
+
|
|
852
|
+
let attributeRefresher: ((view: object) => void) | undefined;
|
|
853
|
+
let childRefresher: ((view: object) => void) | undefined;
|
|
854
|
+
|
|
855
|
+
/** Registers how node objects re-read their attributes and children after a native write. */
|
|
856
|
+
export function onViewRefresh(attributes: (view: object) => void, children: (view: object) => void): void {
|
|
857
|
+
attributeRefresher = attributes;
|
|
858
|
+
childRefresher = children;
|
|
859
|
+
}
|
|
860
|
+
|
|
861
|
+
/** Refills the child arrays of parents whose children a native write changed. */
|
|
862
|
+
function refreshChildLists(state: DocumentState): void {
|
|
863
|
+
if (!state.hasChildLists || !childRefresher) return;
|
|
864
|
+
const touched = state.raw.takeTouched?.();
|
|
865
|
+
if (touched === undefined) return;
|
|
866
|
+
if (touched === null) { forEachView(state, childRefresher); return; }
|
|
867
|
+
for (const local of touched) { const view = cachedView(state, local); if (view) childRefresher(view); }
|
|
868
|
+
}
|
|
869
|
+
|
|
322
870
|
export function routeDocument(document: NativeDocument, options: CheerioOptions = {}): NativeDocument {
|
|
323
871
|
return document.nodeSpace === registry.space ? document : new RoutedDocument(document, options);
|
|
324
872
|
}
|
|
325
873
|
|
|
874
|
+
/** The unanchored routed document node objects of `state` use. */
|
|
875
|
+
export function routedFor(state: DocumentState): RoutedDocument {
|
|
876
|
+
return state.routed ??= new RoutedDocument(state.raw, state.options);
|
|
877
|
+
}
|
|
878
|
+
|
|
326
879
|
export function sameNodeSpace(first: NativeDocument, second: NativeDocument): boolean {
|
|
327
880
|
return first === second || !!first.nodeSpace && first.nodeSpace === second.nodeSpace;
|
|
328
881
|
}
|
|
882
|
+
|
|
883
|
+
/** Whether ids of `state` belong to this copy of the package; nodes of another copy are foreign objects here. */
|
|
884
|
+
export function isLocalState(state: DocumentState): boolean {
|
|
885
|
+
return state.space === registry.space;
|
|
886
|
+
}
|
|
887
|
+
|
|
888
|
+
/** Whether `document` routes ids through this registry. */
|
|
889
|
+
export function isRouted(document: NativeDocument): boolean {
|
|
890
|
+
return document.nodeSpace === registry.space;
|
|
891
|
+
}
|