@frmwrkd/snag-anchor 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.
Files changed (75) hide show
  1. package/LICENSE +19 -0
  2. package/README.md +305 -0
  3. package/dist/babel/component-name.d.ts +42 -0
  4. package/dist/babel/component-name.d.ts.map +1 -0
  5. package/dist/babel/component-name.js +107 -0
  6. package/dist/babel/component-name.js.map +1 -0
  7. package/dist/babel/plugin.d.ts +69 -0
  8. package/dist/babel/plugin.d.ts.map +1 -0
  9. package/dist/babel/plugin.js +272 -0
  10. package/dist/babel/plugin.js.map +1 -0
  11. package/dist/babel/walk.d.ts +8 -0
  12. package/dist/babel/walk.d.ts.map +1 -0
  13. package/dist/babel/walk.js +50 -0
  14. package/dist/babel/walk.js.map +1 -0
  15. package/dist/devtools-json.d.ts +45 -0
  16. package/dist/devtools-json.d.ts.map +1 -0
  17. package/dist/devtools-json.js +117 -0
  18. package/dist/devtools-json.js.map +1 -0
  19. package/dist/esbuild.d.ts +3 -0
  20. package/dist/esbuild.d.ts.map +1 -0
  21. package/dist/esbuild.js +11 -0
  22. package/dist/esbuild.js.map +1 -0
  23. package/dist/index.d.ts +31 -0
  24. package/dist/index.d.ts.map +1 -0
  25. package/dist/index.js +90 -0
  26. package/dist/index.js.map +1 -0
  27. package/dist/loader.cjs +44 -0
  28. package/dist/loader.cjs.map +1 -0
  29. package/dist/loader.d.cts +2 -0
  30. package/dist/loader.d.cts.map +1 -0
  31. package/dist/magic-string.d.ts +23 -0
  32. package/dist/magic-string.d.ts.map +1 -0
  33. package/dist/magic-string.js +23 -0
  34. package/dist/magic-string.js.map +1 -0
  35. package/dist/nuxt.d.ts +14 -0
  36. package/dist/nuxt.d.ts.map +1 -0
  37. package/dist/nuxt.js +29 -0
  38. package/dist/nuxt.js.map +1 -0
  39. package/dist/options.d.ts +101 -0
  40. package/dist/options.d.ts.map +1 -0
  41. package/dist/options.js +122 -0
  42. package/dist/options.js.map +1 -0
  43. package/dist/rollup.d.ts +3 -0
  44. package/dist/rollup.d.ts.map +1 -0
  45. package/dist/rollup.js +11 -0
  46. package/dist/rollup.js.map +1 -0
  47. package/dist/root.d.ts +38 -0
  48. package/dist/root.d.ts.map +1 -0
  49. package/dist/root.js +101 -0
  50. package/dist/root.js.map +1 -0
  51. package/dist/rspack.d.ts +3 -0
  52. package/dist/rspack.d.ts.map +1 -0
  53. package/dist/rspack.js +11 -0
  54. package/dist/rspack.js.map +1 -0
  55. package/dist/stamp.d.ts +66 -0
  56. package/dist/stamp.d.ts.map +1 -0
  57. package/dist/stamp.js +28 -0
  58. package/dist/stamp.js.map +1 -0
  59. package/dist/transform.d.ts +14 -0
  60. package/dist/transform.d.ts.map +1 -0
  61. package/dist/transform.js +54 -0
  62. package/dist/transform.js.map +1 -0
  63. package/dist/vite.d.ts +3 -0
  64. package/dist/vite.d.ts.map +1 -0
  65. package/dist/vite.js +11 -0
  66. package/dist/vite.js.map +1 -0
  67. package/dist/vue/transform.d.ts +50 -0
  68. package/dist/vue/transform.d.ts.map +1 -0
  69. package/dist/vue/transform.js +188 -0
  70. package/dist/vue/transform.js.map +1 -0
  71. package/dist/webpack.d.ts +3 -0
  72. package/dist/webpack.d.ts.map +1 -0
  73. package/dist/webpack.js +15 -0
  74. package/dist/webpack.js.map +1 -0
  75. package/package.json +84 -0
