@praxisjs/vite-plugin 4.0.6 → 4.2.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 (63) hide show
  1. package/CHANGELOG.md +28 -0
  2. package/dist/decorators.d.ts +34 -1
  3. package/dist/decorators.d.ts.map +1 -1
  4. package/dist/decorators.js +469 -25
  5. package/dist/decorators.js.map +1 -1
  6. package/dist/hmr.d.ts +5 -0
  7. package/dist/hmr.d.ts.map +1 -0
  8. package/dist/hmr.js +106 -0
  9. package/dist/hmr.js.map +1 -0
  10. package/dist/index.d.ts +24 -0
  11. package/dist/index.d.ts.map +1 -1
  12. package/dist/index.js +21 -19
  13. package/dist/index.js.map +1 -1
  14. package/dist/keyed-lists.d.ts +28 -0
  15. package/dist/keyed-lists.d.ts.map +1 -0
  16. package/dist/keyed-lists.js +251 -0
  17. package/dist/keyed-lists.js.map +1 -0
  18. package/dist/libraries.d.ts +56 -0
  19. package/dist/libraries.d.ts.map +1 -0
  20. package/dist/libraries.js +281 -0
  21. package/dist/libraries.js.map +1 -0
  22. package/dist/mapped-output.d.ts +34 -0
  23. package/dist/mapped-output.d.ts.map +1 -0
  24. package/dist/mapped-output.js +119 -0
  25. package/dist/mapped-output.js.map +1 -0
  26. package/dist/templates.d.ts +56 -0
  27. package/dist/templates.d.ts.map +1 -0
  28. package/dist/templates.js +708 -0
  29. package/dist/templates.js.map +1 -0
  30. package/package.json +13 -5
  31. package/src/decorators.ts +496 -27
  32. package/src/hmr.ts +108 -0
  33. package/src/index.ts +39 -26
  34. package/src/keyed-lists.ts +285 -0
  35. package/src/libraries.ts +328 -0
  36. package/src/mapped-output.ts +128 -0
  37. package/src/templates.ts +733 -0
  38. package/dist/__tests__/css-esbuild-error.test.d.ts +0 -2
  39. package/dist/__tests__/css-esbuild-error.test.d.ts.map +0 -1
  40. package/dist/__tests__/css-esbuild-error.test.js +0 -25
  41. package/dist/__tests__/css-esbuild-error.test.js.map +0 -1
  42. package/dist/__tests__/css-tsconfig-error.test.d.ts +0 -2
  43. package/dist/__tests__/css-tsconfig-error.test.d.ts.map +0 -1
  44. package/dist/__tests__/css-tsconfig-error.test.js +0 -39
  45. package/dist/__tests__/css-tsconfig-error.test.js.map +0 -1
  46. package/dist/__tests__/css-tsconfig-no-config.test.d.ts +0 -2
  47. package/dist/__tests__/css-tsconfig-no-config.test.d.ts.map +0 -1
  48. package/dist/__tests__/css-tsconfig-no-config.test.js +0 -38
  49. package/dist/__tests__/css-tsconfig-no-config.test.js.map +0 -1
  50. package/dist/__tests__/css.test.d.ts +0 -2
  51. package/dist/__tests__/css.test.d.ts.map +0 -1
  52. package/dist/__tests__/css.test.js +0 -288
  53. package/dist/__tests__/css.test.js.map +0 -1
  54. package/dist/__tests__/index.test.d.ts +0 -2
  55. package/dist/__tests__/index.test.d.ts.map +0 -1
  56. package/dist/__tests__/index.test.js +0 -301
  57. package/dist/__tests__/index.test.js.map +0 -1
  58. package/src/__tests__/css-esbuild-error.test.ts +0 -39
  59. package/src/__tests__/css-tsconfig-error.test.ts +0 -53
  60. package/src/__tests__/css-tsconfig-no-config.test.ts +0 -52
  61. package/src/__tests__/css.test.ts +0 -381
  62. package/src/__tests__/index.test.ts +0 -351
  63. package/tsconfig.json +0 -11
package/src/index.ts CHANGED
@@ -1,21 +1,57 @@
1
1
  import { praxisjsCSS } from "./css.js";
2
2
  import { decoratorLoweringPlugin } from "./decorators.js";
3
+ import { hmrPlugin, transformHmr } from "./hmr.js";
4
+ import { keyedListsPlugin, transformKeyedLists, type KeyedListsPluginOptions } from "./keyed-lists.js";
5
+ import { Libraries, librariesPlugin, type LibrariesOption, type LibraryOptions } from "./libraries.js";
6
+ import { JSX_FILE, templatesPlugin, transformTemplates, type TemplatesPluginOptions } from "./templates.js";
3
7
 
