@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/decorators.ts CHANGED
@@ -1,5 +1,7 @@
1
1
  import ts from "typescript";
2
2
 
3
+ import { shiftedLines } from "./mapped-output.js";
4
+
3
5
  import type { Plugin } from "vite";
4
6
 
5
7
  const JS_LIKE_FILE = /\.[cm]?[jt]sx?(?:$|\?)/;
@@ -37,7 +39,7 @@ function hasRealDecorator(code: string, fileName: string): boolean {
37
39
  return found;
38
40
  }
39
41
 
40
- interface RawSourceMap {
42
+ export interface RawSourceMap {
41
43
  version: number;
42
44
  file?: string;
43
45
  sourceRoot?: string;
@@ -55,6 +57,491 @@ function formatDiagnostics(diagnostics: readonly ts.Diagnostic[]): string {
55
57
  });
56
58
  }
57
59
 
60
+ // ── Claimed fields ───────────────────────────────────────────────────────────
61
+ //
62
+ // TypeScript lowers a decorated field `x = v` to a class field `x = __runInitializers(this,
63
+ // _x_initializers, v)` and runs the field's extra initializers (where decorators install their
64
+ // accessors) at the very next step: as the prefix of the next field's initializer, or in the
65
+ // constructor right after the fields. Defining `x` as a data property and then replacing it with
66
+ // an accessor turns every instance into a slow dictionary-mode object in V8. So the field is
67
+ // removed and, at that next step, `__pxField` defines it: through the innermost decorator's
68
+ // `claim` (see CLAIM in @praxisjs/decorators), which installs its property directly, or as a data
69
+ // property when that decorator has none. It then runs the remaining extra initializers. The order
70
+ // of every step is unchanged.
71
+ //
72
+ // The initializer `v` stays where it was, as the body of a symbol-keyed method that the class's
73
+ // first static block takes off the prototype before anything else runs: it keeps the field's
74
+ // scope (a constructor's parameters and locals would shadow names it reads), `this`, `super` and
75
+ // `new.target`, and the output keeps its lines for the source map.
76
+
77
+ const PX_FIELD_HELPER =
78
+ "const __pxClaim = Symbol.for(\"praxisjs.field.claim\"), __pxValue = Symbol(); " +
79
+ "function __pxTake(p, k) { const f = p[k]; delete p[k]; return f; } " +
80
+ "function __pxField(o, k, inits, extras) { " +
81
+ "let v = inits[__pxValue].call(o); " +
82
+ "for (let i = 0; i < inits.length; i++) v = inits[i].call(o, v); " +
83
+ "let i = 0; const claim = extras.length > 0 ? extras[0][__pxClaim] : undefined; " +
84
+ "if (claim !== undefined) { claim.call(o, v); i = 1; } " +
85
+ "else Object.defineProperty(o, k, { value: v, writable: true, enumerable: true, configurable: true }); " +
86
+ "for (; i < extras.length; i++) extras[i].call(o); }";
87
+
88
+ interface FieldInfo {
89
+ name: string;
90
+ extras: string;
91
+ /** The static block that decorates the field. */
92
+ block: ts.ClassStaticBlockDeclaration;
93
+ }
94
+
95
+ function isRunInitializers(node: ts.Node): node is ts.CallExpression {
96
+ return ts.isCallExpression(node) && ts.isIdentifier(node.expression) && node.expression.text === "__runInitializers"
97
+ && node.arguments.length >= 2 && node.arguments[0].kind === ts.SyntaxKind.ThisKeyword && ts.isIdentifier(node.arguments[1]);
98
+ }
99
+
100
+ // `{ kind: "field", name: "x", static: false, private: false, ... }` → { initializers: { name, extras, block } }
101
+ function decoratedFields(cls: ts.ClassLikeDeclaration): Map<string, FieldInfo> {
102
+ const fields = new Map<string, FieldInfo>();
103
+ const visit = (node: ts.Node, owner: ts.ClassStaticBlockDeclaration): void => {
104
+ if (ts.isCallExpression(node) && ts.isIdentifier(node.expression) && node.expression.text === "__esDecorate" && node.arguments.length === 6) {
105
+ const [, , , ctx, inits, extras] = node.arguments;
106
+ if (ts.isObjectLiteralExpression(ctx) && ts.isIdentifier(inits) && ts.isIdentifier(extras)) {
107
+ const prop = (key: string) => ctx.properties.find((p): p is ts.PropertyAssignment =>
108
+ ts.isPropertyAssignment(p) && ts.isIdentifier(p.name) && p.name.text === key)?.initializer;
109
+ const kind = prop("kind");
110
+ const name = prop("name");
111
+ if (kind !== undefined && ts.isStringLiteral(kind) && kind.text === "field" && name !== undefined && ts.isStringLiteral(name)
112
+ && prop("static")?.kind === ts.SyntaxKind.FalseKeyword && prop("private")?.kind === ts.SyntaxKind.FalseKeyword) {
113
+ fields.set(inits.text, { name: name.text, extras: extras.text, block: owner });
114
+ }
115
+ }
116
+ }
117
+ ts.forEachChild(node, (child) => { visit(child, owner); });
118
+ };
119
+ for (const member of cls.members) if (ts.isClassStaticBlockDeclaration(member)) visit(member, member);
120
+ return fields;
121
+ }
122
+
123
+ interface Edit {
124
+ start: number;
125
+ end: number;
126
+ text: string;
127
+ }
128
+
129
+ const newlines = (code: string) => "\n".repeat(code.split("\n").length - 1);
130
+
131
+ /** Rewrites claimable decorated fields of `output` (TypeScript's lowering); null when none. */
132
+ export function claimFields(output: string, fileName: string): string | null {
133
+ if (!output.includes("__esDecorate(")) return null;
134
+ // The output keeps JSX (`jsx: "preserve"`), which the JSX kind also parses in plain JS.
135
+ const source = ts.createSourceFile(fileName, output, ts.ScriptTarget.Latest, true, ts.ScriptKind.JSX);
136
+ const edits: Edit[] = [];
137
+ // Method keys only need to differ within a class: each one is gone from the prototype once
138
+ // the class is defined.
139
+ let keys = 0;
140
+ const text = (node: ts.Node) => output.slice(node.getStart(source), node.getEnd());
141
+ const visitClass = (cls: ts.ClassLikeDeclaration): void => {
142
+ const fields = decoratedFields(cls);
143
+ if (fields.size === 0) return;
144
+ // extras variables of the removed fields
145
+ const removed = new Set<string>();
146
+ let pending: string[] = [];
147
+ const takes: string[] = [];
148
+ // The code that runs the prefix `__runInitializers(this, extras)` of a member: the pending
149
+ // __pxField calls when `extras` belongs to a removed field, else the prefix after them.
150
+ const prefixCode = (call: ts.CallExpression): string[] => {
151
+ const extras = (call.arguments[1] as ts.Identifier).text;
152
+ const calls = pending;
153
+ pending = [];
154
+ return removed.has(extras) ? calls : [...calls, text(call)];
155
+ };
156
+ for (const member of cls.members) {
157
+ if (!ts.isPropertyDeclaration(member) || member.initializer === undefined) continue;
158
+ if (member.modifiers?.some((m) => m.kind === ts.SyntaxKind.StaticKeyword)) continue;
159
+ // `(prefix, ...value)`, `prefix` alone (a field with no initializer, after a decorated one) or
160
+ // `value`. A comma list nests to the left, so its first item is the innermost left operand.
161
+ const whole = member.initializer;
162
+ const items: ts.Expression[] = [];
163
+ const inner = ts.isParenthesizedExpression(whole) ? whole.expression : whole;
164
+ const flatten = (e: ts.Expression): void => {
165
+ if (ts.isBinaryExpression(e) && e.operatorToken.kind === ts.SyntaxKind.CommaToken) {
166
+ flatten(e.left);
167
+ items.push(e.right);
168
+ } else {
169
+ items.push(e);
170
+ }
171
+ };
172
+ flatten(inner);
173
+ let prefix: ts.CallExpression | null = null;
174
+ if (isRunInitializers(items[0]) && items[0].arguments.length === 2 && (items.length > 1 || inner === whole)) {
175
+ prefix = items[0];
176
+ items.shift();
177
+ }
178
+ const value = items.length === 1 ? items[0] : null;
179
+ // A computed key may carry the evaluation of decorators (TypeScript only puts them in keys
180
+ // that are not literals, and those fields have no literal name to claim them by).
181
+ const literalKey = !ts.isComputedPropertyName(member.name) || ts.isLiteralExpression(member.name.expression);
182
+ const info = literalKey && value !== null && isRunInitializers(value) && value.arguments.length === 3
183
+ ? fields.get((value.arguments[1] as ts.Identifier).text)
184
+ : undefined;
185
+ if (info !== undefined) {
186
+ const call = value as ts.CallExpression;
187
+ const inits = (call.arguments[1] as ts.Identifier).text;
188
+ const initial = call.arguments[2];
189
+ const key = `__pxK${String(takes.length)}`;
190
+ pending = [...(prefix === null ? pending : prefixCode(prefix)), `__pxField(this, ${JSON.stringify(info.name)}, ${inits}, ${info.extras})`];
191
+ removed.add(info.extras);
192
+ takes.push(`${inits}[__pxValue] = __pxTake(this.prototype, ${key});`);
193
+ const head = output.slice(member.getStart(source), initial.getStart(source));
194
+ const tail = output.slice(initial.getEnd(), member.getEnd());
195
+ edits.push(
196
+ { start: member.getStart(source), end: initial.getStart(source), text: `[${key}]() {${newlines(head)} return ` },
197
+ { start: initial.getEnd(), end: member.getEnd(), text: `;${newlines(tail)} }` },
198
+ );
199
+ continue;
200
+ }
201
+ // A removed field's call waits in `pending` until the prefix that runs its extras.
202
+ if (pending.length === 0) continue;
203
+ if (prefix !== null) {
204
+ edits.push({ start: prefix.getStart(source), end: prefix.getEnd(), text: `(${prefixCode(prefix).join(", ")})` });
205
+ } else {
206
+ edits.push(
207
+ { start: whole.getStart(source), end: whole.getStart(source), text: `(${pending.splice(0).join(", ")}, ` },
208
+ { start: whole.getEnd(), end: whole.getEnd(), text: ")" },
209
+ );
210
+ }
211
+ }
212
+ if (takes.length === 0) return;
213
+ keys = Math.max(keys, takes.length);
214
+ const open = [...fields.values()][0].block.body.getStart(source) + 1;
215
+ edits.push({ start: open, end: open, text: ` ${takes.join(" ")}` });
216
+ if (pending.length === 0) return;
217
+ // The last removed field's step is the constructor's `__runInitializers(this, extras);`.
218
+ const ctor = cls.members.find((m): m is ts.ConstructorDeclaration => ts.isConstructorDeclaration(m));
219
+ const statement = ctor?.body?.statements.find((st) => ts.isExpressionStatement(st) && isRunInitializers(st.expression)
220
+ && st.expression.arguments.length === 2 && removed.has((st.expression.arguments[1] as ts.Identifier).text));
221
+ if (statement === undefined) {
222
+ throw new Error(`[praxisjs:decorators] ${fileName}: could not find where the fields of a class finish initializing`);
223
+ }
224
+ edits.push({ start: statement.getStart(source), end: statement.getEnd(), text: `${pending.join("; ")};` });
225
+ };
226
+ const visit = (node: ts.Node): void => {
227
+ if (ts.isClassDeclaration(node) || ts.isClassExpression(node)) visitClass(node);
228
+ ts.forEachChild(node, visit);
229
+ };
230
+ visit(source);
231
+ if (edits.length === 0) return null;
232
+ let result = output;
233
+ for (const edit of edits.sort((a, b) => b.start - a.start)) {
234
+ result = result.slice(0, edit.start) + edit.text + result.slice(edit.end);
235
+ }
236
+ const symbols = Array.from({ length: keys }, (_, i) => `__pxK${String(i)} = Symbol()`).join(", ");
237
+ return `${PX_FIELD_HELPER} const ${symbols};\n${result}`;
238
+ }
239
+
240
+ /** Lines the claimed-fields helper adds in front of the output, for the source map: it is one line. */
241
+ export const PX_FIELD_HELPER_LINES = 1;
242
+
243
+ // ── Shared helpers ───────────────────────────────────────────────────────────
244
+ //
245
+ // TypeScript writes the helpers its decorator lowering calls (`__esDecorate`, `__runInitializers`,
246
+ // ...) at the top of every file it lowers, and claimed fields add theirs: the same kilobyte again in
247
+ // every decorated module of an app, and in every decorated file of a library built with tsc. One
248
+ // copy of each is served from a virtual module instead, and a definition is replaced with an import
249
+ // of it only when it is exactly the one TypeScript writes (read from TypeScript itself, so a version
250
+ // that writes a helper differently keeps its own).
251
+
252
+ /** The module the lowered files import their helpers from. */
253
+ export const HELPERS_ID = "virtual:praxisjs/decorators";
254
+ const RESOLVED_HELPERS_ID = `\0${HELPERS_ID}`;
255
+
256
+ const PX_HELPER_NAMES = ["__pxField", "__pxTake", "__pxValue"];
257
+
258
+ // Every kind of decorated member, so the lowering of this module calls every helper there is.
259
+ const HELPERS_PROBE = `const d = () => undefined;
260
+ @d export class A {
261
+ @d static s = 1; @d f = 1; @d accessor a = 1; @d static accessor b = 1; @d m() {} @d get g() { return 1; } @d set g(v) {}
262
+ @d #p = 1; @d accessor #q = 1; @d #m() {} @d get #g() { return 1; } @d set #g(v) {} @d static #s() {} @d [Symbol.iterator]() {}
263
+ }
264
+ export const B = @d class {};
265
+ export default @d class {}`;
266
+
267
+ function lowerDecorators(code: string, fileName: string): ts.TranspileOutput {
268
+ return ts.transpileModule(code, {
269
+ fileName,
270
+ reportDiagnostics: true,
271
+ compilerOptions: {
272
+ target: ts.ScriptTarget.ES2022,
273
+ module: ts.ModuleKind.ESNext,
274
+ useDefineForClassFields: true,
275
+ jsx: ts.JsxEmit.Preserve,
276
+ sourceMap: true,
277
+ },
278
+ });
279
+ }
280
+
281
+ interface TsHelper {
282
+ name: string;
283
+ start: number;
284
+ end: number;
285
+ /** The function the helper is defined as. */
286
+ fn: string;
287
+ }
288
+
289
+ // `var __x = (this && this.__x) || function (...) { ... };` at the top level.
290
+ function tsHelpers(code: string, source: ts.SourceFile): TsHelper[] {
291
+ const helpers: TsHelper[] = [];
292
+ for (const statement of source.statements) {
293
+ if (!ts.isVariableStatement(statement) || statement.declarationList.declarations.length !== 1) continue;
294
+ const { name, initializer } = statement.declarationList.declarations[0];
295
+ if (!ts.isIdentifier(name) || initializer === undefined || !ts.isBinaryExpression(initializer)) continue;
296
+ if (initializer.operatorToken.kind !== ts.SyntaxKind.BarBarToken) continue;
297
+ const { left, right } = initializer;
298
+ if (code.slice(left.getStart(source), left.getEnd()) !== `(this && this.${name.text})`) continue;
299
+ helpers.push({ name: name.text, start: statement.getStart(source), end: statement.getEnd(), fn: code.slice(right.getStart(source), right.getEnd()) });
300
+ }
301
+ return helpers;
302
+ }
303
+
304
+ let knownHelpers: Map<string, string> | null = null;
305
+
306
+ function typescriptHelpers(): Map<string, string> {
307
+ if (knownHelpers === null) {
308
+ const output = lowerDecorators(HELPERS_PROBE, "/praxisjs-helpers.ts").outputText;
309
+ const source = ts.createSourceFile("/praxisjs-helpers.js", output, ts.ScriptTarget.Latest, true, ts.ScriptKind.JS);
310
+ knownHelpers = new Map(tsHelpers(output, source).map((helper) => [helper.name, helper.fn]));
311
+ }
312
+ return knownHelpers;
313
+ }
314
+
315
+ // ── Compact decoration calls ─────────────────────────────────────────────────
316
+ //
317
+ // Each `__esDecorate(...)` call TypeScript writes carries its whole context object literal, access
318
+ // functions included: about 200 bytes per decorated member once minified, and `__esDecorate` itself
319
+ // handles every kind of member. For the common members (public fields and methods with a literal
320
+ // name, and the class itself) the call becomes one of the helpers below, which build the very same
321
+ // context objects (same keys in the same order, same access functions, a fresh copy per decorator)
322
+ // and apply the results the way `__esDecorate` does for that kind. Any other call is left as it is.
323
+
324
+ const PX_DECORATE_HELPER =
325
+ "function __pxAccept(f) { if (f !== void 0 && typeof f !== \"function\") throw new TypeError(\"Function expected\"); return f; } " +
326
+ "function __pxAddInit(context, state, extras) { context.addInitializer = function (f) { " +
327
+ "if (state.done) throw new TypeError(\"Cannot add initializers after decoration has completed\"); " +
328
+ "extras.push(__pxAccept(f || null)); }; return context; } " +
329
+ "function __pxDecorateField(decorators, name, isStatic, metadata, initializers, extras) { " +
330
+ "const state = { done: false }, has = (obj) => name in obj, get = (obj) => obj[name], set = (obj, value) => { obj[name] = value; }; " +
331
+ "for (let i = decorators.length - 1; i >= 0; i--) { " +
332
+ "const result = __pxAccept((0, decorators[i])(void 0, __pxAddInit({ kind: \"field\", name, static: isStatic, private: false, access: { has, get, set }, metadata }, state, extras))); " +
333
+ "if (result) initializers.unshift(result); } " +
334
+ "state.done = true; } " +
335
+ "function __pxDecorateMethod(ctor, decorators, name, isStatic, metadata, extras) { " +
336
+ "const state = { done: false }, has = (obj) => name in obj, get = (obj) => obj[name]; " +
337
+ "const target = isStatic ? ctor : ctor.prototype, descriptor = Object.getOwnPropertyDescriptor(target, name); " +
338
+ "for (let i = decorators.length - 1; i >= 0; i--) { " +
339
+ "const result = __pxAccept((0, decorators[i])(descriptor.value, __pxAddInit({ kind: \"method\", name, static: isStatic, private: false, access: { has, get }, metadata }, state, extras))); " +
340
+ "if (result) descriptor.value = result; } " +
341
+ "Object.defineProperty(target, name, descriptor); state.done = true; } " +
342
+ "function __pxDecorateClass(descriptor, decorators, metadata, extras) { " +
343
+ "const state = { done: false }, name = descriptor.value.name; " +
344
+ "for (let i = decorators.length - 1; i >= 0; i--) { " +
345
+ "const result = __pxAccept((0, decorators[i])(descriptor.value, __pxAddInit({ kind: \"class\", name, metadata }, state, extras))); " +
346
+ "if (result) descriptor.value = result; } " +
347
+ "state.done = true; } " +
348
+ "function __pxMeta(base) { return typeof Symbol === \"function\" && Symbol.metadata " +
349
+ "? Object.create(arguments.length === 0 ? null : base[Symbol.metadata] ?? null) : void 0; } " +
350
+ "function __pxSetMeta(target, metadata) { if (metadata) Object.defineProperty(target, Symbol.metadata, " +
351
+ "{ enumerable: true, configurable: true, writable: true, value: metadata }); }";
352
+
353
+ const PX_DECORATE_NAMES = ["__pxDecorateField", "__pxDecorateMethod", "__pxDecorateClass", "__pxMeta", "__pxSetMeta"];
354
+
355
+ const METADATA_INIT = /^typeof Symbol === "function" && Symbol\.metadata \? Object\.create\((?:([\w$]+)\[Symbol\.metadata\] \?\? null|null)\) : void 0$/;
356
+ const METADATA_SET = /^if \(([\w$]+)\) Object\.defineProperty\(([\w$]+), Symbol\.metadata, \{ enumerable: true, configurable: true, writable: true, value: \1 \}\);$/;
357
+
358
+ function accessCode(name: string, withSet: boolean): string {
359
+ const member = /^[A-Za-z_$][\w$]*$/.test(name) ? `.${name}` : `[${JSON.stringify(name)}]`;
360
+ const set = withSet ? `, set: (obj, value) => { obj${member} = value; }` : "";
361
+ return `{ has: obj => ${JSON.stringify(name)} in obj, get: obj => obj${member}${set} }`;
362
+ }
363
+
364
+ // The context literal's properties, in order, when each is a plain `key: value`.
365
+ function contextProps(node: ts.Expression): Array<[string, ts.Expression]> | null {
366
+ if (!ts.isObjectLiteralExpression(node)) return null;
367
+ const props: Array<[string, ts.Expression]> = [];
368
+ for (const p of node.properties) {
369
+ if (!ts.isPropertyAssignment(p) || !ts.isIdentifier(p.name)) return null;
370
+ props.push([p.name.text, p.initializer]);
371
+ }
372
+ return props;
373
+ }
374
+
375
+ // The compact call for one `__esDecorate(ctor, descriptorIn, decorators, context, initializers, extras)`, or null.
376
+ function compactCall(call: ts.CallExpression, text: (node: ts.Node) => string): [helper: string, code: string] | null {
377
+ const [ctor, descriptorIn, decorators, context, initializers, extras] = call.arguments;
378
+ const props = contextProps(context);
379
+ if (props === null || !ts.isIdentifier(decorators) || !ts.isIdentifier(extras)) return null;
380
+ const keys = props.map(([key]) => key).join();
381
+ const value = (key: string) => (props.find(([k]) => k === key) as [string, ts.Expression])[1];
382
+ const isNull = (node: ts.Node) => node.kind === ts.SyntaxKind.NullKeyword;
383
+ const metadata = keys.endsWith("metadata") ? value("metadata") : null;
384
+ if (metadata === null || !ts.isIdentifier(metadata)) return null;
385
+ const kind = value("kind");
386
+ if (!ts.isStringLiteral(kind)) return null;
387
+
388
+ if (kind.text === "class") {
389
+ const name = value("name");
390
+ if (keys !== "kind,name,metadata" || !isNull(ctor) || !isNull(initializers)) return null;
391
+ // `_classDescriptor = { value: _classThis }` with `name: _classThis.name`.
392
+ if (!ts.isBinaryExpression(descriptorIn) || descriptorIn.operatorToken.kind !== ts.SyntaxKind.EqualsToken) return null;
393
+ const assigned = contextProps(descriptorIn.right);
394
+ if (assigned?.length !== 1 || assigned[0][0] !== "value" || !ts.isIdentifier(assigned[0][1])) return null;
395
+ if (!ts.isPropertyAccessExpression(name) || name.name.text !== "name" || text(name.expression) !== text(assigned[0][1])) return null;
396
+ return ["__pxDecorateClass", `__pxDecorateClass(${text(descriptorIn)}, ${decorators.text}, ${metadata.text}, ${extras.text})`];
397
+ }
398
+
399
+ if (keys !== "kind,name,static,private,access,metadata" || !isNull(descriptorIn)) return null;
400
+ const name = value("name");
401
+ const isStatic = value("static");
402
+ if (!ts.isStringLiteral(name) || value("private").kind !== ts.SyntaxKind.FalseKeyword) return null;
403
+ if (isStatic.kind !== ts.SyntaxKind.TrueKeyword && isStatic.kind !== ts.SyntaxKind.FalseKeyword) return null;
404
+ const literal = JSON.stringify(name.text);
405
+ if (text(name) !== literal) return null;
406
+ if (kind.text === "field") {
407
+ if (!isNull(ctor) || !ts.isIdentifier(initializers) || text(value("access")) !== accessCode(name.text, true)) return null;
408
+ return ["__pxDecorateField", `__pxDecorateField(${decorators.text}, ${literal}, ${text(isStatic)}, ${metadata.text}, ${initializers.text}, ${extras.text})`];
409
+ }
410
+ if (kind.text === "method") {
411
+ if (ctor.kind !== ts.SyntaxKind.ThisKeyword || !isNull(initializers) || text(value("access")) !== accessCode(name.text, false)) return null;
412
+ return ["__pxDecorateMethod", `__pxDecorateMethod(this, ${decorators.text}, ${literal}, ${text(isStatic)}, ${metadata.text}, ${extras.text})`];
413
+ }
414
+ return null;
415
+ }
416
+
417
+ // Edits that turn the calls of `source` into compact ones, and the helpers they use.
418
+ function compactEdits(source: ts.SourceFile, code: string): { edits: Edit[]; names: Set<string> } {
419
+ const edits: Edit[] = [];
420
+ const names = new Set<string>();
421
+ const text = (node: ts.Node) => code.slice(node.getStart(source), node.getEnd());
422
+ const replace = (node: ts.Node, helper: string, replacement: string): void => {
423
+ const start = node.getStart(source);
424
+ names.add(helper);
425
+ edits.push({ start, end: node.getEnd(), text: replacement + newlines(code.slice(start, node.getEnd())) });
426
+ };
427
+ const visit = (node: ts.Node): void => {
428
+ if (ts.isCallExpression(node) && ts.isIdentifier(node.expression) && node.expression.text === "__esDecorate" && node.arguments.length === 6) {
429
+ const compact = compactCall(node, text);
430
+ if (compact !== null) {
431
+ replace(node, compact[0], compact[1]);
432
+ return;
433
+ }
434
+ } else if (ts.isConditionalExpression(node)) {
435
+ const match = METADATA_INIT.exec(text(node));
436
+ if (match !== null) {
437
+ // The base class group is absent from the match of a class with no `extends`.
438
+ const base = match[1] as string | undefined;
439
+ replace(node, "__pxMeta", base === undefined ? "__pxMeta()" : `__pxMeta(${base})`);
440
+ return;
441
+ }
442
+ } else if (ts.isIfStatement(node)) {
443
+ const match = METADATA_SET.exec(text(node));
444
+ if (match !== null) {
445
+ replace(node, "__pxSetMeta", `__pxSetMeta(${match[2]}, ${match[1]});`);
446
+ return;
447
+ }
448
+ }
449
+ ts.forEachChild(node, visit);
450
+ };
451
+ visit(source);
452
+ return { edits, names };
453
+ }
454
+
455
+ /** The source of the virtual module: every helper TypeScript's decorator lowering calls, and PraxisJS's own. */
456
+ export function helpersModule(): string {
457
+ const lines = [...typescriptHelpers()].map(([name, fn]) => `export var ${name} = ${fn};`);
458
+ return `${lines.join("\n")}\n${PX_FIELD_HELPER}\n${PX_DECORATE_HELPER}\nexport { ${[...PX_HELPER_NAMES, ...PX_DECORATE_NAMES].join(", ")} };\n`;
459
+ }
460
+
461
+ /**
462
+ * Replaces the helper definitions of `code` that the virtual module has with one import of them,
463
+ * or returns null when there is none. Every line stays where it was, so a source map of `code`
464
+ * still fits the result.
465
+ */
466
+ export function shareHelpers(code: string, fileName: string, kind = ts.ScriptKind.JSX): string | null {
467
+ const known = typescriptHelpers();
468
+ const names: string[] = [];
469
+ const edits: Edit[] = [];
470
+ if (code.startsWith(PX_FIELD_HELPER)) {
471
+ names.push(...PX_HELPER_NAMES);
472
+ edits.push({ start: 0, end: PX_FIELD_HELPER.length, text: "" });
473
+ }
474
+ const source = ts.createSourceFile(fileName, code, ts.ScriptTarget.Latest, true, kind);
475
+ for (const helper of tsHelpers(code, source)) {
476
+ if (known.get(helper.name) !== helper.fn) continue;
477
+ names.push(helper.name);
478
+ edits.push({ start: helper.start, end: helper.end, text: newlines(code.slice(helper.start, helper.end)) });
479
+ }
480
+ if (names.length === 0) return null;
481
+ // The compact calls stand in for `__esDecorate`, so only a module that imports it gets them.
482
+ if (names.includes("__esDecorate")) {
483
+ const compact = compactEdits(source, code);
484
+ edits.push(...compact.edits);
485
+ names.push(...compact.names);
486
+ }
487
+ edits[0].text = `import { ${names.join(", ")} } from "${HELPERS_ID}";${edits[0].text}`;
488
+ let result = code;
489
+ for (const edit of edits.sort((a, b) => b.start - a.start)) result = result.slice(0, edit.start) + edit.text + result.slice(edit.end);
490
+ return result;
491
+ }
492
+
493
+ // A file of a library built with tsc may hold the same helpers. Only an ES module can take an import.
494
+ function shareCompiledHelpers(code: string, fileName: string): string | null {
495
+ if (!code.includes("(this && this.__") && !code.startsWith(PX_FIELD_HELPER)) return null;
496
+ const kind = scriptKindFor(fileName);
497
+ if (!ts.isExternalModule(ts.createSourceFile(fileName, code, ts.ScriptTarget.Latest, false, kind))) return null;
498
+ return shareHelpers(code, fileName, kind);
499
+ }
500
+
501
+ /**
502
+ * Lowers the decorators of a module, or for a module already lowered by tsc, shares its helpers
503
+ * and, in a library that opts in (`isLibrary`), claims its decorated fields. Null when there is
504
+ * nothing to change.
505
+ */
506
+ export function transformDecorators(
507
+ code: string,
508
+ id: string,
509
+ isLibrary: ((id: string) => boolean) | undefined,
510
+ warn: (message: string) => void,
511
+ ): { code: string; map: RawSourceMap | null } | null {
512
+ if (!JS_LIKE_FILE.test(id)) return null;
513
+ const fileName = cleanFileName(id);
514
+ if (!code.includes("@") || !hasRealDecorator(code, fileName)) {
515
+ // Already lowered, by tsc: a library that opts in gets its fields claimed all the same.
516
+ let claimed: string | null = null;
517
+ if (code.includes("__esDecorate(") && isLibrary?.(id) === true) {
518
+ try {
519
+ claimed = claimFields(code, fileName);
520
+ } catch (error) {
521
+ warn(`${(error as Error).message}; its decorated fields are left as published`);
522
+ }
523
+ }
524
+ const shared = shareCompiledHelpers(claimed ?? code, fileName);
525
+ if (claimed === null) return shared === null ? null : { code: shared, map: null };
526
+ return { code: shared ?? claimed, map: shiftedLines(code, fileName, PX_FIELD_HELPER_LINES) };
527
+ }
528
+
529
+ const { outputText, sourceMapText, diagnostics } = lowerDecorators(code, fileName);
530
+
531
+ const errors = diagnostics?.filter((d) => d.category === ts.DiagnosticCategory.Error) ?? [];
532
+ if (errors.length > 0) {
533
+ throw new Error(`[praxisjs:decorators] ${fileName}:\n${formatDiagnostics(errors)}`);
534
+ }
535
+
536
+ const claimed = claimFields(outputText, fileName);
537
+ const map = sourceMapText ? (JSON.parse(sourceMapText) as RawSourceMap) : null;
538
+ const output = claimed ?? outputText;
539
+ return {
540
+ code: shareHelpers(output, fileName) ?? output,
541
+ map: claimed === null || map === null ? map : { ...map, mappings: ";".repeat(PX_FIELD_HELPER_LINES) + map.mappings },
542
+ };
543
+ }
544
+
58
545
  // oxc doesn't lower TC39 (non-legacy) decorators yet — `@Decorator()`
