Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
188 changes: 1 addition & 187 deletions src/tsUtil.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,15 +2,7 @@ import { describe, expect, mock, test } from 'bun:test';
import * as fs from 'node:fs';
import { join } from 'node:path';
import ts from 'typescript';
import {
createProgram,
extractTypeAliases,
getTypeName,
isHtmlOrBodyType,
processTypeScript,
traverseNode,
visit,
} from './tsUtil';
import { createProgram, processTypeScript, traverseNode, visit } from './tsUtil';

// Helper function to create test TypeScript source files
function createTestSourceFile(code: string, fileName: string = 'test.ts'): ts.SourceFile {
Expand Down Expand Up @@ -71,138 +63,6 @@ describe('TypeScript Utility Functions', () => {
});
});

describe('isHtmlOrBodyType', () => {
test('should return true for Html type alias', () => {
const code = `
import { Html } from './html';
type MyPage = Html<"content">;
`;

const sourceFile = createTestSourceFile(code);
let foundHtmlType = false;

function checkNode(node: ts.Node) {
if (ts.isTypeAliasDeclaration(node) && node.name.text === 'MyPage') {
foundHtmlType = isHtmlOrBodyType(node);
}
ts.forEachChild(node, checkNode);
}

checkNode(sourceFile);
expect(foundHtmlType).toBe(true);
});

test('should return true for Body type alias', () => {
const code = `
import { Body } from './html';
type MyBody = Body<"content">;
`;

const sourceFile = createTestSourceFile(code);
let foundBodyType = false;

function checkNode(node: ts.Node) {
if (ts.isTypeAliasDeclaration(node) && node.name.text === 'MyBody') {
foundBodyType = isHtmlOrBodyType(node);
}
ts.forEachChild(node, checkNode);
}

checkNode(sourceFile);
expect(foundBodyType).toBe(true);
});

test('should return false for other type aliases', () => {
const code = `
type MyDiv = Div<"content">;
type MyString = string;
`;

const sourceFile = createTestSourceFile(code);
const results: boolean[] = [];

function checkNode(node: ts.Node) {
if (ts.isTypeAliasDeclaration(node)) {
results.push(isHtmlOrBodyType(node));
}
ts.forEachChild(node, checkNode);
}

checkNode(sourceFile);
expect(results.every((result) => result === false)).toBe(true);
});
});

describe('extractTypeAliases', () => {
test('should extract all type aliases from source file', () => {
const code = `
type First = string;
type Second = number;
interface NotATypeAlias {
value: string;
}
type Third = boolean;
`;

const sourceFile = createTestSourceFile(code);
const aliases = extractTypeAliases(sourceFile);

expect(aliases).toHaveLength(3);
expect(aliases[0].name.text).toBe('First');
expect(aliases[1].name.text).toBe('Second');
expect(aliases[2].name.text).toBe('Third');
});

test('should return empty array for file without type aliases', () => {
const code = `
interface OnlyInterface {
value: string;
}

const variable = "test";

function myFunction() {
return true;
}
`;

const sourceFile = createTestSourceFile(code);
const aliases = extractTypeAliases(sourceFile);

expect(aliases).toHaveLength(0);
});
});

describe('getTypeName', () => {
test('should return type name for type reference', () => {
const code = `
import { Html } from './html';
type MyPage = Html<"content">;
`;

const sourceFile = createTestSourceFile(code);
const aliases = extractTypeAliases(sourceFile);

expect(aliases).toHaveLength(1);
const typeName = getTypeName(aliases[0]);
expect(typeName).toBe('Html');
});

test('should return null for non-type-reference aliases', () => {
const code = `
type MyString = string;
type MyObject = { value: number };
`;

const sourceFile = createTestSourceFile(code);
const aliases = extractTypeAliases(sourceFile);

expect(aliases).toHaveLength(2);
expect(getTypeName(aliases[0])).toBeNull();
expect(getTypeName(aliases[1])).toBeNull();
});
});

