diff --git a/src/html.ts b/src/html.ts deleted file mode 100644 index b585018..0000000 --- a/src/html.ts +++ /dev/null @@ -1,133 +0,0 @@ -// HTML Type System - 型安全なHTML構造を定義 - -export type Text = string; - -export type HTMLElement = - | Text - | { - children: (HTMLElement | Text)[] | HTMLElement | Text; - }; - -// HTML Brand Types -declare const htmlBrand: unique symbol; -export type Html = T extends HTMLElement[] - ? { - [htmlBrand]: 'html'; - children: T; - } - : T extends HTMLElement - ? { - [htmlBrand]: 'html'; - children: T; - } - : T extends Text - ? { - [htmlBrand]: 'html'; - children: T; - } - : never; - -declare const bodyBrand: unique symbol; -export type Body = T extends Html< - HTMLElement | HTMLElement[] -> - ? never - : T extends HTMLElement[] - ? { - [bodyBrand]: 'body'; - children: T; - } - : T extends HTMLElement - ? { - [bodyBrand]: 'body'; - children: T; - } - : never; - -// エラー型定義 -type InvalidDivContent = { - __error: `❌
cannot contain or elements. Invalid HTML structure.`; - __invalidType: T; -}; - -declare const divBrand: unique symbol; - -export type Div = T extends Html - ? InvalidDivContent - : T extends Body - ? InvalidDivContent - : T extends HTMLElement[] - ? { - [divBrand]: 'div'; - children: T; - } - : T extends HTMLElement - ? { - [divBrand]: 'div'; - children: T; - } - : never; - -type InvalidPContent = { - __error: `❌

cannot contain block elements. Only inline elements are allowed in

