@precedence-dev/instrument 0.1.0 → 0.2.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/README.md +44 -19
- package/dist/generate/cli.js +41 -34
- package/dist/generate/explain.js +7 -5
- package/dist/generate/instrument.d.ts +29 -21
- package/dist/generate/instrument.js +87 -70
- package/dist/generate/unplugin.d.ts +7 -10
- package/dist/generate/unplugin.js +8 -14
- package/package.json +2 -2
package/README.md
CHANGED
|
@@ -17,42 +17,67 @@ actually run the generated code to prove it.
|
|
|
17
17
|
|
|
18
18
|
```
|
|
19
19
|
# dry-run: print the diff, touch nothing
|
|
20
|
-
precedence-instrument --plan plan.json --dir src
|
|
20
|
+
precedence-instrument --plan plan.json --dir src
|
|
21
21
|
|
|
22
22
|
# apply it (refuses on a dirty git tree, so the injection lands in its own commit)
|
|
23
|
-
precedence-instrument --plan plan.json --dir src --
|
|
23
|
+
precedence-instrument --plan plan.json --dir src --apply
|
|
24
24
|
|
|
25
25
|
# CI: fail the build if the committed source has drifted from the plan
|
|
26
|
-
precedence-instrument --plan plan.json --dir src --
|
|
26
|
+
precedence-instrument --plan plan.json --dir src --check
|
|
27
27
|
```
|
|
28
28
|
|
|
29
29
|
`--plan` is the events export from the picker (`@precedence-dev/sdk`) or the viewer
|
|
30
30
|
(`@precedence-dev/viewer`). `--dir` is repeatable. `-h` prints every flag.
|
|
31
31
|
|
|
32
|
-
##
|
|
32
|
+
## What it bakes
|
|
33
33
|
|
|
34
|
-
|
|
35
|
-
| --- | --- | --- |
|
|
36
|
-
| `direct` (default) | `track("event_name", { pm_id, ...props })`, baked in; adds the `--track` import | no — it's all in the source |
|
|
37
|
-
| `runtime` | `globalThis.__pm?.("<anchor id>", { ...in-scope props })`, no import | yes — enable/disable, rename, retarget, change the discriminator, narrow props: all plan edits |
|
|
34
|
+
At every tracked branch it splices one complete call:
|
|
38
35
|
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
36
|
+
```js
|
|
37
|
+
precedence.track("checkout_completed", { psc_id: "p_1qzcbxk", amount });
|
|
38
|
+
```
|
|
39
|
+
|
|
40
|
+
plus `import { precedence } from "@precedence-dev/sdk"` in that file.
|
|
41
|
+
|
|
42
|
+
- **`psc_id`** — a short cyrb53 hash of the anchor's structural id
|
|
43
|
+
(`src/Checkout.tsx#Checkout::form|onSubmit|ok`). The readable id never enters
|
|
44
|
+
the bundle or a third-party destination; the plan maps the two.
|
|
45
|
+
- **the customer's selected properties** — `{ amount }` here, shorthand for an
|
|
46
|
+
in-scope binding, or an accessor expression, or `undefined /* not in scope */`.
|
|
47
|
+
- **not** the `outcome` / `placement` discriminator — that stays in the plan and
|
|
48
|
+
the SDK overlay adds it.
|
|
49
|
+
|
|
50
|
+
The call works on its own — deliver it by calling `installPrecedence({ endpoint })`
|
|
51
|
+
once at your app root ([`@precedence-dev/sdk`](https://github.com/precedence-dev/sdk)).
|
|
52
|
+
|
|
53
|
+
Add `planUrl` to `installPrecedence` and the SDK loads the plan as a **live
|
|
54
|
+
overlay**: it matches each baked call by `psc_id` and lets the plan rename the
|
|
55
|
+
event, narrow its props, add or change the discriminator, or disable it — no
|
|
56
|
+
rebuild. The next `precedence-instrument` run re-bakes the source to match, and
|
|
57
|
+
the overlay for that anchor goes quiet. So plan edits are live immediately and
|
|
58
|
+
converge back into the source on the next build; only a brand-new call site (a
|
|
59
|
+
new anchor) or a newly-surfaced prop needs a build.
|
|
60
|
+
|
|
61
|
+
`--track "<callee> from <module>"` retargets the call to your own function (the
|
|
62
|
+
callee may be dotted). Then that function is yours to keep matching the plan.
|
|
63
|
+
|
|
64
|
+
**Synthetic anchors** — links and bare `<button>`s with no handler — have no call
|
|
65
|
+
site to bake into. `precedence-instrument` stamps them with `data-precedence-id`
|
|
66
|
+
and generates a self-contained click-listener module: `--delegated <file>` writes
|
|
67
|
+
it, import it once at your app root. It calls the same `precedence.track`, so the
|
|
68
|
+
overlay applies to it too.
|
|
45
69
|
|
|
46
70
|
## Debug a live event: `--explain`
|
|
47
71
|
|
|
48
|
-
Every
|
|
49
|
-
the dashboard and run:
|
|
72
|
+
Every baked call carries `psc_id`. Copy one from the dashboard and run:
|
|
50
73
|
|
|
51
74
|
```
|
|
52
|
-
precedence-instrument --plan plan.json --dir src --explain "
|
|
53
|
-
precedence-instrument --plan plan.json --dir src --explain "<
|
|
75
|
+
precedence-instrument --plan plan.json --dir src --explain "p_1qzcbxk"
|
|
76
|
+
precedence-instrument --plan plan.json --dir src --explain "<psc_id>" --json # structured
|
|
54
77
|
```
|
|
55
78
|
|
|
79
|
+
(`--explain` also accepts the readable structural id, if your plan is stale.)
|
|
80
|
+
|
|
56
81
|
It doesn't instrument anything — it traces the id back to its exact fire site
|
|
57
82
|
and reports, per property, the **structural** reasons a value can arrive null
|
|
58
83
|
(out of scope here → baked `undefined`; the branch fires precisely when the
|
|
@@ -92,7 +117,7 @@ src/
|
|
|
92
117
|
├── generate/
|
|
93
118
|
│ ├── instrument.ts the resolver + edit primitives + safety wrapping
|
|
94
119
|
│ ├── unplugin.ts the bundler-plugin delivery path
|
|
95
|
-
│ ├── explain.ts trace a fired event's
|
|
120
|
+
│ ├── explain.ts trace a fired event's psc_id back to its source
|
|
96
121
|
│ └── cli.ts the precedence-instrument binary
|
|
97
122
|
├── discover.ts resolve --dir/--files/--changed-since to a file list
|
|
98
123
|
└── util.ts (tiny, no analysis logic — see below)
|
package/dist/generate/cli.js
CHANGED
|
@@ -38,9 +38,9 @@ Object.defineProperty(exports, "__esModule", { value: true });
|
|
|
38
38
|
* precedence-instrument, CI transform. Splice tracking calls into the source from a
|
|
39
39
|
* plan (the events a PM defined in the picker / viewer "copy events" export).
|
|
40
40
|
*
|
|
41
|
-
* precedence-instrument --plan plan.json --dir src --
|
|
42
|
-
* precedence-instrument --plan plan.json --dir src --
|
|
43
|
-
* precedence-instrument --plan plan.json --dir src --track
|
|
41
|
+
* precedence-instrument --plan plan.json --dir src --apply # precedence.track + import
|
|
42
|
+
* precedence-instrument --plan plan.json --dir src --check # CI: fail if drifted
|
|
43
|
+
* precedence-instrument --plan plan.json --dir src --track "t from @/lib/analytics" --apply
|
|
44
44
|
*/
|
|
45
45
|
const fs = __importStar(require("fs"));
|
|
46
46
|
const path = __importStar(require("path"));
|
|
@@ -49,20 +49,20 @@ const discover_1 = require("../discover");
|
|
|
49
49
|
const util_1 = require("../util");
|
|
50
50
|
const instrument_1 = require("./instrument");
|
|
51
51
|
const explain_1 = require("./explain");
|
|
52
|
-
const HELP = `precedence-instrument,
|
|
52
|
+
const HELP = `precedence-instrument, bake tracking calls into the source from a plan
|
|
53
53
|
|
|
54
54
|
USAGE
|
|
55
|
-
precedence-instrument --plan plan.json --dir src --
|
|
55
|
+
precedence-instrument --plan plan.json --dir src --apply
|
|
56
56
|
|
|
57
57
|
OPTIONS
|
|
58
58
|
--plan <file> the events export from the picker / viewer (required)
|
|
59
59
|
--dir <path> source dir to scan (repeatable) (required)
|
|
60
|
-
--track <spec>
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
"
|
|
65
|
-
|
|
60
|
+
--track <spec> the call target — a complete precedence.track("name",
|
|
61
|
+
{ psc_id, ...props }) call is baked in (a plan overlay in
|
|
62
|
+
@precedence-dev/sdk can still rename / retune it live)
|
|
63
|
+
(default: "precedence.track from @precedence-dev/sdk", import added)
|
|
64
|
+
"myTrack from @/lib/analytics" -> bake into your own fn + import it
|
|
65
|
+
"myTrack" -> assume it's already in scope
|
|
66
66
|
--types resolve declared types via the TS checker (needs a tsconfig).
|
|
67
67
|
Required for callback-edge-rule continuation anchors (mutate onSuccess/
|
|
68
68
|
onError, .then(f,g)).
|
|
@@ -72,16 +72,18 @@ OPTIONS
|
|
|
72
72
|
stays reviewable in a diff
|
|
73
73
|
--allow-dirty let --apply run against a dirty git tree anyway
|
|
74
74
|
--out-dir <dir> write changed files here instead of in place
|
|
75
|
-
--
|
|
75
|
+
--delegated <file> write the synthetic-anchor click listener here (links / bare
|
|
76
|
+
buttons — they have no call site); import it once at your app root
|
|
76
77
|
--check exit 1 if any file would change (for CI verification)
|
|
77
78
|
--json machine-readable report to stdout
|
|
78
79
|
--reanchor don't instrument, repair a plan whose ids stopped resolving
|
|
79
80
|
(e.g. a component was renamed) by matching on fingerprint,
|
|
80
81
|
write the fixed plan to --out for review
|
|
81
82
|
--out <file> where --reanchor writes the repaired plan (default: <plan>.reanchored.json)
|
|
82
|
-
--explain <
|
|
83
|
-
|
|
84
|
-
|
|
83
|
+
--explain <psc_id> don't instrument, trace one psc_id (the p_… hash seen in the
|
|
84
|
+
dashboard, or the readable structural id) back to its fire
|
|
85
|
+
site and report, per property, when it can arrive null.
|
|
86
|
+
Pair with --json for the structured report.
|
|
85
87
|
-h, --help
|
|
86
88
|
|
|
87
89
|
EXIT
|
|
@@ -94,26 +96,20 @@ const OPTIONS = new Map([
|
|
|
94
96
|
["--plan", (o, next) => { o.plan = next(); }],
|
|
95
97
|
["--dir", (o, next) => { o.paths.push(next()); }],
|
|
96
98
|
["--track", (o, next) => { o.track = next(); }],
|
|
97
|
-
["--emit", (o, next) => {
|
|
98
|
-
const v = next();
|
|
99
|
-
if (v !== "direct" && v !== "runtime")
|
|
100
|
-
fail("--emit must be direct|runtime");
|
|
101
|
-
o.emit = v;
|
|
102
|
-
}],
|
|
103
99
|
["--apply", (o) => { o.apply = true; }],
|
|
104
100
|
["--allow-dirty", (o) => { o.allowDirty = true; }],
|
|
105
101
|
["--types", (o) => { o.types = true; }],
|
|
106
102
|
["--tsconfig", (o, next) => { o.tsconfig = next(); }],
|
|
107
103
|
["--check", (o) => { o.check = true; }],
|
|
108
104
|
["--out-dir", (o, next) => { o.outDir = next(); }],
|
|
109
|
-
["--
|
|
105
|
+
["--delegated", (o, next) => { o.delegated = next(); }],
|
|
110
106
|
["--json", (o) => { o.json = true; }],
|
|
111
107
|
["--reanchor", (o) => { o.reanchor = true; }],
|
|
112
108
|
["--out", (o, next) => { o.out = next(); }],
|
|
113
109
|
["--explain", (o, next) => { o.explain = next(); }],
|
|
114
110
|
]);
|
|
115
111
|
function parseArgs(argv) {
|
|
116
|
-
const o = { plan: "", paths: [], track: "",
|
|
112
|
+
const o = { plan: "", paths: [], track: "", apply: false, allowDirty: false, check: false, types: false, tsconfig: "", outDir: "", json: false, delegated: "", reanchor: false, out: "", explain: "" };
|
|
117
113
|
const cur = { i: 0 };
|
|
118
114
|
const next = (a) => { const v = argv[++cur.i]; if (v === undefined)
|
|
119
115
|
fail(`missing value for ${a}`); return v; };
|
|
@@ -135,7 +131,7 @@ function parseArgs(argv) {
|
|
|
135
131
|
fail("--plan is required");
|
|
136
132
|
if (!o.paths.length)
|
|
137
133
|
fail("--dir is required");
|
|
138
|
-
// --track optional
|
|
134
|
+
// --track optional: defaults to precedence.track from @precedence-dev/sdk (in instrument.ts)
|
|
139
135
|
return o;
|
|
140
136
|
}
|
|
141
137
|
/** fill an LCS length table, `dp[i][j]` = LCS of `A[i:]` and `B[j:]`. */
|
|
@@ -262,14 +258,14 @@ function printTextReport(r) {
|
|
|
262
258
|
r.skipped.forEach((x) => process.stderr.write(` - ${x.event} skipped: ${x.reason}${x.id ? ` (${x.id})` : ""}\n`));
|
|
263
259
|
process.stdout.write(`\n${added.length} call(s) in ${r.files.length} file(s)${unchanged ? `, ${unchanged} already in place` : ""}, ${r.delegated.length} delegated, ${r.warnings.length} drift, ${r.skipped.length} skipped\n`);
|
|
264
260
|
}
|
|
265
|
-
function
|
|
266
|
-
if (o.
|
|
267
|
-
fs.mkdirSync(path.dirname(path.resolve(o.
|
|
268
|
-
fs.writeFileSync(o.
|
|
269
|
-
process.stdout.write(` wrote ${o.
|
|
261
|
+
function emitDelegatedModule(r, o) {
|
|
262
|
+
if (o.delegated && r.delegatedModule) {
|
|
263
|
+
fs.mkdirSync(path.dirname(path.resolve(o.delegated)), { recursive: true });
|
|
264
|
+
fs.writeFileSync(o.delegated, r.delegatedModule);
|
|
265
|
+
process.stdout.write(` wrote ${o.delegated} (${r.delegated.length} synthetic anchor(s), import it once at your app root)\n`);
|
|
270
266
|
}
|
|
271
|
-
else if (r.
|
|
272
|
-
process.stdout.write(`\n---
|
|
267
|
+
else if (r.delegatedModule && !o.json) {
|
|
268
|
+
process.stdout.write(`\n--- synthetic-anchor listener (pass --delegated <file> to write it) ---\n${r.delegatedModule}\n`);
|
|
273
269
|
}
|
|
274
270
|
}
|
|
275
271
|
function writeChanges(r, o) {
|
|
@@ -302,7 +298,7 @@ function main() {
|
|
|
302
298
|
return;
|
|
303
299
|
}
|
|
304
300
|
const r = (0, instrument_1.instrument)(inputs, plan, {
|
|
305
|
-
track: o.track,
|
|
301
|
+
track: o.track, types: o.types, tsconfig: o.tsconfig || undefined,
|
|
306
302
|
});
|
|
307
303
|
if (o.json)
|
|
308
304
|
process.stdout.write(JSON.stringify(r, null, 2) + "\n");
|
|
@@ -313,7 +309,7 @@ function main() {
|
|
|
313
309
|
// longer resolves cleanly against the source, both are CI failures.
|
|
314
310
|
if (o.check)
|
|
315
311
|
process.exit(changed.length || r.skipped.length ? 1 : 0);
|
|
316
|
-
|
|
312
|
+
emitDelegatedModule(r, o);
|
|
317
313
|
writeChanges(r, o);
|
|
318
314
|
}
|
|
319
315
|
const VERDICT_MARK = {
|
|
@@ -323,8 +319,19 @@ const VERDICT_MARK = {
|
|
|
323
319
|
"maybe-null": "⚠ may be null",
|
|
324
320
|
"resolves": "· ok",
|
|
325
321
|
};
|
|
322
|
+
/** `--explain` accepts the readable structural id or the baked `p_…` hash; the
|
|
323
|
+
* plan maps one to the other. */
|
|
324
|
+
function resolveExplainId(plan, arg) {
|
|
325
|
+
if (!arg.startsWith("p_"))
|
|
326
|
+
return arg;
|
|
327
|
+
for (const ev of plan.events)
|
|
328
|
+
for (const a of ev.anchors)
|
|
329
|
+
if ((0, instrument_1.pscId)(a.id) === arg)
|
|
330
|
+
return a.id;
|
|
331
|
+
return arg; // let explain() report the miss
|
|
332
|
+
}
|
|
326
333
|
function doExplain(inputs, plan, o) {
|
|
327
|
-
const r = (0, explain_1.explain)(inputs, plan, o.explain);
|
|
334
|
+
const r = (0, explain_1.explain)(inputs, plan, resolveExplainId(plan, o.explain));
|
|
328
335
|
if (o.json) {
|
|
329
336
|
process.stdout.write(JSON.stringify(r, null, 2) + "\n");
|
|
330
337
|
process.exit(r.resolved ? 0 : 1);
|
package/dist/generate/explain.js
CHANGED
|
@@ -35,11 +35,13 @@ var __importStar = (this && this.__importStar) || (function () {
|
|
|
35
35
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
36
36
|
exports.explain = explain;
|
|
37
37
|
/**
|
|
38
|
-
* `precedence-instrument --explain <
|
|
38
|
+
* `precedence-instrument --explain <psc_id>` — the reverse direction of the pipeline.
|
|
39
39
|
*
|
|
40
|
-
* Every emitted event carries `
|
|
41
|
-
* in the analytics dashboard, this traces it
|
|
42
|
-
* says, per property, whether and when the value can
|
|
40
|
+
* Every emitted event carries `psc_id` (a hash of the structural anchor id; the
|
|
41
|
+
* plan maps it back). Given one seen in the analytics dashboard, this traces it
|
|
42
|
+
* to the exact fire site and says, per property, whether and when the value can
|
|
43
|
+
* arrive null. (The CLI resolves a `p_…` hash to the structural id via the plan
|
|
44
|
+
* before calling this; `explain()` itself takes the structural id.)
|
|
43
45
|
*
|
|
44
46
|
* Static analysis only. It enumerates the STRUCTURAL reasons a value is absent:
|
|
45
47
|
* · out of lexical scope at this site → instrumenter bakes `undefined`
|
|
@@ -131,7 +133,7 @@ function anchorFor(plan, id) {
|
|
|
131
133
|
function whyMiss(loaded, id) {
|
|
132
134
|
const m = id.split("|")[0].match(/^(.+)#([^#]*)::(.+)$/);
|
|
133
135
|
if (!m)
|
|
134
|
-
return "unrecognised id — copy
|
|
136
|
+
return "unrecognised id — copy the psc_id from the dashboard verbatim (the p_… hash, or the readable id if the plan is stale)";
|
|
135
137
|
const [, file, comp] = m;
|
|
136
138
|
const L = loaded.find((x) => (0, build_1.norm)(x.file) === (0, build_1.norm)(file) || (0, build_1.norm)(x.file).endsWith("/" + (0, build_1.norm)(file)));
|
|
137
139
|
if (!L)
|
|
@@ -31,19 +31,14 @@ export interface Plan {
|
|
|
31
31
|
events: PlanEvent[];
|
|
32
32
|
}
|
|
33
33
|
export interface InstrumentOpts {
|
|
34
|
-
/**
|
|
35
|
-
*
|
|
36
|
-
*
|
|
37
|
-
*
|
|
38
|
-
*
|
|
39
|
-
*
|
|
40
|
-
*
|
|
41
|
-
*/
|
|
42
|
-
emit?: "direct" | "runtime";
|
|
43
|
-
/** direct mode only: "<name> from <module>" adds an import; "<name>" assumes global */
|
|
34
|
+
/** the call target. Default `precedence.track from @precedence-dev/sdk` — a
|
|
35
|
+
* complete `precedence.track("event", { psc_id, ...props })` call plus an
|
|
36
|
+
* `import { precedence } from "@precedence-dev/sdk"`. The call works on its
|
|
37
|
+
* own; loading a plan overlay in the SDK lets a rename / prop-narrow /
|
|
38
|
+
* discriminator change / disable take effect before the next build re-bakes it.
|
|
39
|
+
* `"<callee> from <module>"` retargets it (callee may be dotted); a bare
|
|
40
|
+
* `"<callee>"` assumes it's already in scope. */
|
|
44
41
|
track?: string;
|
|
45
|
-
/** runtime mode: the global to call (default "globalThis.__pm") */
|
|
46
|
-
emitGlobal?: string;
|
|
47
42
|
/**
|
|
48
43
|
* resolve declared types via the TS checker. REQUIRED to instrument callback-edge-rule
|
|
49
44
|
* `continuation` anchors (`mutate(x, { onSuccess })`, `p.then(f, g)`) — the
|
|
@@ -78,7 +73,6 @@ export interface Delegation {
|
|
|
78
73
|
element: string;
|
|
79
74
|
ref: string;
|
|
80
75
|
attrs: string[];
|
|
81
|
-
props: Record<string, string>;
|
|
82
76
|
}
|
|
83
77
|
export interface InstrumentResult {
|
|
84
78
|
files: {
|
|
@@ -92,9 +86,11 @@ export interface InstrumentResult {
|
|
|
92
86
|
/** id resolved, but the code moved on since the plan, injected anyway, verify */
|
|
93
87
|
warnings: Warning[];
|
|
94
88
|
delegated: Delegation[];
|
|
95
|
-
/** a
|
|
96
|
-
*
|
|
97
|
-
|
|
89
|
+
/** a self-contained click/navigate listener module for the synthetic anchors
|
|
90
|
+
* (links / bare buttons) — they have no call site to bake into. Import it
|
|
91
|
+
* once at your app root; it calls `precedence.track`, so the plan overlay
|
|
92
|
+
* applies to it too. */
|
|
93
|
+
delegatedModule?: string;
|
|
98
94
|
}
|
|
99
95
|
/** what a single-module transform returns; null when the plan has nothing here */
|
|
100
96
|
export interface FileInstrumentResult {
|
|
@@ -108,9 +104,14 @@ export interface FileInstrumentResult {
|
|
|
108
104
|
}
|
|
109
105
|
/** every distinct source file a plan references */
|
|
110
106
|
export declare function planFiles(plan: Plan): string[];
|
|
111
|
-
/**
|
|
112
|
-
*
|
|
113
|
-
|
|
107
|
+
/** reserved property key: the anchor hash on every baked call. It's the key the
|
|
108
|
+
* SDK's plan overlay matches on; the readable structural id lives only in the
|
|
109
|
+
* plan. */
|
|
110
|
+
export declare const PSC_ID_KEY = "psc_id";
|
|
111
|
+
/** cyrb53 — a tiny 53-bit string hash. `@precedence-dev/sdk` has a byte-for-byte
|
|
112
|
+
* copy (`pscId` in its `plan.ts`); the two must stay in sync so the baked
|
|
113
|
+
* `psc_id` matches the SDK's plan index. */
|
|
114
|
+
export declare function pscId(structuralId: string): string;
|
|
114
115
|
/**
|
|
115
116
|
* Wrap the raw tracking call so it can never affect the surrounding control
|
|
116
117
|
* flow: a synchronous throw is caught and swallowed, and if it returns a
|
|
@@ -121,6 +122,11 @@ export declare const PM_ID_KEY = "pm_id";
|
|
|
121
122
|
* code does; there is no prior behavior here to disturb.
|
|
122
123
|
*/
|
|
123
124
|
export declare function safeStatement(call: string): string;
|
|
125
|
+
/** direct mode's default call target: the SDK's own client. `precedence.track` is
|
|
126
|
+
* a contract `@precedence-dev/sdk` owns end-to-end, so the injected call stays
|
|
127
|
+
* deterministic without Precedence ever seeing the analytics vendor. Override
|
|
128
|
+
* with `--track "<fn> from <module>"` to bake calls into your own function. */
|
|
129
|
+
export declare const DEFAULT_TRACK = "precedence.track from @precedence-dev/sdk";
|
|
124
130
|
export interface ReanchorResult {
|
|
125
131
|
plan: Plan;
|
|
126
132
|
repointed: {
|
|
@@ -159,7 +165,9 @@ export declare function instrumentFile(code: string, id: string, plan: Plan, opt
|
|
|
159
165
|
* transformed every file. */
|
|
160
166
|
export declare function planDelegations(plan: Plan): Delegation[];
|
|
161
167
|
/** one document-level listener for every synthetic (link / bare-button) anchor,
|
|
162
|
-
* keyed on the `data-precedence-id="<structural id>"` the transform stamps onto
|
|
163
|
-
* same element
|
|
168
|
+
* keyed on the `data-precedence-id="<structural id>"` the transform stamps onto
|
|
169
|
+
* the same element. It calls the same `precedence.track`, so a loaded plan
|
|
170
|
+
* overlays these clicks exactly as it overlays baked calls. One string, one
|
|
171
|
+
* code path, so it can't drift. Import it once at your app root. */
|
|
164
172
|
export declare function buildDelegatedModule(dels: Delegation[], track: string): string;
|
|
165
173
|
export {};
|
|
@@ -36,8 +36,9 @@ var __importDefault = (this && this.__importDefault) || function (mod) {
|
|
|
36
36
|
return (mod && mod.__esModule) ? mod : { "default": mod };
|
|
37
37
|
};
|
|
38
38
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
39
|
-
exports.
|
|
39
|
+
exports.DEFAULT_TRACK = exports.PSC_ID_KEY = void 0;
|
|
40
40
|
exports.planFiles = planFiles;
|
|
41
|
+
exports.pscId = pscId;
|
|
41
42
|
exports.safeStatement = safeStatement;
|
|
42
43
|
exports.reanchor = reanchor;
|
|
43
44
|
exports.instrument = instrument;
|
|
@@ -213,9 +214,6 @@ function resolveContextAccessor(ap, via, out, evName) {
|
|
|
213
214
|
}
|
|
214
215
|
/** how to render one property in the call object */
|
|
215
216
|
function propPart(p, ev, anchor) {
|
|
216
|
-
const stat = anchor.staticProps || {};
|
|
217
|
-
if (p in stat)
|
|
218
|
-
return `${p}: ${JSON.stringify(stat[p])}`;
|
|
219
217
|
const acc = ev.accessors && ev.accessors[p];
|
|
220
218
|
if (typeof acc === "string")
|
|
221
219
|
return acc === p ? p : `${p}: ${acc}`;
|
|
@@ -223,31 +221,37 @@ function propPart(p, ev, anchor) {
|
|
|
223
221
|
return `${p}: undefined /* not in scope here */`;
|
|
224
222
|
return p; // shorthand, the picker checked it resolves
|
|
225
223
|
}
|
|
226
|
-
/**
|
|
227
|
-
*
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
224
|
+
/** reserved property key: the anchor hash on every baked call. It's the key the
|
|
225
|
+
* SDK's plan overlay matches on; the readable structural id lives only in the
|
|
226
|
+
* plan. */
|
|
227
|
+
exports.PSC_ID_KEY = "psc_id";
|
|
228
|
+
/** cyrb53 — a tiny 53-bit string hash. `@precedence-dev/sdk` has a byte-for-byte
|
|
229
|
+
* copy (`pscId` in its `plan.ts`); the two must stay in sync so the baked
|
|
230
|
+
* `psc_id` matches the SDK's plan index. */
|
|
231
|
+
function pscId(structuralId) {
|
|
232
|
+
let h1 = 0xdeadbeef;
|
|
233
|
+
let h2 = 0x41c6ce57;
|
|
234
|
+
for (let i = 0; i < structuralId.length; i++) {
|
|
235
|
+
const ch = structuralId.charCodeAt(i);
|
|
236
|
+
h1 = Math.imul(h1 ^ ch, 2654435761);
|
|
237
|
+
h2 = Math.imul(h2 ^ ch, 1597334677);
|
|
238
|
+
}
|
|
239
|
+
h1 = Math.imul(h1 ^ (h1 >>> 16), 2246822507) ^ Math.imul(h2 ^ (h2 >>> 13), 3266489909);
|
|
240
|
+
h2 = Math.imul(h2 ^ (h2 >>> 16), 2246822507) ^ Math.imul(h1 ^ (h1 >>> 13), 3266489909);
|
|
241
|
+
return "p_" + (4294967296 * (2097151 & h2) + (h1 >>> 0)).toString(36);
|
|
242
|
+
}
|
|
243
|
+
/** the baked call: `precedence.track("event_name", { psc_id: "p_…", …the props
|
|
244
|
+
* the plan selects })`. The discriminator (`outcome` / `placement`) is *not*
|
|
245
|
+
* baked — the SDK's plan overlay adds it, and can rename / narrow / drop the
|
|
246
|
+
* call, all by `psc_id`. */
|
|
247
|
+
function emitCall(ev, anchor, callee) {
|
|
231
248
|
const seen = new Set();
|
|
232
|
-
const parts = [...new Set(ev.properties)]
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
if (!seen.has(exports.
|
|
236
|
-
parts.unshift(`${exports.
|
|
237
|
-
return `${
|
|
238
|
-
}
|
|
239
|
-
/** runtime mode: `globalThis.__pm?.("<id>", { …in-scope + ambient props })`.
|
|
240
|
-
* the id is arg 1 — the runtime layers it onto the payload as `pm_id` at emit
|
|
241
|
-
* time, along with static / discriminator props from the plan. */
|
|
242
|
-
function runtimeCall(g, ev, anchor) {
|
|
243
|
-
const skip = new Set([...Object.keys(anchor.staticProps || {}), ...(anchor.missingProps || [])]);
|
|
244
|
-
const parts = [...new Set(ev.properties)].filter((p) => !skip.has(p)).map((p) => propPart(p, ev, anchor));
|
|
245
|
-
return `${g}?.(${JSON.stringify(anchor.id)}${obj(parts)})`;
|
|
246
|
-
}
|
|
247
|
-
function emitCall(ev, anchor, opts, fnName) {
|
|
248
|
-
return opts.emit === "runtime"
|
|
249
|
-
? runtimeCall(opts.emitGlobal || "globalThis.__pm", ev, anchor)
|
|
250
|
-
: directCall(fnName, ev, anchor);
|
|
249
|
+
const parts = [...new Set(ev.properties)]
|
|
250
|
+
.filter((p) => !(anchor.staticProps && p in anchor.staticProps))
|
|
251
|
+
.map((p) => { seen.add(p); return propPart(p, ev, anchor); });
|
|
252
|
+
if (!seen.has(exports.PSC_ID_KEY))
|
|
253
|
+
parts.unshift(`${exports.PSC_ID_KEY}: ${JSON.stringify(pscId(anchor.id))}`);
|
|
254
|
+
return `${callee}(${JSON.stringify(ev.name)}${obj(parts)})`;
|
|
251
255
|
}
|
|
252
256
|
function indentAt(sf, pos) {
|
|
253
257
|
const lineStart = sf.text.lastIndexOf("\n", pos - 1) + 1;
|
|
@@ -277,7 +281,7 @@ function assertNever(x) {
|
|
|
277
281
|
function safeStatement(call) {
|
|
278
282
|
// Promise.resolve covers void / Promise / thenable clients and, unlike `.catch`
|
|
279
283
|
// on an inferred `void`, type-checks in a strict consumer project.
|
|
280
|
-
return `try { void Promise.resolve(${call}).catch(() => {}); } catch (
|
|
284
|
+
return `try { void Promise.resolve(${call}).catch(() => {}); } catch (__pscE) {}`;
|
|
281
285
|
}
|
|
282
286
|
/** the same guard, as a single expression — for sites where `call` has to sit
|
|
283
287
|
* inside a larger expression (a comma operator) rather than stand alone. */
|
|
@@ -328,22 +332,34 @@ function planEdit(site, call, sf) {
|
|
|
328
332
|
return assertNever(site);
|
|
329
333
|
}
|
|
330
334
|
}
|
|
335
|
+
/** direct mode's default call target: the SDK's own client. `precedence.track` is
|
|
336
|
+
* a contract `@precedence-dev/sdk` owns end-to-end, so the injected call stays
|
|
337
|
+
* deterministic without Precedence ever seeing the analytics vendor. Override
|
|
338
|
+
* with `--track "<fn> from <module>"` to bake calls into your own function. */
|
|
339
|
+
exports.DEFAULT_TRACK = "precedence.track from @precedence-dev/sdk";
|
|
340
|
+
/** `"<callee> from <module>"` → `{ callee, importName, spec }`; a bare `"<callee>"`
|
|
341
|
+
* → no import. `callee` may be dotted (`precedence.track`, `client.track`); the
|
|
342
|
+
* import binds its head (`precedence`, `client`). */
|
|
331
343
|
function parseTrack(track) {
|
|
332
|
-
const m = track.match(/^\s*([A-Za-z_$][\w$]*)\s+from\s+(.+?)\s*$/);
|
|
344
|
+
const m = track.match(/^\s*([A-Za-z_$][\w$]*(?:\.[\w$]+)*)\s+from\s+(.+?)\s*$/);
|
|
333
345
|
if (!m)
|
|
334
|
-
return {
|
|
346
|
+
return { callee: track.trim() || "track", importName: "", spec: "" };
|
|
335
347
|
const spec = m[2].replace(/^['"]|['"]$/g, "");
|
|
336
|
-
return {
|
|
348
|
+
return { callee: m[1], importName: m[1].split(".")[0], spec };
|
|
349
|
+
}
|
|
350
|
+
function importLineFor(track) {
|
|
351
|
+
const { importName, spec } = parseTrack(track);
|
|
352
|
+
return spec ? `import { ${importName} } from ${JSON.stringify(spec)};` : "";
|
|
337
353
|
}
|
|
338
|
-
/** add `import {
|
|
354
|
+
/** add `import { <head> } from "spec"` unless the file already has that binding */
|
|
339
355
|
function addImport(s, code, track) {
|
|
340
|
-
const {
|
|
356
|
+
const { importName, spec } = parseTrack(track);
|
|
341
357
|
if (!spec)
|
|
342
358
|
return;
|
|
343
|
-
const re = new RegExp(`import\\s*\\{[^}]*\\b${
|
|
359
|
+
const re = new RegExp(`import\\s*\\{[^}]*\\b${importName}\\b[^}]*\\}\\s*from\\s*['"]${spec.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")}['"]`);
|
|
344
360
|
if (re.test(code))
|
|
345
361
|
return;
|
|
346
|
-
const line =
|
|
362
|
+
const line = importLineFor(track);
|
|
347
363
|
// after the last `import … "spec";` (spans multi-line lists, matches side-effect imports)
|
|
348
364
|
const lastImp = [...code.matchAll(/^import\b[\s\S]*?['"][^'"]*['"]\s*;?/gm)].pop();
|
|
349
365
|
if (lastImp && lastImp.index != null)
|
|
@@ -360,35 +376,35 @@ function render(code, edits, id, opts) {
|
|
|
360
376
|
else
|
|
361
377
|
s.overwrite(e.start, e.end, e.text);
|
|
362
378
|
}
|
|
363
|
-
//
|
|
364
|
-
// stamps
|
|
365
|
-
if (
|
|
366
|
-
addImport(s, code, opts.track ||
|
|
379
|
+
// the baked calls need the track import, unless the only edits are
|
|
380
|
+
// `data-precedence-id` stamps (whose listener module carries its own import).
|
|
381
|
+
if (edits.some((e) => e.kind !== "stamp"))
|
|
382
|
+
addImport(s, code, opts.track || exports.DEFAULT_TRACK);
|
|
367
383
|
return {
|
|
368
384
|
code: s.toString(),
|
|
369
385
|
map: s.generateMap({ source: id, hires: true, includeContent: true }),
|
|
370
386
|
};
|
|
371
387
|
}
|
|
372
388
|
/** resolve + verify + plan-edit one anchor, appending its results to `out`. */
|
|
373
|
-
function collectAnchor(a, ev, index, loaded, opts,
|
|
389
|
+
function collectAnchor(a, ev, index, loaded, opts, callee, out) {
|
|
374
390
|
const ap = resolveAnchor(a, ev, index, loaded, out, opts);
|
|
375
391
|
if (!ap)
|
|
376
392
|
return;
|
|
377
393
|
const drifted = recordDrift(a, ap, ev, out);
|
|
378
394
|
const evR = ev.accessors ? resolveEventAccessors(ev, ap, out) : ev;
|
|
379
|
-
const call = emitCall(evR, a,
|
|
395
|
+
const call = emitCall(evR, a, callee);
|
|
380
396
|
if (ap.site.kind === "delegated")
|
|
381
397
|
applyDelegated(a, ev, ap, out);
|
|
382
398
|
else
|
|
383
399
|
applyInlineEdit(a, ev, ap, call, drifted, out);
|
|
384
400
|
}
|
|
385
401
|
/** resolve + verify + plan-edit every plan anchor (optionally just one file's) */
|
|
386
|
-
function collect(plan, index, loaded, opts,
|
|
402
|
+
function collect(plan, index, loaded, opts, callee, onlyFile) {
|
|
387
403
|
const out = { byFile: new Map(), applied: [], skipped: [], warnings: [], delegated: [] };
|
|
388
404
|
for (const ev of plan.events) {
|
|
389
405
|
for (const a of ev.anchors) {
|
|
390
406
|
if (!onlyFile || anchorFile(a.id) === onlyFile) {
|
|
391
|
-
collectAnchor(a, ev, index, loaded, opts,
|
|
407
|
+
collectAnchor(a, ev, index, loaded, opts, callee, out);
|
|
392
408
|
}
|
|
393
409
|
}
|
|
394
410
|
}
|
|
@@ -435,9 +451,8 @@ function resolveEventAccessors(ev, ap, out) {
|
|
|
435
451
|
function applyDelegated(a, ev, ap, out) {
|
|
436
452
|
const statKeys = new Set(Object.keys(a.staticProps || {}));
|
|
437
453
|
out.delegated.push({
|
|
438
|
-
event: ev.name, element: ap.element, ref: ap.id, // the structural id
|
|
454
|
+
event: ev.name, element: ap.element, ref: ap.id, // the structural id → hashed to psc_id in the module
|
|
439
455
|
attrs: (ap.autoAttrs && ap.autoAttrs.length ? ap.autoAttrs : (ev.properties || [])).filter((p) => !statKeys.has(p)),
|
|
440
|
-
props: a.staticProps || {},
|
|
441
456
|
});
|
|
442
457
|
const en = ap.elementNode;
|
|
443
458
|
if (!en || !(ts.isJsxOpeningElement(en) || ts.isJsxSelfClosingElement(en)))
|
|
@@ -451,13 +466,12 @@ function applyDelegated(a, ev, ap, out) {
|
|
|
451
466
|
out.byFile.set(ap.file, list);
|
|
452
467
|
}
|
|
453
468
|
/** true when a previous run already spliced this anchor's call into the file.
|
|
454
|
-
*
|
|
455
|
-
*
|
|
456
|
-
*
|
|
457
|
-
*
|
|
458
|
-
* applyDelegated's data-precedence-id check, create-react-app eject's git check). */
|
|
469
|
+
* The baked call writes `psc_id: "p_…"` and the hash is globally unique, so a
|
|
470
|
+
* substring hit is a safe "already done". An inserting codemod needs this:
|
|
471
|
+
* unlike a pattern-replacing one, an insert doesn't stop matching on the next
|
|
472
|
+
* pass (cf. addImport's regex guard, applyDelegated's data-precedence-id check). */
|
|
459
473
|
function alreadyInjected(sf, anchorId) {
|
|
460
|
-
return sf.text.includes(JSON.stringify(anchorId));
|
|
474
|
+
return sf.text.includes(JSON.stringify(pscId(anchorId)));
|
|
461
475
|
}
|
|
462
476
|
/** an outcome anchor: splice the `track(...)` call at the branch's inject site */
|
|
463
477
|
function applyInlineEdit(a, ev, ap, call, drifted, out) {
|
|
@@ -563,8 +577,8 @@ function reanchor(inputs, plan, opts = {}) {
|
|
|
563
577
|
function instrument(inputs, plan, opts) {
|
|
564
578
|
const { loaded } = (0, build_1.analyze)(inputs, { types: opts.types, tsconfig: opts.tsconfig });
|
|
565
579
|
const index = attachIndex(loaded);
|
|
566
|
-
const {
|
|
567
|
-
const c = collect(plan, index, loaded, opts,
|
|
580
|
+
const { callee } = parseTrack(opts.track || exports.DEFAULT_TRACK);
|
|
581
|
+
const c = collect(plan, index, loaded, opts, callee);
|
|
568
582
|
const src = new Map(loaded.map((L) => [(0, build_1.norm)(L.file), L.sf.text]));
|
|
569
583
|
const res = {
|
|
570
584
|
files: [], applied: c.applied, skipped: c.skipped, warnings: c.warnings, delegated: c.delegated,
|
|
@@ -587,9 +601,10 @@ function instrument(inputs, plan, opts) {
|
|
|
587
601
|
}
|
|
588
602
|
res.files.push({ file, before, after: r.code, map: r.map });
|
|
589
603
|
}
|
|
590
|
-
//
|
|
591
|
-
|
|
592
|
-
|
|
604
|
+
// synthetic anchors (links / bare buttons) have no call site to bake into —
|
|
605
|
+
// ship a self-contained listener module for them.
|
|
606
|
+
if (res.delegated.length) {
|
|
607
|
+
res.delegatedModule = buildDelegatedModule(res.delegated, opts.track || exports.DEFAULT_TRACK);
|
|
593
608
|
}
|
|
594
609
|
return res;
|
|
595
610
|
}
|
|
@@ -602,8 +617,8 @@ function instrumentFile(code, id, plan, opts) {
|
|
|
602
617
|
if (!loaded.length)
|
|
603
618
|
return { file: planFile, code, map: null, applied: [], warnings: [], skipped: [], delegated: [] };
|
|
604
619
|
const index = attachIndex(loaded);
|
|
605
|
-
const {
|
|
606
|
-
const c = collect(plan, index, loaded, opts,
|
|
620
|
+
const { callee } = parseTrack(opts.track || exports.DEFAULT_TRACK);
|
|
621
|
+
const c = collect(plan, index, loaded, opts, callee, planFile);
|
|
607
622
|
const edits = c.byFile.get((0, build_1.norm)(planFile)) || [...c.byFile.values()][0] || [];
|
|
608
623
|
const r = edits.length ? render(code, edits, id, opts) : { code, map: null };
|
|
609
624
|
return {
|
|
@@ -625,17 +640,20 @@ function planDelegations(plan) {
|
|
|
625
640
|
event: ev.name, element: a.element || "",
|
|
626
641
|
ref: a.id, // the structural id, matches what the transform stamps as data-precedence-id
|
|
627
642
|
attrs: (ev.properties || []).filter((p) => !statKeys.has(p)),
|
|
628
|
-
props: a.staticProps || {},
|
|
629
643
|
});
|
|
630
644
|
}
|
|
631
645
|
}
|
|
632
646
|
return out;
|
|
633
647
|
}
|
|
634
648
|
/** one document-level listener for every synthetic (link / bare-button) anchor,
|
|
635
|
-
* keyed on the `data-precedence-id="<structural id>"` the transform stamps onto
|
|
636
|
-
* same element
|
|
649
|
+
* keyed on the `data-precedence-id="<structural id>"` the transform stamps onto
|
|
650
|
+
* the same element. It calls the same `precedence.track`, so a loaded plan
|
|
651
|
+
* overlays these clicks exactly as it overlays baked calls. One string, one
|
|
652
|
+
* code path, so it can't drift. Import it once at your app root. */
|
|
637
653
|
function buildDelegatedModule(dels, track) {
|
|
638
|
-
const {
|
|
654
|
+
const { callee } = parseTrack(track);
|
|
655
|
+
const imp = importLineFor(track);
|
|
656
|
+
const importLine = imp ? imp + "\n" : "";
|
|
639
657
|
const rows = dels.map((d) => {
|
|
640
658
|
const readers = d.attrs.map((a) => {
|
|
641
659
|
const dom = a === "label" ? "getAttribute('aria-label')"
|
|
@@ -643,13 +661,12 @@ function buildDelegatedModule(dels, track) {
|
|
|
643
661
|
: "getAttribute(" + JSON.stringify(a) + ")";
|
|
644
662
|
return `${JSON.stringify(a)}: el.${dom}`;
|
|
645
663
|
});
|
|
646
|
-
const
|
|
647
|
-
|
|
648
|
-
return ` ${JSON.stringify(d.ref)}: { name: ${JSON.stringify(d.event)}, props: (el: Element): Record<string, unknown> => ({ ${[idPart].concat(readers, stat).join(", ")} }) },`;
|
|
664
|
+
const idPart = `${JSON.stringify(exports.PSC_ID_KEY)}: ${JSON.stringify(pscId(d.ref))}`;
|
|
665
|
+
return ` ${JSON.stringify(d.ref)}: { name: ${JSON.stringify(d.event)}, props: (el: Element): Record<string, unknown> => ({ ${[idPart].concat(readers).join(", ")} }) },`;
|
|
649
666
|
});
|
|
650
667
|
return `${importLine}// generated by precedence-instrument, needs data-precedence-id stamps in the production build
|
|
651
|
-
type
|
|
652
|
-
const
|
|
668
|
+
type PscDelegated = { name: string; props: (el: Element) => Record<string, unknown> };
|
|
669
|
+
const PSC_DELEGATED: Record<string, PscDelegated> = {
|
|
653
670
|
${rows.join("\n")}
|
|
654
671
|
};
|
|
655
672
|
if (typeof document !== "undefined") {
|
|
@@ -657,8 +674,8 @@ if (typeof document !== "undefined") {
|
|
|
657
674
|
const el = (e.target instanceof Element) ? e.target.closest("[data-precedence-id]") : null;
|
|
658
675
|
if (!el) return;
|
|
659
676
|
const id = el.getAttribute("data-precedence-id");
|
|
660
|
-
const hit = id ?
|
|
661
|
-
if (hit) { try { void Promise.resolve(${
|
|
677
|
+
const hit = id ? PSC_DELEGATED[id] : undefined;
|
|
678
|
+
if (hit) { try { void Promise.resolve(${callee}(hit.name, hit.props(el))).catch(() => {}); } catch (__pscE) {} }
|
|
662
679
|
}, true);
|
|
663
680
|
}
|
|
664
681
|
`;
|
|
@@ -2,17 +2,14 @@ import { type Plan, type Skipped } from "./instrument";
|
|
|
2
2
|
export interface PmInstrumentOptions {
|
|
3
3
|
/** the `copy events` export, a parsed plan, or a path to the JSON file */
|
|
4
4
|
plan: Plan | string;
|
|
5
|
-
/**
|
|
6
|
-
* "
|
|
7
|
-
*
|
|
8
|
-
*
|
|
9
|
-
*
|
|
10
|
-
*/
|
|
11
|
-
emit?: "direct" | "runtime";
|
|
12
|
-
/** direct mode: "<name> from <module>" adds the import; "<name>" assumes it's global.
|
|
13
|
-
* default "console.log", every event logs "[pm] <name>", no import. */
|
|
5
|
+
/** the call target. Default `precedence.track from @precedence-dev/sdk` — a
|
|
6
|
+
* complete `precedence.track("event", { psc_id, ...props })` call plus an
|
|
7
|
+
* auto-added import. Load a plan overlay in the SDK to rename / disable /
|
|
8
|
+
* retune events before the next build re-bakes them. `"<callee> from
|
|
9
|
+
* <module>"` retargets it; a bare `"<callee>"` assumes it's in scope. */
|
|
14
10
|
track?: string;
|
|
15
|
-
/**
|
|
11
|
+
/** id for the synthetic-anchor listener module (import once at your app root;
|
|
12
|
+
* default "virtual:pm-tracking") */
|
|
16
13
|
virtualModuleId?: string;
|
|
17
14
|
/** per-file callback, wire drift / skips into your build log or fail the build */
|
|
18
15
|
onReport?: (r: {
|
|
@@ -38,8 +38,8 @@ exports.farmPlugin = exports.esbuildPlugin = exports.rspackPlugin = exports.roll
|
|
|
38
38
|
* precedence-instrument as a bundler transform, the production delivery path.
|
|
39
39
|
*
|
|
40
40
|
* One `transform` hook, adapted to Vite / webpack / Rollup / esbuild / Rspack by
|
|
41
|
-
* unplugin. Source files never change on disk; the `track(…)` calls
|
|
42
|
-
* in the bundle, so instrumentation is idempotent and sourcemap-clean by
|
|
41
|
+
* unplugin. Source files never change on disk; the `precedence.track(…)` calls
|
|
42
|
+
* exist only in the bundle, so instrumentation is idempotent and sourcemap-clean by
|
|
43
43
|
* construction, and CI reviews the plan diff, not a machine-rewritten tree.
|
|
44
44
|
*
|
|
45
45
|
* One implementation, six bundlers: per-module `transform(code, id)`, plan/config
|
|
@@ -49,19 +49,16 @@ exports.farmPlugin = exports.esbuildPlugin = exports.rspackPlugin = exports.roll
|
|
|
49
49
|
* import { webpackPlugin as pmInstrument } from "@precedence-dev/instrument/unplugin";
|
|
50
50
|
* const nextConfig = {
|
|
51
51
|
* webpack(config) {
|
|
52
|
-
* config.plugins.push(pmInstrument({
|
|
53
|
-
* plan: "./tracking-plan.json",
|
|
54
|
-
* track: "track from @/lib/analytics",
|
|
55
|
-
* }));
|
|
52
|
+
* config.plugins.push(pmInstrument({ plan: "./tracking-plan.json" }));
|
|
56
53
|
* return config;
|
|
57
54
|
* },
|
|
58
55
|
* };
|
|
59
56
|
*
|
|
60
57
|
* // vite.config.ts
|
|
61
58
|
* import { vitePlugin as pmInstrument } from "@precedence-dev/instrument/unplugin";
|
|
62
|
-
* export default { plugins: [pmInstrument({ plan
|
|
59
|
+
* export default { plugins: [pmInstrument({ plan })] };
|
|
63
60
|
*
|
|
64
|
-
* // app entry, ONCE, pulls in the
|
|
61
|
+
* // app entry, ONCE, pulls in the click listener for synthetic anchors
|
|
65
62
|
* import "virtual:pm-tracking";
|
|
66
63
|
*/
|
|
67
64
|
const fs = __importStar(require("fs"));
|
|
@@ -72,7 +69,7 @@ exports.unpluginPmInstrument = (0, unplugin_1.createUnplugin)((options) => {
|
|
|
72
69
|
const plan = typeof options.plan === "string"
|
|
73
70
|
? JSON.parse(fs.readFileSync(options.plan, "utf8"))
|
|
74
71
|
: options.plan;
|
|
75
|
-
const opts = { track: options.track
|
|
72
|
+
const opts = { track: options.track }; // track defaults to precedence.track in instrument.ts
|
|
76
73
|
const files = (0, instrument_1.planFiles)(plan);
|
|
77
74
|
const delegated = (0, instrument_1.planDelegations)(plan); // from the plan alone, no build ordering dependency
|
|
78
75
|
const VID = options.virtualModuleId || "virtual:pm-tracking";
|
|
@@ -108,12 +105,9 @@ exports.unpluginPmInstrument = (0, unplugin_1.createUnplugin)((options) => {
|
|
|
108
105
|
load(id) {
|
|
109
106
|
if (id !== RESOLVED)
|
|
110
107
|
return null;
|
|
111
|
-
if (options.emit === "runtime") {
|
|
112
|
-
return "// runtime mode: import { installPrecedence } from '@precedence-dev/sdk' and call it at your app root instead of importing this\nexport {};\n";
|
|
113
|
-
}
|
|
114
108
|
return delegated.length
|
|
115
|
-
? (0, instrument_1.buildDelegatedModule)(delegated, options.track ||
|
|
116
|
-
: "// precedence: no
|
|
109
|
+
? (0, instrument_1.buildDelegatedModule)(delegated, options.track || instrument_1.DEFAULT_TRACK)
|
|
110
|
+
: "// precedence: no synthetic (link / bare-button) anchors in the plan\nexport {};\n";
|
|
117
111
|
},
|
|
118
112
|
};
|
|
119
113
|
});
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@precedence-dev/instrument",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.2.0",
|
|
4
4
|
"description": "Splice tracking calls into source (or a build) from a plan produced by Precedence's analysis engine. Source-available so anyone can read exactly what code it generates and where it puts it.",
|
|
5
5
|
"license": "FSL-1.1-ALv2",
|
|
6
6
|
"author": "Precedence",
|
|
@@ -39,7 +39,7 @@
|
|
|
39
39
|
"test": "npm run build && node --test \"test/**/*.test.mjs\" && node test/invariants.mjs"
|
|
40
40
|
},
|
|
41
41
|
"dependencies": {
|
|
42
|
-
"@precedence-dev/cli": "^0.
|
|
42
|
+
"@precedence-dev/cli": "^0.2.0",
|
|
43
43
|
"magic-string": "^0.30.21",
|
|
44
44
|
"typescript": "^5.6.3",
|
|
45
45
|
"unplugin": "^2.3.11"
|