describe('traverseNode', () => {
test('should handle type literal node with brand property', () => {
const code = `
Expand Down Expand Up @@ -351,49 +211,3 @@ describe('TypeScript Utility Functions', () => {
});
});
});

describe('Integration Tests', () => {
test('should work with complete HTML type structure', () => {
const code = `
import { Html, Body, Div, P } from './html';

type MyPage = Html<
Body<
Div<
P<"Hello World">
>
>
>;
`;

const sourceFile = createTestSourceFile(code);
const aliases = extractTypeAliases(sourceFile);

expect(aliases).toHaveLength(1);
expect(aliases[0].name.text).toBe('MyPage');
expect(isHtmlOrBodyType(aliases[0])).toBe(true);
expect(getTypeName(aliases[0])).toBe('Html');
});

test('should identify multiple HTML/Body types', () => {
const code = `
import { Html, Body, Div } from './html';

type Page1 = Html<"Content 1">;
type Page2 = Body<"Content 2">;
type NotHtml = Div<"Content 3">;
`;

const sourceFile = createTestSourceFile(code);
const aliases = extractTypeAliases(sourceFile);

expect(aliases).toHaveLength(3);

const htmlBodyTypes = aliases.filter((alias) => isHtmlOrBodyType(alias));
expect(htmlBodyTypes).toHaveLength(2);

const typeNames = htmlBodyTypes.map((alias) => getTypeName(alias));
expect(typeNames).toContain('Html');
expect(typeNames).toContain('Body');
});
});
67 changes: 12 additions & 55 deletions src/tsUtil.ts
Original file line number Diff line number Diff line change
@@ -1,19 +1,15 @@
// TypeScript AST Processing Utilities

import { createWriteStream } from 'node:fs';
import ts from 'typescript';
import ts, { SyntaxKind } from 'typescript';
import { type HtmlJson, renderToStream } from './html';

export function traverseNode(node: ts.Node, indent: number = 0): HtmlJson {
if (ts.isTypeLiteralNode(node)) {
let tag = '';
let children: (HtmlJson | string)[] = [];
node.members.forEach((member) => {
if (
member.name &&
ts.isIdentifierOrThisTypeNode(member.name) &&
ts.isPropertySignature(member)
) {
if (member.name && ts.isPropertySignature(member) && ts.isIdentifier(member.name)) {
if (
member.type &&
ts.isLiteralTypeNode(member.type) &&
Expand All @@ -37,6 +33,8 @@ export function traverseNode(node: ts.Node, indent: number = 0): HtmlJson {
member.name.expression.escapedText
) {
tag = member.name.expression.escapedText.replace('Brand', '');
} else {
throw new Error('Unexpected type');
}
});
return {
Expand All @@ -49,7 +47,11 @@ export function traverseNode(node: ts.Node, indent: number = 0): HtmlJson {
}

export function visit(node: ts.Node, checker: ts.TypeChecker, outPath: string) {
if (ts.isTypeAliasDeclaration(node)) {
if (ts.isSourceFile(node)) {
ts.forEachChild(node, (childNode) => {
visit(childNode, checker, outPath);
});
} else if (ts.isTypeAliasDeclaration(node)) {
if (
ts.isTypeReferenceNode(node.type) &&
node.type.typeName &&
Expand All @@ -58,13 +60,9 @@ export function visit(node: ts.Node, checker: ts.TypeChecker, outPath: string) {
) {
try {
const type = checker.getTypeAtLocation(node);
const stringJSON = checker.typeToTypeNode(
type,
undefined,
ts.NodeBuilderFlags.InTypeAlias | ts.NodeBuilderFlags.NoTruncation
);
if (stringJSON) {
const result = traverseNode(stringJSON, 0);
const typeNode = checker.typeToTypeNode(type, undefined, ts.NodeBuilderFlags.NoTruncation);
if (typeNode) {
const result = traverseNode(typeNode, 0);
const writeStream = createWriteStream(outPath, { flags: 'w' });
renderToStream(result, writeStream);
writeStream.end('\n');
Expand All @@ -74,7 +72,6 @@ export function visit(node: ts.Node, checker: ts.TypeChecker, outPath: string) {
}
}
}
ts.forEachChild(node, (childNode) => visit(childNode, checker, outPath));
}

export function createProgram(
Expand Down Expand Up @@ -119,43 +116,3 @@ export function processTypeScript(filePath: string, outPath: string): boolean {
return false;
}
}

// TypeScript AST Helper Functions
export function isHtmlOrBodyType(node: ts.Node): boolean {
if (ts.isTypeAliasDeclaration(node)) {
if (
ts.isTypeReferenceNode(node.type) &&
node.type.typeName &&
ts.isIdentifier(node.type.typeName)
) {
const typeName = node.type.typeName.escapedText;
return typeName === 'Html' || typeName === 'Body';
}
}
return false;
}

export function extractTypeAliases(node: ts.Node): ts.TypeAliasDeclaration[] {
const aliases: ts.TypeAliasDeclaration[] = [];

function collectAliases(n: ts.Node) {
if (ts.isTypeAliasDeclaration(n)) {
aliases.push(n);
}
ts.forEachChild(n, collectAliases);
}

collectAliases(node);
return aliases;
}

export function getTypeName(typeAlias: ts.TypeAliasDeclaration): string | null {
if (
ts.isTypeReferenceNode(typeAlias.type) &&
typeAlias.type.typeName &&
ts.isIdentifier(typeAlias.type.typeName)
) {
return typeAlias.type.typeName.escapedText.toString();
}
return null;
}