.`; - __invalidType: T; -}; - -declare const pBrand: unique symbol; - -export type P = T extends Div - ? InvalidPContent - : T extends Html - ? InvalidPContent - : T extends Body - ? InvalidPContent - : T extends HTMLElement[] - ? { - [pBrand]: 'p'; - children: T; - } - : T extends HTMLElement - ? { - [pBrand]: 'p'; - children: T; - } - : never; - -// HTML JSON構造体 -export type HtmlJson = { - tag: string; - children: (HtmlJson | string)[]; -}; - -// HTML レンダリング関数 -export function renderToHtml(input: HtmlJson, indent: number = 0): string { - const space = ' '.repeat(indent * 2); - return `${space}<${input.tag}> -${input.children - .map((item) => { - if (typeof item === 'string') { - return `${space} ${item}`; - } else { - return renderToHtml(item, indent + 1); - } - }) - .join('\n')} -${space}`; -} - -export function renderToStream( - input: HtmlJson, - writeStream: { write: (data: string) => void }, - indent: number = 0 -) { - const space = ' '.repeat(indent * 2); - writeStream.write(`${space}<${input.tag}>\n`); - input.children.forEach((item) => { - if (typeof item === 'string') { - writeStream.write(`${space} ${item}\n`); - } else { - renderToStream(item, writeStream, indent + 1); - } - }); - writeStream.write(`${space}\n`); -} diff --git a/src/html/attributes.ts b/src/html/attributes.ts new file mode 100644 index 0000000..1a11144 --- /dev/null +++ b/src/html/attributes.ts @@ -0,0 +1,143 @@ +export interface GlobalAttributes { + id?: string; + class?: string; + style?: string; + title?: string; + lang?: string; + dir?: 'ltr' | 'rtl' | 'auto'; + tabindex?: number; + accesskey?: string; + contenteditable?: boolean | 'true' | 'false'; + draggable?: boolean | 'true' | 'false'; + hidden?: boolean; + spellcheck?: boolean | 'true' | 'false'; + translate?: 'yes' | 'no'; + [key: `data-${string}`]: string | number | boolean; + role?: string; + [key: `aria-${string}`]: string | number | boolean; +} + +export interface EventAttributes { + onclick?: string; + ondblclick?: string; + onmousedown?: string; + onmouseup?: string; + onmouseover?: string; + onmousemove?: string; + onmouseout?: string; + onkeydown?: string; + onkeyup?: string; + onkeypress?: string; + onfocus?: string; + onblur?: string; + onchange?: string; + onsubmit?: string; + onload?: string; + onunload?: string; + onresize?: string; + onscroll?: string; +} + +export interface FormAttributes { + name?: string; + value?: string | number; + type?: string; + placeholder?: string; + required?: boolean; + disabled?: boolean; + readonly?: boolean; + checked?: boolean; + selected?: boolean; + maxlength?: number; + minlength?: number; + max?: number | string; + min?: number | string; + step?: number | string; + pattern?: string; + autocomplete?: 'on' | 'off' | string; + autofocus?: boolean; + multiple?: boolean; + size?: number; + form?: string; + formaction?: string; + formenctype?: string; + formmethod?: 'get' | 'post'; + formnovalidate?: boolean; + formtarget?: '_blank' | '_self' | '_parent' | '_top' | string; +} + +export interface LinkResourceAttributes { + href?: string; + src?: string; + srcset?: string; + sizes?: string; + media?: string; + rel?: string; + type?: string; + download?: boolean | string; + target?: '_blank' | '_self' | '_parent' | '_top' | string; + hreflang?: string; + referrerpolicy?: + | 'no-referrer' + | 'no-referrer-when-downgrade' + | 'origin' + | 'origin-when-cross-origin' + | 'same-origin' + | 'strict-origin' + | 'strict-origin-when-cross-origin' + | 'unsafe-url'; +} + +export interface MediaAttributes { + alt?: string; + width?: number | string; + height?: number | string; + loading?: 'eager' | 'lazy'; + decoding?: 'sync' | 'async' | 'auto'; + crossorigin?: 'anonymous' | 'use-credentials'; + usemap?: string; + ismap?: boolean; + autoplay?: boolean; + controls?: boolean; + loop?: boolean; + muted?: boolean; + preload?: 'none' | 'metadata' | 'auto'; + poster?: string; +} + +export interface TableAttributes { + colspan?: number; + rowspan?: number; + headers?: string; + scope?: 'row' | 'col' | 'rowgroup' | 'colgroup'; +} + +export interface MetadataAttributes { + charset?: string; + content?: string; + 'http-equiv'?: string; + property?: string; +} + +export interface ListAttributes { + start?: number; + reversed?: boolean; + type?: '1' | 'a' | 'A' | 'i' | 'I'; +} + +export interface EmbeddedAttributes { + sandbox?: string; + allow?: string; + allowfullscreen?: boolean; + allowpaymentrequest?: boolean; +} + +export type AllHTMLAttributes = GlobalAttributes & + EventAttributes & + FormAttributes & + LinkResourceAttributes & + MediaAttributes & + TableAttributes & + MetadataAttributes & + ListAttributes & + EmbeddedAttributes; diff --git a/src/html/index.ts b/src/html/index.ts new file mode 100644 index 0000000..343e99f --- /dev/null +++ b/src/html/index.ts @@ -0,0 +1,3 @@ +export * from './attributes'; +export * from './tags'; +export * from './util'; diff --git a/src/html/tags.ts b/src/html/tags.ts new file mode 100644 index 0000000..fecead5 --- /dev/null +++ b/src/html/tags.ts @@ -0,0 +1,304 @@ +import type { AllHTMLAttributes } from './attributes'; + +export type Text = string; + +export type HTMLElement = + | Text + | { + children: (HTMLElement | Text)[] | HTMLElement | Text; + attributes?: AllHTMLAttributes; + }; + +declare const htmlBrand: unique symbol; +export type Html< + T extends HTMLElement[] | HTMLElement, + A extends AllHTMLAttributes = {}, +> = T extends HTMLElement[] + ? { + [htmlBrand]: 'html'; + children: T; + attributes: A; + } + : T extends HTMLElement + ? { + [htmlBrand]: 'html'; + children: T; + attributes: A; + } + : T extends Text + ? { + [htmlBrand]: 'html'; + children: T; + attributes: A; + } + : never; + +declare const bodyBrand: unique symbol; +export type Body< + T extends HTMLElement[] | HTMLElement, + A extends AllHTMLAttributes = {}, +> = T extends Html + ? never + : T extends HTMLElement[] + ? { + [bodyBrand]: 'body'; + children: T; + attributes: A; + } + : T extends HTMLElement + ? { + [bodyBrand]: 'body'; + children: T; + attributes: A; + } + : never; + +type InvalidDivContent = { + __error: `❌

