@acornly/core 0.1.0 → 0.3.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/adapter.d.ts +27 -2
- package/dist/adapter.d.ts.map +1 -1
- package/dist/adapter.js +5 -0
- package/dist/adapter.js.map +1 -1
- package/dist/anchoring/canvas.d.ts +33 -0
- package/dist/anchoring/canvas.d.ts.map +1 -0
- package/dist/anchoring/canvas.js +112 -0
- package/dist/anchoring/canvas.js.map +1 -0
- package/dist/anchoring/context-provider.d.ts +63 -0
- package/dist/anchoring/context-provider.d.ts.map +1 -0
- package/dist/anchoring/context-provider.js +78 -0
- package/dist/anchoring/context-provider.js.map +1 -0
- package/dist/anchoring/describe.d.ts +36 -2
- package/dist/anchoring/describe.d.ts.map +1 -1
- package/dist/anchoring/describe.js +102 -20
- package/dist/anchoring/describe.js.map +1 -1
- package/dist/anchoring/fingerprint.d.ts +47 -13
- package/dist/anchoring/fingerprint.d.ts.map +1 -1
- package/dist/anchoring/fingerprint.js +58 -68
- package/dist/anchoring/fingerprint.js.map +1 -1
- package/dist/anchoring/index.d.ts +5 -0
- package/dist/anchoring/index.d.ts.map +1 -1
- package/dist/anchoring/index.js +5 -0
- package/dist/anchoring/index.js.map +1 -1
- package/dist/anchoring/media.d.ts +2 -1
- package/dist/anchoring/media.d.ts.map +1 -1
- package/dist/anchoring/media.js +7 -2
- package/dist/anchoring/media.js.map +1 -1
- package/dist/anchoring/pdf.d.ts +204 -0
- package/dist/anchoring/pdf.d.ts.map +1 -0
- package/dist/anchoring/pdf.js +608 -0
- package/dist/anchoring/pdf.js.map +1 -0
- package/dist/anchoring/profiles.d.ts +140 -0
- package/dist/anchoring/profiles.d.ts.map +1 -0
- package/dist/anchoring/profiles.js +682 -0
- package/dist/anchoring/profiles.js.map +1 -0
- package/dist/anchoring/providers.d.ts +16 -1
- package/dist/anchoring/providers.d.ts.map +1 -1
- package/dist/anchoring/providers.js +15 -0
- package/dist/anchoring/providers.js.map +1 -1
- package/dist/anchoring/resolver.d.ts +23 -5
- package/dist/anchoring/resolver.d.ts.map +1 -1
- package/dist/anchoring/resolver.js +32 -13
- package/dist/anchoring/resolver.js.map +1 -1
- package/dist/anchoring/site-profiles.d.ts +31 -0
- package/dist/anchoring/site-profiles.d.ts.map +1 -0
- package/dist/anchoring/site-profiles.js +27 -0
- package/dist/anchoring/site-profiles.js.map +1 -0
- package/dist/body-prepare.d.ts +33 -0
- package/dist/body-prepare.d.ts.map +1 -0
- package/dist/body-prepare.js +88 -0
- package/dist/body-prepare.js.map +1 -0
- package/dist/body-size.d.ts +20 -0
- package/dist/body-size.d.ts.map +1 -0
- package/dist/body-size.js +137 -0
- package/dist/body-size.js.map +1 -0
- package/dist/body.d.ts +9 -42
- package/dist/body.d.ts.map +1 -1
- package/dist/body.js +6 -207
- package/dist/body.js.map +1 -1
- package/dist/composite.d.ts +17 -1
- package/dist/composite.d.ts.map +1 -1
- package/dist/composite.js +27 -2
- package/dist/composite.js.map +1 -1
- package/dist/document-key.d.ts +36 -0
- package/dist/document-key.d.ts.map +1 -0
- package/dist/document-key.js +40 -0
- package/dist/document-key.js.map +1 -0
- package/dist/document-url.d.ts +9 -0
- package/dist/document-url.d.ts.map +1 -1
- package/dist/document-url.js +9 -0
- package/dist/document-url.js.map +1 -1
- package/dist/ids.d.ts +4 -4
- package/dist/ids.d.ts.map +1 -1
- package/dist/ids.js +4 -4
- package/dist/ids.js.map +1 -1
- package/dist/index.d.ts +6 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +6 -0
- package/dist/index.js.map +1 -1
- package/dist/kinds.d.ts +7 -2
- package/dist/kinds.d.ts.map +1 -1
- package/dist/kinds.js +12 -3
- package/dist/kinds.js.map +1 -1
- package/dist/model.d.ts +36 -5
- package/dist/model.d.ts.map +1 -1
- package/dist/model.js +7 -1
- package/dist/model.js.map +1 -1
- package/dist/page-placement.d.ts +2 -2
- package/dist/page-placement.js +3 -2
- package/dist/page-placement.js.map +1 -1
- package/dist/pdf-key.d.ts +10 -0
- package/dist/pdf-key.d.ts.map +1 -0
- package/dist/pdf-key.js +22 -0
- package/dist/pdf-key.js.map +1 -0
- package/dist/placement.d.ts +20 -4
- package/dist/placement.d.ts.map +1 -1
- package/dist/placement.js +41 -5
- package/dist/placement.js.map +1 -1
- package/dist/region-image.d.ts +45 -0
- package/dist/region-image.d.ts.map +1 -0
- package/dist/region-image.js +76 -0
- package/dist/region-image.js.map +1 -0
- package/dist/scope.d.ts +17 -2
- package/dist/scope.d.ts.map +1 -1
- package/dist/scope.js +28 -4
- package/dist/scope.js.map +1 -1
- package/dist/store/store.d.ts +1 -1
- package/dist/store/store.d.ts.map +1 -1
- package/dist/store/store.js +11 -8
- package/dist/store/store.js.map +1 -1
- package/dist/testing/anchor-provider-contract.d.ts +3 -2
- package/dist/testing/anchor-provider-contract.d.ts.map +1 -1
- package/dist/testing/anchor-provider-contract.js +75 -22
- package/dist/testing/anchor-provider-contract.js.map +1 -1
- package/dist/testing/contract.d.ts +6 -0
- package/dist/testing/contract.d.ts.map +1 -1
- package/dist/testing/contract.js +22 -0
- package/dist/testing/contract.js.map +1 -1
- package/dist/testing/thread-contract.d.ts.map +1 -1
- package/dist/testing/thread-contract.js +2 -1
- package/dist/testing/thread-contract.js.map +1 -1
- package/dist/thread-write.d.ts +35 -0
- package/dist/thread-write.d.ts.map +1 -0
- package/dist/thread-write.js +69 -0
- package/dist/thread-write.js.map +1 -0
- package/dist/thread.d.ts +0 -21
- package/dist/thread.d.ts.map +1 -1
- package/dist/thread.js +2 -53
- package/dist/thread.js.map +1 -1
- package/dist/w3c.d.ts.map +1 -1
- package/dist/w3c.js +35 -6
- package/dist/w3c.js.map +1 -1
- package/package.json +6 -2
|
@@ -0,0 +1,682 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Site profiles: most sites the generic engine cannot see well need no code of their own, only a
|
|
3
|
+
* description of where their stable units are (the lines of a diff, keyed by line number). A
|
|
4
|
+
* profile is that description as plain JSON, read by one built-in anchor provider, `profile`. See
|
|
5
|
+
* the design doc's "Hard pages in M7 > Anchor providers".
|
|
6
|
+
*
|
|
7
|
+
* A profile names the sites it is for (`hosts`, `paths`) and, with CSS selectors only, the units
|
|
8
|
+
* (`unit`), what each unit's key is read from (`key`) and where its text is (`text`). No profile
|
|
9
|
+
* field is code or a regular expression, so a profile can be checked (`validateProfile`) and come
|
|
10
|
+
* from a list the extension did not ship with.
|
|
11
|
+
*
|
|
12
|
+
* The provider describes a text selection inside one unit as `{ profile, rev, unit, start, end }`:
|
|
13
|
+
* the profile's name and revision, the unit's key, and offsets in the unit's normalized text. To
|
|
14
|
+
* resolve, it finds the unit by key and checks that the offsets still cover the note's stored quote;
|
|
15
|
+
* when they don't, it looks for the quote in the same unit, then (with `follow`) in the other units,
|
|
16
|
+
* nearest key first, which is how a note on a line follows it to a newer commit. Anything else is
|
|
17
|
+
* left to the generic engine: a selection across units, a note without a quote, a page whose units
|
|
18
|
+
* are not rendered yet.
|
|
19
|
+
*/
|
|
20
|
+
import { normalizeText } from "./normalize.js";
|
|
21
|
+
import { anchorProviders, PROVIDER_NAME, } from "./providers.js";
|
|
22
|
+
export { FIRST_PARTY_PROFILES, GITHUB_DIFF_PROFILE } from "./site-profiles.js";
|
|
23
|
+
/** Longest a profile may be, as JSON text. */
|
|
24
|
+
export const PROFILE_MAX = 16 * 1024;
|
|
25
|
+
/** The name of the provider that reads profiles. */
|
|
26
|
+
export const PROFILE_PROVIDER = "profile";
|
|
27
|
+
/** Longest a unit key may be. */
|
|
28
|
+
const KEY_MAX = 200;
|
|
29
|
+
/** Longest a CSS selector in a profile may be. */
|
|
30
|
+
const SELECTOR_MAX = 500;
|
|
31
|
+
/** Most hosts or paths in a profile. */
|
|
32
|
+
const LIST_MAX = 32;
|
|
33
|
+
/** Most units looked at when following a quote, and candidates normalized. */
|
|
34
|
+
const SCAN_MAX = 20_000;
|
|
35
|
+
const CANDIDATES_MAX = 20;
|
|
36
|
+
/** Longest stretch of a stored prefix or suffix compared with a candidate's surroundings. */
|
|
37
|
+
const CONTEXT_MAX = 32;
|
|
38
|
+
/** Most places of the quote looked at in one unit. */
|
|
39
|
+
const PER_UNIT_MAX = 20;
|
|
40
|
+
/** Default time one resolve may take before it leaves the note to the generic engine: well under
|
|
41
|
+
* the registry's budget, so no stored value can get the provider turned off for the page. */
|
|
42
|
+
export const PROFILE_RESOLVE_MS = 8;
|
|
43
|
+
/** How many units are scanned between looks at the clock. */
|
|
44
|
+
const CLOCK_EVERY = 16;
|
|
45
|
+
/** How much of the stored surroundings the text inside a unit must agree with to confirm the
|
|
46
|
+
* stored place by itself. */
|
|
47
|
+
const OWN_ENOUGH = 8;
|
|
48
|
+
/** Most earlier siblings a key part's `prev` looks at. */
|
|
49
|
+
const PREV_MAX = 8;
|
|
50
|
+
const HOST = /^(\*\.)?([a-z0-9]([a-z0-9-]{0,61}[a-z0-9])?\.)*[a-z0-9]([a-z0-9-]{0,61}[a-z0-9])?$/;
|
|
51
|
+
const ATTR = /^[a-z_][a-z0-9_.:-]{0,63}$/;
|
|
52
|
+
const PATH_SEGMENT = /^[A-Za-z0-9._~!$&'()+,;=:@%-]+$/;
|
|
53
|
+
const PROFILE_KEYS = ["name", "rev", "hosts", "paths", "unit", "key", "text", "follow"];
|
|
54
|
+
const KEY_PART_KEYS = ["closest", "prev", "in", "attr"];
|
|
55
|
+
/** Thrown by `validateProfile` for a malformed profile. */
|
|
56
|
+
export class ProfileError extends TypeError {
|
|
57
|
+
constructor(message) {
|
|
58
|
+
super(message);
|
|
59
|
+
this.name = "ProfileError";
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
/**
|
|
63
|
+
* Checks a profile and returns a frozen copy of it. Throws a ProfileError for anything but plain
|
|
64
|
+
* JSON of the shape above within PROFILE_MAX characters: unknown fields, a path pattern that is not
|
|
65
|
+
* one, a selector that is not valid CSS. Selectors are parsed with `document` (default the global
|
|
66
|
+
* one); where there is none (a service worker), only their form is checked, and a selector the page
|
|
67
|
+
* later refuses turns that profile off for the page.
|
|
68
|
+
*/
|
|
69
|
+
export function validateProfile(input, document = globalThis.document) {
|
|
70
|
+
// Serialized once, and the copy made from that same text: no getter, prototype or later change
|
|
71
|
+
// reaches what is checked.
|
|
72
|
+
let json;
|
|
73
|
+
try {
|
|
74
|
+
json = JSON.stringify(input);
|
|
75
|
+
}
|
|
76
|
+
catch {
|
|
77
|
+
json = undefined;
|
|
78
|
+
}
|
|
79
|
+
if (typeof json !== "string")
|
|
80
|
+
throw new ProfileError("a site profile must be plain JSON");
|
|
81
|
+
if (json.length > PROFILE_MAX) {
|
|
82
|
+
throw new ProfileError(`a site profile must be under ${PROFILE_MAX} characters`);
|
|
83
|
+
}
|
|
84
|
+
const p = JSON.parse(json);
|
|
85
|
+
if (!p || typeof p !== "object" || Array.isArray(p)) {
|
|
86
|
+
throw new ProfileError("a site profile must be an object");
|
|
87
|
+
}
|
|
88
|
+
const name = typeof p.name === "string" ? p.name : "";
|
|
89
|
+
const fail = (what) => {
|
|
90
|
+
throw new ProfileError(`site profile "${name}": ${what}`);
|
|
91
|
+
};
|
|
92
|
+
for (const k of Object.keys(p))
|
|
93
|
+
if (!PROFILE_KEYS.includes(k))
|
|
94
|
+
fail(`unknown field "${k}"`);
|
|
95
|
+
if (!PROVIDER_NAME.test(name))
|
|
96
|
+
fail(`name must match ${String(PROVIDER_NAME)}`);
|
|
97
|
+
if (!Number.isSafeInteger(p.rev) || p.rev < 1)
|
|
98
|
+
fail("rev must be a positive integer");
|
|
99
|
+
const hosts = list(p.hosts, "hosts", fail);
|
|
100
|
+
if (hosts.length === 0)
|
|
101
|
+
fail("hosts must name at least one host");
|
|
102
|
+
for (const h of hosts) {
|
|
103
|
+
if (!HOST.test(h))
|
|
104
|
+
fail(`"${h}" is not a lower case host name`);
|
|
105
|
+
// `*.com` would be every site under a top-level domain: a wildcard needs at least two labels.
|
|
106
|
+
// (A public suffix such as `*.co.uk` still passes: a list from outside the release checks its
|
|
107
|
+
// wildcards against the public suffix list, which core does not carry.)
|
|
108
|
+
if (h.startsWith("*.") && h.split(".").length < 3)
|
|
109
|
+
fail(`"${h}" is too wide a wildcard`);
|
|
110
|
+
}
|
|
111
|
+
if (p.paths !== undefined) {
|
|
112
|
+
const paths = list(p.paths, "paths", fail);
|
|
113
|
+
if (paths.length === 0)
|
|
114
|
+
fail("paths, when given, must hold at least one pattern");
|
|
115
|
+
for (const path of paths)
|
|
116
|
+
if (!isPathPattern(path))
|
|
117
|
+
fail(`"${path}" is not a path pattern`);
|
|
118
|
+
}
|
|
119
|
+
const selector = (v, field) => checkSelector(v, field, fail, document);
|
|
120
|
+
selector(p.unit, "unit");
|
|
121
|
+
if (p.text !== undefined)
|
|
122
|
+
selector(p.text, "text");
|
|
123
|
+
if (!Array.isArray(p.key) || p.key.length < 1 || p.key.length > 3) {
|
|
124
|
+
fail("key must be a list of one to three parts");
|
|
125
|
+
}
|
|
126
|
+
for (const part of p.key) {
|
|
127
|
+
if (!part || typeof part !== "object" || Array.isArray(part))
|
|
128
|
+
fail("each key part must be an object");
|
|
129
|
+
const k = part;
|
|
130
|
+
for (const f of Object.keys(k))
|
|
131
|
+
if (!KEY_PART_KEYS.includes(f))
|
|
132
|
+
fail(`unknown key field "${f}"`);
|
|
133
|
+
if (typeof k.attr !== "string" || !ATTR.test(k.attr))
|
|
134
|
+
fail("each key part needs an attribute name");
|
|
135
|
+
for (const f of ["closest", "prev", "in"]) {
|
|
136
|
+
if (k[f] !== undefined)
|
|
137
|
+
selector(k[f], `key.${f}`);
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
if (p.follow !== undefined && typeof p.follow !== "boolean")
|
|
141
|
+
fail("follow must be true or false");
|
|
142
|
+
return deepFreeze(p);
|
|
143
|
+
}
|
|
144
|
+
function list(v, field, fail) {
|
|
145
|
+
if (!Array.isArray(v) || v.length > LIST_MAX || v.some((s) => typeof s !== "string")) {
|
|
146
|
+
fail(`${field} must be a list of at most ${LIST_MAX} strings`);
|
|
147
|
+
}
|
|
148
|
+
return v;
|
|
149
|
+
}
|
|
150
|
+
/** Fails unless `v` is a CSS selector `document` accepts. */
|
|
151
|
+
function checkSelector(v, field, fail, document) {
|
|
152
|
+
if (typeof v !== "string" || !v.trim() || v.length > SELECTOR_MAX) {
|
|
153
|
+
fail(`${field} must be a CSS selector of at most ${SELECTOR_MAX} characters`);
|
|
154
|
+
}
|
|
155
|
+
// A selector cannot run code, but a rule body has no place here, and `:has()` can make one
|
|
156
|
+
// query cost far more than the provider's time budget before it is measured.
|
|
157
|
+
// Nor escapes or comments, which could spell a forbidden name (`:\68 as(`, `:/**/has(`).
|
|
158
|
+
if (/[{}\\]|\/\*/.test(v))
|
|
159
|
+
fail(`${field} is not a CSS selector`);
|
|
160
|
+
if (/:has\s*\(/i.test(v))
|
|
161
|
+
fail(`${field} may not use :has()`);
|
|
162
|
+
if (!document)
|
|
163
|
+
return;
|
|
164
|
+
try {
|
|
165
|
+
document.createDocumentFragment().querySelector(v);
|
|
166
|
+
}
|
|
167
|
+
catch {
|
|
168
|
+
fail(`${field} is not a valid CSS selector`);
|
|
169
|
+
}
|
|
170
|
+
}
|
|
171
|
+
function isPathPattern(path) {
|
|
172
|
+
if (!path.startsWith("/") || path.length > 200)
|
|
173
|
+
return false;
|
|
174
|
+
const segments = path.slice(1).split("/");
|
|
175
|
+
return segments.every((s, i) => s === "*" ||
|
|
176
|
+
(s === "**" && i === segments.length - 1) ||
|
|
177
|
+
(s !== "**" && PATH_SEGMENT.test(s)));
|
|
178
|
+
}
|
|
179
|
+
function deepFreeze(v) {
|
|
180
|
+
if (v && typeof v === "object") {
|
|
181
|
+
for (const child of Object.values(v))
|
|
182
|
+
deepFreeze(child);
|
|
183
|
+
Object.freeze(v);
|
|
184
|
+
}
|
|
185
|
+
return v;
|
|
186
|
+
}
|
|
187
|
+
/** Whether `profile` is for this URL. */
|
|
188
|
+
export function profileMatches(profile, url) {
|
|
189
|
+
if (url.protocol !== "https:" && url.protocol !== "http:")
|
|
190
|
+
return false;
|
|
191
|
+
const host = url.hostname.toLowerCase();
|
|
192
|
+
const hostOk = profile.hosts.some((h) => h.startsWith("*.") ? host.endsWith(h.slice(1)) && host.length > h.length - 1 : host === h);
|
|
193
|
+
if (!hostOk)
|
|
194
|
+
return false;
|
|
195
|
+
if (!profile.paths)
|
|
196
|
+
return true;
|
|
197
|
+
const segments = url.pathname.replace(/\/+$/, "").slice(1).split("/");
|
|
198
|
+
return profile.paths.some((pattern) => pathMatches(pattern, segments));
|
|
199
|
+
}
|
|
200
|
+
function pathMatches(pattern, segments) {
|
|
201
|
+
const parts = pattern.replace(/\/+$/, "").slice(1).split("/");
|
|
202
|
+
for (let i = 0; i < parts.length; i++) {
|
|
203
|
+
const part = parts[i];
|
|
204
|
+
if (part === "**")
|
|
205
|
+
return true;
|
|
206
|
+
const seg = segments[i];
|
|
207
|
+
if (seg === undefined || seg === "")
|
|
208
|
+
return false;
|
|
209
|
+
if (part !== "*" && part !== seg)
|
|
210
|
+
return false;
|
|
211
|
+
}
|
|
212
|
+
return parts.length === segments.length;
|
|
213
|
+
}
|
|
214
|
+
/**
|
|
215
|
+
* The `profile` provider: reads every profile added to it. The core keeps one for the page
|
|
216
|
+
* (`profileProvider`, registered by `registerAnchorProfiles`); tests and embedders can make their
|
|
217
|
+
* own.
|
|
218
|
+
*/
|
|
219
|
+
export class ProfileProvider {
|
|
220
|
+
name = PROFILE_PROVIDER;
|
|
221
|
+
version = 1;
|
|
222
|
+
/** By the object it was added as, so adding the same list again (two SDK instances) is counted. */
|
|
223
|
+
entries = new Map();
|
|
224
|
+
now;
|
|
225
|
+
limitMs;
|
|
226
|
+
constructor(profiles = [], options = {}) {
|
|
227
|
+
this.now = options.now ?? (() => performance.now());
|
|
228
|
+
this.limitMs = options.limitMs ?? PROFILE_RESOLVE_MS;
|
|
229
|
+
if (profiles.length)
|
|
230
|
+
this.add(profiles);
|
|
231
|
+
}
|
|
232
|
+
/**
|
|
233
|
+
* Adds profiles; returns a function that removes them. Throws a ProfileError, adding none, for a
|
|
234
|
+
* malformed profile or another one of the same name. A registry that already asked `matches`
|
|
235
|
+
* keeps its answer until told otherwise: use `registerAnchorProfiles`, or call the registry's
|
|
236
|
+
* `rematch` after adding to a registered provider.
|
|
237
|
+
*/
|
|
238
|
+
add(profiles) {
|
|
239
|
+
if (!Array.isArray(profiles))
|
|
240
|
+
throw new ProfileError("site profiles must be a list");
|
|
241
|
+
const fresh = new Map();
|
|
242
|
+
const names = new Set(this.profiles.map((p) => p.name));
|
|
243
|
+
for (const input of profiles) {
|
|
244
|
+
if (this.entries.has(input) || fresh.has(input))
|
|
245
|
+
continue;
|
|
246
|
+
const profile = validateProfile(input);
|
|
247
|
+
if (names.has(profile.name)) {
|
|
248
|
+
throw new ProfileError(`a site profile named "${profile.name}" is already added`);
|
|
249
|
+
}
|
|
250
|
+
names.add(profile.name);
|
|
251
|
+
fresh.set(input, profile);
|
|
252
|
+
}
|
|
253
|
+
const added = [...new Set(profiles)];
|
|
254
|
+
for (const input of added) {
|
|
255
|
+
const entry = this.entries.get(input);
|
|
256
|
+
if (entry)
|
|
257
|
+
entry.count++;
|
|
258
|
+
else
|
|
259
|
+
this.entries.set(input, { profile: fresh.get(input), count: 1 });
|
|
260
|
+
}
|
|
261
|
+
let removed = false;
|
|
262
|
+
return () => {
|
|
263
|
+
if (removed)
|
|
264
|
+
return;
|
|
265
|
+
removed = true;
|
|
266
|
+
for (const input of added) {
|
|
267
|
+
const entry = this.entries.get(input);
|
|
268
|
+
if (entry && --entry.count === 0)
|
|
269
|
+
this.entries.delete(input);
|
|
270
|
+
}
|
|
271
|
+
};
|
|
272
|
+
}
|
|
273
|
+
/** The profiles added, in the order they were added. */
|
|
274
|
+
get profiles() {
|
|
275
|
+
return [...this.entries.values()].map((e) => e.profile);
|
|
276
|
+
}
|
|
277
|
+
matches(url) {
|
|
278
|
+
return this.profiles.some((p) => profileMatches(p, url));
|
|
279
|
+
}
|
|
280
|
+
describe(target, context) {
|
|
281
|
+
if (!("startContainer" in target))
|
|
282
|
+
return null;
|
|
283
|
+
for (const profile of this.profiles) {
|
|
284
|
+
if (!profileMatches(profile, context.url))
|
|
285
|
+
continue;
|
|
286
|
+
const value = this.guard(profile, context, () => describeIn(profile, target, context));
|
|
287
|
+
if (value)
|
|
288
|
+
return value;
|
|
289
|
+
}
|
|
290
|
+
return null;
|
|
291
|
+
}
|
|
292
|
+
resolve(value, _version, context) {
|
|
293
|
+
const v = asValue(value);
|
|
294
|
+
const quote = context.quote;
|
|
295
|
+
if (!v || !quote)
|
|
296
|
+
return null;
|
|
297
|
+
const profile = this.profiles.find((p) => p.name === v.profile);
|
|
298
|
+
if (!profile || v.rev > profile.rev || !profileMatches(profile, context.url))
|
|
299
|
+
return null;
|
|
300
|
+
return this.guard(profile, context, () => resolveIn(profile, v, context, this.now, this.limitMs));
|
|
301
|
+
}
|
|
302
|
+
/** Per document: the profiles whose selectors it refused. */
|
|
303
|
+
refused = new WeakMap();
|
|
304
|
+
/**
|
|
305
|
+
* Runs one profile's work; a selector the page's engine refuses (checked without a DOM, or
|
|
306
|
+
* syntax this browser lacks) turns only that profile off for the document.
|
|
307
|
+
*/
|
|
308
|
+
guard(profile, context, fn) {
|
|
309
|
+
const refused = this.refused.get(context.document);
|
|
310
|
+
if (refused?.has(profile))
|
|
311
|
+
return null;
|
|
312
|
+
try {
|
|
313
|
+
return fn();
|
|
314
|
+
}
|
|
315
|
+
catch (err) {
|
|
316
|
+
if (err?.name !== "SyntaxError")
|
|
317
|
+
throw err;
|
|
318
|
+
if (refused)
|
|
319
|
+
refused.add(profile);
|
|
320
|
+
else
|
|
321
|
+
this.refused.set(context.document, new WeakSet([profile]));
|
|
322
|
+
return null;
|
|
323
|
+
}
|
|
324
|
+
}
|
|
325
|
+
}
|
|
326
|
+
/** The page's profile provider. */
|
|
327
|
+
export const profileProvider = new ProfileProvider();
|
|
328
|
+
/**
|
|
329
|
+
* Adds site profiles for the page and registers the `profile` provider with the page's providers
|
|
330
|
+
* (`anchorProviders`). Returns a function that removes them. Throws a ProfileError for a malformed
|
|
331
|
+
* profile, or another of the same name; nothing is added then.
|
|
332
|
+
*/
|
|
333
|
+
export function registerAnchorProfiles(profiles, providers = anchorProviders) {
|
|
334
|
+
const remove = profileProvider.add(profiles);
|
|
335
|
+
let unregister;
|
|
336
|
+
try {
|
|
337
|
+
unregister = providers.register(profileProvider);
|
|
338
|
+
}
|
|
339
|
+
catch (err) {
|
|
340
|
+
remove();
|
|
341
|
+
throw err;
|
|
342
|
+
}
|
|
343
|
+
providers.rematch();
|
|
344
|
+
return () => {
|
|
345
|
+
remove();
|
|
346
|
+
unregister();
|
|
347
|
+
providers.rematch();
|
|
348
|
+
};
|
|
349
|
+
}
|
|
350
|
+
function asValue(value) {
|
|
351
|
+
if (!value || typeof value !== "object" || Array.isArray(value))
|
|
352
|
+
return null;
|
|
353
|
+
const v = value;
|
|
354
|
+
if (typeof v.profile !== "string" || typeof v.unit !== "string" || v.unit.length > KEY_MAX) {
|
|
355
|
+
return null;
|
|
356
|
+
}
|
|
357
|
+
if (!Number.isSafeInteger(v.rev) || v.rev < 1)
|
|
358
|
+
return null;
|
|
359
|
+
if (!Number.isSafeInteger(v.start) || !Number.isSafeInteger(v.end))
|
|
360
|
+
return null;
|
|
361
|
+
if (v.start < 0 || v.end <= v.start)
|
|
362
|
+
return null;
|
|
363
|
+
return v;
|
|
364
|
+
}
|
|
365
|
+
/** The unit's key, or null when one of its parts is missing. */
|
|
366
|
+
function keyOf(profile, unit) {
|
|
367
|
+
const parts = [];
|
|
368
|
+
for (const part of profile.key) {
|
|
369
|
+
let el = unit;
|
|
370
|
+
if (part.closest)
|
|
371
|
+
el = el.closest(part.closest);
|
|
372
|
+
if (el && part.prev) {
|
|
373
|
+
// A few steps at most: a profile whose `prev` matches nothing must not walk a long list
|
|
374
|
+
// once per unit.
|
|
375
|
+
let sib = el.previousElementSibling;
|
|
376
|
+
for (let n = 1; sib && !sib.matches(part.prev); n++) {
|
|
377
|
+
sib = n < PREV_MAX ? sib.previousElementSibling : null;
|
|
378
|
+
}
|
|
379
|
+
el = sib;
|
|
380
|
+
}
|
|
381
|
+
if (el && part.in)
|
|
382
|
+
el = el.querySelector(part.in);
|
|
383
|
+
const value = el?.getAttribute(part.attr);
|
|
384
|
+
if (!value)
|
|
385
|
+
return null;
|
|
386
|
+
parts.push(value);
|
|
387
|
+
}
|
|
388
|
+
const key = parts.join(":");
|
|
389
|
+
return key.length <= KEY_MAX ? key : null;
|
|
390
|
+
}
|
|
391
|
+
function textOf(profile, unit) {
|
|
392
|
+
return profile.text ? unit.querySelector(profile.text) : unit;
|
|
393
|
+
}
|
|
394
|
+
/** The unit holding `node`, inside the root. */
|
|
395
|
+
function unitAround(profile, node, root) {
|
|
396
|
+
const el = node.nodeType === 1 ? node : node.parentElement;
|
|
397
|
+
const unit = el?.closest(profile.unit) ?? null;
|
|
398
|
+
if (!unit)
|
|
399
|
+
return null;
|
|
400
|
+
if (root.nodeType === 1 && !root.contains(unit))
|
|
401
|
+
return null;
|
|
402
|
+
return unit;
|
|
403
|
+
}
|
|
404
|
+
function describeIn(profile, range, context) {
|
|
405
|
+
const unit = unitAround(profile, range.startContainer, context.root);
|
|
406
|
+
if (!unit || unitAround(profile, range.endContainer, context.root) !== unit)
|
|
407
|
+
return null;
|
|
408
|
+
const textEl = textOf(profile, unit);
|
|
409
|
+
const key = textEl && keyOf(profile, unit);
|
|
410
|
+
if (!key)
|
|
411
|
+
return null;
|
|
412
|
+
// An end outside the text (a selection from the line's number cell) is clamped to it.
|
|
413
|
+
const text = normalizeText(textEl);
|
|
414
|
+
let { start, end } = text.offsetsFromRange(range);
|
|
415
|
+
// Trimmed as the generic quote is, so the two hold the same text.
|
|
416
|
+
while (start < end && text.text[start] === " ")
|
|
417
|
+
start++;
|
|
418
|
+
while (end > start && text.text[end - 1] === " ")
|
|
419
|
+
end--;
|
|
420
|
+
if (start >= end)
|
|
421
|
+
return null;
|
|
422
|
+
return { profile: profile.name, rev: profile.rev, unit: key, start, end };
|
|
423
|
+
}
|
|
424
|
+
/** Thrown inside a resolve that ran out of time. */
|
|
425
|
+
const OUT_OF_TIME = Symbol("out of time");
|
|
426
|
+
function resolveIn(profile, v, context, now, limitMs) {
|
|
427
|
+
const quote = context.quote;
|
|
428
|
+
const deadline = now() + limitMs;
|
|
429
|
+
const tick = (i) => {
|
|
430
|
+
if (i % CLOCK_EVERY === 0 && now() > deadline)
|
|
431
|
+
throw OUT_OF_TIME;
|
|
432
|
+
};
|
|
433
|
+
const units = context.root.querySelectorAll(profile.unit);
|
|
434
|
+
const count = Math.min(units.length, SCAN_MAX);
|
|
435
|
+
/** The stored place when nothing better was found: confirmed by the text inside its unit. */
|
|
436
|
+
let keep = () => null;
|
|
437
|
+
try {
|
|
438
|
+
// A key from an older revision of the profile may mean something else now: only its quote is
|
|
439
|
+
// followed.
|
|
440
|
+
let keyed = -1;
|
|
441
|
+
if (v.rev === profile.rev) {
|
|
442
|
+
for (let i = 0; i < count; i++) {
|
|
443
|
+
tick(i);
|
|
444
|
+
if (keyOf(profile, units[i]) === v.unit) {
|
|
445
|
+
keyed = i;
|
|
446
|
+
break;
|
|
447
|
+
}
|
|
448
|
+
}
|
|
449
|
+
}
|
|
450
|
+
const raw = new Map();
|
|
451
|
+
const textAt = (i) => {
|
|
452
|
+
let t = raw.get(i);
|
|
453
|
+
if (t === undefined)
|
|
454
|
+
raw.set(i, (t = rawText(profile, units[i])));
|
|
455
|
+
return t;
|
|
456
|
+
};
|
|
457
|
+
const elAt = (i) => textOf(profile, units[i]);
|
|
458
|
+
const prefix = (context.prefix ?? "").slice(-CONTEXT_MAX);
|
|
459
|
+
const suffix = (context.suffix ?? "").slice(0, CONTEXT_MAX);
|
|
460
|
+
const stored = prefix.length + suffix.length;
|
|
461
|
+
/** The least agreement a place other than the stored one needs: half the stored surroundings. */
|
|
462
|
+
const least = Math.ceil(stored / 2);
|
|
463
|
+
const places = [];
|
|
464
|
+
const texts = new Map();
|
|
465
|
+
/** Every place of the quote in a unit, scored by how well its surroundings agree with the
|
|
466
|
+
* stored prefix and suffix. */
|
|
467
|
+
const placesIn = (index, distance) => {
|
|
468
|
+
const textEl = textOf(profile, units[index]);
|
|
469
|
+
if (!textEl)
|
|
470
|
+
return;
|
|
471
|
+
// Built afresh: the page may have changed since a cached one was built.
|
|
472
|
+
const text = normalizeText(textEl);
|
|
473
|
+
texts.set(index, text);
|
|
474
|
+
let found = 0;
|
|
475
|
+
for (let at = text.text.indexOf(quote); at >= 0 && found < PER_UNIT_MAX; at = text.text.indexOf(quote, at + 1)) {
|
|
476
|
+
found++;
|
|
477
|
+
const before = around(textAt, elAt, count, index, -1, text.text.slice(0, at), prefix.length);
|
|
478
|
+
const after = around(textAt, elAt, count, index, 1, text.text.slice(at + quote.length), suffix.length);
|
|
479
|
+
const p = agree(prefix, before, -1);
|
|
480
|
+
const q = agree(suffix, after, 1);
|
|
481
|
+
const ownBefore = text.text.slice(0, at);
|
|
482
|
+
const ownAfter = text.text.slice(at + quote.length);
|
|
483
|
+
places.push({
|
|
484
|
+
index,
|
|
485
|
+
at,
|
|
486
|
+
score: p + q,
|
|
487
|
+
full: stored > 0 &&
|
|
488
|
+
p === Math.min(prefix.length, before.length) &&
|
|
489
|
+
q === Math.min(suffix.length, after.length),
|
|
490
|
+
own: ownAgreement(prefix, suffix, ownBefore, ownAfter),
|
|
491
|
+
stored: index === keyed && at === v.start,
|
|
492
|
+
distance,
|
|
493
|
+
});
|
|
494
|
+
}
|
|
495
|
+
};
|
|
496
|
+
const answer = (place, confidence) => {
|
|
497
|
+
const ranges = texts.get(place.index).rangesFromOffsets(place.at, place.at + quote.length);
|
|
498
|
+
return ranges && ranges.length ? { ranges, confidence } : null;
|
|
499
|
+
};
|
|
500
|
+
// The common case: the stored unit still holds the quote where it was, in the same
|
|
501
|
+
// surroundings.
|
|
502
|
+
let likely;
|
|
503
|
+
if (keyed >= 0) {
|
|
504
|
+
placesIn(keyed, -1);
|
|
505
|
+
const same = places.find((p) => p.stored && p.full);
|
|
506
|
+
if (same)
|
|
507
|
+
return answer(same, 1);
|
|
508
|
+
// In the same text inside its unit, when there is enough of it to tell: kept unless another
|
|
509
|
+
// place fits the stored surroundings better (the same line, moved, with a copy now in its
|
|
510
|
+
// place).
|
|
511
|
+
likely = places.find((p) => p.stored && (p.own >= Math.min(OWN_ENOUGH, stored) || stored === 0));
|
|
512
|
+
if (likely && !profile.follow)
|
|
513
|
+
return answer(likely, 1);
|
|
514
|
+
if (likely) {
|
|
515
|
+
const stored = likely;
|
|
516
|
+
keep = () => answer(stored, 1);
|
|
517
|
+
}
|
|
518
|
+
}
|
|
519
|
+
// Otherwise, with `follow`, the nearest other units whose raw text has the quote's words: each
|
|
520
|
+
// distance worked out once, only the nearest kept.
|
|
521
|
+
if (profile.follow) {
|
|
522
|
+
const nearest = [];
|
|
523
|
+
const words = quote.split(" ");
|
|
524
|
+
for (let i = 0; i < count; i++) {
|
|
525
|
+
tick(i);
|
|
526
|
+
if (i === keyed || !hasWords(textAt(i), words))
|
|
527
|
+
continue;
|
|
528
|
+
const key = keyOf(profile, units[i]);
|
|
529
|
+
if (!key)
|
|
530
|
+
continue;
|
|
531
|
+
const distance = keyDistance(key, v.unit);
|
|
532
|
+
const last = nearest[nearest.length - 1];
|
|
533
|
+
if (nearest.length === CANDIDATES_MAX && distance >= last.distance)
|
|
534
|
+
continue;
|
|
535
|
+
let j = nearest.length;
|
|
536
|
+
while (j > 0 && nearest[j - 1].distance > distance)
|
|
537
|
+
j--;
|
|
538
|
+
nearest.splice(j, 0, { index: i, distance });
|
|
539
|
+
if (nearest.length > CANDIDATES_MAX)
|
|
540
|
+
nearest.pop();
|
|
541
|
+
}
|
|
542
|
+
for (const [n, { index, distance }] of nearest.entries()) {
|
|
543
|
+
tick(n * CLOCK_EVERY);
|
|
544
|
+
placesIn(index, distance);
|
|
545
|
+
}
|
|
546
|
+
}
|
|
547
|
+
if (likely && !places.some((p) => p !== likely && p.full && p.score > likely.score)) {
|
|
548
|
+
return answer(likely, 1);
|
|
549
|
+
}
|
|
550
|
+
if (places.length === 0)
|
|
551
|
+
return null;
|
|
552
|
+
places.sort((a, b) => b.score - a.score ||
|
|
553
|
+
Number(b.stored) - Number(a.stored) ||
|
|
554
|
+
a.distance - b.distance ||
|
|
555
|
+
Math.abs(a.at - v.start) - Math.abs(b.at - v.start));
|
|
556
|
+
const best = places[0];
|
|
557
|
+
const tied = places.length > 1 && places[1].score === best.score;
|
|
558
|
+
// A note stored without any surroundings moves only to the one place holding its quote.
|
|
559
|
+
if (stored === 0)
|
|
560
|
+
return places.length === 1 ? answer(best, 0.8) : null;
|
|
561
|
+
// Too little of what was around the note is around this place: an unrelated line holding the
|
|
562
|
+
// same short text (`}`). The generic engine decides, and may leave the note unplaced.
|
|
563
|
+
if (best.score < least)
|
|
564
|
+
return keep();
|
|
565
|
+
// Another place whose surroundings agree with the stored ones, and nothing else as well.
|
|
566
|
+
if (best.full && !tied)
|
|
567
|
+
return answer(best, 0.95);
|
|
568
|
+
// Most of the surroundings agree and nothing agrees better: shown as approximate. (The stored
|
|
569
|
+
// place, unconfirmed above, is left to the generic engine, which reads the page's own text.)
|
|
570
|
+
if (!tied && !best.stored)
|
|
571
|
+
return answer(best, 0.8);
|
|
572
|
+
// Several places fit as well as each other: the generic engine, which reads the whole page's
|
|
573
|
+
// text, decides.
|
|
574
|
+
return keep();
|
|
575
|
+
}
|
|
576
|
+
catch (err) {
|
|
577
|
+
if (err === OUT_OF_TIME)
|
|
578
|
+
return keep();
|
|
579
|
+
throw err;
|
|
580
|
+
}
|
|
581
|
+
}
|
|
582
|
+
/** A unit's text as the DOM has it, with whitespace collapsed and invisible characters removed
|
|
583
|
+
* (normalization rules 3 and 5): cheap, close to the normalized text. */
|
|
584
|
+
function rawText(profile, unit) {
|
|
585
|
+
return collapse(textOf(profile, unit)?.textContent ?? "");
|
|
586
|
+
}
|
|
587
|
+
/** Whether `raw` holds the words in order. */
|
|
588
|
+
function hasWords(raw, words) {
|
|
589
|
+
let from = 0;
|
|
590
|
+
for (const w of words) {
|
|
591
|
+
const at = raw.indexOf(w, from);
|
|
592
|
+
if (at < 0)
|
|
593
|
+
return false;
|
|
594
|
+
from = at + w.length;
|
|
595
|
+
}
|
|
596
|
+
return true;
|
|
597
|
+
}
|
|
598
|
+
/**
|
|
599
|
+
* The text before (`dir` -1) or after (1) a place, at least `want` characters when the page has
|
|
600
|
+
* them: `own` from its unit, then each neighbouring unit's text with whatever the page shows
|
|
601
|
+
* between the two (a diff's hunk header), whitespace collapsed as the normalized text has it.
|
|
602
|
+
*/
|
|
603
|
+
function around(textAt, elAt, count, index, dir, own, want) {
|
|
604
|
+
let text = own;
|
|
605
|
+
let inner = elAt(index);
|
|
606
|
+
for (let i = index + dir, n = 0; text.length < want && n < 8 && i >= 0 && i < count && inner; i += dir, n++) {
|
|
607
|
+
const el = elAt(i);
|
|
608
|
+
if (!el)
|
|
609
|
+
break;
|
|
610
|
+
const gap = dir < 0 ? between(el, inner, -1) : between(inner, el, 1);
|
|
611
|
+
const t = textAt(i);
|
|
612
|
+
text = dir < 0 ? `${t} ${gap} ${text}` : `${text} ${gap} ${t}`;
|
|
613
|
+
text = text.replace(/ {2,}/g, " ");
|
|
614
|
+
inner = el;
|
|
615
|
+
}
|
|
616
|
+
return text;
|
|
617
|
+
}
|
|
618
|
+
/** Most characters of the page between two units read. */
|
|
619
|
+
const BETWEEN_MAX = 200;
|
|
620
|
+
/** The text the page has between the end of `a` and the start of `b`, whitespace collapsed: at
|
|
621
|
+
* most BETWEEN_MAX characters, those nearest the place (`near` -1 means nearest `b`). */
|
|
622
|
+
function between(a, b, near) {
|
|
623
|
+
const range = a.ownerDocument.createRange();
|
|
624
|
+
try {
|
|
625
|
+
range.setStart(a, a.childNodes.length);
|
|
626
|
+
range.setEnd(b, 0);
|
|
627
|
+
}
|
|
628
|
+
catch {
|
|
629
|
+
return "";
|
|
630
|
+
}
|
|
631
|
+
if (range.collapsed)
|
|
632
|
+
return "";
|
|
633
|
+
const text = collapse(range.toString());
|
|
634
|
+
return near < 0 ? text.slice(-BETWEEN_MAX) : text.slice(0, BETWEEN_MAX);
|
|
635
|
+
}
|
|
636
|
+
function collapse(text) {
|
|
637
|
+
return text
|
|
638
|
+
.replace(/[\u00AD\u200B\u2060\uFEFF]/g, "")
|
|
639
|
+
.replace(/[\s\u00A0\u1680\u2000-\u200A\u2028\u2029\u202F\u205F\u3000]+/g, " ")
|
|
640
|
+
.trim();
|
|
641
|
+
}
|
|
642
|
+
/** How much of the stored prefix and suffix the text beside a place in its unit agrees with, or
|
|
643
|
+
* -1 when any of that text disagrees. */
|
|
644
|
+
function ownAgreement(prefix, suffix, before, after) {
|
|
645
|
+
const p = agree(prefix, before, -1);
|
|
646
|
+
const q = agree(suffix, after, 1);
|
|
647
|
+
const whole = p === Math.min(prefix.length, before.length) && q === Math.min(suffix.length, after.length);
|
|
648
|
+
return whole ? p + q : -1;
|
|
649
|
+
}
|
|
650
|
+
/** How many characters of `stored` agree with `seen`, counted from the place outwards. */
|
|
651
|
+
function agree(stored, seen, dir) {
|
|
652
|
+
const n = Math.min(stored.length, seen.length);
|
|
653
|
+
let k = 0;
|
|
654
|
+
while (k < n &&
|
|
655
|
+
(dir < 0 ? stored[stored.length - 1 - k] === seen[seen.length - 1 - k] : stored[k] === seen[k])) {
|
|
656
|
+
k++;
|
|
657
|
+
}
|
|
658
|
+
return k;
|
|
659
|
+
}
|
|
660
|
+
/**
|
|
661
|
+
* How far one key is from another. A key is a stem and a trailing number (`diff-…R151`, or just
|
|
662
|
+
* `151`): keys whose stems share a longer start (the same file and side of a diff) are nearer, then
|
|
663
|
+
* those whose numbers (line numbers) are closer.
|
|
664
|
+
*/
|
|
665
|
+
function keyDistance(a, b) {
|
|
666
|
+
const [stemA, numA] = splitKey(a);
|
|
667
|
+
const [stemB, numB] = splitKey(b);
|
|
668
|
+
let common = 0;
|
|
669
|
+
while (common < stemA.length && common < stemB.length && stemA[common] === stemB[common])
|
|
670
|
+
common++;
|
|
671
|
+
if (common === stemA.length && common === stemB.length)
|
|
672
|
+
common = KEY_MAX;
|
|
673
|
+
const gap = numA === null || numB === null ? GAP_MAX : Math.min(Math.abs(numA - numB), GAP_MAX);
|
|
674
|
+
// Every character of shared stem outranks any gap between numbers.
|
|
675
|
+
return (KEY_MAX - common) * (GAP_MAX + 1) + gap;
|
|
676
|
+
}
|
|
677
|
+
const GAP_MAX = 1e7;
|
|
678
|
+
function splitKey(key) {
|
|
679
|
+
const m = /^(.*?)(\d{1,9})$/.exec(key);
|
|
680
|
+
return m ? [m[1], Number(m[2])] : [key, null];
|
|
681
|
+
}
|
|
682
|
+
//# sourceMappingURL=profiles.js.map
|