@pikku/vite 0.0.0-stage → 0.12.1
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 +21 -0
- package/README.md +22 -2
- package/dist/index.d.ts +6 -0
- package/dist/index.js +4 -0
- package/dist/inject-testids.d.ts +21 -0
- package/dist/inject-testids.js +302 -0
- package/dist/pikku-config.d.ts +16 -0
- package/dist/pikku-config.js +46 -0
- package/dist/pikku-vite.d.ts +17 -0
- package/dist/pikku-vite.js +22 -0
- package/dist/test-ids.d.ts +19 -0
- package/dist/test-ids.js +43 -0
- package/package.json +50 -4
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2021 - present Yasser Fadl and Pikku contributors
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
CHANGED
|
@@ -1,3 +1,23 @@
|
|
|
1
|
-
#
|
|
1
|
+
# @pikku/vite
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
The Vite plugins a Pikku frontend needs, behind one line.
|
|
4
|
+
|
|
5
|
+
```ts
|
|
6
|
+
// vite.config.ts
|
|
7
|
+
import { pikkuVite } from '@pikku/vite'
|
|
8
|
+
|
|
9
|
+
export default defineConfig({
|
|
10
|
+
plugins: [tanstackStart(), pikkuVite(), react()],
|
|
11
|
+
})
|
|
12
|
+
```
|
|
13
|
+
|
|
14
|
+
`pikkuVite()` reads `pikku.config.json` and applies:
|
|
15
|
+
|
|
16
|
+
- `testIds()` — adds `data-testid` anchors to JSX for tests and scenarios. With
|
|
17
|
+
`PIKKUSTUDIO_CHANGES_PANEL=true` it also adds the source anchors Pikku Studio's
|
|
18
|
+
changes panel uses.
|
|
19
|
+
- `crossSiteSession({ envFile })` from `@pikku/better-auth/vite` — in dev only, and only when
|
|
20
|
+
the project has Better Auth (its generated `auth/auth.gen.ts` exists). `envFile` is
|
|
21
|
+
`clientFiles.envFile`.
|
|
22
|
+
|
|
23
|
+
`testIds()` is also exported for apps that want it alone.
|
package/dist/index.d.ts
ADDED
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
export { pikkuVite } from './pikku-vite.js';
|
|
2
|
+
export type { PikkuViteOptions } from './pikku-vite.js';
|
|
3
|
+
export { testIds } from './test-ids.js';
|
|
4
|
+
export { default as injectTestIds } from './inject-testids.js';
|
|
5
|
+
export { readPikkuViteProject, findPikkuConfig } from './pikku-config.js';
|
|
6
|
+
export type { PikkuViteProject } from './pikku-config.js';
|
package/dist/index.js
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
import type { PluginObj, types as BabelTypes } from '@babel/core';
|
|
2
|
+
export interface InjectTestIdsOptions {
|
|
3
|
+
/**
|
|
4
|
+
* Stamp `data-cloud-src="src/routes/app.orders.tsx:42"` beside every testid.
|
|
5
|
+
*
|
|
6
|
+
* Off unless cloud turns it on for the build, because this writes the app's
|
|
7
|
+
* own source layout into markup an end user can read in view-source. It is
|
|
8
|
+
* also the expensive half of the changes panel: one attribute per addressable
|
|
9
|
+
* element, where the panel's own code is one chunk.
|
|
10
|
+
*
|
|
11
|
+
* The path is interned per module — one string constant the elements in that
|
|
12
|
+
* file concatenate their line onto — so a file with forty controls ships its
|
|
13
|
+
* path once rather than forty times.
|
|
14
|
+
*/
|
|
15
|
+
sourceAnchors?: boolean;
|
|
16
|
+
/** Repo root the stamped paths are relative to. Defaults to the build's cwd. */
|
|
17
|
+
root?: string;
|
|
18
|
+
}
|
|
19
|
+
export default function injectTestIds({ types: t }: {
|
|
20
|
+
types: typeof BabelTypes;
|
|
21
|
+
}, options?: InjectTestIdsOptions): PluginObj;
|
|
@@ -0,0 +1,302 @@
|
|
|
1
|
+
import path from 'node:path';
|
|
2
|
+
/**
|
|
3
|
+
* Stamp the addresses browser scenarios use, at compile time.
|
|
4
|
+
*
|
|
5
|
+
* Two rules, because a scenario needs to name a control AND, when the same control appears
|
|
6
|
+
* twice on screen, say which one:
|
|
7
|
+
*
|
|
8
|
+
* - **A control** gets `data-testid` = its i18n MESSAGE KEY. Every other address the app
|
|
9
|
+
* already has is something it is free to change without meaning to: role+accessible-name
|
|
10
|
+
* reads the RENDERED COPY, which here is a paraglide message, so a scenario written
|
|
11
|
+
* against it is really asserting a translated string and breaks on a reworded button or a
|
|
12
|
+
* locale switch. A CSS selector breaks on a redesign. Message TEXT is copy and changes
|
|
13
|
+
* freely; the message KEY is the control's meaning, and only changes when the control's
|
|
14
|
+
* meaning does — at which point a scenario naming the old one SHOULD fail.
|
|
15
|
+
* - **A component's root element** gets `data-testid` = its component name, kebab-cased.
|
|
16
|
+
* Keys are NOT unique — `m.common__save()` is meant to be reused — so the id alone is
|
|
17
|
+
* ambiguous whenever two forms are open at once. That is not a flaw to design out: this
|
|
18
|
+
* app's own console steps already handle testids matching several nodes by filtering
|
|
19
|
+
* (`addon-console.steps.ts`), and pikku's TestIdSelector has `within`/`containing` for
|
|
20
|
+
* exactly this. Scoping only works if the containers are addressable too, which is what
|
|
21
|
+
* this second rule provides — `clicks common__save within entry-form`.
|
|
22
|
+
*
|
|
23
|
+
* Deriving both removes the discipline problem rather than policing it: nothing has to be
|
|
24
|
+
* added to a component, and the ids are identical on every build.
|
|
25
|
+
*/
|
|
26
|
+
/** The shadcn controls, the router's link and the native elements, by tag. */
|
|
27
|
+
const INTERACTIVE = new Set([
|
|
28
|
+
'Button',
|
|
29
|
+
'Link',
|
|
30
|
+
'Input',
|
|
31
|
+
'InputOTP',
|
|
32
|
+
'Textarea',
|
|
33
|
+
'Checkbox',
|
|
34
|
+
'Switch',
|
|
35
|
+
'Slider',
|
|
36
|
+
'Toggle',
|
|
37
|
+
'ToggleGroupItem',
|
|
38
|
+
'RadioGroupItem',
|
|
39
|
+
'SelectTrigger',
|
|
40
|
+
'SelectItem',
|
|
41
|
+
'DropdownMenuItem',
|
|
42
|
+
'DropdownMenuCheckboxItem',
|
|
43
|
+
'DropdownMenuRadioItem',
|
|
44
|
+
'TabsTrigger',
|
|
45
|
+
'AccordionTrigger',
|
|
46
|
+
'CommandItem',
|
|
47
|
+
'button',
|
|
48
|
+
'a',
|
|
49
|
+
'input',
|
|
50
|
+
'textarea',
|
|
51
|
+
'select',
|
|
52
|
+
]);
|
|
53
|
+
/** The elements whose text names the control beside them: `<Label htmlFor="email">`. */
|
|
54
|
+
const LABELS = new Set(['Label', 'FieldLabel', 'FormLabel', 'label']);
|
|
55
|
+
/**
|
|
56
|
+
* Where a control's meaning is written, most specific first.
|
|
57
|
+
*
|
|
58
|
+
* `aria-label` carries it for the icon-only buttons with no visible text — the same
|
|
59
|
+
* attribute a screen reader needs, so a control this cannot address is usually one a screen
|
|
60
|
+
* reader cannot announce either.
|
|
61
|
+
*/
|
|
62
|
+
const LABEL_ATTRIBUTES = ['label', 'aria-label', 'placeholder', 'title'];
|
|
63
|
+
const kebab = (name) => name
|
|
64
|
+
.replace(/([a-z0-9])([A-Z])/g, '$1-$2')
|
|
65
|
+
.replace(/([A-Z]+)([A-Z][a-z])/g, '$1-$2')
|
|
66
|
+
.toLowerCase();
|
|
67
|
+
const isInteractive = (tag) => !!tag && INTERACTIVE.has(tag);
|
|
68
|
+
const tagName = (node) => {
|
|
69
|
+
if (node.type === 'JSXIdentifier')
|
|
70
|
+
return node.name;
|
|
71
|
+
if (node.type === 'JSXMemberExpression') {
|
|
72
|
+
const object = node.object;
|
|
73
|
+
if (object.type !== 'JSXIdentifier')
|
|
74
|
+
return null;
|
|
75
|
+
return `${object.name}.${node.property.name}`;
|
|
76
|
+
}
|
|
77
|
+
return null;
|
|
78
|
+
};
|
|
79
|
+
/**
|
|
80
|
+
* The paraglide message key behind an expression, e.g. `m.entry__save_label()` → the key.
|
|
81
|
+
*
|
|
82
|
+
* Searches rather than matching one shape, because a label is as often `cond ? m.a() : m.b()`
|
|
83
|
+
* as a bare call. The FIRST key wins: across a conditional the branches are one control
|
|
84
|
+
* saying two things, and an id that changed with state could not be addressed at all.
|
|
85
|
+
*/
|
|
86
|
+
function messageKey(node) {
|
|
87
|
+
if (!node)
|
|
88
|
+
return null;
|
|
89
|
+
if (node.type === 'CallExpression') {
|
|
90
|
+
const callee = node.callee;
|
|
91
|
+
if (callee.type === 'MemberExpression' &&
|
|
92
|
+
callee.object.type === 'Identifier' &&
|
|
93
|
+
callee.object.name === 'm' &&
|
|
94
|
+
callee.property.type === 'Identifier') {
|
|
95
|
+
return callee.property.name;
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
const nested = [];
|
|
99
|
+
if (node.type === 'JSXExpressionContainer')
|
|
100
|
+
nested.push(node.expression);
|
|
101
|
+
if (node.type === 'ConditionalExpression')
|
|
102
|
+
nested.push(node.consequent, node.alternate);
|
|
103
|
+
if (node.type === 'LogicalExpression')
|
|
104
|
+
nested.push(node.left, node.right);
|
|
105
|
+
if (node.type === 'TemplateLiteral')
|
|
106
|
+
nested.push(...node.expressions);
|
|
107
|
+
if (node.type === 'CallExpression')
|
|
108
|
+
nested.push(...node.arguments);
|
|
109
|
+
for (const child of nested) {
|
|
110
|
+
const key = messageKey(child);
|
|
111
|
+
if (key)
|
|
112
|
+
return key;
|
|
113
|
+
}
|
|
114
|
+
return null;
|
|
115
|
+
}
|
|
116
|
+
/** A literal `name`/`id` — the stable identity a control has when its label is dynamic. */
|
|
117
|
+
function literalIdentity(attributes) {
|
|
118
|
+
return (literalAttribute(attributes, 'name') ?? literalAttribute(attributes, 'id'));
|
|
119
|
+
}
|
|
120
|
+
function literalAttribute(attributes, wanted) {
|
|
121
|
+
for (const attribute of attributes) {
|
|
122
|
+
if (attribute.type !== 'JSXAttribute')
|
|
123
|
+
continue;
|
|
124
|
+
const attributeName = attribute.name.type === 'JSXIdentifier' ? attribute.name.name : null;
|
|
125
|
+
if (attributeName !== wanted)
|
|
126
|
+
continue;
|
|
127
|
+
if (attribute.value?.type === 'StringLiteral')
|
|
128
|
+
return attribute.value.value;
|
|
129
|
+
}
|
|
130
|
+
return null;
|
|
131
|
+
}
|
|
132
|
+
/**
|
|
133
|
+
* The message key of the label that names this control from beside it, as shadcn forms do:
|
|
134
|
+
* `<Label htmlFor="email">{m.common__email()}</Label>` next to `<Input id="email" />`, in the
|
|
135
|
+
* same parent. A label pointing at a different id belongs to a different control.
|
|
136
|
+
*/
|
|
137
|
+
function siblingLabelKey(element) {
|
|
138
|
+
const parent = element.parentPath.parentPath?.node;
|
|
139
|
+
if (parent?.type !== 'JSXElement')
|
|
140
|
+
return null;
|
|
141
|
+
const id = literalAttribute(element.node.attributes, 'id');
|
|
142
|
+
for (const child of parent.children) {
|
|
143
|
+
if (child.type !== 'JSXElement')
|
|
144
|
+
continue;
|
|
145
|
+
const label = child.openingElement;
|
|
146
|
+
if (!LABELS.has(tagName(label.name) ?? ''))
|
|
147
|
+
continue;
|
|
148
|
+
const target = literalAttribute(label.attributes, 'htmlFor');
|
|
149
|
+
if (target && id && target !== id)
|
|
150
|
+
continue;
|
|
151
|
+
for (const text of child.children) {
|
|
152
|
+
const key = messageKey(text);
|
|
153
|
+
if (key)
|
|
154
|
+
return key;
|
|
155
|
+
}
|
|
156
|
+
}
|
|
157
|
+
return null;
|
|
158
|
+
}
|
|
159
|
+
export default function injectTestIds({ types: t }, options = {}) {
|
|
160
|
+
const anchors = options.sourceAnchors === true;
|
|
161
|
+
const root = options.root ?? process.cwd();
|
|
162
|
+
let anchorIdentifier = null;
|
|
163
|
+
let anchorPath = null;
|
|
164
|
+
let anchorUsed = false;
|
|
165
|
+
const attributeName = (attribute) => attribute.type === 'JSXAttribute' && attribute.name.type === 'JSXIdentifier'
|
|
166
|
+
? attribute.name.name
|
|
167
|
+
: null;
|
|
168
|
+
/**
|
|
169
|
+
* Whether this element is already spoken for. A hand-written id always wins — it is how a
|
|
170
|
+
* list row carries the id of the record it shows, which no static rule could derive. A
|
|
171
|
+
* spread may carry one, and merging into it would need a runtime helper for a case that is
|
|
172
|
+
* already addressable, so leave those to the author too.
|
|
173
|
+
*/
|
|
174
|
+
const claimed = (element) => element.attributes.some((attribute) => attributeName(attribute) === 'data-testid' ||
|
|
175
|
+
attribute.type === 'JSXSpreadAttribute');
|
|
176
|
+
const stamp = (element, value) => {
|
|
177
|
+
element.attributes.push(t.jsxAttribute(t.jsxIdentifier('data-testid'), t.stringLiteral(value)));
|
|
178
|
+
};
|
|
179
|
+
/**
|
|
180
|
+
* Anchors go on elements that are ALREADY claimed by a hand-written testid too
|
|
181
|
+
* — knowing an element's name and knowing where its JSX lives are different
|
|
182
|
+
* questions, and the author answering the first does not answer the second.
|
|
183
|
+
*/
|
|
184
|
+
const anchor = (element) => {
|
|
185
|
+
if (!anchors || !anchorIdentifier)
|
|
186
|
+
return;
|
|
187
|
+
const line = element.loc?.start.line;
|
|
188
|
+
if (!line)
|
|
189
|
+
return;
|
|
190
|
+
if (element.attributes.some((attribute) => attributeName(attribute) === 'data-cloud-src')) {
|
|
191
|
+
return;
|
|
192
|
+
}
|
|
193
|
+
anchorUsed = true;
|
|
194
|
+
element.attributes.push(t.jsxAttribute(t.jsxIdentifier('data-cloud-src'), t.jsxExpressionContainer(t.binaryExpression('+', t.cloneNode(anchorIdentifier), t.stringLiteral(`:${line}`)))));
|
|
195
|
+
};
|
|
196
|
+
/** A component is a PascalCase function — the same rule React itself uses to tell them apart. */
|
|
197
|
+
const componentName = (path) => {
|
|
198
|
+
const node = path.node;
|
|
199
|
+
if (node.type === 'FunctionDeclaration' && node.id) {
|
|
200
|
+
return /^[A-Z]/.test(node.id.name) ? node.id.name : null;
|
|
201
|
+
}
|
|
202
|
+
const parent = path.parent;
|
|
203
|
+
if (parent.type === 'VariableDeclarator' &&
|
|
204
|
+
parent.id.type === 'Identifier') {
|
|
205
|
+
return /^[A-Z]/.test(parent.id.name) ? parent.id.name : null;
|
|
206
|
+
}
|
|
207
|
+
return null;
|
|
208
|
+
};
|
|
209
|
+
return {
|
|
210
|
+
name: 'pikku-inject-testids',
|
|
211
|
+
visitor: {
|
|
212
|
+
Program: {
|
|
213
|
+
enter(program, state) {
|
|
214
|
+
anchorIdentifier = null;
|
|
215
|
+
anchorPath = null;
|
|
216
|
+
anchorUsed = false;
|
|
217
|
+
if (!anchors || !state.filename)
|
|
218
|
+
return;
|
|
219
|
+
anchorPath = path
|
|
220
|
+
.relative(root, state.filename)
|
|
221
|
+
.split(path.sep)
|
|
222
|
+
.join('/');
|
|
223
|
+
anchorIdentifier = program.scope.generateUidIdentifier('cloudSrc');
|
|
224
|
+
},
|
|
225
|
+
exit(program) {
|
|
226
|
+
if (!anchorUsed || !anchorIdentifier || !anchorPath)
|
|
227
|
+
return;
|
|
228
|
+
program.unshiftContainer('body', t.variableDeclaration('const', [
|
|
229
|
+
t.variableDeclarator(anchorIdentifier, t.stringLiteral(anchorPath)),
|
|
230
|
+
]));
|
|
231
|
+
},
|
|
232
|
+
},
|
|
233
|
+
JSXOpeningElement(path) {
|
|
234
|
+
if (!isInteractive(tagName(path.node.name)))
|
|
235
|
+
return;
|
|
236
|
+
anchor(path.node);
|
|
237
|
+
if (claimed(path.node))
|
|
238
|
+
return;
|
|
239
|
+
let key = null;
|
|
240
|
+
for (const wanted of LABEL_ATTRIBUTES) {
|
|
241
|
+
for (const attribute of path.node.attributes) {
|
|
242
|
+
if (attributeName(attribute) !== wanted ||
|
|
243
|
+
attribute.type !== 'JSXAttribute')
|
|
244
|
+
continue;
|
|
245
|
+
key = messageKey(attribute.value);
|
|
246
|
+
if (key)
|
|
247
|
+
break;
|
|
248
|
+
}
|
|
249
|
+
if (key)
|
|
250
|
+
break;
|
|
251
|
+
}
|
|
252
|
+
// A button says what it does in its children rather than a label prop.
|
|
253
|
+
if (!key && path.parentPath.node.type === 'JSXElement') {
|
|
254
|
+
for (const child of path.parentPath.node.children) {
|
|
255
|
+
key = messageKey(child);
|
|
256
|
+
if (key)
|
|
257
|
+
break;
|
|
258
|
+
}
|
|
259
|
+
}
|
|
260
|
+
key ??= siblingLabelKey(path);
|
|
261
|
+
key ??= literalIdentity(path.node.attributes);
|
|
262
|
+
// No key, no id — DELIBERATELY. The alternative is a positional fallback
|
|
263
|
+
// (`today-button-3`) that shifts the moment a control is added above it, turning
|
|
264
|
+
// every scenario using it into a flake. A control with no message key and no `name`
|
|
265
|
+
// has no stable identity to expose, and the fix belongs upstream: give it an
|
|
266
|
+
// `aria-label`, which it needs for a screen reader anyway.
|
|
267
|
+
if (key)
|
|
268
|
+
stamp(path.node, key);
|
|
269
|
+
},
|
|
270
|
+
Function(path) {
|
|
271
|
+
const name = componentName(path);
|
|
272
|
+
if (!name)
|
|
273
|
+
return;
|
|
274
|
+
const id = kebab(name);
|
|
275
|
+
// Every element this component can RETURN, so a component that renders a loading
|
|
276
|
+
// state and a loaded one is addressable in both. Returns inside a nested function
|
|
277
|
+
// belong to that function (a render prop, a map callback), not to this component.
|
|
278
|
+
path.traverse({
|
|
279
|
+
Function(inner) {
|
|
280
|
+
inner.skip();
|
|
281
|
+
},
|
|
282
|
+
ReturnStatement(statement) {
|
|
283
|
+
const returned = statement.node.argument;
|
|
284
|
+
if (returned?.type !== 'JSXElement')
|
|
285
|
+
return;
|
|
286
|
+
anchor(returned.openingElement);
|
|
287
|
+
if (claimed(returned.openingElement))
|
|
288
|
+
return;
|
|
289
|
+
// A component whose root IS a control (a lone `<Select>` wrapped in nothing)
|
|
290
|
+
// keeps its OWN key — the more specific address, and there is nothing inside it
|
|
291
|
+
// to scope anyway. Without this the container rule would win purely because
|
|
292
|
+
// babel reaches the function before its JSX, and the control would be
|
|
293
|
+
// addressable only by the name of the component wrapping it.
|
|
294
|
+
if (isInteractive(tagName(returned.openingElement.name)))
|
|
295
|
+
return;
|
|
296
|
+
stamp(returned.openingElement, id);
|
|
297
|
+
},
|
|
298
|
+
});
|
|
299
|
+
},
|
|
300
|
+
},
|
|
301
|
+
};
|
|
302
|
+
}
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
/** What the Vite plugins need to know about the project, read from `pikku.config.json`. */
|
|
2
|
+
export interface PikkuViteProject {
|
|
3
|
+
/** Directory holding `pikku.config.json`. */
|
|
4
|
+
configDir: string;
|
|
5
|
+
/** True when the project declares Better Auth, i.e. its generated auth wiring exists. */
|
|
6
|
+
betterAuth: boolean;
|
|
7
|
+
/** Absolute path of the generated `env.gen.ts` exporting `apiUrl()`, when configured. */
|
|
8
|
+
envFile?: string;
|
|
9
|
+
}
|
|
10
|
+
/** Walk up from `from` to the nearest `pikku.config.json`. */
|
|
11
|
+
export declare const findPikkuConfig: (from: string) => string | undefined;
|
|
12
|
+
/**
|
|
13
|
+
* Read the project's `pikku.config.json`, found from `from` upward. A project
|
|
14
|
+
* with none gets the defaults: test ids only.
|
|
15
|
+
*/
|
|
16
|
+
export declare const readPikkuViteProject: (from?: string) => PikkuViteProject | undefined;
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
import { existsSync, readFileSync } from 'node:fs';
|
|
2
|
+
import { dirname, isAbsolute, join, resolve } from 'node:path';
|
|
3
|
+
/** Walk up from `from` to the nearest `pikku.config.json`. */
|
|
4
|
+
export const findPikkuConfig = (from) => {
|
|
5
|
+
let dir = resolve(from);
|
|
6
|
+
for (;;) {
|
|
7
|
+
const candidate = join(dir, 'pikku.config.json');
|
|
8
|
+
if (existsSync(candidate))
|
|
9
|
+
return candidate;
|
|
10
|
+
const parent = dirname(dir);
|
|
11
|
+
if (parent === dir)
|
|
12
|
+
return undefined;
|
|
13
|
+
dir = parent;
|
|
14
|
+
}
|
|
15
|
+
};
|
|
16
|
+
const absolute = (base, path) => isAbsolute(path) ? path : join(base, path);
|
|
17
|
+
/**
|
|
18
|
+
* Where the CLI writes the Better Auth wiring: `scaffold.pikkuDir`, or `scaffold/`
|
|
19
|
+
* beside the first source directory. Its presence is the project saying it uses
|
|
20
|
+
* Better Auth, the same signal `pikku all` acts on.
|
|
21
|
+
*/
|
|
22
|
+
const authWiringFile = (config, configDir) => {
|
|
23
|
+
const rootDir = absolute(configDir, config.rootDir ?? '.');
|
|
24
|
+
const scaffoldDir = config.scaffold?.pikkuDir
|
|
25
|
+
? absolute(rootDir, config.scaffold.pikkuDir)
|
|
26
|
+
: absolute(rootDir, join(config.srcDirectories?.[0] ?? 'src', 'scaffold'));
|
|
27
|
+
return join(scaffoldDir, 'auth', 'auth.gen.ts');
|
|
28
|
+
};
|
|
29
|
+
/**
|
|
30
|
+
* Read the project's `pikku.config.json`, found from `from` upward. A project
|
|
31
|
+
* with none gets the defaults: test ids only.
|
|
32
|
+
*/
|
|
33
|
+
export const readPikkuViteProject = (from = process.cwd()) => {
|
|
34
|
+
const configPath = findPikkuConfig(from);
|
|
35
|
+
if (!configPath)
|
|
36
|
+
return undefined;
|
|
37
|
+
const configDir = dirname(configPath);
|
|
38
|
+
const config = JSON.parse(readFileSync(configPath, 'utf8'));
|
|
39
|
+
return {
|
|
40
|
+
configDir,
|
|
41
|
+
betterAuth: existsSync(authWiringFile(config, configDir)),
|
|
42
|
+
envFile: config.clientFiles?.envFile
|
|
43
|
+
? absolute(configDir, config.clientFiles.envFile)
|
|
44
|
+
: undefined,
|
|
45
|
+
};
|
|
46
|
+
};
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
import type { PluginOption } from 'vite';
|
|
2
|
+
export interface PikkuViteOptions {
|
|
3
|
+
/** Where to start looking for `pikku.config.json`. Defaults to the directory vite runs in. */
|
|
4
|
+
from?: string;
|
|
5
|
+
}
|
|
6
|
+
/**
|
|
7
|
+
* The Pikku plugins a frontend needs, chosen from `pikku.config.json`:
|
|
8
|
+
*
|
|
9
|
+
* - `testIds()` always — scenarios drive built apps, not only the dev server.
|
|
10
|
+
* - the Better Auth cross-site session relay when the project declares Better
|
|
11
|
+
* Auth (dev server only; see `@pikku/better-auth/vite`).
|
|
12
|
+
*
|
|
13
|
+
* ```ts
|
|
14
|
+
* export default defineConfig({ plugins: [tanstackStart(), pikkuVite(), react()] })
|
|
15
|
+
* ```
|
|
16
|
+
*/
|
|
17
|
+
export declare const pikkuVite: (options?: PikkuViteOptions) => PluginOption[];
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
import { readPikkuViteProject } from './pikku-config.js';
|
|
2
|
+
import { testIds } from './test-ids.js';
|
|
3
|
+
/**
|
|
4
|
+
* The Pikku plugins a frontend needs, chosen from `pikku.config.json`:
|
|
5
|
+
*
|
|
6
|
+
* - `testIds()` always — scenarios drive built apps, not only the dev server.
|
|
7
|
+
* - the Better Auth cross-site session relay when the project declares Better
|
|
8
|
+
* Auth (dev server only; see `@pikku/better-auth/vite`).
|
|
9
|
+
*
|
|
10
|
+
* ```ts
|
|
11
|
+
* export default defineConfig({ plugins: [tanstackStart(), pikkuVite(), react()] })
|
|
12
|
+
* ```
|
|
13
|
+
*/
|
|
14
|
+
export const pikkuVite = (options = {}) => {
|
|
15
|
+
const project = readPikkuViteProject(options.from);
|
|
16
|
+
const plugins = [testIds()];
|
|
17
|
+
if (project?.betterAuth) {
|
|
18
|
+
// Loaded on demand: a project without Better Auth need not have it installed.
|
|
19
|
+
plugins.push(import('@pikku/better-auth/vite').then(({ crossSiteSession }) => crossSiteSession({ envFile: project.envFile })));
|
|
20
|
+
}
|
|
21
|
+
return plugins;
|
|
22
|
+
};
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
import type { Plugin } from 'vite';
|
|
2
|
+
/**
|
|
3
|
+
* Run the testid stamper over the app's JSX.
|
|
4
|
+
*
|
|
5
|
+
* A standalone pre-transform rather than a `babel` option on `@vitejs/plugin-react`, which
|
|
6
|
+
* mirrors how `apps/console` runs its `data-om` plugin and for the same reason:
|
|
7
|
+
* plugin-react@6 is the rolldown/oxc build and has NO babel option, so a transform hung off
|
|
8
|
+
* that option stops running on the version bump — silently, since the app still builds and
|
|
9
|
+
* only the scenarios notice. Owning the babel pass costs one extra parse of the app's own
|
|
10
|
+
* source in exchange for surviving that.
|
|
11
|
+
*
|
|
12
|
+
* NOT `apply: 'serve'`: scenarios drive the built sandbox app as well as the dev server, so
|
|
13
|
+
* a dev-only stamp would leave every deployed app unaddressable.
|
|
14
|
+
*
|
|
15
|
+
* The source anchors ride along on the same parse, but only when cloud asks
|
|
16
|
+
* for them — they put the app's file layout in view-source, and the panel that
|
|
17
|
+
* reads them is gated on the same flag.
|
|
18
|
+
*/
|
|
19
|
+
export declare function testIds(): Plugin;
|
package/dist/test-ids.js
ADDED
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
import * as babel from '@babel/core';
|
|
2
|
+
import injectTestIds from './inject-testids.js';
|
|
3
|
+
const JSX_FILE = /\.[jt]sx$/;
|
|
4
|
+
/**
|
|
5
|
+
* Run the testid stamper over the app's JSX.
|
|
6
|
+
*
|
|
7
|
+
* A standalone pre-transform rather than a `babel` option on `@vitejs/plugin-react`, which
|
|
8
|
+
* mirrors how `apps/console` runs its `data-om` plugin and for the same reason:
|
|
9
|
+
* plugin-react@6 is the rolldown/oxc build and has NO babel option, so a transform hung off
|
|
10
|
+
* that option stops running on the version bump — silently, since the app still builds and
|
|
11
|
+
* only the scenarios notice. Owning the babel pass costs one extra parse of the app's own
|
|
12
|
+
* source in exchange for surviving that.
|
|
13
|
+
*
|
|
14
|
+
* NOT `apply: 'serve'`: scenarios drive the built sandbox app as well as the dev server, so
|
|
15
|
+
* a dev-only stamp would leave every deployed app unaddressable.
|
|
16
|
+
*
|
|
17
|
+
* The source anchors ride along on the same parse, but only when cloud asks
|
|
18
|
+
* for them — they put the app's file layout in view-source, and the panel that
|
|
19
|
+
* reads them is gated on the same flag.
|
|
20
|
+
*/
|
|
21
|
+
export function testIds() {
|
|
22
|
+
const sourceAnchors = process.env.PIKKUSTUDIO_CHANGES_PANEL === 'true';
|
|
23
|
+
return {
|
|
24
|
+
name: 'pikku:testids',
|
|
25
|
+
enforce: 'pre',
|
|
26
|
+
async transform(code, id) {
|
|
27
|
+
const file = id.split('?')[0];
|
|
28
|
+
if (!file || !JSX_FILE.test(file) || file.includes('/node_modules/'))
|
|
29
|
+
return null;
|
|
30
|
+
const result = await babel.transformAsync(code, {
|
|
31
|
+
filename: file,
|
|
32
|
+
babelrc: false,
|
|
33
|
+
configFile: false,
|
|
34
|
+
sourceMaps: true,
|
|
35
|
+
parserOpts: { plugins: ['jsx', 'typescript'] },
|
|
36
|
+
plugins: [[injectTestIds, { sourceAnchors }]],
|
|
37
|
+
});
|
|
38
|
+
if (!result?.code)
|
|
39
|
+
return null;
|
|
40
|
+
return { code: result.code, map: result.map };
|
|
41
|
+
},
|
|
42
|
+
};
|
|
43
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,52 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@pikku/vite",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"
|
|
5
|
-
|
|
6
|
-
|
|
3
|
+
"version": "0.12.1",
|
|
4
|
+
"repository": {
|
|
5
|
+
"type": "git",
|
|
6
|
+
"url": "git+https://github.com/pikkujs/pikku.git",
|
|
7
|
+
"directory": "packages/frontend/vite"
|
|
8
|
+
},
|
|
9
|
+
"description": "Vite plugins for Pikku frontends — scenario test ids and the Better Auth cross-site session relay, applied from pikku.config.json",
|
|
10
|
+
"author": "yasser.fadl@gmail.com",
|
|
11
|
+
"license": "MIT",
|
|
12
|
+
"type": "module",
|
|
13
|
+
"main": "dist/index.js",
|
|
14
|
+
"module": "dist/index.js",
|
|
15
|
+
"types": "dist/index.d.ts",
|
|
16
|
+
"sideEffects": false,
|
|
17
|
+
"exports": {
|
|
18
|
+
".": {
|
|
19
|
+
"types": "./dist/index.d.ts",
|
|
20
|
+
"import": "./dist/index.js"
|
|
21
|
+
},
|
|
22
|
+
"./package.json": "./package.json"
|
|
23
|
+
},
|
|
24
|
+
"files": [
|
|
25
|
+
"dist",
|
|
26
|
+
"!dist/**/*.tsbuildinfo"
|
|
27
|
+
],
|
|
28
|
+
"scripts": {
|
|
29
|
+
"tsc": "tsc && tsc -p tsconfig.tests.json",
|
|
30
|
+
"build": "bun run clean && tsc -b",
|
|
31
|
+
"test": "bun test --timeout 120000 src",
|
|
32
|
+
"prepublishOnly": "bun run build",
|
|
33
|
+
"clean": "rm -rf dist tsconfig.tsbuildinfo"
|
|
34
|
+
},
|
|
35
|
+
"dependencies": {
|
|
36
|
+
"@babel/core": "^7.26.0"
|
|
37
|
+
},
|
|
38
|
+
"peerDependencies": {
|
|
39
|
+
"@pikku/better-auth": "^0.12.61",
|
|
40
|
+
"vite": "^5 || ^6 || ^7"
|
|
41
|
+
},
|
|
42
|
+
"peerDependenciesMeta": {
|
|
43
|
+
"@pikku/better-auth": {
|
|
44
|
+
"optional": true
|
|
45
|
+
}
|
|
46
|
+
},
|
|
47
|
+
"devDependencies": {
|
|
48
|
+
"@pikku/better-auth": "workspace:*",
|
|
49
|
+
"@types/babel__core": "^7.20.5",
|
|
50
|
+
"vite": "^7.0.0"
|
|
51
|
+
}
|
|
52
|
+
}
|