cannot contain or elements. Invalid HTML structure.`; + __invalidType: T; +}; + +declare const divBrand: unique symbol; + +export type Div< + T extends HTMLElement[] | HTMLElement, + A extends AllHTMLAttributes = {}, +> = T extends Html + ? InvalidDivContent + : T extends Body + ? InvalidDivContent + : T extends HTMLElement[] + ? { + [divBrand]: 'div'; + children: T; + attributes: A; + } + : T extends HTMLElement + ? { + [divBrand]: 'div'; + children: T; + attributes: A; + } + : never; + +type InvalidPContent = { + __error: `❌

cannot contain block elements. Only inline elements are allowed in

.`; + __invalidType: T; +}; + +declare const pBrand: unique symbol; + +export type P< + T extends HTMLElement[] | HTMLElement, + A extends AllHTMLAttributes = {}, +> = T extends Div + ? InvalidPContent + : T extends Html + ? InvalidPContent + : T extends Body + ? InvalidPContent + : T extends HTMLElement[] + ? { + [pBrand]: 'p'; + children: T; + attributes: A; + } + : T extends HTMLElement + ? { + [pBrand]: 'p'; + children: T; + attributes: A; + } + : never; + +type InvalidH1Content = { + __error: `❌

cannot contain block elements. Only inline elements are allowed in

.`; + __invalidType: T; +}; + +declare const h1Brand: unique symbol; + +export type H1< + T extends HTMLElement[] | HTMLElement, + A extends AllHTMLAttributes = {}, +> = T extends Div + ? InvalidH1Content + : T extends Html + ? InvalidH1Content + : T extends Body + ? InvalidH1Content + : T extends P + ? InvalidH1Content + : T extends HTMLElement[] + ? { + [h1Brand]: 'h1'; + children: T; + attributes: A; + } + : T extends HTMLElement + ? { + [h1Brand]: 'h1'; + children: T; + attributes: A; + } + : never; + +type InvalidH2Content = { + __error: `❌

cannot contain block elements. Only inline elements are allowed in

.`; + __invalidType: T; +}; + +declare const h2Brand: unique symbol; + +export type H2< + T extends HTMLElement[] | HTMLElement, + A extends AllHTMLAttributes = {}, +> = T extends Div + ? InvalidH2Content + : T extends Html + ? InvalidH2Content + : T extends Body + ? InvalidH2Content + : T extends P + ? InvalidH2Content + : T extends H1 + ? InvalidH2Content + : T extends HTMLElement[] + ? { + [h2Brand]: 'h2'; + children: T; + attributes: A; + } + : T extends HTMLElement + ? { + [h2Brand]: 'h2'; + children: T; + attributes: A; + } + : never; + +type InvalidH3Content = { + __error: `❌

cannot contain block elements. Only inline elements are allowed in

.`; + __invalidType: T; +}; + +declare const h3Brand: unique symbol; + +export type H3< + T extends HTMLElement[] | HTMLElement, + A extends AllHTMLAttributes = {}, +> = T extends Div + ? InvalidH3Content + : T extends Html + ? InvalidH3Content + : T extends Body + ? InvalidH3Content + : T extends P + ? InvalidH3Content + : T extends H1 + ? InvalidH3Content + : T extends H2 + ? InvalidH3Content + : T extends HTMLElement[] + ? { + [h3Brand]: 'h3'; + children: T; + attributes: A; + } + : T extends HTMLElement + ? { + [h3Brand]: 'h3'; + children: T; + attributes: A; + } + : never; + +type InvalidH4Content = { + __error: `❌

cannot contain block elements. Only inline elements are allowed in