4
- import type { PluginOption, ViteDevServer } from "vite";
8
+ import type { PluginOption } from "vite";
5
9
 
6
10
  export { praxisjsCSS };
7
11
  export { decoratorLoweringPlugin };
12
+ export { hmrPlugin, transformHmr };
13
+ export { keyedListsPlugin, transformKeyedLists, type KeyedListsPluginOptions };
14
+ export { type LibrariesOption, type LibraryOptions };
15
+ export { templatesPlugin, transformTemplates, type TemplatesPluginOptions };
8
16
 
9
17
  export interface PraxisJSVitePluginOptions {
10
18
  hmr?: boolean;
11
19
  autoImport?: boolean;
20
+ /**
21
+ * Rewrites keyed `{() => list.map((item) => <Row key={…} />)}` into `<For>` so rows are reused
22
+ * instead of rebuilt. `false` turns it off. Default `true`.
23
+ */
24
+ keyedLists?: boolean | KeyedListsPluginOptions;
25
+ /**
26
+ * Compiles JSX trees of host elements into templates cloned with one `cloneNode(true)` per
27
+ * render, binding only their dynamic parts. `false` leaves every element to `jsx()`. Default `true`.
28
+ */
29
+ templates?: boolean | TemplatesPluginOptions;
30
+ /**
31
+ * Dependencies compiled like the app's own code. By default, the packages whose package.json has
32
+ * `"praxisjs": { "optimize": true }`. An object opts packages in (`true`, or where their sources
33
+ * are) or out (`false`) by name; `false` compiles no dependency.
34
+ */
35
+ libraries?: boolean | LibrariesOption;
12
36
  }
13
37
 
14
38
  export function praxisjs(options: PraxisJSVitePluginOptions = {}): PluginOption[] {
15
- const { hmr = true, autoImport = true } = options;
39
+ const { hmr = true, autoImport = true, keyedLists = true, templates = true, libraries = true } = options;
40
+ const registry = libraries === false ? null : new Libraries(libraries === true ? {} : libraries);
41
+ const isLibrary = registry === null ? undefined : (id: string) => registry.isLibraryFile(id);
42
+ const transforms = {
43
+ keyedLists: keyedLists !== false,
44
+ templates: templates === false ? null : (templates === true ? undefined : templates.include) ?? JSX_FILE,
45
+ };
16
46
 
17
47
  return [
18
- decoratorLoweringPlugin(),
48
+ ...(keyedLists === false ? [] : [keyedListsPlugin(keyedLists === true ? {} : keyedLists, isLibrary)]),
49
+ // Before the decorator lowering, which turns class declarations into `let X = (() => …)()`.
50
+ ...(hmr ? [hmrPlugin()] : []),
51
+ decoratorLoweringPlugin(isLibrary),
52
+ ...(templates === false ? [] : [templatesPlugin(templates === true ? {} : templates, isLibrary)]),
53
+ // After the transforms that add imports, so the JSX pragma it adds stays the first line.
54
+ ...(registry === null ? [] : [librariesPlugin(registry, transforms)]),
19
55
  {
20
56
  name: "praxisjs:core",
21
57
  enforce: "pre",
@@ -33,29 +69,6 @@ export function praxisjs(options: PraxisJSVitePluginOptions = {}): PluginOption[
33
69
  return null;
34
70
  },
35
71
  },
36
- ...(hmr
37
- ? [
38
- {
39
- name: "praxisjs:hmr",
40
- enforce: "post" as const,
41
- handleHotUpdate({
42
- file,
43
- server,
44
- }: {
45
- file: string;
46
- server: ViteDevServer;
47
- }) {
48
- if (!file.endsWith(".tsx") && !file.endsWith(".ts")) return;
49
-
50
- server.ws.send({
51
- type: "custom",
52
- event: "praxisjs:component-update",
53
- data: { file },
54
- });
55
- },
56
- },
57
- ]
58
- : []),
59
72
  ];
60
73
  }
61
74
 