59
546
  // syntax passes through unchanged and no JS runtime executes it directly:
60
547
  // https://github.com/oxc-project/oxc/issues/9170
@@ -65,7 +552,7 @@ function formatDiagnostics(diagnostics: readonly ts.Diagnostic[]): string {
65
552
  // so its initializer runs after `super()` automatically — no separate
66
553
  // ordering fix-up is needed. JSX is left untouched (`jsx: "preserve"`) for
67
554
  // oxc's own JSX pipeline to handle.
68
- export function decoratorLoweringPlugin(): Plugin {
555
+ export function decoratorLoweringPlugin(isLibrary?: (id: string) => boolean): Plugin {
69
556
  return {
70
557
  name: "praxisjs:decorators",
71
558
  // Must run before Vite's built-in esbuild TS transform: esbuild's TC39
@@ -76,32 +563,14 @@ export function decoratorLoweringPlugin(): Plugin {
76
563
  // below never even sees it, and the field silently ends up `undefined` at
77
564
  // runtime with no build-time error.
78
565
  enforce: "pre",
566
+ resolveId(id) {
567
+ return id === HELPERS_ID ? RESOLVED_HELPERS_ID : null;
568
+ },
569
+ load(id) {
570
+ return id === RESOLVED_HELPERS_ID ? helpersModule() : null;
571
+ },
79
572
  transform(code, id) {
80
- if (!JS_LIKE_FILE.test(id) || !code.includes("@")) return null;
81
- const fileName = cleanFileName(id);
82
- if (!hasRealDecorator(code, fileName)) return null;
83
-
84
- const { outputText, sourceMapText, diagnostics } = ts.transpileModule(code, {
85
- fileName,
86
- reportDiagnostics: true,
87
- compilerOptions: {
88
- target: ts.ScriptTarget.ES2022,
89
- module: ts.ModuleKind.ESNext,
90
- useDefineForClassFields: true,
91
- jsx: ts.JsxEmit.Preserve,
92
- sourceMap: true,
93
- },
94
- });
95
-
96
- const errors = diagnostics?.filter((d) => d.category === ts.DiagnosticCategory.Error) ?? [];
97
- if (errors.length > 0) {
98
- throw new Error(`[praxisjs:decorators] ${fileName}:\n${formatDiagnostics(errors)}`);
99
- }
100
-
101
- return {
102
- code: outputText,
103
- map: sourceMapText ? (JSON.parse(sourceMapText) as RawSourceMap) : null,
104
- };
573
+ return transformDecorators(code, id, isLibrary, (message) => { this.warn(message); });
105
574
  },
106
575
  };
107
576
  }
package/src/hmr.ts ADDED
@@ -0,0 +1,108 @@
1
+ import ts from "typescript";
2
+
3
+ import type { Plugin } from "vite";
4
+
5
+ // Component hot replacement, development only. Every top-level class of a module is registered
6
+ // with the runtime under `<file>#<class>` (the runtime ignores classes that are not components),
7
+ // so an edited module that runs again replaces its components in place (see @praxisjs/runtime's
8
+ // hmr.ts). The module accepts its own update only when all it exports are classes: anything else
9
+ // it exports (a helper, a constant, a store) may be held by the modules that import it, so those
10
+ // updates keep Vite's default handling, which runs the importers again or reloads the page. A
11
+ // module whose new exports turn out not to be components at runtime also hands the update on.
12
+
13
+ const SCRIPT_FILE = /\.[cm]?[jt]sx?$/;
14
+
15
+ function scriptKindFor(fileName: string): ts.ScriptKind {
16
+ if (fileName.endsWith("x")) return fileName.endsWith(".jsx") ? ts.ScriptKind.JSX : ts.ScriptKind.TSX;
17
+ return fileName.endsWith(".js") || fileName.endsWith(".mjs") || fileName.endsWith(".cjs") ? ts.ScriptKind.JS : ts.ScriptKind.TS;
18
+ }
19
+
20
+ function hasModifier(node: ts.Node, kind: ts.SyntaxKind): boolean {
21
+ return ts.canHaveModifiers(node) && (ts.getModifiers(node)?.some((m) => m.kind === kind) ?? false);
22
+ }
23
+
24
+ interface ModuleShape {
25
+ classes: string[];
26
+ /** Every value the module exports is one of its top-level classes. */
27
+ onlyClasses: boolean;
28
+ }
29
+
30
+ function moduleShape(source: ts.SourceFile): ModuleShape {
31
+ const classes: string[] = [];
32
+ let exportsValue = false;
33
+ let exportsOther = false;
34
+ const exportedNames: string[] = [];
35
+ for (const statement of source.statements) {
36
+ if (ts.isClassDeclaration(statement) && !hasModifier(statement, ts.SyntaxKind.DeclareKeyword)) {
37
+ const exported = hasModifier(statement, ts.SyntaxKind.ExportKeyword);
38
+ if (statement.name !== undefined) classes.push(statement.name.text);
39
+ if (exported) {
40
+ exportsValue = true;
41
+ // `export default class {}` has no binding to register.
42
+ if (statement.name === undefined) exportsOther = true;
43
+ }
44
+ } else if (ts.isExportAssignment(statement)) {
45
+ exportsValue = true;
46
+ if (ts.isIdentifier(statement.expression)) exportedNames.push(statement.expression.text);
47
+ else exportsOther = true;
48
+ } else if (ts.isExportDeclaration(statement)) {
49
+ if (statement.isTypeOnly) continue;
50
+ exportsValue = true;
51
+ const clause = statement.exportClause;
52
+ if (statement.moduleSpecifier !== undefined || clause === undefined || !ts.isNamedExports(clause)) {
53
+ exportsOther = true;
54
+ continue;
55
+ }
56
+ for (const element of clause.elements) {
57
+ if (!element.isTypeOnly) exportedNames.push((element.propertyName ?? element.name).text);
58
+ }
59
+ } else if (
60
+ hasModifier(statement, ts.SyntaxKind.ExportKeyword) &&
61
+ !ts.isInterfaceDeclaration(statement) &&
62
+ !ts.isTypeAliasDeclaration(statement) &&
63
+ !hasModifier(statement, ts.SyntaxKind.DeclareKeyword)
64
+ ) {
65
+ exportsValue = true;
66
+ exportsOther = true;
67
+ }
68
+ }
69
+ const onlyClasses = exportsValue && !exportsOther && exportedNames.every((name) => classes.includes(name));
70
+ return { classes, onlyClasses };
71
+ }
72
+
73
+ /** Appends the hot replacement code to a module; null when it declares no top-level class. */
74
+ export function transformHmr(code: string, id: string): string | null {
75
+ const fileName = id.split("?")[0];
76
+ if (!SCRIPT_FILE.test(fileName) || !code.includes("class")) return null;
77
+ const source = ts.createSourceFile(fileName, code, ts.ScriptTarget.Latest, false, scriptKindFor(fileName));
78
+ const { classes, onlyClasses } = moduleShape(source);
79
+ if (classes.length === 0) return null;
80
+ // Appended, not prepended: imports are hoisted, and every original line keeps its place for the
81
+ // source map.
82
+ let footer = '\nimport { _$hmrRegister as __pxHotRegister, _$hmrAccepts as __pxHotAccepts } from "@praxisjs/runtime";\n';
83
+ for (const name of classes) footer += `__pxHotRegister(${name}, ${JSON.stringify(`${fileName}#${name}`)});\n`;
84
+ if (onlyClasses) {
85
+ footer += "if (import.meta.hot) import.meta.hot.accept((mod) => { if (!__pxHotAccepts(mod)) import.meta.hot.invalidate(); });\n";
86
+ }
87
+ return code + footer;
88
+ }
89
+
90
+ export function hmrPlugin(): Plugin {
91
+ let root = "/";
92
+ return {
93
+ name: "praxisjs:hmr",
94
+ apply: "serve",
95
+ enforce: "pre",
96
+ configResolved(config) {
97
+ root = config.root.endsWith("/") ? config.root : `${config.root}/`;
98
+ },
99
+ transform(code, id, options) {
100
+ // The app's own modules only: dependencies (including workspace packages linked from outside
101
+ // the project) are not edited while it runs, and may not resolve @praxisjs/runtime.
102
+ if (options?.ssr === true || !id.startsWith(root) || id.includes("/node_modules/")) return null;
103
+ const result = transformHmr(code, id);
104
+ // Code only appended at the end: the existing source map still holds.
105
+ return result === null ? null : { code: result, map: null };
106
+ },
107
+ };
108
+ }