.`; + __invalidType: T; +}; + +declare const h4Brand: unique symbol; + +export type H4< + T extends HTMLElement[] | HTMLElement, + A extends AllHTMLAttributes = {}, +> = T extends Div + ? InvalidH4Content + : T extends Html + ? InvalidH4Content + : T extends Body + ? InvalidH4Content + : T extends P + ? InvalidH4Content + : T extends H1 + ? InvalidH4Content + : T extends H2 + ? InvalidH4Content + : T extends H3 + ? InvalidH4Content + : T extends HTMLElement[] + ? { + [h4Brand]: 'h4'; + children: T; + attributes: A; + } + : T extends HTMLElement + ? { + [h4Brand]: 'h4'; + children: T; + attributes: A; + } + : never; + +type InvalidH5Content = { + __error: `❌
cannot contain block elements. Only inline elements are allowed in
.`; + __invalidType: T; +}; + +declare const h5Brand: unique symbol; + +export type H5< + T extends HTMLElement[] | HTMLElement, + A extends AllHTMLAttributes = {}, +> = T extends Div + ? InvalidH5Content + : T extends Html + ? InvalidH5Content + : T extends Body + ? InvalidH5Content + : T extends P + ? InvalidH5Content + : T extends H1 + ? InvalidH5Content + : T extends H2 + ? InvalidH5Content + : T extends H3 + ? InvalidH5Content + : T extends H4 + ? InvalidH5Content + : T extends HTMLElement[] + ? { + [h5Brand]: 'h5'; + children: T; + attributes: A; + } + : T extends HTMLElement + ? { + [h5Brand]: 'h5'; + children: T; + attributes: A; + } + : never; + +declare const imgBrand: unique symbol; + +export type Img = { + [K in typeof imgBrand | 'children' | 'attributes']: K extends typeof imgBrand + ? 'img' + : K extends 'children' + ? never + : K extends 'attributes' + ? A + : never; +}; diff --git a/src/html/util.ts b/src/html/util.ts new file mode 100644 index 0000000..1729ff9 --- /dev/null +++ b/src/html/util.ts @@ -0,0 +1,32 @@ +export type HtmlJson = { + tag: string; + children: (HtmlJson | string)[] | undefined; + attributes: + | { + key: string; + value: string; + }[] + | undefined; +}; + +export function renderToStream( + input: HtmlJson, + writeStream: { write: (data: string) => void }, + indent: number = 0 +) { + const space = ' '.repeat(indent * 2); + const attributes = input.attributes?.map((item) => `${item.key}="${item.value}"`).join(' '); + if (input.children) { + writeStream.write(`${space}<${input.tag}${attributes ? ` ${attributes}` : ''}>\n`); + input.children?.forEach((item) => { + if (typeof item === 'string') { + writeStream.write(`${space} ${item}\n`); + } else { + renderToStream(item, writeStream, indent + 1); + } + }); + writeStream.write(`${space}\n`); + } else { + writeStream.write(`${space}<${input.tag}${attributes ? ` ${attributes}` : ''} />\n`); + } +} diff --git a/src/tsUtil.test.ts b/src/tsUtil.test.ts index 9779f88..dc09705 100644 --- a/src/tsUtil.test.ts +++ b/src/tsUtil.test.ts @@ -126,7 +126,7 @@ describe('TypeScript Utility Functions', () => { findNonTypeLiteral(sourceFile); if (nonTypeLiteralNode) { - expect(() => traverseNode(nonTypeLiteralNode!)).toThrow('Unexpected type'); + expect(() => traverseNode(nonTypeLiteralNode!, 0)).toThrow('Unexpected type'); } }); }); diff --git a/src/tsUtil.ts b/src/tsUtil.ts index 09d05cd..9d6e9d4 100644 --- a/src/tsUtil.ts +++ b/src/tsUtil.ts @@ -1,45 +1,103 @@ // TypeScript AST Processing Utilities +import assert from 'node:assert'; import { createWriteStream } from 'node:fs'; import ts, { SyntaxKind } from 'typescript'; import { type HtmlJson, renderToStream } from './html'; +function parseAttributes(node: ts.Node | undefined): HtmlJson['attributes'] | undefined { + if (node && ts.isTypeLiteralNode(node)) { + return node.members.map((obj) => { + if ( + ts.isPropertySignature(obj) && + ts.isIdentifier(obj.name) && + obj.type && + ts.isLiteralTypeNode(obj.type) && + ts.isStringLiteral(obj.type.literal) + ) { + return { key: obj.name.escapedText.toString(), value: obj.type.literal.text }; + } else { + throw new Error('Invalid attribute was found in attributes.'); + } + }); + } + throw new Error('Unexpected error when parsing attributes.'); +} + +function parseChild(node: ts.Node, indent: number): HtmlJson | string { + if (ts.isLiteralTypeNode(node) && ts.isStringLiteral(node.literal)) { + return node.literal.text; + } else if (ts.isTypeLiteralNode(node)) { + return traverseNode(node, indent); + } else if (ts.isTypeReferenceNode(node)) { + // Handle Image and other void elements that may remain as TypeReference + if (ts.isIdentifier(node.typeName)) { + const tagName = node.typeName.escapedText.toString().toLowerCase(); + // Parse attributes from type arguments if present + let attributes: { key: string; value: string }[] | undefined; + if (node.typeArguments && node.typeArguments.length > 0) { + attributes = parseAttributes(node.typeArguments[0]); + } + return { + tag: tagName, + children: undefined, + attributes, + }; + } + throw new Error(`unexpected type reference while parsing children elements.`); + } else { + console.log(node); + throw new Error( + `unexpected type while parsing children elements. node kind is ${SyntaxKind[node.kind]}` + ); + } +} + +function parseChildren(node: ts.Node, indent: number): HtmlJson['children'] { + // node should be propertySignature + assert(ts.isPropertySignature(node)); + // type is not undefined + assert(node.type); + if (ts.isTypeLiteralNode(node.type)) { + return [traverseNode(node.type, indent + 2)]; + } else if (ts.isLiteralTypeNode(node.type) && ts.isStringLiteral(node.type.literal)) { + return [node.type.literal.text]; + } else if (ts.isTupleTypeNode(node.type)) { + return node.type.elements.map((val) => parseChild(val, indent)); + } else { + throw new Error('Unexpected error when parsing children.'); + } +} + export function traverseNode(node: ts.Node, indent: number = 0): HtmlJson { if (ts.isTypeLiteralNode(node)) { let tag = ''; - let children: (HtmlJson | string)[] = []; + let children: (HtmlJson | string)[] | undefined = []; + let attributes: { key: string; value: string }[] | undefined; node.members.forEach((member) => { - if (member.name && ts.isPropertySignature(member) && ts.isIdentifier(member.name)) { - if ( - member.type && - ts.isLiteralTypeNode(member.type) && - ts.isStringLiteral(member.type.literal) - ) { - children = [member.type.literal.text]; - } else if (member.type && ts.isPropertySignature(member)) { - if (ts.isTupleTypeNode(member.type)) { - // loop - children = member.type.elements - .filter((type) => type) - .map((type) => traverseNode(type, indent + 2)); - } else if (ts.isTypeLiteralNode(member.type)) { - children = [traverseNode(member.type, indent + 2)]; - } - } - } else if ( - member.name && + if ( + ts.isPropertySignature(member) && ts.isComputedPropertyName(member.name) && - ts.isIdentifier(member.name.expression) && - member.name.expression.escapedText + ts.isIdentifier(member.name.expression) ) { - tag = member.name.expression.escapedText.replace('Brand', ''); - } else { - throw new Error('Unexpected type'); + tag = member.name.expression.escapedText.toString().replace('Brand', ''); + } else if (ts.isPropertySignature(member) && ts.isIdentifier(member.name) && member.type) { + switch (member.name.escapedText) { + case 'attributes': + attributes = parseAttributes(member.type); + break; + case 'children': + children = parseChildren(member, indent + 2); + break; + default: + throw new Error(`unexpected type ${member.type}`); + } } }); return { tag, children, + attributes, }; } else { throw new Error('Unexpected type'); @@ -60,7 +118,11 @@ export function visit(node: ts.Node, checker: ts.TypeChecker, outPath: string) { ) { try { const type = checker.getTypeAtLocation(node); - const typeNode = checker.typeToTypeNode(type, undefined, ts.NodeBuilderFlags.NoTruncation); + const typeNode = checker.typeToTypeNode( + type, + undefined, + ts.NodeBuilderFlags.NoTruncation | ts.NodeBuilderFlags.NoTypeReduction + ); if (typeNode) { const result = traverseNode(typeNode, 0); const writeStream = createWriteStream(outPath, { flags: 'w' });