@@ -0,0 +1,285 @@
1
+ import ts from "typescript";
2
+
3
+ import { MappedOutput, type SourceMapV3 } from "./mapped-output.js";
4
+
5
+ import type { Plugin } from "vite";
6
+
7
+ export { encodeVlq, type SourceMapV3 } from "./mapped-output.js";
8
+
9
+ const JSX_FILE = /\.[jt]sx(?:$|\?)/;
10
+ const FOR_BINDING = "__PraxisFor";
11
+ const ITEM = "__praxisItem";
12
+ const INDEX = "__praxisIndex";
13
+ const KEY_GLOBALS = new Set(["String", "Number", "JSON", "Math", "undefined"]);
14
+
15
+ interface Match {
16
+ start: number;
17
+ end: number;
18
+ /** Keep the child a function: the parent is a component (it may call it) or a fragment. */
19
+ wrap: boolean;
20
+ receiver: ts.Expression;
21
+ params: readonly ts.ParameterDeclaration[];
22
+ key: ts.Expression;
23
+ element: ts.JsxElement | ts.JsxSelfClosingElement;
24
+ }
25
+
26
+ export interface KeyedListWarning {
27
+ message: string;
28
+ line: number;
29
+ column: number;
30
+ }
31
+
32
+ export interface KeyedListResult {
33
+ code: string;
34
+ map: SourceMapV3 | null;
35
+ warnings: KeyedListWarning[];
36
+ }
37
+
38
+
39
+ function unwrap(node: ts.Expression): ts.Expression {
40
+ let current = node;
41
+ while (ts.isParenthesizedExpression(current)) current = current.expression;
42
+ return current;
43
+ }
44
+
45
+ function rootTag(element: ts.JsxElement | ts.JsxSelfClosingElement): ts.JsxOpeningLikeElement {
46
+ return ts.isJsxElement(element) ? element.openingElement : element;
47
+ }
48
+
49
+ function keyAttribute(element: ts.JsxElement | ts.JsxSelfClosingElement): ts.JsxAttribute | undefined {
50
+ for (const prop of rootTag(element).attributes.properties) {
51
+ if (ts.isJsxAttribute(prop) && ts.isIdentifier(prop.name) && prop.name.text === "key") return prop;
52
+ }
53
+ return undefined;
54
+ }
55
+
56
+ // Only the attributes of the elements the callback can return count: a `key` deeper in a row belongs
57
+ // to a nested list, not to this one.
58
+ function hasKeyAttribute(node: ts.Node): boolean {
59
+ let found = false;
60
+ const visit = (child: ts.Node): void => {
61
+ if (found) return;
62
+ if (ts.isJsxAttribute(child) && ts.isIdentifier(child.name) && child.name.text === "key") {
63
+ found = true;
64
+ return;
65
+ }
66
+ if (ts.isJsxElement(child)) {
67
+ visit(child.openingElement);
68
+ return;
69
+ }
70
+ ts.forEachChild(child, visit);
71
+ };
72
+ visit(node);
73
+ return found;
74
+ }
75
+
76
+ function boundNames(name: ts.BindingName, out: Set<string>): void {
77
+ if (ts.isIdentifier(name)) {
78
+ out.add(name.text);
79
+ return;
80
+ }
81
+ for (const element of name.elements) {
82
+ if (ts.isBindingElement(element)) boundNames(element.name, out);
83
+ }
84
+ }
85
+
86
+ // Identifiers that are references (not property names, not object literal keys).
87
+ function isReference(node: ts.Identifier): boolean {
88
+ const parent = node.parent;
89
+ if (ts.isPropertyAccessExpression(parent) && parent.name === node) return false;
90
+ if (ts.isPropertyAssignment(parent) && parent.name === node) return false;
91
+ return true;
92
+ }
93
+
94
+ // The key function runs untracked, once per item, outside the original arrow: it may only depend
95
+ // on the callback's own parameters, or moving it would change what it computes.
96
+ function keyDependsOnlyOnParams(key: ts.Expression, params: Set<string>): boolean {
97
+ let ok = true;
98
+ const visit = (node: ts.Node): void => {
99
+ if (!ok || ts.isTypeNode(node)) return;
100
+ if (node.kind === ts.SyntaxKind.ThisKeyword) {
101
+ ok = false;
102
+ return;
103
+ }
104
+ if (ts.isIdentifier(node) && isReference(node) && !params.has(node.text) && !KEY_GLOBALS.has(node.text)) {
105
+ ok = false;
106
+ return;
107
+ }
108
+ ts.forEachChild(node, visit);
109
+ };
110
+ visit(key);
111
+ return ok;
112
+ }
113
+
114
+ type Candidate = { match: Match } | { reason: string } | null;
115
+
116
+ // Under an intrinsic element a function child is only ever mounted as a reactive child, so it can
117
+ // become the <For> element itself. A component may call a function child (render props), and
118
+ // fragments treat children differently, so there the rewrite stays a function returning the <For>.
119
+ function isIntrinsicParent(parent: ts.Node): boolean {
120
+ if (!ts.isJsxElement(parent)) return false;
121
+ const tag = parent.openingElement.tagName;
122
+ return ts.isIdentifier(tag) && /^[a-z]/.test(tag.text);
123
+ }
124
+
125
+ function matchList(container: ts.JsxExpression): Candidate {
126
+ const expression = container.expression;
127
+ if (expression === undefined || !ts.isArrowFunction(expression)) return null;
128
+ if (expression.parameters.length > 0 || ts.isBlock(expression.body)) return null;
129
+
130
+ const call = unwrap(expression.body);
131
+ if (!ts.isCallExpression(call) || !ts.isPropertyAccessExpression(call.expression)) return null;
132
+ if (call.expression.name.text !== "map") return null;
133
+ const callback = call.arguments[0] as ts.Expression | undefined;
134
+ if (callback === undefined || !hasKeyAttribute(callback)) return null;
135
+
136
+ if (call.arguments.length !== 1 || call.typeArguments !== undefined) {
137
+ return { reason: "`.map()` is called with more than one argument or with type arguments" };
138
+ }
139
+ if (!ts.isArrowFunction(callback)) {
140
+ return { reason: "the `.map()` callback is not an arrow function" };
141
+ }
142
+ if (callback.modifiers?.some((m) => m.kind === ts.SyntaxKind.AsyncKeyword) === true) {
143
+ return { reason: "the `.map()` callback is async" };
144
+ }
145
+ const params = callback.parameters;
146
+ if (params.length < 1 || params.length > 2 || params.some((p) => p.dotDotDotToken !== undefined || p.initializer !== undefined)) {
147
+ return { reason: "the `.map()` callback must take (item) or (item, index), without defaults or rest parameters" };
148
+ }
149
+ if (ts.isBlock(callback.body)) {
150
+ return { reason: "the `.map()` callback has a block body; return the element directly" };
151
+ }
152
+ const element = unwrap(callback.body);
153
+ if (!ts.isJsxElement(element) && !ts.isJsxSelfClosingElement(element)) {
154
+ return { reason: "the `.map()` callback does not return a single JSX element" };
155
+ }
156
+ const attribute = keyAttribute(element);
157
+ if (attribute === undefined) {
158
+ return { reason: "`key` is not on the element the callback returns" };
159
+ }
160
+ const initializer = attribute.initializer;
161
+ if (initializer === undefined || !ts.isJsxExpression(initializer) || initializer.expression === undefined) {
162
+ return { reason: "`key` is a constant; every row would share it" };
163
+ }
164
+ const names = new Set<string>();
165
+ for (const param of params) boundNames(param.name, names);
166
+ if (!keyDependsOnlyOnParams(initializer.expression, names)) {
167
+ return { reason: "`key` reads something other than the callback's parameters" };
168
+ }
169
+ return {
170
+ match: {
171
+ start: container.getStart(),
172
+ end: container.getEnd(),
173
+ wrap: !isIntrinsicParent(container.parent),
174
+ receiver: call.expression.expression,
175
+ params,
176
+ key: initializer.expression,
177
+ element,
178
+ },
179
+ };
180
+ }
181
+
182
+ // ── rewrite ──────────────────────────────────────────────────────────────────
183
+
184
+ function emitRange(out: MappedOutput, matches: Match[], from: number, to: number, source: ts.SourceFile): void {
185
+ let cursor = from;
186
+ for (const match of matches) {
187
+ if (match.start < cursor || match.end > to) continue;
188
+ out.copy(cursor, match.start);
189
+ emitMatch(out, matches, match, source);
190
+ cursor = match.end;
191
+ }
192
+ out.copy(cursor, to);
193
+ }
194
+
195
+ function emitMatch(out: MappedOutput, matches: Match[], match: Match, source: ts.SourceFile): void {
196
+ const { receiver, params, key, element } = match;
197
+ const paramsStart = params[0].getStart(source);
198
+ const paramsEnd = params[params.length - 1].getEnd();
199
+
200
+ out.insert(`${match.wrap ? "{() => " : ""}<${FOR_BINDING} each={() => (`, match.start);
201
+ out.copy(receiver.getStart(source), receiver.getEnd());
202
+ out.insert(")} by={(", receiver.getEnd());
203
+ out.copy(paramsStart, paramsEnd);
204
+ out.insert(") => (", paramsEnd);
205
+ out.copy(key.getStart(source), key.getEnd());
206
+ out.insert(`)}>{(${ITEM}${params.length > 1 ? `, ${INDEX}` : ""}) => () => { const `, key.getEnd());
207
+ out.copy(params[0].name.getStart(source), params[0].name.getEnd());
208
+ out.insert(` = ${ITEM}(); `, params[0].name.getEnd());
209
+ if (params.length > 1) {
210
+ out.insert("const ", params[1].getStart(source));
211
+ out.copy(params[1].name.getStart(source), params[1].name.getEnd());
212
+ out.insert(` = ${INDEX}(); `, params[1].name.getEnd());
213
+ }
214
+ out.insert("return (", element.getStart(source));
215
+ emitRange(out, matches, element.getStart(source), element.getEnd(), source);
216
+ out.insert(`); }}</${FOR_BINDING}>${match.wrap ? "}" : ""}`, match.end);
217
+ }
218
+
219
+ /**
220
+ * Rewrites `{() => list.map((item) => <Row key={item.id} … />)}` into the equivalent `<For>`, so a
221
+ * keyed `.map()` reuses rows instead of rebuilding the whole list on every change. Each row re-renders
222
+ * on its own when its item, its index (if the callback takes one) or anything else it reads changes,
223
+ * which is what the original arrow did for the whole list. Anything that doesn't match the pattern
224
+ * exactly is left alone; keyed `.map()` calls that can't be rewritten produce a warning.
225
+ */
226
+ export function transformKeyedLists(code: string, fileName: string): KeyedListResult | null {
227
+ if (!code.includes(".map") || !code.includes("key")) return null;
228
+ const kind = fileName.endsWith(".jsx") ? ts.ScriptKind.JSX : ts.ScriptKind.TSX;
229
+ const source = ts.createSourceFile(fileName, code, ts.ScriptTarget.Latest, true, kind);
230
+
231
+ const matches: Match[] = [];
232
+ const warnings: KeyedListWarning[] = [];
233
+ const visit = (node: ts.Node): void => {
234
+ if (ts.isJsxExpression(node) && (ts.isJsxElement(node.parent) || ts.isJsxFragment(node.parent))) {
235
+ const candidate = matchList(node);
236
+ if (candidate !== null && "match" in candidate) matches.push(candidate.match);
237
+ else if (candidate !== null) {
238
+ const { line, character } = source.getLineAndCharacterOfPosition(node.getStart(source));
239
+ warnings.push({
240
+ line: line + 1,
241
+ column: character + 1,
242
+ message:
243
+ `keyed \`.map()\` left as is (${candidate.reason}): the whole list is rebuilt whenever it changes. ` +
244
+ "Use <For each={…} by={…}> from @praxisjs/runtime to keep rows.",
245
+ });
246
+ }
247
+ }
248
+ ts.forEachChild(node, visit);
249
+ };
250
+ visit(source);
251
+
252
+ if (matches.length === 0) return warnings.length > 0 ? { code, map: null, warnings } : null;
253
+
254
+ const out = new MappedOutput(code);
255
+ out.insert(`import { For as ${FOR_BINDING} } from "@praxisjs/runtime";\n`, 0);
256
+ emitRange(out, matches, 0, code.length, source);
257
+ return { ...out.result(fileName), warnings };
258
+ }
259
+
260
+ export interface KeyedListsPluginOptions {
261
+ /** Report keyed `.map()` calls that couldn't be rewritten. Default `true`. */
262
+ warn?: boolean;
263
+ }
264
+
265
+ /** `isLibrary` tells the files of the dependencies compiled like the app's own code. */
266
+ export function keyedListsPlugin(options: KeyedListsPluginOptions = {}, isLibrary?: (id: string) => boolean): Plugin {
267
+ const { warn = true } = options;
268
+ return {
269
+ name: "praxisjs:keyed-lists",
270
+ enforce: "pre",
271
+ transform(code, id) {
272
+ const dependency = id.includes("/node_modules/");
273
+ if (!JSX_FILE.test(id) || (dependency && isLibrary?.(id) !== true)) return null;
274
+ const fileName = id.split("?")[0];
275
+ const result = transformKeyedLists(code, fileName);
276
+ if (result === null) return null;
277
+ // A dependency's code is not the app's to change.
278
+ if (warn && !dependency) {
279
+ for (const w of result.warnings) this.warn(`${fileName}:${String(w.line)}:${String(w.column)} ${w.message}`);
280
+ }
281
+ if (result.map === null) return null;
282
+ return { code: result.code, map: result.map };
283
+ },
284
+ };
285
+ }