package/LICENSE ADDED
@@ -0,0 +1,19 @@
1
+ Apache License
2
+ Version 2.0, January 2004
3
+ http://www.apache.org/licenses/
4
+
5
+ Licensed under the Apache License, Version 2.0 (the "License");
6
+ you may not use this file except in compliance with the License.
7
+ You may obtain a copy of the License at
8
+
9
+ http://www.apache.org/licenses/LICENSE-2.0
10
+
11
+ Unless required by applicable law or agreed to in writing, software
12
+ distributed under the License is distributed on an "AS IS" BASIS,
13
+ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
14
+ See the License for the specific language governing permissions and
15
+ limitations under the License.
16
+
17
+ Copyright 2026 Snag contributors
18
+
19
+ Full text: https://www.apache.org/licenses/LICENSE-2.0.txt
package/README.md ADDED
@@ -0,0 +1,305 @@
1
+ # @frmwrkd/snag-anchor
2
+
3
+ An [unplugin](https://github.com/unjs/unplugin) that stamps
4
+ `data-snag="path:line:col"` onto DOM elements at build time.
5
+
6
+ ```html
7
+ <button data-snag="src/checkout/PromoField.tsx:42:7" data-snag-cmp="PromoField">
8
+ ```
9
+
10
+ Two attributes, self-contained — **no global registry, no indirection**.
11
+ LocatorJS's default `data-locatorjs-id` mode needs a `window.__LOCATOR_DATA__`
12
+ lookup table; this deliberately does not, because only `data-*` attributes cross
13
+ the ISOLATED-world boundary. Expando properties the page attaches to DOM nodes
14
+ (`__reactFiber$*`, `__svelte_meta`) are invisible to an ISOLATED content script,
15
+ so tier 0 is the only resolution path that needs no round trip into the page:
16
+ one `getAttribute` and you're done.
17
+
18
+ **Licence:** Apache-2.0.
19
+ **Spec:** `docs/02-BACKEND-SPEC.md` §2. Locked decisions live in
20
+ `docs/00-MASTER-BUILD-PLAN.md` §7 — don't relitigate them.
21
+
22
+ ---
23
+
24
+ ## Install and wire up
25
+
26
+ ```ts
27
+ // vite.config.ts
28
+ import { defineConfig } from 'vite';
29
+ import snagAnchor from '@frmwrkd/snag-anchor/vite';
30
+
31
+ export default defineConfig({ plugins: [snagAnchor()] });
32
+ ```
33
+
34
+ ```js
35
+ // webpack.config.js
36
+ const snagAnchor = require('@frmwrkd/snag-anchor/webpack').default;
37
+ module.exports = { plugins: [snagAnchor()] };
38
+ ```
39
+
40
+ Also available: `@frmwrkd/snag-anchor/rollup`, `/rspack`, `/esbuild`, `/nuxt`,
41
+ `/babel` (a plain Babel plugin) and `/loader` (a webpack-shaped loader, see
42
+ [Next.js and Turbopack](#nextjs-and-turbopack)).
43
+
44
+ ---
45
+
46
+ ## Options
47
+
48
+ Every default below is the one in `docs/02-BACKEND-SPEC.md` §2.3.
49
+
50
+ | Option | Type | Default | |
51
+ |---|---|---|---|
52
+ | `enabled` | `boolean` | `process.env.NODE_ENV !== 'production'` | Master switch. |
53
+ | `attribute` | `string` | `'data-snag'` | |
54
+ | `componentAttribute` | `string` | `'data-snag-cmp'` | |
55
+ | `root` | `string` | git root | Every emitted path is relative to this. |
56
+ | `include` | `string[]` | `['**/*.{jsx,tsx,vue}']` | |
57
+ | `exclude` | `string[]` | `['**/node_modules/**','**/*.test.*','**/*.stories.*']` | Beats `include`. |
58
+ | `intrinsicOnly` | `boolean` | `true` | Stamp DOM elements, not component tags. |
59
+ | `emitRepoMap` | `boolean` | `true` | Reserved — see [Not yet wired](#not-yet-wired). |
60
+ | `devtoolsJson` | `boolean` | `true` | Serve the well-known project-root file. |
61
+ | `production` | `false \| 'minimal'` | `false` | `'minimal'` = component name only, no path. |
62
+
63
+ ---
64
+
65
+ ## The information-disclosure tradeoff — read before turning production on
66
+
67
+ `data-snag` publishes a source path into the DOM. In development that is the
68
+ entire point. In a **public production build** it publishes your directory
69
+ layout, your internal component names, and — for anyone willing to read a few
70
+ hundred of them — a decent sketch of your architecture, to anyone with DevTools
71
+ open.
72
+
73
+ So the gate is two-layered and fails closed:
74
+
75
+ - `enabled` defaults to `process.env.NODE_ENV !== 'production'`, which switches
76
+ the plugin off entirely in a production build.
77
+ - Even with `enabled: true` forced on, a production build can only ever reach
78
+ `'minimal'`. The `production` option's type is `false | 'minimal'` — **there
79
+ is no value that means "full paths in production"**, by design.
80
+ - The Vite integration additionally re-reads `config.isProduction` in
81
+ `configResolved` and downgrades on its own if Vite says "production" while
82
+ `NODE_ENV` says nothing.
83
+
84
+ `production: 'minimal'` emits **only** `data-snag-cmp="PromoField"` — a
85
+ component name, never a path, never a line number. That is a real but much
86
+ smaller disclosure: it leaks your component vocabulary. It exists because a
87
+ staging screenshot with a component name still beats nothing, which is how
88
+ FullStory ships theirs. Use it on internal staging; think twice before shipping
89
+ it to the open internet.
90
+
91
+ If an element has no resolvable component name, `'minimal'` emits nothing at all
92
+ for it. A missing attribute is honest; a made-up one is not.
93
+
94
+ ---
95
+
96
+ ## What gets stamped, and what deliberately does not
97
+
98
+ ### Intrinsic elements only
99
+
100
+ `data-snag` goes on **lowercase JSX elements** — `<div>`, `<button>`,
101
+ `<my-widget>` — and never on `<PromoField />`. A `data-snag` prop on a component
102
+ element is silently swallowed unless that component happens to spread its props
103
+ onto a DOM node, and an attribute that lands nowhere is worse than no attribute:
104
+ the resolver cannot tell "not stamped" from "stamped and dropped".
105
+
106
+ The intrinsic test is Babel's own `isCompatTag` — the tag name starts with a
107
+ lowercase ASCII letter. `<Foo.Bar>` (a member expression) and `<svg:circle>` (a
108
+ namespaced name) are never stamped.
109
+
110
+ `intrinsicOnly: false` additionally stamps component elements. That is only
111
+ useful for frameworks that forward unknown props onto a DOM node by default;
112
+ leave it off unless you have measured that it helps.
113
+
114
+ ### Fragments
115
+
116
+ `<>…</>` parses to a different AST node entirely and is never visited.
117
+ `<React.Fragment>` is a member expression, so it is not intrinsic. Neither is
118
+ ever stamped.
119
+
120
+ ### An existing `data-snag`
121
+
122
+ User override wins. An element that already carries `data-snag` (or
123
+ `data-snag-cmp`) is left exactly as written.
124
+
125
+ ### Vue
126
+
127
+ The SFC template is transformed at `enforce: 'pre'`, before
128
+ `@vitejs/plugin-vue` compiles it. Host elements are stamped; `<MyChild/>`,
129
+ `<template>` and `<slot>` are not — the last two are compiler constructs that
130
+ never reach the DOM. `@vue/compiler-sfc` reports template positions **absolute
131
+ within the whole `.vue` file**, so the emitted line counts from the top of the
132
+ file including the `<script setup>` block. The component name is the SFC's file
133
+ name.
134
+
135
+ ### Svelte — nothing, on purpose
136
+
137
+ `.svelte` is **absent from the default `include`, deliberately**. Svelte 5
138
+ already attaches
139
+
140
+ ```js
141
+ element.__svelte_meta = { loc: { file: 'src/App.svelte', line: 12, column: 5 } }
142
+ ```
143
+
144
+ to every element in dev, with zero setup, and `@frmwrkd/snag-resolver` reads it directly
145
+ at **tier 1**. Injecting an attribute there would duplicate framework metadata
146
+ that can only ever disagree with it. See `docs/02-BACKEND-SPEC.md` §2.2 and
147
+ `docs/03-RESEARCH-APPENDIX.md` §2.2, technique #3.
148
+
149
+ ### Plain HTML with no build step
150
+
151
+ Not supported at tier 0, by design. It falls to tier 4, where the resolver
152
+ reports a route and a selector, zero candidates, and **no fabricated path**.
153
+
154
+ ---
155
+
156
+ ## Line and column semantics
157
+
158
+ - `line` is **1-based** and points at the `<` of the element's **opening tag**.
159
+ - `col` is **1-based**. Babel reports columns 0-based; this package adds one,
160
+ because the consumer is `vscode://file/{path}:{line}:{col}` and every editor's
161
+ "go to line:column" is 1-based. Vue's parser already reports 1-based columns,
162
+ so both languages emit the same convention.
163
+
164
+ The invariant, which `packages/anchor/test/` asserts against every fixture by
165
+ reading the file back off disk:
166
+
167
+ ```
168
+ sourceLines[line - 1].charAt(col - 1) === '<'
169
+ ```
170
+
171
+ Paths are **repo-relative and POSIX-normalised**, so the same attribute works
172
+ locally, in a container, and on a preview deploy — and so a capture filed on
173
+ Windows still matches a repo map built on Linux. The absolute root is published
174
+ separately, below.
175
+
176
+ ---
177
+
178
+ ## The absolute project root
179
+
180
+ With `devtoolsJson: true` (the default) the Vite dev server also serves:
181
+
182
+ ```
183
+ GET /.well-known/appspecific/com.chrome.devtools.json
184
+ { "workspace": { "root": "/Users/theo/dev/myapp", "uuid": "…" } }
185
+ ```
186
+
187
+ The extension fetches this from the page origin and joins it to the
188
+ repo-relative half, so `src/App.tsx:42:7` becomes a path Claude Code can open.
189
+ It only works on localhost, which is exactly the case that matters.
190
+
191
+ The uuid is a **UUID v5 derived from the absolute root**, not a random one:
192
+ Chrome DevTools binds a workspace folder to it, and a fresh uuid on every
193
+ restart would silently unbind the folder each time the dev server restarts.
194
+
195
+ For bundlers other than Vite, mount the middleware yourself — register it
196
+ **unmounted**, or connect will rewrite `req.url` to `/` and it will swallow the
197
+ index route:
198
+
199
+ ```ts
200
+ import { createDevtoolsJsonMiddleware } from '@frmwrkd/snag-anchor';
201
+ server.middlewares.use(createDevtoolsJsonMiddleware('/abs/project/root'));
202
+ ```
203
+
204
+ ---
205
+
206
+ ## Next.js and Turbopack
207
+
208
+ `create-next-app` on Next 15 defaults `next dev` to Turbopack, and
209
+ `turbopack.rules` maps file extensions to **webpack-shaped loaders**, not to
210
+ unplugin plugins. So the transform also ships as a plain CommonJS loader:
211
+
212
+ ```js
213
+ // next.config.js
214
+ module.exports = {
215
+ turbopack: {
216
+ rules: {
217
+ '*.{jsx,tsx}': { loaders: ['@frmwrkd/snag-anchor/loader'], as: '*.{jsx,tsx}' },
218
+ },
219
+ },
220
+ };
221
+ ```
222
+
223
+ **This integration is unverified.** `docs/03-RESEARCH-APPENDIX.md` §2.7 marks it
224
+ `[U]`, and no primary source confirms an unplugin-based transform can be
225
+ registered through `turbopack.rules`. It has not been run against a real
226
+ Turbopack dev server here. If Turbopack rejects the loader,
227
+ `docs/02-BACKEND-SPEC.md` §2.6 criterion 6 explicitly permits `next dev
228
+ --no-turbo` — which runs the webpack pipeline, where both
229
+ `@frmwrkd/snag-anchor/webpack` and this loader work — with the gap recorded as a known
230
+ limitation.
231
+
232
+ There is no native SWC plugin. A real one would have to be a Rust-compiled
233
+ binary; the loader covers the same ground at the bundler level, for both the
234
+ Turbopack and the webpack pipelines.
235
+
236
+ ---
237
+
238
+ ## What this is NOT built on
239
+
240
+ React 19 **removed** `fiber._debugSource`. `jsxDEV`'s `source` argument was
241
+ dropped in 19.0/19.1 and removed from the signature entirely in 19.2;
242
+ `createElement` explicitly strips `__source`/`__self`; `@babel/preset-react` now
243
+ defaults `developmentSourceSelf` to `false` for exactly that reason.
244
+
245
+ That break took out `click-to-component`,
246
+ `vite-plugin-react-click-to-component`, `react-dev-inspector`, `code-inspector`,
247
+ `radon-ide` and LocatorJS's React path **simultaneously**. This package depends
248
+ on none of them and reads none of those fields. The Babel visitor is written
249
+ from scratch and is about forty lines. See `docs/03-RESEARCH-APPENDIX.md` §2.1
250
+ (marked `[V]` — verified against React's source).
251
+
252
+ ---
253
+
254
+ ## How the transform works
255
+
256
+ Parse, then **splice** — never re-print. `@babel/parser` produces the AST, the
257
+ insertion offsets are computed from it, and the attribute text is inserted into
258
+ the original source with `magic-string`. The file is never handed to a code
259
+ generator.
260
+
261
+ That matters three times over:
262
+
263
+ 1. **Correctness.** Re-printing a `.tsx` file round-trips syntax this plugin has
264
+ no business touching. A splice cannot corrupt what it does not read.
265
+ 2. **Line accuracy.** An insertion that never contains a newline cannot move a
266
+ line — the emitted line number stays true to the original file.
267
+ 3. **Build overhead.** `docs/02-BACKEND-SPEC.md` §2.6 criterion 4 caps overhead
268
+ at 8%. Parse + splice is a fraction of parse + generate.
269
+
270
+ The attribute is inserted **after the last existing attribute**, so a
271
+ `{...props}` spread cannot overwrite the stamp with a value forwarded from a
272
+ parent.
273
+
274
+ A transform that throws breaks the developer's dev server, so every failure path
275
+ here returns the source untouched instead: unparseable file, unsupported
276
+ template language, no filename to derive a path from. The cost of a missed
277
+ anchor is a tier-3 fallback. The cost of a thrown error is somebody's afternoon.
278
+
279
+ `@frmwrkd/snag-anchor/babel` is the same logic as a real Babel `PluginObj`, for wiring
280
+ into a `babel.config.js` (React Native, Storybook, Next's Babel path). Both
281
+ paths share one decision core, and `test/parity.test.ts` asserts they emit
282
+ identical attributes for the same input.
283
+
284
+ ---
285
+
286
+ ## Not yet wired
287
+
288
+ `emitRepoMap` is accepted and carried on the resolved options, but nothing runs
289
+ yet — `@frmwrkd/snag-repomap` is milestone M3 and is not a dependency of this package.
290
+ The option exists so that a user's config does not need to change when it lands.
291
+
292
+ ---
293
+
294
+ ## Tests
295
+
296
+ ```bash
297
+ pnpm --filter @frmwrkd/snag-anchor test
298
+ ```
299
+
300
+ Fixtures live in `fixtures/` (see `fixtures/README.md`). The assertion that
301
+ matters is in `packages/anchor/test/helpers/anchors.ts`: every emitted
302
+ `data-snag` is read back out of the transformed output, the file it names is
303
+ opened at the line and column it names, and the element's opening tag has to
304
+ really be there. A snapshot can lock in an off-by-one for ever; reading the file
305
+ back cannot.
@@ -0,0 +1,42 @@
1
+ /**
2
+ * "Set `data-snag-cmp` to the **nearest enclosing function/component name**"
3
+ * — doc 02 §2.2, rule 2.
4
+ *
5
+ * This is the second of the two rules, kept deliberately separate from the
6
+ * `data-snag` path rule: the path says *where the tag is written*, the
7
+ * component name says *what owns it*. They answer different questions and one
8
+ * can be present without the other (`production: 'minimal'` emits only the
9
+ * name).
10
+ *
11
+ * ## Why the *nearest named* scope and not the nearest scope
12
+ *
13
+ * The common React shape is an anonymous callback inside a component:
14
+ *
15
+ * ```tsx
16
+ * function PricingGrid({ plans }) {
17
+ * return <ul>{plans.map((plan) => <li key={plan.id}>{plan.name}</li>)}</ul>;
18
+ * }
19
+ * ```
20
+ *
21
+ * The `<li>`'s nearest enclosing function is the anonymous arrow passed to
22
+ * `.map()`. Reporting `""` there would be useless, so an unnameable scope is
23
+ * skipped and the walk continues outward — `<li>` reports `PricingGrid`.
24
+ *
25
+ * ## Why this takes an ancestor array rather than a Babel `NodePath`
26
+ *
27
+ * There are two call sites — the string-splicing transform (which walks the AST
28
+ * itself and keeps its own stack) and the real Babel plugin (which has
29
+ * `path.parentPath`). Both can produce a plain array of ancestor nodes, so the
30
+ * naming logic lives here once and cannot drift between the two paths.
31
+ */
32
+ import type { Node } from '@babel/types';
33
+ /**
34
+ * @param ancestors Ancestors of the JSX element, **outermost first** — index 0
35
+ * is `File`, the last entry is the element's direct parent. Scanned from the
36
+ * end, so "nearest" means "highest index".
37
+ * @returns The nearest enclosing nameable function/class scope, or `undefined`
38
+ * when there genuinely isn't one. `undefined` means *no attribute is
39
+ * emitted*: a missing name is honest, an invented one is not (doc 00 §3).
40
+ */
41
+ export declare function resolveComponentName(ancestors: readonly Node[]): string | undefined;
42
+ //# sourceMappingURL=component-name.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"component-name.d.ts","sourceRoot":"","sources":["../../src/babel/component-name.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GA8BG;AACH,OAAO,KAAK,EAAE,IAAI,EAAE,MAAM,cAAc,CAAC;AAEzC;;;;;;;GAOG;AACH,wBAAgB,oBAAoB,CAAC,SAAS,EAAE,SAAS,IAAI,EAAE,GAAG,MAAM,GAAG,SAAS,CAQnF"}
@@ -0,0 +1,107 @@
1
+ /**
2
+ * @param ancestors Ancestors of the JSX element, **outermost first** — index 0
3
+ * is `File`, the last entry is the element's direct parent. Scanned from the
4
+ * end, so "nearest" means "highest index".
5
+ * @returns The nearest enclosing nameable function/class scope, or `undefined`
6
+ * when there genuinely isn't one. `undefined` means *no attribute is
7
+ * emitted*: a missing name is honest, an invented one is not (doc 00 §3).
8
+ */
9
+ export function resolveComponentName(ancestors) {
10
+ for (let i = ancestors.length - 1; i >= 0; i -= 1) {
11
+ const node = ancestors[i];
12
+ if (node === undefined)
13
+ continue;
14
+ const name = nameOfScope(node, ancestors, i);
15
+ if (name !== undefined && name !== '')
16
+ return name;
17
+ }
18
+ return undefined;
19
+ }
20
+ function nameOfScope(node, ancestors, index) {
21
+ switch (node.type) {
22
+ // `function App() {}`, `const App = function App() {}`
23
+ case 'FunctionDeclaration':
24
+ case 'FunctionExpression':
25
+ return node.id?.name ?? nameFromHost(ancestors, index);
26
+ // `const App = () => <div/>`, `memo(() => <div/>)`
27
+ case 'ArrowFunctionExpression':
28
+ return nameFromHost(ancestors, index);
29
+ // A class component. Reached after `ClassMethod` declines to name itself.
30
+ case 'ClassDeclaration':
31
+ case 'ClassExpression':
32
+ return node.id?.name ?? nameFromHost(ancestors, index);
33
+ // `{ renderRow() { return <tr/> } }`
34
+ case 'ObjectMethod':
35
+ return keyName(node.key, node.computed);
36
+ // `render() { return <div/> }` inside a class — the *class* is the
37
+ // component, not the method, so decline and let the walk continue outward.
38
+ case 'ClassMethod':
39
+ case 'ClassPrivateMethod':
40
+ return undefined;
41
+ default:
42
+ return undefined;
43
+ }
44
+ }
45
+ /**
46
+ * Derive a name for an anonymous function from whatever it is attached to,
47
+ * transparently stepping over higher-order wrappers.
48
+ *
49
+ * `const PromoField = memo(forwardRef((props, ref) => <input/>))` must resolve
50
+ * to `PromoField`: the arrow's parent is a `CallExpression`, whose parent is
51
+ * another `CallExpression`, whose parent is the `VariableDeclarator` that
52
+ * actually carries the name. Anything that is not a recognised wrapper stops
53
+ * the search rather than guessing.
54
+ */
55
+ function nameFromHost(ancestors, index) {
56
+ for (let i = index - 1; i >= 0; i -= 1) {
57
+ const parent = ancestors[i];
58
+ if (parent === undefined)
59
+ return undefined;
60
+ switch (parent.type) {
61
+ case 'VariableDeclarator':
62
+ return parent.id.type === 'Identifier' ? parent.id.name : undefined;
63
+ case 'ObjectProperty':
64
+ return keyName(parent.key, parent.computed);
65
+ case 'ClassProperty':
66
+ return keyName(parent.key, parent.computed);
67
+ case 'ClassPrivateProperty':
68
+ return parent.key.id.name;
69
+ case 'AssignmentExpression':
70
+ return assignmentTargetName(parent.left);
71
+ // Higher-order wrappers and type-only nodes: keep walking outward.
72
+ case 'CallExpression':
73
+ case 'NewExpression':
74
+ case 'ParenthesizedExpression':
75
+ case 'TSAsExpression':
76
+ case 'TSSatisfiesExpression':
77
+ case 'TSNonNullExpression':
78
+ case 'TSTypeAssertion':
79
+ case 'TSInstantiationExpression':
80
+ case 'TypeCastExpression':
81
+ continue;
82
+ default:
83
+ return undefined;
84
+ }
85
+ }
86
+ return undefined;
87
+ }
88
+ function keyName(key, computed) {
89
+ if (computed)
90
+ return undefined;
91
+ if (key.type === 'Identifier')
92
+ return key.name;
93
+ if (key.type === 'StringLiteral')
94
+ return key.value;
95
+ if (key.type === 'PrivateName')
96
+ return key.id.name;
97
+ return undefined;
98
+ }
99
+ function assignmentTargetName(left) {
100
+ if (left.type === 'Identifier')
101
+ return left.name;
102
+ if (left.type === 'MemberExpression' && !left.computed && left.property.type === 'Identifier') {
103
+ return left.property.name;
104
+ }
105
+ return undefined;
106
+ }
107
+ //# sourceMappingURL=component-name.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"component-name.js","sourceRoot":"","sources":["../../src/babel/component-name.ts"],"names":[],"mappings":"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"}
@@ -0,0 +1,69 @@
1
+ import type { ParserPlugin } from '@babel/parser';
2
+ import type { JSXOpeningElement, Node } from '@babel/types';
3
+ import type { PluginObj, PluginPass } from '@babel/core';
4
+ import type { AnchorMode } from '../options.js';
5
+ import type { AnchorSourceMap, StampContext, StampSite } from '../stamp.js';
6
+ export type { AnchorAttribute, AnchorSourceMap, StampContext, StampSite } from '../stamp.js';
7
+ export { buildAttributes, escapeAttributeValue } from '../stamp.js';
8
+ /**
9
+ * Babel's own `isCompatTag`: a JSX identifier is an intrinsic host element iff
10
+ * it starts with a lowercase ASCII letter. `<div>` and `<my-widget>` are
11
+ * intrinsic; `<Foo>`, `<_Foo>`, `<Foo.Bar>` and `<svg:circle>` are not.
12
+ */
13
+ export declare function isIntrinsicTagName(name: string): boolean;
14
+ export interface TagInfo {
15
+ tag: string;
16
+ intrinsic: boolean;
17
+ }
18
+ export declare function tagInfo(name: JSXOpeningElement['name']): TagInfo | null;
19
+ export declare function hasAttribute(node: JSXOpeningElement, attribute: string): boolean;
20
+ /**
21
+ * doc 02 §2.2: "Skip if a `data-snag` attribute already exists (user override
22
+ * wins)." Extended to the component attribute too, so `production: 'minimal'`
23
+ * — where `data-snag` is never emitted — respects an override just the same.
24
+ */
25
+ export declare function shouldStamp(node: JSXOpeningElement, ctx: StampContext): boolean;
26
+ export declare function parserPluginsFor(filename: string): ParserPlugin[];
27
+ /**
28
+ * Collect every stamp site in an already-parsed file, in source order.
29
+ *
30
+ * The insertion offset is the end of the **last existing attribute**, or the
31
+ * end of the tag name when there are none. That places the injected attribute
32
+ * last, after any `{...props}` spread, so the stamp always wins: an element
33
+ * that receives a forwarded `data-snag` from a parent still reports the line it
34
+ * is actually written on. It also sidesteps arithmetic on `/>` vs `>`.
35
+ */
36
+ export declare function collectStampSites(ast: Node, ctx: StampContext): StampSite[];
37
+ export interface StampResult {
38
+ code: string;
39
+ map: AnchorSourceMap;
40
+ sites: StampSite[];
41
+ }
42
+ /**
43
+ * Transform a JSX/TSX source string.
44
+ *
45
+ * Returns `null` when nothing was stamped **and** when the file could not be
46
+ * parsed. A source transform that throws breaks the user's dev server; this one
47
+ * fails open and leaves the file exactly as it found it. The cost of a missed
48
+ * anchor is a tier-3 fallback; the cost of a thrown error is a broken build.
49
+ */
50
+ export declare function stampJsx(code: string, ctx: StampContext, filename?: string): StampResult | null;
51
+ export interface SnagBabelPluginOptions {
52
+ attribute?: string;
53
+ componentAttribute?: string;
54
+ intrinsicOnly?: boolean;
55
+ mode?: AnchorMode;
56
+ /** Repo-relative path override. Derived from the filename + `root` when absent. */
57
+ file?: string;
58
+ /** Absolute project root. Defaults to git-root detection from `process.cwd()`. */
59
+ root?: string;
60
+ }
61
+ /**
62
+ * `@frmwrkd/snag-anchor/babel` — usable directly in a `babel.config.js`:
63
+ *
64
+ * ```js
65
+ * plugins: [['@frmwrkd/snag-anchor/babel', { mode: 'full' }]]
66
+ * ```
67
+ */
68
+ export default function snagAnchorBabelPlugin(): PluginObj<PluginPass>;
69
+ //# sourceMappingURL=plugin.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"plugin.d.ts","sourceRoot":"","sources":["../../src/babel/plugin.ts"],"names":[],"mappings":"AAuDA,OAAO,KAAK,EAAE,YAAY,EAAE,MAAM,eAAe,CAAC;AAElD,OAAO,KAAK,EAAE,iBAAiB,EAAE,IAAI,EAAE,MAAM,cAAc,CAAC;AAC5D,OAAO,KAAK,EAAE,SAAS,EAAE,UAAU,EAAE,MAAM,aAAa,CAAC;AAIzD,OAAO,KAAK,EAAE,UAAU,EAAE,MAAM,eAAe,CAAC;AAIhD,OAAO,KAAK,EAAE,eAAe,EAAE,YAAY,EAAE,SAAS,EAAE,MAAM,aAAa,CAAC;AAI5E,YAAY,EAAE,eAAe,EAAE,eAAe,EAAE,YAAY,EAAE,SAAS,EAAE,MAAM,aAAa,CAAC;AAC7F,OAAO,EAAE,eAAe,EAAE,oBAAoB,EAAE,MAAM,aAAa,CAAC;AAIpE;;;;GAIG;AACH,wBAAgB,kBAAkB,CAAC,IAAI,EAAE,MAAM,GAAG,OAAO,CAExD;AAED,MAAM,WAAW,OAAO;IACtB,GAAG,EAAE,MAAM,CAAC;IACZ,SAAS,EAAE,OAAO,CAAC;CACpB;AAED,wBAAgB,OAAO,CAAC,IAAI,EAAE,iBAAiB,CAAC,MAAM,CAAC,GAAG,OAAO,GAAG,IAAI,CAsBvE;AAQD,wBAAgB,YAAY,CAAC,IAAI,EAAE,iBAAiB,EAAE,SAAS,EAAE,MAAM,GAAG,OAAO,CAEhF;AAED;;;;GAIG;AACH,wBAAgB,WAAW,CAAC,IAAI,EAAE,iBAAiB,EAAE,GAAG,EAAE,YAAY,GAAG,OAAO,CAW/E;AAID,wBAAgB,gBAAgB,CAAC,QAAQ,EAAE,MAAM,GAAG,YAAY,EAAE,CAajE;AAED;;;;;;;;GAQG;AACH,wBAAgB,iBAAiB,CAAC,GAAG,EAAE,IAAI,EAAE,GAAG,EAAE,YAAY,GAAG,SAAS,EAAE,CA2B3E;AAED,MAAM,WAAW,WAAW;IAC1B,IAAI,EAAE,MAAM,CAAC;IACb,GAAG,EAAE,eAAe,CAAC;IACrB,KAAK,EAAE,SAAS,EAAE,CAAC;CACpB;AAED;;;;;;;GAOG;AACH,wBAAgB,QAAQ,CAAC,IAAI,EAAE,MAAM,EAAE,GAAG,EAAE,YAAY,EAAE,QAAQ,CAAC,EAAE,MAAM,GAAG,WAAW,GAAG,IAAI,CA6B/F;AAID,MAAM,WAAW,sBAAsB;IACrC,SAAS,CAAC,EAAE,MAAM,CAAC;IACnB,kBAAkB,CAAC,EAAE,MAAM,CAAC;IAC5B,aAAa,CAAC,EAAE,OAAO,CAAC;IACxB,IAAI,CAAC,EAAE,UAAU,CAAC;IAClB,mFAAmF;IACnF,IAAI,CAAC,EAAE,MAAM,CAAC;IACd,kFAAkF;IAClF,IAAI,CAAC,EAAE,MAAM,CAAC;CACf;AAeD;;;;;;GAMG;AACH,MAAM,CAAC,OAAO,UAAU,qBAAqB,IAAI,SAAS,CAAC,UAAU,CAAC,CAiDrE"}