@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.
@@ -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
+ };