@paikko/widget 0.1.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 +131 -0
- package/package.json +41 -0
- package/src/PaikkoNav.tsx +92 -0
- package/src/PaikkoProvider.tsx +79 -0
- package/src/ReportButton.tsx +591 -0
- package/src/build/provenancePlugin.cjs +200 -0
- package/src/capture.ts +991 -0
- package/src/index.ts +33 -0
|
@@ -0,0 +1,200 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* paikko provenance plugin (Babel).
|
|
3
|
+
*
|
|
4
|
+
* This is the "click -> source" unlock. At build time it injects two data
|
|
5
|
+
* attributes onto every JSX host element (lowercase tag) it visits:
|
|
6
|
+
*
|
|
7
|
+
* data-src="file:line:col" - build-time provenance of the element, the
|
|
8
|
+
* same "file:line:col" shape used everywhere in
|
|
9
|
+
* the contract (ReportTarget.src, TraceQuery.src,
|
|
10
|
+
* TraceRequest.src). When a user clicks an
|
|
11
|
+
* element in <ReportButton>'s picker, this is
|
|
12
|
+
* read straight off the DOM node.
|
|
13
|
+
* data-paikko-component="Name" - the nearest enclosing React component's
|
|
14
|
+
* display name, so the report can name the
|
|
15
|
+
* owning component without runtime fiber walking.
|
|
16
|
+
*
|
|
17
|
+
* Only host (DOM) elements get `data-src` (component elements forward props, they
|
|
18
|
+
* aren't real DOM nodes); both host and component elements are tagged with the
|
|
19
|
+
* enclosing component name so the attribute propagates intent. We skip <Fragment>
|
|
20
|
+
* and elements that already carry a `data-src` (idempotent re-runs, hand-authored
|
|
21
|
+
* overrides).
|
|
22
|
+
*
|
|
23
|
+
* ## Wire-up
|
|
24
|
+
*
|
|
25
|
+
* Babel route (.babelrc / babel.config.js) - add to `plugins`:
|
|
26
|
+
*
|
|
27
|
+
* {
|
|
28
|
+
* "plugins": [
|
|
29
|
+
* ["./src/paikko/build/provenancePlugin.js", { "rootDir": "." }]
|
|
30
|
+
* ]
|
|
31
|
+
* }
|
|
32
|
+
*
|
|
33
|
+
* Note: adding a .babelrc opts the whole project out of Next's default SWC
|
|
34
|
+
* compiler. If you want to keep SWC for everything else, instead run this as a
|
|
35
|
+
* one-off codemod, or port it to an SWC plugin. For dev/preview builds where
|
|
36
|
+
* provenance matters most, the Babel route is the simplest correct option.
|
|
37
|
+
*
|
|
38
|
+
* Next.js config (next.config.js) - if using SWC and you only need this in
|
|
39
|
+
* development, you can gate it; with the Babel route Next picks up .babelrc
|
|
40
|
+
* automatically. The plugin is build-time only and emits no runtime code beyond
|
|
41
|
+
* the static attributes. By DEFAULT it no-ops in production builds (NODE_ENV ===
|
|
42
|
+
* "production"), so the `data-src` paths never leak into shipped HTML; pass an
|
|
43
|
+
* explicit `enabled` (see Options) to override.
|
|
44
|
+
*
|
|
45
|
+
* ## Options
|
|
46
|
+
*
|
|
47
|
+
* rootDir - base dir to relativize filenames against (default process.cwd()).
|
|
48
|
+
* attr - override the src attribute name (default "data-src").
|
|
49
|
+
* componentAttr - override the component attribute (default "data-paikko-component").
|
|
50
|
+
* enabled - tri-state gate for emitting the attributes:
|
|
51
|
+
* true -> always emit;
|
|
52
|
+
* false -> never emit (no-op);
|
|
53
|
+
* undefined -> DEFAULT: emit only when NODE_ENV !== "production",
|
|
54
|
+
* so provenance never leaks source paths into a prod
|
|
55
|
+
* build unless you opt in explicitly. A JSON `.babelrc`
|
|
56
|
+
* cannot read env vars - use `.babelrc.js` to pass an
|
|
57
|
+
* explicit `enabled` if you need finer control.
|
|
58
|
+
*/
|
|
59
|
+
|
|
60
|
+
"use strict";
|
|
61
|
+
|
|
62
|
+
const path = require("path");
|
|
63
|
+
|
|
64
|
+
/**
|
|
65
|
+
* Resolve the tri-state `enabled` option. Explicit booleans win; otherwise
|
|
66
|
+
* default to dev-only so a production build does not ship `data-src` source
|
|
67
|
+
* paths in its HTML.
|
|
68
|
+
*/
|
|
69
|
+
function isEnabled(opt) {
|
|
70
|
+
if (opt === true) return true;
|
|
71
|
+
if (opt === false) return false;
|
|
72
|
+
return process.env.NODE_ENV !== "production";
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
module.exports = function provenancePlugin(babel) {
|
|
76
|
+
const { types: t } = babel;
|
|
77
|
+
|
|
78
|
+
/** Relativize an absolute filename to rootDir, with forward slashes. */
|
|
79
|
+
function relFile(filename, rootDir) {
|
|
80
|
+
if (!filename) return "unknown";
|
|
81
|
+
const rel = path.relative(rootDir, filename);
|
|
82
|
+
return rel.split(path.sep).join("/");
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/** True if the JSX opening element already has an attribute named `name`. */
|
|
86
|
+
function hasAttr(openingElement, name) {
|
|
87
|
+
return openingElement.attributes.some(
|
|
88
|
+
(attr) =>
|
|
89
|
+
t.isJSXAttribute(attr) &&
|
|
90
|
+
t.isJSXIdentifier(attr.name, { name }),
|
|
91
|
+
);
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/** JSX tag is a host (DOM) element if it's a lowercase identifier. */
|
|
95
|
+
function isHostElement(openingElement) {
|
|
96
|
+
const nameNode = openingElement.name;
|
|
97
|
+
if (!t.isJSXIdentifier(nameNode)) return false; // member/namespaced => component
|
|
98
|
+
const tag = nameNode.name;
|
|
99
|
+
return /^[a-z]/.test(tag);
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
/** Tag name is <Fragment> / <React.Fragment> - skip those, they emit no node. */
|
|
103
|
+
function isFragment(openingElement) {
|
|
104
|
+
const nameNode = openingElement.name;
|
|
105
|
+
if (t.isJSXIdentifier(nameNode)) return nameNode.name === "Fragment";
|
|
106
|
+
if (t.isJSXMemberExpression(nameNode)) {
|
|
107
|
+
return t.isJSXIdentifier(nameNode.property, { name: "Fragment" });
|
|
108
|
+
}
|
|
109
|
+
return false;
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
/**
|
|
113
|
+
* Find the display name of the component enclosing this JSX. Walks up the path
|
|
114
|
+
* to the nearest function/class declaration or variable-bound arrow/function and
|
|
115
|
+
* returns its name. Returns null if anonymous / not found.
|
|
116
|
+
*/
|
|
117
|
+
function enclosingComponentName(jsxPath) {
|
|
118
|
+
let p = jsxPath;
|
|
119
|
+
while (p) {
|
|
120
|
+
const node = p.node;
|
|
121
|
+
if (
|
|
122
|
+
(t.isFunctionDeclaration(node) || t.isClassDeclaration(node)) &&
|
|
123
|
+
node.id &&
|
|
124
|
+
t.isIdentifier(node.id)
|
|
125
|
+
) {
|
|
126
|
+
return node.id.name;
|
|
127
|
+
}
|
|
128
|
+
if (
|
|
129
|
+
(t.isFunctionExpression(node) || t.isArrowFunctionExpression(node)) &&
|
|
130
|
+
p.parentPath &&
|
|
131
|
+
t.isVariableDeclarator(p.parentPath.node) &&
|
|
132
|
+
t.isIdentifier(p.parentPath.node.id)
|
|
133
|
+
) {
|
|
134
|
+
return p.parentPath.node.id.name;
|
|
135
|
+
}
|
|
136
|
+
p = p.parentPath;
|
|
137
|
+
}
|
|
138
|
+
return null;
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
/** Build a `data-foo="value"` JSX attribute node. */
|
|
142
|
+
function stringAttr(name, value) {
|
|
143
|
+
return t.jsxAttribute(t.jsxIdentifier(name), t.stringLiteral(value));
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
return {
|
|
147
|
+
name: "paikko-provenance",
|
|
148
|
+
visitor: {
|
|
149
|
+
JSXOpeningElement(jsxPath, state) {
|
|
150
|
+
const opts = state.opts || {};
|
|
151
|
+
if (!isEnabled(opts.enabled)) return;
|
|
152
|
+
|
|
153
|
+
const rootDir = opts.rootDir
|
|
154
|
+
? path.resolve(opts.rootDir)
|
|
155
|
+
: process.cwd();
|
|
156
|
+
const srcAttrName = opts.attr || "data-src";
|
|
157
|
+
const componentAttrName =
|
|
158
|
+
opts.componentAttr || "data-paikko-component";
|
|
159
|
+
|
|
160
|
+
const opening = jsxPath.node;
|
|
161
|
+
if (isFragment(opening)) return;
|
|
162
|
+
|
|
163
|
+
const loc = opening.loc;
|
|
164
|
+
if (!loc) return; // no source position (generated node) - nothing to point at
|
|
165
|
+
|
|
166
|
+
const filename =
|
|
167
|
+
(state.file && state.file.opts && state.file.opts.filename) || null;
|
|
168
|
+
const file = relFile(filename, rootDir);
|
|
169
|
+
const line = loc.start.line;
|
|
170
|
+
// Babel columns are 0-based; the contract's "file:line:col" is 1-based.
|
|
171
|
+
const col = loc.start.column + 1;
|
|
172
|
+
const src = `${file}:${line}:${col}`;
|
|
173
|
+
|
|
174
|
+
const host = isHostElement(opening);
|
|
175
|
+
|
|
176
|
+
// data-src only on real DOM nodes (host elements). Component elements
|
|
177
|
+
// forward props and would either error or leak the attr to their root.
|
|
178
|
+
if (host && !hasAttr(opening, srcAttrName)) {
|
|
179
|
+
opening.attributes.push(stringAttr(srcAttrName, src));
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
// data-paikko-component on host elements so the clicked DOM node knows its
|
|
183
|
+
// owning component without a runtime fiber walk.
|
|
184
|
+
if (host && !hasAttr(opening, componentAttrName)) {
|
|
185
|
+
const component = enclosingComponentName(
|
|
186
|
+
jsxPath.findParent(
|
|
187
|
+
(p) =>
|
|
188
|
+
p.isFunction() ||
|
|
189
|
+
p.isClassMethod() ||
|
|
190
|
+
p.isClassDeclaration(),
|
|
191
|
+
) || jsxPath,
|
|
192
|
+
);
|
|
193
|
+
if (component) {
|
|
194
|
+
opening.attributes.push(stringAttr(componentAttrName, component));
|
|
195
|
+
}
|
|
196
|
+
}
|
|
197
|
+
},
|
|
198
|
+
},
|
|
199
|
+
};
|
|
200
|
+
};
|