@praxisjs/vite-plugin 4.1.0 → 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.
- package/CHANGELOG.md +6 -0
- package/dist/decorators.d.ts +19 -1
- package/dist/decorators.d.ts.map +1 -1
- package/dist/decorators.js +50 -27
- package/dist/decorators.js.map +1 -1
- package/dist/index.d.ts +8 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +14 -5
- package/dist/index.js.map +1 -1
- package/dist/keyed-lists.d.ts +2 -1
- package/dist/keyed-lists.d.ts.map +1 -1
- package/dist/keyed-lists.js +6 -3
- package/dist/keyed-lists.js.map +1 -1
- package/dist/libraries.d.ts +56 -0
- package/dist/libraries.d.ts.map +1 -0
- package/dist/libraries.js +281 -0
- package/dist/libraries.js.map +1 -0
- package/dist/mapped-output.d.ts +2 -0
- package/dist/mapped-output.d.ts.map +1 -1
- package/dist/mapped-output.js +12 -0
- package/dist/mapped-output.js.map +1 -1
- package/dist/templates.d.ts +7 -2
- package/dist/templates.d.ts.map +1 -1
- package/dist/templates.js +4 -3
- package/dist/templates.js.map +1 -1
- package/package.json +1 -1
- package/src/decorators.ts +57 -28
- package/src/index.ts +21 -5
- package/src/keyed-lists.ts +6 -3
- package/src/libraries.ts +328 -0
- package/src/mapped-output.ts +12 -0
- package/src/templates.ts +8 -4
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;
|
|
@@ -488,6 +490,58 @@ export function shareHelpers(code: string, fileName: string, kind = ts.ScriptKin
|
|
|
488
490
|
return result;
|
|
489
491
|
}
|
|
490
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
|
+
|
|
491
545
|
// oxc doesn't lower TC39 (non-legacy) decorators yet — `@Decorator()`
|
|
492
546
|
// syntax passes through unchanged and no JS runtime executes it directly:
|
|
493
547
|
// https://github.com/oxc-project/oxc/issues/9170
|
|
@@ -498,7 +552,7 @@ export function shareHelpers(code: string, fileName: string, kind = ts.ScriptKin
|
|
|
498
552
|
// so its initializer runs after `super()` automatically — no separate
|
|
499
553
|
// ordering fix-up is needed. JSX is left untouched (`jsx: "preserve"`) for
|
|
500
554
|
// oxc's own JSX pipeline to handle.
|
|
501
|
-
export function decoratorLoweringPlugin(): Plugin {
|
|
555
|
+
export function decoratorLoweringPlugin(isLibrary?: (id: string) => boolean): Plugin {
|
|
502
556
|
return {
|
|
503
557
|
name: "praxisjs:decorators",
|
|
504
558
|
// Must run before Vite's built-in esbuild TS transform: esbuild's TC39
|
|
@@ -516,32 +570,7 @@ export function decoratorLoweringPlugin(): Plugin {
|
|
|
516
570
|
return id === RESOLVED_HELPERS_ID ? helpersModule() : null;
|
|
517
571
|
},
|
|
518
572
|
transform(code, id) {
|
|
519
|
-
|
|
520
|
-
const fileName = cleanFileName(id);
|
|
521
|
-
if (!code.includes("@") || !hasRealDecorator(code, fileName)) {
|
|
522
|
-
// A file of a library built with tsc may hold the same helpers. Only an ES module can take
|
|
523
|
-
// an import.
|
|
524
|
-
if (!code.includes("(this && this.__")) return null;
|
|
525
|
-
const kind = scriptKindFor(fileName);
|
|
526
|
-
if (!ts.isExternalModule(ts.createSourceFile(fileName, code, ts.ScriptTarget.Latest, false, kind))) return null;
|
|
527
|
-
const shared = shareHelpers(code, fileName, kind);
|
|
528
|
-
return shared === null ? null : { code: shared, map: null };
|
|
529
|
-
}
|
|
530
|
-
|
|
531
|
-
const { outputText, sourceMapText, diagnostics } = lowerDecorators(code, fileName);
|
|
532
|
-
|
|
533
|
-
const errors = diagnostics?.filter((d) => d.category === ts.DiagnosticCategory.Error) ?? [];
|
|
534
|
-
if (errors.length > 0) {
|
|
535
|
-
throw new Error(`[praxisjs:decorators] ${fileName}:\n${formatDiagnostics(errors)}`);
|
|
536
|
-
}
|
|
537
|
-
|
|
538
|
-
const claimed = claimFields(outputText, fileName);
|
|
539
|
-
const map = sourceMapText ? (JSON.parse(sourceMapText) as RawSourceMap) : null;
|
|
540
|
-
const output = claimed ?? outputText;
|
|
541
|
-
return {
|
|
542
|
-
code: shareHelpers(output, fileName) ?? output,
|
|
543
|
-
map: claimed === null || map === null ? map : { ...map, mappings: ";".repeat(PX_FIELD_HELPER_LINES) + map.mappings },
|
|
544
|
-
};
|
|
573
|
+
return transformDecorators(code, id, isLibrary, (message) => { this.warn(message); });
|
|
545
574
|
},
|
|
546
575
|
};
|
|
547
576
|
}
|
package/src/index.ts
CHANGED
|
@@ -2,7 +2,8 @@ import { praxisjsCSS } from "./css.js";
|
|
|
2
2
|
import { decoratorLoweringPlugin } from "./decorators.js";
|
|
3
3
|
import { hmrPlugin, transformHmr } from "./hmr.js";
|
|
4
4
|
import { keyedListsPlugin, transformKeyedLists, type KeyedListsPluginOptions } from "./keyed-lists.js";
|
|
5
|
-
import {
|
|
5
|
+
import { Libraries, librariesPlugin, type LibrariesOption, type LibraryOptions } from "./libraries.js";
|
|
6
|
+
import { JSX_FILE, templatesPlugin, transformTemplates, type TemplatesPluginOptions } from "./templates.js";
|
|
6
7
|
|
|
7
8
|
import type { PluginOption } from "vite";
|
|
8
9
|
|
|
@@ -10,6 +11,7 @@ export { praxisjsCSS };
|
|
|
10
11
|
export { decoratorLoweringPlugin };
|
|
11
12
|
export { hmrPlugin, transformHmr };
|
|
12
13
|
export { keyedListsPlugin, transformKeyedLists, type KeyedListsPluginOptions };
|
|
14
|
+
export { type LibrariesOption, type LibraryOptions };
|
|
13
15
|
export { templatesPlugin, transformTemplates, type TemplatesPluginOptions };
|
|
14
16
|
|
|
15
17
|
export interface PraxisJSVitePluginOptions {
|
|
@@ -25,17 +27,31 @@ export interface PraxisJSVitePluginOptions {
|
|
|
25
27
|
* render, binding only their dynamic parts. `false` leaves every element to `jsx()`. Default `true`.
|
|
26
28
|
*/
|
|
27
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;
|
|
28
36
|
}
|
|
29
37
|
|
|
30
38
|
export function praxisjs(options: PraxisJSVitePluginOptions = {}): PluginOption[] {
|
|
31
|
-
const { hmr = true, autoImport = true, keyedLists = true, templates = 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
|
+
};
|
|
32
46
|
|
|
33
47
|
return [
|
|
34
|
-
...(keyedLists === false ? [] : [keyedListsPlugin(keyedLists === true ? {} : keyedLists)]),
|
|
48
|
+
...(keyedLists === false ? [] : [keyedListsPlugin(keyedLists === true ? {} : keyedLists, isLibrary)]),
|
|
35
49
|
// Before the decorator lowering, which turns class declarations into `let X = (() => …)()`.
|
|
36
50
|
...(hmr ? [hmrPlugin()] : []),
|
|
37
|
-
decoratorLoweringPlugin(),
|
|
38
|
-
...(templates === false ? [] : [templatesPlugin(templates === true ? {} : templates)]),
|
|
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)]),
|
|
39
55
|
{
|
|
40
56
|
name: "praxisjs:core",
|
|
41
57
|
enforce: "pre",
|
package/src/keyed-lists.ts
CHANGED
|
@@ -262,17 +262,20 @@ export interface KeyedListsPluginOptions {
|
|
|
262
262
|
warn?: boolean;
|
|
263
263
|
}
|
|
264
264
|
|
|
265
|
-
|
|
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 {
|
|
266
267
|
const { warn = true } = options;
|
|
267
268
|
return {
|
|
268
269
|
name: "praxisjs:keyed-lists",
|
|
269
270
|
enforce: "pre",
|
|
270
271
|
transform(code, id) {
|
|
271
|
-
|
|
272
|
+
const dependency = id.includes("/node_modules/");
|
|
273
|
+
if (!JSX_FILE.test(id) || (dependency && isLibrary?.(id) !== true)) return null;
|
|
272
274
|
const fileName = id.split("?")[0];
|
|
273
275
|
const result = transformKeyedLists(code, fileName);
|
|
274
276
|
if (result === null) return null;
|
|
275
|
-
|
|
277
|
+
// A dependency's code is not the app's to change.
|
|
278
|
+
if (warn && !dependency) {
|
|
276
279
|
for (const w of result.warnings) this.warn(`${fileName}:${String(w.line)}:${String(w.column)} ${w.message}`);
|
|
277
280
|
}
|
|
278
281
|
if (result.map === null) return null;
|
package/src/libraries.ts
ADDED
|
@@ -0,0 +1,328 @@
|
|
|
1
|
+
import { createHash } from "node:crypto";
|
|
2
|
+
import { existsSync, readFileSync, realpathSync } from "node:fs";
|
|
3
|
+
import { dirname, join, resolve, sep } from "node:path";
|
|
4
|
+
|
|
5
|
+
import { HELPERS_ID, helpersModule, transformDecorators } from "./decorators.js";
|
|
6
|
+
import { transformKeyedLists } from "./keyed-lists.js";
|
|
7
|
+
import { MappedOutput } from "./mapped-output.js";
|
|
8
|
+
import { transformTemplates } from "./templates.js";
|
|
9
|
+
|
|
10
|
+
import type { Plugin, Rolldown } from "vite";
|
|
11
|
+
|
|
12
|
+
// A PraxisJS library built with tsc reaches an app as plain JS: none of the build-time work the
|
|
13
|
+
// app's own files get (claimed fields, compiled templates, keyed lists) happens to it. A library
|
|
14
|
+
// opts in with a `praxisjs` field in its package.json; an app can also opt a package in or out by
|
|
15
|
+
// name. Its compiled files then get their decorated fields claimed, and when it ships its sources
|
|
16
|
+
// and says where they are (`rootDir`, `outDir`, as in its tsconfig), every import of a compiled file
|
|
17
|
+
// loads its source instead, which the app compiles like its own.
|
|
18
|
+
|
|
19
|
+
export interface LibraryOptions {
|
|
20
|
+
/** Where the package keeps the sources its build output comes from (tsc's `rootDir`), relative to its package.json. */
|
|
21
|
+
rootDir?: string;
|
|
22
|
+
/** Where its build output is (tsc's `outDir`). With `rootDir`, the package is compiled from its sources. */
|
|
23
|
+
outDir?: string;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* Packages compiled like the app's own code, by name (`"@scope/*"` names a whole scope): `true` or
|
|
28
|
+
* options opt a package in, `false` leaves it as published even when its package.json opts in.
|
|
29
|
+
*/
|
|
30
|
+
export type LibrariesOption = Record<string, boolean | LibraryOptions>;
|
|
31
|
+
|
|
32
|
+
/** What the app compiles in its own files, and so in the libraries' too. */
|
|
33
|
+
export interface LibraryTransforms {
|
|
34
|
+
keyedLists: boolean;
|
|
35
|
+
/** The ids the template compiler takes, or null when it is off. */
|
|
36
|
+
templates: RegExp | null;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
interface PackageInfo {
|
|
40
|
+
dir: string;
|
|
41
|
+
library: boolean;
|
|
42
|
+
/** Absolute and ending in a separator, when the package is compiled from its sources. */
|
|
43
|
+
source: { rootDir: string; outDir: string } | null;
|
|
44
|
+
/** Its dependencies, peer and optional ones included. */
|
|
45
|
+
deps: string[];
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
interface PackageJson {
|
|
49
|
+
name?: unknown;
|
|
50
|
+
praxisjs?: unknown;
|
|
51
|
+
dependencies?: unknown;
|
|
52
|
+
devDependencies?: unknown;
|
|
53
|
+
peerDependencies?: unknown;
|
|
54
|
+
optionalDependencies?: unknown;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
// What tsc compiles to each output extension.
|
|
58
|
+
const SOURCE_EXTENSIONS = new Map([
|
|
59
|
+
[".js", [".tsx", ".ts", ".jsx", ".js"]],
|
|
60
|
+
[".jsx", [".tsx", ".jsx"]],
|
|
61
|
+
[".mjs", [".mts", ".mjs"]],
|
|
62
|
+
]);
|
|
63
|
+
const JSX_FILE = /\.[jt]sx$/;
|
|
64
|
+
const JSX_PRAGMA = "/* @jsxImportSource @praxisjs/jsx */ ";
|
|
65
|
+
|
|
66
|
+
const isRecord = (value: unknown): value is Record<string, unknown> =>
|
|
67
|
+
typeof value === "object" && value !== null && !Array.isArray(value);
|
|
68
|
+
|
|
69
|
+
const keys = (value: unknown): string[] => (isRecord(value) ? Object.keys(value) : []);
|
|
70
|
+
|
|
71
|
+
const cleanId = (id: string) => id.split("?")[0];
|
|
72
|
+
|
|
73
|
+
function readJson(file: string): PackageJson | null {
|
|
74
|
+
try {
|
|
75
|
+
const json: unknown = JSON.parse(readFileSync(file, "utf8"));
|
|
76
|
+
return isRecord(json) ? json : null;
|
|
77
|
+
} catch {
|
|
78
|
+
return null;
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/** `@scope/name/sub` → `@scope/name`, `name/sub` → `name`. */
|
|
83
|
+
function packageName(specifier: string): string {
|
|
84
|
+
const parts = specifier.split("/");
|
|
85
|
+
return specifier.startsWith("@") ? parts.slice(0, 2).join("/") : parts[0];
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
const inside = (dir: string, path: string) => path.startsWith(dir.endsWith(sep) ? dir : dir + sep);
|
|
89
|
+
|
|
90
|
+
export class Libraries {
|
|
91
|
+
/** The package each directory belongs to, or null outside of any. */
|
|
92
|
+
private readonly packages = new Map<string, PackageInfo | null>();
|
|
93
|
+
/** Names of the libraries compiled from their sources, found by `discover`. */
|
|
94
|
+
private readonly sourceNames = new Set<string>();
|
|
95
|
+
|
|
96
|
+
constructor(readonly option: LibrariesOption = {}) {}
|
|
97
|
+
|
|
98
|
+
clear(): void {
|
|
99
|
+
this.packages.clear();
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
private setting(name: string): boolean | LibraryOptions | undefined {
|
|
103
|
+
if (name in this.option) return this.option[name];
|
|
104
|
+
const scope = `${name.split("/")[0]}/*`;
|
|
105
|
+
return name.startsWith("@") && scope in this.option ? this.option[scope] : undefined;
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
/** The package whose package.json is in `dir`, or null when it has none with a name. */
|
|
109
|
+
private packageIn(dir: string): PackageInfo | null {
|
|
110
|
+
const json = readJson(join(dir, "package.json"));
|
|
111
|
+
if (json === null || typeof json.name !== "string") return null;
|
|
112
|
+
const field = isRecord(json.praxisjs) ? json.praxisjs : {};
|
|
113
|
+
const setting = this.setting(json.name);
|
|
114
|
+
const library = setting === undefined ? field.optimize === true : setting !== false;
|
|
115
|
+
const options = isRecord(setting) ? setting : field;
|
|
116
|
+
const path = (value: unknown) => {
|
|
117
|
+
if (typeof value !== "string") return null;
|
|
118
|
+
const full = resolve(dir, value);
|
|
119
|
+
return inside(dir, full) ? full + sep : null;
|
|
120
|
+
};
|
|
121
|
+
const rootDir = path(options.rootDir);
|
|
122
|
+
const outDir = path(options.outDir);
|
|
123
|
+
return {
|
|
124
|
+
dir,
|
|
125
|
+
library,
|
|
126
|
+
source: library && rootDir !== null && outDir !== null ? { rootDir, outDir } : null,
|
|
127
|
+
deps: [...new Set([...keys(json.dependencies), ...keys(json.peerDependencies), ...keys(json.optionalDependencies)])],
|
|
128
|
+
};
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
/** The package `file` belongs to. */
|
|
132
|
+
private packageOf(file: string): PackageInfo | null {
|
|
133
|
+
const visited: string[] = [];
|
|
134
|
+
let found: PackageInfo | null = null;
|
|
135
|
+
for (let dir = dirname(file); ; dir = dirname(dir)) {
|
|
136
|
+
const cached = this.packages.get(dir);
|
|
137
|
+
if (cached !== undefined) {
|
|
138
|
+
found = cached;
|
|
139
|
+
break;
|
|
140
|
+
}
|
|
141
|
+
visited.push(dir);
|
|
142
|
+
found = this.packageIn(dir);
|
|
143
|
+
if (found !== null || dirname(dir) === dir) break;
|
|
144
|
+
}
|
|
145
|
+
for (const dir of visited) this.packages.set(dir, found);
|
|
146
|
+
return found;
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
/** Whether module `id` is a file of a library compiled like the app's own code. */
|
|
150
|
+
isLibraryFile(id: string): boolean {
|
|
151
|
+
return !id.startsWith("\0") && this.packageOf(cleanId(id))?.library === true;
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
/** The source a compiled file of a library comes from, or null when it is not compiled from its sources. */
|
|
155
|
+
sourceFile(file: string): string | null {
|
|
156
|
+
const source = this.packageOf(file)?.source;
|
|
157
|
+
if (source == null || !inside(source.outDir, file)) return null;
|
|
158
|
+
const relative = file.slice(source.outDir.length);
|
|
159
|
+
const extension = /\.[^./\\]+$/.exec(relative)?.[0] ?? "";
|
|
160
|
+
const candidates = SOURCE_EXTENSIONS.get(extension);
|
|
161
|
+
if (candidates === undefined) return null;
|
|
162
|
+
const base = join(source.rootDir, relative.slice(0, -extension.length));
|
|
163
|
+
return candidates.map((candidate) => base + candidate).find((candidate) => existsSync(candidate)) ?? null;
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
/** Whether resolving `specifier` from `importer` may land in a compiled file that has a source. */
|
|
167
|
+
mayResolveToSource(specifier: string, importer: string | undefined): boolean {
|
|
168
|
+
if (specifier.startsWith("\0")) return false;
|
|
169
|
+
if (!specifier.startsWith(".") && !specifier.startsWith("/")) return this.sourceNames.has(packageName(specifier));
|
|
170
|
+
if (importer === undefined || importer.startsWith("\0")) return false;
|
|
171
|
+
const file = cleanId(importer);
|
|
172
|
+
const source = this.packageOf(file)?.source;
|
|
173
|
+
return source != null && inside(source.outDir, file);
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
/** The libraries the app depends on, directly or through other libraries, and those the option names. */
|
|
177
|
+
discover(root: string): string[] {
|
|
178
|
+
const found = new Set<string>();
|
|
179
|
+
const visited = new Set<string>();
|
|
180
|
+
const json = readJson(join(root, "package.json"));
|
|
181
|
+
const deps = json === null ? [] : [
|
|
182
|
+
...keys(json.dependencies), ...keys(json.devDependencies), ...keys(json.peerDependencies), ...keys(json.optionalDependencies),
|
|
183
|
+
];
|
|
184
|
+
const queue = [{ dir: root, deps }];
|
|
185
|
+
// Grows while it is walked: an array iterator reads the length at every step.
|
|
186
|
+
for (const next of queue) {
|
|
187
|
+
for (const dep of next.deps) {
|
|
188
|
+
const installed = this.installed(dep, next.dir);
|
|
189
|
+
if (installed?.library !== true) continue;
|
|
190
|
+
found.add(dep);
|
|
191
|
+
if (installed.source !== null) this.sourceNames.add(dep);
|
|
192
|
+
if (visited.has(installed.dir)) continue;
|
|
193
|
+
visited.add(installed.dir);
|
|
194
|
+
queue.push({ dir: installed.dir, deps: installed.deps });
|
|
195
|
+
}
|
|
196
|
+
}
|
|
197
|
+
for (const [name, value] of Object.entries(this.option)) {
|
|
198
|
+
if (value !== false && !name.endsWith("/*")) found.add(name);
|
|
199
|
+
}
|
|
200
|
+
return [...found];
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
/** The package `name` resolves to from `dir`, as Node looks it up. */
|
|
204
|
+
private installed(name: string, dir: string): PackageInfo | null {
|
|
205
|
+
for (let current = dir; ; current = dirname(current)) {
|
|
206
|
+
const candidate = join(current, "node_modules", name);
|
|
207
|
+
if (existsSync(join(candidate, "package.json"))) {
|
|
208
|
+
const real = realpathSync(candidate);
|
|
209
|
+
const info = this.packageIn(real);
|
|
210
|
+
this.packages.set(real, info);
|
|
211
|
+
return info;
|
|
212
|
+
}
|
|
213
|
+
if (dirname(current) === current) return null;
|
|
214
|
+
}
|
|
215
|
+
}
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
type ResolveSkippingSelf = (source: string, importer: string | undefined) => Promise<Rolldown.PartialResolvedId | null>;
|
|
219
|
+
|
|
220
|
+
// The compiled file a module resolves to, swapped for its source when the library has one.
|
|
221
|
+
async function resolveToSource(libraries: Libraries, resolveSkippingSelf: ResolveSkippingSelf, source: string, importer: string | undefined) {
|
|
222
|
+
if (!libraries.mayResolveToSource(source, importer)) return null;
|
|
223
|
+
const resolved = await resolveSkippingSelf(source, importer);
|
|
224
|
+
if (resolved === null || (resolved.external !== undefined && resolved.external !== false)) return resolved;
|
|
225
|
+
const file = cleanId(resolved.id);
|
|
226
|
+
const sourceFile = libraries.sourceFile(file);
|
|
227
|
+
return sourceFile === null ? resolved : { ...resolved, id: sourceFile + resolved.id.slice(file.length) };
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
// The JSX runtime the library is written against, pinned in a comment that leads the file: the app
|
|
231
|
+
// configures the one of its own files, and the dependency optimizer has none.
|
|
232
|
+
function pinJsxRuntime(libraries: Libraries, code: string, id: string) {
|
|
233
|
+
const file = cleanId(id);
|
|
234
|
+
if (!JSX_FILE.test(file) || code.includes("@jsxImportSource") || !libraries.isLibraryFile(id)) return null;
|
|
235
|
+
const out = new MappedOutput(code);
|
|
236
|
+
out.insert(JSX_PRAGMA, 0);
|
|
237
|
+
out.copy(0, code.length);
|
|
238
|
+
return out.result(file);
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
/**
|
|
242
|
+
* The same transforms, for Vite's dependency optimizer: it bundles the dependencies it pre-bundles
|
|
243
|
+
* before any Vite plugin sees them. Vite keys its cache of pre-bundled dependencies on the names of
|
|
244
|
+
* these plugins, so the first one carries a hash of what they do.
|
|
245
|
+
*/
|
|
246
|
+
export function prebundlePlugins(libraries: Libraries, transforms: LibraryTransforms): Rolldown.Plugin[] {
|
|
247
|
+
const isLibrary = (id: string) => libraries.isLibraryFile(id);
|
|
248
|
+
const fingerprint = createHash("sha256")
|
|
249
|
+
.update(JSON.stringify({ option: libraries.option, keyedLists: transforms.keyedLists, templates: transforms.templates?.toString() }))
|
|
250
|
+
.digest("hex")
|
|
251
|
+
.slice(0, 8);
|
|
252
|
+
const plugins: Rolldown.Plugin[] = [{
|
|
253
|
+
name: `praxisjs:libraries(${fingerprint})`,
|
|
254
|
+
resolveId(source, importer, options) {
|
|
255
|
+
return resolveToSource(libraries, (s, i) => this.resolve(s, i, { ...options, skipSelf: true }), source, importer);
|
|
256
|
+
},
|
|
257
|
+
}];
|
|
258
|
+
if (transforms.keyedLists) {
|
|
259
|
+
plugins.push({
|
|
260
|
+
name: "praxisjs:keyed-lists",
|
|
261
|
+
transform(code, id) {
|
|
262
|
+
if (!JSX_FILE.test(cleanId(id)) || !isLibrary(id)) return null;
|
|
263
|
+
const result = transformKeyedLists(code, cleanId(id));
|
|
264
|
+
return result?.map == null ? null : { code: result.code, map: result.map };
|
|
265
|
+
},
|
|
266
|
+
});
|
|
267
|
+
}
|
|
268
|
+
plugins.push({
|
|
269
|
+
name: "praxisjs:decorators",
|
|
270
|
+
resolveId(id) {
|
|
271
|
+
return id === HELPERS_ID ? `\0${HELPERS_ID}` : null;
|
|
272
|
+
},
|
|
273
|
+
load(id) {
|
|
274
|
+
return id === `\0${HELPERS_ID}` ? helpersModule() : null;
|
|
275
|
+
},
|
|
276
|
+
transform(code, id) {
|
|
277
|
+
if (!isLibrary(id)) return null;
|
|
278
|
+
return transformDecorators(code, id, isLibrary, (message) => { this.warn(message); });
|
|
279
|
+
},
|
|
280
|
+
});
|
|
281
|
+
const { templates } = transforms;
|
|
282
|
+
if (templates !== null) {
|
|
283
|
+
plugins.push({
|
|
284
|
+
name: "praxisjs:templates",
|
|
285
|
+
transform(code, id) {
|
|
286
|
+
return templates.test(id) && isLibrary(id) ? transformTemplates(code, cleanId(id)) : null;
|
|
287
|
+
},
|
|
288
|
+
});
|
|
289
|
+
}
|
|
290
|
+
plugins.push({
|
|
291
|
+
name: "praxisjs:libraries-jsx",
|
|
292
|
+
transform(code, id) {
|
|
293
|
+
return pinJsxRuntime(libraries, code, id);
|
|
294
|
+
},
|
|
295
|
+
});
|
|
296
|
+
return plugins;
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
/**
|
|
300
|
+
* Lets the other PraxisJS plugins compile the libraries that opt in: runs them inside Vite's
|
|
301
|
+
* dependency optimizer too, keeps the libraries out of SSR externals (Node would load them as
|
|
302
|
+
* published), loads their sources in place of their build output, and pins the JSX runtime their
|
|
303
|
+
* files compile against.
|
|
304
|
+
*/
|
|
305
|
+
export function librariesPlugin(libraries: Libraries, transforms: LibraryTransforms): Plugin {
|
|
306
|
+
return {
|
|
307
|
+
name: "praxisjs:libraries",
|
|
308
|
+
enforce: "pre",
|
|
309
|
+
config(config) {
|
|
310
|
+
const found = libraries.discover(resolve(config.root ?? process.cwd()));
|
|
311
|
+
if (found.length === 0) return null;
|
|
312
|
+
return {
|
|
313
|
+
optimizeDeps: { rolldownOptions: { plugins: prebundlePlugins(libraries, transforms) } },
|
|
314
|
+
ssr: { noExternal: found },
|
|
315
|
+
};
|
|
316
|
+
},
|
|
317
|
+
buildStart() {
|
|
318
|
+
libraries.clear();
|
|
319
|
+
},
|
|
320
|
+
resolveId(source, importer, options) {
|
|
321
|
+
return resolveToSource(libraries, (s, i) => this.resolve(s, i, { ...options, skipSelf: true }), source, importer);
|
|
322
|
+
},
|
|
323
|
+
transform(code, id) {
|
|
324
|
+
// After the other transforms that add imports at the top, so the pragma still leads the file.
|
|
325
|
+
return pinJsxRuntime(libraries, code, id);
|
|
326
|
+
},
|
|
327
|
+
};
|
|
328
|
+
}
|
package/src/mapped-output.ts
CHANGED
|
@@ -114,3 +114,15 @@ export class MappedOutput {
|
|
|
114
114
|
}
|
|
115
115
|
}
|
|
116
116
|
|
|
117
|
+
/** A map of `source` moved down by `lines` generated lines, each of its lines otherwise in place. */
|
|
118
|
+
export function shiftedLines(source: string, file: string, lines: number): SourceMapV3 {
|
|
119
|
+
const count = source.split("\n").length;
|
|
120
|
+
return {
|
|
121
|
+
version: 3,
|
|
122
|
+
file,
|
|
123
|
+
sources: [file],
|
|
124
|
+
sourcesContent: [source],
|
|
125
|
+
names: [],
|
|
126
|
+
mappings: ";".repeat(lines) + ["AAAA", ...Array<string>(count - 1).fill("AACA")].join(";"),
|
|
127
|
+
};
|
|
128
|
+
}
|
package/src/templates.ts
CHANGED
|
@@ -14,7 +14,7 @@ import type { Plugin } from "vite";
|
|
|
14
14
|
// `children` attribute, unknown HTML entities or `await`/`yield` inside an expression are left to
|
|
15
15
|
// jsx().
|
|
16
16
|
|
|
17
|
-
const JSX_FILE = /\.[jt]sx(?:$|\?)/;
|
|
17
|
+
export const JSX_FILE = /\.[jt]sx(?:$|\?)/;
|
|
18
18
|
const P = "_$px";
|
|
19
19
|
const HELPERS = [
|
|
20
20
|
"template", "html", "element", "prop", "reactive", "insert", "reactiveOnly", "insertOnly", "scope", "event",
|
|
@@ -711,17 +711,21 @@ function prepend(header: TemplateResult, body: TemplateResult): TemplateResult {
|
|
|
711
711
|
}
|
|
712
712
|
|
|
713
713
|
export interface TemplatesPluginOptions {
|
|
714
|
-
/**
|
|
714
|
+
/**
|
|
715
|
+
* Restricts the transform to matching module ids. Default: every .jsx/.tsx outside node_modules,
|
|
716
|
+
* and those of the libraries that opt in.
|
|
717
|
+
*/
|
|
715
718
|
include?: RegExp;
|
|
716
719
|
}
|
|
717
720
|
|
|
718
|
-
|
|
721
|
+
/** `isLibrary` tells the files of the dependencies compiled like the app's own code. */
|
|
722
|
+
export function templatesPlugin(options: TemplatesPluginOptions = {}, isLibrary?: (id: string) => boolean): Plugin {
|
|
719
723
|
const include = options.include ?? JSX_FILE;
|
|
720
724
|
return {
|
|
721
725
|
name: "praxisjs:templates",
|
|
722
726
|
enforce: "pre",
|
|
723
727
|
transform(code, id) {
|
|
724
|
-
if (!include.test(id) || id.includes("/node_modules/")) return null;
|
|
728
|
+
if (!include.test(id) || (id.includes("/node_modules/") && isLibrary?.(id) !== true)) return null;
|
|
725
729
|
const result = transformTemplates(code, id.split("?")[0]);
|
|
726
730
|
return result === null ? null : { code: result.code, map: result.map };
|
|
727
731
|
},
|