Bogfix: update server module stripping function.

This commit is contained in:
Benjamin Toby 2026-03-22 12:32:15 +01:00
parent c3e1341ff8
commit e2df3256f4
6 changed files with 157 additions and 78 deletions

View File

@ -71,12 +71,6 @@ The goal is a framework that is:
Configure the `@moduletrace` scope to point at the registry — pick one: Configure the `@moduletrace` scope to point at the registry — pick one:
**`.npmrc`** (works with npm, bun, and most tools):
```ini
@moduletrace:registry=https://git.tben.me/api/packages/moduletrace/npm/
```
**`bunfig.toml`** (Bun-native): **`bunfig.toml`** (Bun-native):
```toml ```toml
@ -84,6 +78,12 @@ Configure the `@moduletrace` scope to point at the registry — pick one:
"@moduletrace" = { registry = "https://git.tben.me/api/packages/moduletrace/npm/" } "@moduletrace" = { registry = "https://git.tben.me/api/packages/moduletrace/npm/" }
``` ```
**`.npmrc`** (works with npm, bun, and most tools):
```ini
@moduletrace:registry=https://git.tben.me/api/packages/moduletrace/npm/
```
Then install: Then install:
```bash ```bash

View File

@ -1,25 +1,29 @@
import path from "path"; import path from "path";
import ts from "typescript"; import ts from "typescript";
export default function stripServerSideLogic({ txt_code, file_path }) { export default function stripServerSideLogic({ txt_code, file_path }) {
const file_dir_name = path.dirname(file_path);
// 1. Initial Parse of the source
const sourceFile = ts.createSourceFile("file.tsx", txt_code, ts.ScriptTarget.Latest, true, ts.ScriptKind.TSX); const sourceFile = ts.createSourceFile("file.tsx", txt_code, ts.ScriptTarget.Latest, true, ts.ScriptKind.TSX);
const printer = ts.createPrinter(); const printer = ts.createPrinter();
const file_fir_name = path.dirname(file_path); /**
const transformer = (context) => (rootNode) => { * PASS 1: Remove 'server' export and resolve absolute paths
*/
const stripTransformer = (context) => (rootNode) => {
const visitor = (node) => { const visitor = (node) => {
// 1. Strip the 'server' export // Remove 'export const server'
if (ts.isVariableStatement(node) && if (ts.isVariableStatement(node) &&
node.modifiers?.some((m) => m.kind === ts.SyntaxKind.ExportKeyword)) { node.modifiers?.some((m) => m.kind === ts.SyntaxKind.ExportKeyword)) {
const isServer = node.declarationList.declarations.some((d) => ts.isIdentifier(d.name) && d.name.text === "server"); if (node.declarationList.declarations.some((d) => ts.isIdentifier(d.name) &&
if (isServer) d.name.text === "server")) {
return undefined; return undefined;
} }
// 2. Convert relative imports to absolute imports }
// Convert relative imports to absolute
if (ts.isImportDeclaration(node)) { if (ts.isImportDeclaration(node)) {
const moduleSpecifier = node.moduleSpecifier; const specifier = node.moduleSpecifier;
if (ts.isStringLiteral(moduleSpecifier) && if (ts.isStringLiteral(specifier) &&
moduleSpecifier.text.startsWith(".")) { specifier.text.startsWith(".")) {
// Resolve the relative path to an absolute filesystem path const absolutePath = path.resolve(file_dir_name, specifier.text);
const absolutePath = path.resolve(file_fir_name, moduleSpecifier.text);
return ts.factory.updateImportDeclaration(node, node.modifiers, node.importClause, ts.factory.createStringLiteral(absolutePath), node.attributes); return ts.factory.updateImportDeclaration(node, node.modifiers, node.importClause, ts.factory.createStringLiteral(absolutePath), node.attributes);
} }
} }
@ -27,36 +31,62 @@ export default function stripServerSideLogic({ txt_code, file_path }) {
}; };
return ts.visitNode(rootNode, visitor); return ts.visitNode(rootNode, visitor);
}; };
const result = ts.transform(sourceFile, [transformer]); const step1Result = ts.transform(sourceFile, [stripTransformer]);
const intermediate = printer.printFile(result.transformed[0]); const intermediateCode = printer.printFile(step1Result.transformed[0]);
// Pass 2: Cleanup unused imports (Same logic as before) // 2. Re-parse to get a "Clean Slate" AST
const cleanSource = ts.createSourceFile("clean.tsx", intermediate, ts.ScriptTarget.Latest, true, ts.ScriptKind.TSX); const cleanSource = ts.createSourceFile("clean.tsx", intermediateCode, ts.ScriptTarget.Latest, true, ts.ScriptKind.TSX);
const cleanup = (context) => (rootNode) => { /**
* PASS 2: Collect all used Identifiers and Prune Imports
*/
const usedIdentifiers = new Set();
function walkAndFindUsages(node) {
if (ts.isIdentifier(node)) {
// We only care about identifiers that AREN'T the names in the import statements themselves
const parent = node.parent;
const isImportName = ts.isImportSpecifier(parent) ||
ts.isImportClause(parent) ||
ts.isNamespaceImport(parent);
if (!isImportName) {
usedIdentifiers.add(node.text);
}
}
ts.forEachChild(node, walkAndFindUsages);
}
walkAndFindUsages(cleanSource);
const cleanupTransformer = (context) => (rootNode) => {
const visitor = (node) => { const visitor = (node) => {
if (ts.isImportDeclaration(node)) { if (ts.isImportDeclaration(node)) {
const clause = node.importClause; const clause = node.importClause;
if (!clause) if (!clause)
return node; return node;
// 1. Clean Named Imports: { A, B }
if (clause.namedBindings && if (clause.namedBindings &&
ts.isNamedImports(clause.namedBindings)) { ts.isNamedImports(clause.namedBindings)) {
const used = clause.namedBindings.elements.filter((el) => { const activeElements = clause.namedBindings.elements.filter((el) => usedIdentifiers.has(el.name.text));
const regex = new RegExp(`\\b${el.name.text}\\b`, "g"); // If no named imports are used and there is no default import, nix the whole line
return ((intermediate.match(regex) || []).length > 1); if (activeElements.length === 0 && !clause.name)
});
if (used.length === 0)
return undefined; return undefined;
return ts.factory.updateImportDeclaration(node, node.modifiers, ts.factory.updateImportClause(clause, clause.isTypeOnly, clause.name, ts.factory.createNamedImports(used)), node.moduleSpecifier, node.attributes); // If we have some named imports left, update the node
return ts.factory.updateImportDeclaration(node, node.modifiers, ts.factory.updateImportClause(clause, clause.isTypeOnly,
// Only keep default import if it's used
clause.name &&
usedIdentifiers.has(clause.name.text)
? clause.name
: undefined, ts.factory.createNamedImports(activeElements)), node.moduleSpecifier, node.attributes);
} }
if (clause.name) { // 2. Clean Default Imports: import X from '...'
const regex = new RegExp(`\\b${clause.name.text}\\b`, "g"); if (clause.name && !usedIdentifiers.has(clause.name.text)) {
if ((intermediate.match(regex) || []).length <= 1) // If there are no named bindings attached, nix the whole line
if (!clause.namedBindings)
return undefined; return undefined;
// Otherwise, just strip the default name and keep the named bindings
return ts.factory.updateImportDeclaration(node, node.modifiers, ts.factory.updateImportClause(clause, clause.isTypeOnly, undefined, clause.namedBindings), node.moduleSpecifier, node.attributes);
} }
} }
return ts.visitEachChild(node, visitor, context); return ts.visitEachChild(node, visitor, context);
}; };
return ts.visitNode(rootNode, visitor); return ts.visitNode(rootNode, visitor);
}; };
const final = ts.transform(cleanSource, [cleanup]); const finalResult = ts.transform(cleanSource, [cleanupTransformer]);
return printer.printFile(final.transformed[0]); return printer.printFile(finalResult.transformed[0]);
} }

View File

@ -14,6 +14,9 @@ export default async function rewritePagesModule(params) {
for (let i = 0; i < target_pages.length; i++) { for (let i = 0; i < target_pages.length; i++) {
const page_path = target_pages[i]; const page_path = target_pages[i];
const dst_path = pagePathTransform({ page_path }); const dst_path = pagePathTransform({ page_path });
if (page_path.match(/__root\.tsx?/)) {
continue;
}
const origin_page_content = await Bun.file(page_path).text(); const origin_page_content = await Bun.file(page_path).text();
const dst_page_content = stripServerSideLogic({ const dst_page_content = stripServerSideLogic({
txt_code: origin_page_content, txt_code: origin_page_content,

View File

@ -2,7 +2,7 @@
"name": "@moduletrace/bunext", "name": "@moduletrace/bunext",
"module": "index.ts", "module": "index.ts",
"type": "module", "type": "module",
"version": "1.0.13", "version": "1.0.14",
"main": "dist/index.js", "main": "dist/index.js",
"types": "dist/index.d.ts", "types": "dist/index.d.ts",
"exports": { "exports": {

View File

@ -7,6 +7,9 @@ type Params = {
}; };
export default function stripServerSideLogic({ txt_code, file_path }: Params) { export default function stripServerSideLogic({ txt_code, file_path }: Params) {
const file_dir_name = path.dirname(file_path);
// 1. Initial Parse of the source
const sourceFile = ts.createSourceFile( const sourceFile = ts.createSourceFile(
"file.tsx", "file.tsx",
txt_code, txt_code,
@ -15,38 +18,42 @@ export default function stripServerSideLogic({ txt_code, file_path }: Params) {
ts.ScriptKind.TSX, ts.ScriptKind.TSX,
); );
const printer = ts.createPrinter(); const printer = ts.createPrinter();
const file_fir_name = path.dirname(file_path);
const transformer: ts.TransformerFactory<ts.SourceFile> = /**
* PASS 1: Remove 'server' export and resolve absolute paths
*/
const stripTransformer: ts.TransformerFactory<ts.SourceFile> =
(context) => (rootNode) => { (context) => (rootNode) => {
const visitor = (node: ts.Node): ts.Node | undefined => { const visitor = (node: ts.Node): ts.Node | undefined => {
// 1. Strip the 'server' export // Remove 'export const server'
if ( if (
ts.isVariableStatement(node) && ts.isVariableStatement(node) &&
node.modifiers?.some( node.modifiers?.some(
(m) => m.kind === ts.SyntaxKind.ExportKeyword, (m) => m.kind === ts.SyntaxKind.ExportKeyword,
) )
) { ) {
const isServer = node.declarationList.declarations.some( if (
node.declarationList.declarations.some(
(d) => (d) =>
ts.isIdentifier(d.name) && d.name.text === "server", ts.isIdentifier(d.name) &&
); d.name.text === "server",
if (isServer) return undefined; )
) {
return undefined;
}
} }
// 2. Convert relative imports to absolute imports // Convert relative imports to absolute
if (ts.isImportDeclaration(node)) { if (ts.isImportDeclaration(node)) {
const moduleSpecifier = node.moduleSpecifier; const specifier = node.moduleSpecifier;
if ( if (
ts.isStringLiteral(moduleSpecifier) && ts.isStringLiteral(specifier) &&
moduleSpecifier.text.startsWith(".") specifier.text.startsWith(".")
) { ) {
// Resolve the relative path to an absolute filesystem path
const absolutePath = path.resolve( const absolutePath = path.resolve(
file_fir_name, file_dir_name,
moduleSpecifier.text, specifier.text,
); );
return ts.factory.updateImportDeclaration( return ts.factory.updateImportDeclaration(
node, node,
node.modifiers, node.modifiers,
@ -56,67 +63,102 @@ export default function stripServerSideLogic({ txt_code, file_path }: Params) {
); );
} }
} }
return ts.visitEachChild(node, visitor, context); return ts.visitEachChild(node, visitor, context);
}; };
return ts.visitNode(rootNode, visitor) as ts.SourceFile; return ts.visitNode(rootNode, visitor) as ts.SourceFile;
}; };
const result = ts.transform(sourceFile, [transformer]); const step1Result = ts.transform(sourceFile, [stripTransformer]);
const intermediate = printer.printFile(result.transformed[0]); const intermediateCode = printer.printFile(step1Result.transformed[0]);
// Pass 2: Cleanup unused imports (Same logic as before) // 2. Re-parse to get a "Clean Slate" AST
const cleanSource = ts.createSourceFile( const cleanSource = ts.createSourceFile(
"clean.tsx", "clean.tsx",
intermediate, intermediateCode,
ts.ScriptTarget.Latest, ts.ScriptTarget.Latest,
true, true,
ts.ScriptKind.TSX, ts.ScriptKind.TSX,
); );
const cleanup: ts.TransformerFactory<ts.SourceFile> =
/**
* PASS 2: Collect all used Identifiers and Prune Imports
*/
const usedIdentifiers = new Set<string>();
function walkAndFindUsages(node: ts.Node) {
if (ts.isIdentifier(node)) {
// We only care about identifiers that AREN'T the names in the import statements themselves
const parent = node.parent;
const isImportName =
ts.isImportSpecifier(parent) ||
ts.isImportClause(parent) ||
ts.isNamespaceImport(parent);
if (!isImportName) {
usedIdentifiers.add(node.text);
}
}
ts.forEachChild(node, walkAndFindUsages);
}
walkAndFindUsages(cleanSource);
const cleanupTransformer: ts.TransformerFactory<ts.SourceFile> =
(context) => (rootNode) => { (context) => (rootNode) => {
const visitor = (node: ts.Node): ts.Node | undefined => { const visitor = (node: ts.Node): ts.Node | undefined => {
if (ts.isImportDeclaration(node)) { if (ts.isImportDeclaration(node)) {
const clause = node.importClause; const clause = node.importClause;
if (!clause) return node; if (!clause) return node;
// 1. Clean Named Imports: { A, B }
if ( if (
clause.namedBindings && clause.namedBindings &&
ts.isNamedImports(clause.namedBindings) ts.isNamedImports(clause.namedBindings)
) { ) {
const used = clause.namedBindings.elements.filter( const activeElements =
(el) => { clause.namedBindings.elements.filter((el) =>
const regex = new RegExp( usedIdentifiers.has(el.name.text),
`\\b${el.name.text}\\b`,
"g",
); );
return (
(intermediate.match(regex) || []).length > 1 // If no named imports are used and there is no default import, nix the whole line
); if (activeElements.length === 0 && !clause.name)
}, return undefined;
);
if (used.length === 0) return undefined; // If we have some named imports left, update the node
return ts.factory.updateImportDeclaration( return ts.factory.updateImportDeclaration(
node, node,
node.modifiers, node.modifiers,
ts.factory.updateImportClause( ts.factory.updateImportClause(
clause, clause,
clause.isTypeOnly, clause.isTypeOnly,
clause.name, // Only keep default import if it's used
ts.factory.createNamedImports(used), clause.name &&
usedIdentifiers.has(clause.name.text)
? clause.name
: undefined,
ts.factory.createNamedImports(activeElements),
), ),
node.moduleSpecifier, node.moduleSpecifier,
node.attributes, node.attributes,
); );
} }
if (clause.name) { // 2. Clean Default Imports: import X from '...'
const regex = new RegExp( if (clause.name && !usedIdentifiers.has(clause.name.text)) {
`\\b${clause.name.text}\\b`, // If there are no named bindings attached, nix the whole line
"g", if (!clause.namedBindings) return undefined;
// Otherwise, just strip the default name and keep the named bindings
return ts.factory.updateImportDeclaration(
node,
node.modifiers,
ts.factory.updateImportClause(
clause,
clause.isTypeOnly,
undefined,
clause.namedBindings,
),
node.moduleSpecifier,
node.attributes,
); );
if ((intermediate.match(regex) || []).length <= 1)
return undefined;
} }
} }
return ts.visitEachChild(node, visitor, context); return ts.visitEachChild(node, visitor, context);
@ -124,6 +166,6 @@ export default function stripServerSideLogic({ txt_code, file_path }: Params) {
return ts.visitNode(rootNode, visitor) as ts.SourceFile; return ts.visitNode(rootNode, visitor) as ts.SourceFile;
}; };
const final = ts.transform(cleanSource, [cleanup]); const finalResult = ts.transform(cleanSource, [cleanupTransformer]);
return printer.printFile(final.transformed[0]); return printer.printFile(finalResult.transformed[0]);
} }

View File

@ -21,6 +21,10 @@ export default async function rewritePagesModule(params?: Params) {
const page_path = target_pages[i]; const page_path = target_pages[i];
const dst_path = pagePathTransform({ page_path }); const dst_path = pagePathTransform({ page_path });
if (page_path.match(/__root\.tsx?/)) {
continue;
}
const origin_page_content = await Bun.file(page_path).text(); const origin_page_content = await Bun.file(page_path).text();
const dst_page_content = stripServerSideLogic({ const dst_page_content = stripServerSideLogic({
txt_code: origin_page_content, txt_code: origin_page_content,