@gwigz/slua-tstl-plugin 1.2.0 → 1.3.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/README.md CHANGED
@@ -354,7 +354,10 @@ Safe for string and number defaults (both truthy in Lua). Not applied to `false`
354
354
 
355
355
  ## Keeping output small
356
356
 
357
- Some TypeScript patterns pull in large TSTL runtime helpers. Here are recommendations for keeping output lean:
357
+ Some TypeScript patterns pull in large TSTL runtime helpers. Here are recommendations for keeping output lean.
358
+
359
+ > [!TIP]
360
+ > Install [`@gwigz/slua-oxlint-config`](../oxlint-config) to enforce these recommendations at lint time.
358
361
 
359
362
  ### Avoid `delete` on objects
360
363
 
@@ -444,3 +447,7 @@ const seen: Record<string, boolean> = {}
444
447
  ```bash
445
448
  bun run build
446
449
  ```
450
+
451
+ ## Documentation
452
+
453
+ Full API reference and usage examples are available at [slua.gwigz.link/docs/slua](https://slua.gwigz.link/docs/slua).
@@ -0,0 +1,11 @@
1
+ import type { Plugin } from "rollup";
2
+ interface ResolverOptions {
3
+ paths: Record<string, string[]>;
4
+ baseUrl: string;
5
+ }
6
+ /**
7
+ * Rollup plugin that resolves tsconfig `paths` to .ts source files
8
+ * and strips types so rollup can parse them for tree-shaking analysis.
9
+ */
10
+ export declare function createTsconfigResolverPlugin(options: ResolverOptions): Plugin;
11
+ export {};
@@ -0,0 +1,64 @@
1
+ import * as ts from "typescript";
2
+ import { dirname, join } from "node:path";
3
+ const STRIP_TYPES_OPTIONS = {
4
+ target: ts.ScriptTarget.ESNext,
5
+ module: ts.ModuleKind.ESNext,
6
+ verbatimModuleSyntax: false,
7
+ };
8
+ /**
9
+ * Rollup plugin that resolves tsconfig `paths` to .ts source files
10
+ * and strips types so rollup can parse them for tree-shaking analysis.
11
+ */
12
+ export function createTsconfigResolverPlugin(options) {
13
+ const { paths, baseUrl } = options;
14
+ const matchers = Object.entries(paths)
15
+ .filter(([pattern]) => pattern.includes("*"))
16
+ .map(([pattern, substitutions]) => {
17
+ const starIndex = pattern.indexOf("*");
18
+ return {
19
+ prefix: pattern.substring(0, starIndex),
20
+ suffix: pattern.substring(starIndex + 1),
21
+ substitutions,
22
+ };
23
+ });
24
+ return {
25
+ name: "tsconfig-resolver",
26
+ resolveId(source, importer) {
27
+ for (const { prefix, suffix, substitutions } of matchers) {
28
+ if (source.startsWith(prefix) && source.endsWith(suffix)) {
29
+ const wildcard = source.slice(prefix.length, source.length - suffix.length || undefined);
30
+ for (const sub of substitutions) {
31
+ const resolved = sub.replace("*", wildcard);
32
+ const full = join(baseUrl, resolved);
33
+ if (ts.sys.fileExists(full)) {
34
+ return full;
35
+ }
36
+ }
37
+ }
38
+ }
39
+ if (importer &&
40
+ importer.endsWith(".ts") &&
41
+ (source.startsWith("./") || source.startsWith("../"))) {
42
+ const dir = dirname(importer);
43
+ const withExt = join(dir, `${source}.ts`);
44
+ if (ts.sys.fileExists(withExt))
45
+ return withExt;
46
+ const asIndex = join(dir, source, "index.ts");
47
+ if (ts.sys.fileExists(asIndex))
48
+ return asIndex;
49
+ }
50
+ return null;
51
+ },
52
+ load(id) {
53
+ if (!id.endsWith(".ts"))
54
+ return null;
55
+ const source = ts.sys.readFile(id);
56
+ if (source === undefined)
57
+ return null;
58
+ const result = ts.transpileModule(source, {
59
+ compilerOptions: STRIP_TYPES_OPTIONS,
60
+ });
61
+ return result.outputText;
62
+ },
63
+ };
64
+ }
@@ -0,0 +1,8 @@
1
+ /**
2
+ * Remove exported declarations (and their unreferenced internal dependencies)
3
+ * from TypeScript source, keeping only the declarations whose names are in
4
+ * `survivingExports` plus anything they transitively reference.
5
+ *
6
+ * Returns the modified TypeScript source string.
7
+ */
8
+ export declare function stripDeadExports(source: string, survivingExports: Set<string>): string;
@@ -0,0 +1,88 @@
1
+ import * as ts from "typescript";
2
+ /**
3
+ * Remove exported declarations (and their unreferenced internal dependencies)
4
+ * from TypeScript source, keeping only the declarations whose names are in
5
+ * `survivingExports` plus anything they transitively reference.
6
+ *
7
+ * Returns the modified TypeScript source string.
8
+ */
9
+ export function stripDeadExports(source, survivingExports) {
10
+ const sourceFile = ts.createSourceFile("module.ts", source, ts.ScriptTarget.Latest, true);
11
+ // 1. Map declaration names to their nodes
12
+ const declarations = new Map();
13
+ const stmtNames = new Map();
14
+ for (const stmt of sourceFile.statements) {
15
+ const names = getDeclaredNames(stmt);
16
+ stmtNames.set(stmt, names);
17
+ for (const name of names) {
18
+ declarations.set(name, stmt);
19
+ }
20
+ }
21
+ // 2. Build reference graph: which declarations reference which others
22
+ const references = new Map();
23
+ for (const [name, node] of declarations) {
24
+ const refs = new Set();
25
+ collectReferences(node, declarations, refs);
26
+ refs.delete(name); // Remove self-reference
27
+ references.set(name, refs);
28
+ }
29
+ // 3. Walk from surviving exports to find all reachable declarations
30
+ const reachable = new Set();
31
+ function markReachable(name) {
32
+ if (reachable.has(name))
33
+ return;
34
+ reachable.add(name);
35
+ const refs = references.get(name);
36
+ if (refs) {
37
+ for (const ref of refs)
38
+ markReachable(ref);
39
+ }
40
+ }
41
+ for (const name of survivingExports) {
42
+ if (declarations.has(name))
43
+ markReachable(name);
44
+ }
45
+ // 4. Transform: remove unreachable declarations
46
+ const transformer = () => {
47
+ return (sf) => {
48
+ const filtered = sf.statements.filter((stmt) => {
49
+ const names = stmtNames.get(stmt) ?? [];
50
+ if (names.length === 0)
51
+ return true;
52
+ return names.some((n) => reachable.has(n));
53
+ });
54
+ return ts.factory.updateSourceFile(sf, filtered);
55
+ };
56
+ };
57
+ const result = ts.transform(sourceFile, [transformer]);
58
+ const printer = ts.createPrinter({ newLine: ts.NewLineKind.LineFeed });
59
+ const printed = printer.printFile(result.transformed[0]);
60
+ result.dispose();
61
+ return printed;
62
+ }
63
+ function getDeclaredNames(node) {
64
+ if (ts.isFunctionDeclaration(node) && node.name) {
65
+ return [node.name.text];
66
+ }
67
+ if (ts.isVariableStatement(node)) {
68
+ return node.declarationList.declarations
69
+ .filter((d) => ts.isIdentifier(d.name))
70
+ .map((d) => d.name.text);
71
+ }
72
+ if (ts.isClassDeclaration(node) && node.name) {
73
+ return [node.name.text];
74
+ }
75
+ // Re-exports: export { spawn } or export { spawn } from "./internal/spawn"
76
+ if (ts.isExportDeclaration(node) && node.exportClause && ts.isNamedExports(node.exportClause)) {
77
+ return node.exportClause.elements.map((e) => e.name.text);
78
+ }
79
+ return [];
80
+ }
81
+ function collectReferences(node, declarations, refs) {
82
+ if (ts.isIdentifier(node)) {
83
+ if (declarations.has(node.text)) {
84
+ refs.add(node.text);
85
+ }
86
+ }
87
+ ts.forEachChild(node, (child) => collectReferences(child, declarations, refs));
88
+ }
@@ -0,0 +1,13 @@
1
+ export interface ShakeOptions {
2
+ /** Entry file paths (same files you'd pass as luaBundleEntry). */
3
+ entry: string[];
4
+ /** Path to tsconfig.json (used to resolve module paths). */
5
+ tsconfig: string;
6
+ }
7
+ export interface ShakeResult {
8
+ /** All files to pass to TSTL (entry files + module files). */
9
+ files: string[];
10
+ /** Map of module file path to Set of surviving export names. */
11
+ survivingExports: Map<string, Set<string>>;
12
+ }
13
+ export declare function shakeModules(options: ShakeOptions): Promise<ShakeResult>;
package/dist/shake.js ADDED
@@ -0,0 +1,74 @@
1
+ import * as ts from "typescript";
2
+ import { resolve, dirname } from "node:path";
3
+ import { createTsconfigResolverPlugin } from "./shake-plugin.js";
4
+ function readTsconfigPaths(tsconfigPath) {
5
+ const absolute = resolve(tsconfigPath);
6
+ const configFile = ts.readConfigFile(absolute, ts.sys.readFile);
7
+ if (configFile.error) {
8
+ const msg = ts.flattenDiagnosticMessageText(configFile.error.messageText, "\n");
9
+ throw new Error(`shakeModules: failed to read tsconfig at ${absolute}: ${msg}`);
10
+ }
11
+ const parsed = ts.parseJsonConfigFileContent(configFile.config, ts.sys, dirname(absolute));
12
+ if (parsed.errors.length > 0) {
13
+ const msg = parsed.errors
14
+ .map((e) => ts.flattenDiagnosticMessageText(e.messageText, "\n"))
15
+ .join("\n");
16
+ throw new Error(`shakeModules: tsconfig errors in ${absolute}: ${msg}`);
17
+ }
18
+ return {
19
+ paths: parsed.options.paths ?? {},
20
+ baseUrl: parsed.options.baseUrl ?? dirname(absolute),
21
+ };
22
+ }
23
+ export async function shakeModules(options) {
24
+ let rollup;
25
+ try {
26
+ rollup = await import("rollup");
27
+ }
28
+ catch {
29
+ throw new Error("shakeModules requires rollup as a peer dependency. Install it with: bun add -d rollup");
30
+ }
31
+ const { paths, baseUrl } = readTsconfigPaths(options.tsconfig);
32
+ const resolverPlugin = createTsconfigResolverPlugin({ paths, baseUrl });
33
+ const resolvedModuleFiles = new Set();
34
+ const entryFiles = options.entry.map((e) => resolve(e));
35
+ const wrappedPlugin = {
36
+ name: "tsconfig-resolver-wrapper",
37
+ resolveId(source, importer) {
38
+ const result = resolverPlugin.resolveId.call(this, source, importer);
39
+ if (typeof result === "string") {
40
+ resolvedModuleFiles.add(result);
41
+ return result;
42
+ }
43
+ if (!importer)
44
+ return null;
45
+ return { id: source, external: true };
46
+ },
47
+ load(id) {
48
+ return resolverPlugin.load.call(this, id);
49
+ },
50
+ };
51
+ const bundle = await rollup.rollup({
52
+ input: entryFiles,
53
+ plugins: [wrappedPlugin],
54
+ treeshake: true,
55
+ });
56
+ const { output } = await bundle.generate({
57
+ format: "esm",
58
+ preserveModules: true,
59
+ });
60
+ await bundle.close();
61
+ const survivingExports = new Map();
62
+ for (const chunk of output) {
63
+ if (chunk.type !== "chunk" || !chunk.facadeModuleId)
64
+ continue;
65
+ if (resolvedModuleFiles.has(chunk.facadeModuleId)) {
66
+ survivingExports.set(chunk.facadeModuleId, new Set(chunk.exports));
67
+ }
68
+ }
69
+ const fileSet = new Set(entryFiles);
70
+ for (const moduleFile of resolvedModuleFiles) {
71
+ fileSet.add(moduleFile);
72
+ }
73
+ return { files: [...fileSet], survivingExports };
74
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@gwigz/slua-tstl-plugin",
3
- "version": "1.2.0",
3
+ "version": "1.3.1",
4
4
  "description": "TypeScriptToLua plugin for targeting Second Life's SLua runtime",
5
5
  "license": "MIT",
6
6
  "repository": {
@@ -18,6 +18,10 @@
18
18
  ".": {
19
19
  "types": "./dist/index.d.ts",
20
20
  "default": "./dist/index.js"
21
+ },
22
+ "./shake": {
23
+ "types": "./dist/shake.d.ts",
24
+ "default": "./dist/shake.js"
21
25
  }
22
26
  },
23
27
  "publishConfig": {
@@ -28,10 +32,16 @@
28
32
  "test": "bun test"
29
33
  },
30
34
  "dependencies": {
35
+ "@gwigz/slua-types": "^1.2.0",
31
36
  "typescript-to-lua": "^1.33.0"
32
37
  },
33
38
  "peerDependencies": {
34
- "@gwigz/slua-types": "^1.0.0",
39
+ "rollup": "^4.0.0",
35
40
  "typescript": "~5.9.3"
41
+ },
42
+ "peerDependenciesMeta": {
43
+ "rollup": {
44
+ "optional": true
45
+ }
36
46
  }
37
47
  }