@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.
- package/LICENSE +19 -0
- package/README.md +305 -0
- package/dist/babel/component-name.d.ts +42 -0
- package/dist/babel/component-name.d.ts.map +1 -0
- package/dist/babel/component-name.js +107 -0
- package/dist/babel/component-name.js.map +1 -0
- package/dist/babel/plugin.d.ts +69 -0
- package/dist/babel/plugin.d.ts.map +1 -0
- package/dist/babel/plugin.js +272 -0
- package/dist/babel/plugin.js.map +1 -0
- package/dist/babel/walk.d.ts +8 -0
- package/dist/babel/walk.d.ts.map +1 -0
- package/dist/babel/walk.js +50 -0
- package/dist/babel/walk.js.map +1 -0
- package/dist/devtools-json.d.ts +45 -0
- package/dist/devtools-json.d.ts.map +1 -0
- package/dist/devtools-json.js +117 -0
- package/dist/devtools-json.js.map +1 -0
- package/dist/esbuild.d.ts +3 -0
- package/dist/esbuild.d.ts.map +1 -0
- package/dist/esbuild.js +11 -0
- package/dist/esbuild.js.map +1 -0
- package/dist/index.d.ts +31 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +90 -0
- package/dist/index.js.map +1 -0
- package/dist/loader.cjs +44 -0
- package/dist/loader.cjs.map +1 -0
- package/dist/loader.d.cts +2 -0
- package/dist/loader.d.cts.map +1 -0
- package/dist/magic-string.d.ts +23 -0
- package/dist/magic-string.d.ts.map +1 -0
- package/dist/magic-string.js +23 -0
- package/dist/magic-string.js.map +1 -0
- package/dist/nuxt.d.ts +14 -0
- package/dist/nuxt.d.ts.map +1 -0
- package/dist/nuxt.js +29 -0
- package/dist/nuxt.js.map +1 -0
- package/dist/options.d.ts +101 -0
- package/dist/options.d.ts.map +1 -0
- package/dist/options.js +122 -0
- package/dist/options.js.map +1 -0
- package/dist/rollup.d.ts +3 -0
- package/dist/rollup.d.ts.map +1 -0
- package/dist/rollup.js +11 -0
- package/dist/rollup.js.map +1 -0
- package/dist/root.d.ts +38 -0
- package/dist/root.d.ts.map +1 -0
- package/dist/root.js +101 -0
- package/dist/root.js.map +1 -0
- package/dist/rspack.d.ts +3 -0
- package/dist/rspack.d.ts.map +1 -0
- package/dist/rspack.js +11 -0
- package/dist/rspack.js.map +1 -0
- package/dist/stamp.d.ts +66 -0
- package/dist/stamp.d.ts.map +1 -0
- package/dist/stamp.js +28 -0
- package/dist/stamp.js.map +1 -0
- package/dist/transform.d.ts +14 -0
- package/dist/transform.d.ts.map +1 -0
- package/dist/transform.js +54 -0
- package/dist/transform.js.map +1 -0
- package/dist/vite.d.ts +3 -0
- package/dist/vite.d.ts.map +1 -0
- package/dist/vite.js +11 -0
- package/dist/vite.js.map +1 -0
- package/dist/vue/transform.d.ts +50 -0
- package/dist/vue/transform.d.ts.map +1 -0
- package/dist/vue/transform.js +188 -0
- package/dist/vue/transform.js.map +1 -0
- package/dist/webpack.d.ts +3 -0
- package/dist/webpack.d.ts.map +1 -0
- package/dist/webpack.js +15 -0
- package/dist/webpack.js.map +1 -0
- 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"}
|