diff --git a/.gitignore b/.gitignore index 52551e30..4e4e95e8 100644 --- a/.gitignore +++ b/.gitignore @@ -5,4 +5,5 @@ node_modules dist lib *.tgz +yarn.lock diff --git a/README.md b/README.md index bd256153..1a9996c9 100644 --- a/README.md +++ b/README.md @@ -61,7 +61,7 @@ The analyze command analyses an optional `` and emits the output to | Option | Type | Description | | --------------------------- | -------------------------------- | ---------------------------------------------------------------------------- | -| `--format ` | `markdown` \| `json` \| `vscode` | Specify output format. Default is `markdown`. | +| `--format ` | `markdown` \| `json` \| `vscode` \| `webtypes` | Specify output format. Default is `markdown`. | | `--outDir ` | `directory path` | Direct output to a directory where each file corresponds to a web component. | | `--outFile ` | `file path` | Concatenate and emit output to a single file. | | `--outFiles ` | `file path with pattern` | Emit output to multiple files using a pattern. Available substitutions:
**{dir}**: The directory of the component
**{filename}**: The filename (without ext) of the component
**{tagname}**: The element's tag name | @@ -107,6 +107,33 @@ wca analyze src --format vscode --outFile vscode-html-custom-data.json VSCode supports a JSON format called [vscode custom data](https://github.com/microsoft/vscode-custom-data) for the built in html editor which is set using `html.customData` vscode setting. Web Component Analyzer can output this format. +### webtypes + + +```bash +wca analyze src --format webtypes --outFile web-types-custom.json +``` + +To configure web-types (name, version, etc.) add this section to your `package.json`. `name` and `version` fields are required if wca is not ran from npm `package.json` `scripts` section. +Otherwise it will take `package.json` project `name` and `version`. + +You can use the `wca-config` section to configure other options as well. + +```json +"wca": { + "webtypesConfig": { + "name": "your-project-name", + "version": "0.0.1", + "framework": "lit", + "description-markup": "markdown" + } +} +``` + +Web-types format is a description for IDE completion, see [web-types](https://github.com/JetBrains/web-types/tree/master/packages) + +See [web-component-analyzer web-types dedicated page](./doc/web-types.md) for project setup. + [![-----------------------------------------------------](https://raw.githubusercontent.com/andreasbm/readme/master/assets/lines/colored.png)](#how-does-this-tool-analyze-my-components) [![-----------------------------------------------------](https://raw.githubusercontent.com/andreasbm/readme/master/assets/lines/colored.png)](#how-to-document-your-components-using-jsdoc) diff --git a/doc/web-types.md b/doc/web-types.md new file mode 100644 index 00000000..f96d49ed --- /dev/null +++ b/doc/web-types.md @@ -0,0 +1,153 @@ +# Web-types + +## web-component-analyzer usage + +### With package.json configuration + +You can add a `wca` section in your `package.json` to configure wca for web-types build. + +```json +"wca": { + "webtypesConfig": { + "name": "your-project-name", + "version": "0.0.1", + "framework": "lit", + "description-markup": "markdown" + } +} +``` + +To run wca then use command: + +```bash +wca analyze src --format webtypes --outFile web-types-custom.json +``` + +If you run `wca` from project npm `scripts` section of `package.json`, you can omit `name` and `version` property +in `webtypesConfig`, this will take package `name` and `version` by default. + +### With command line only + +You can also avoid updating `package.json` `wca` section and use only command line by providing a json configuration to `--webtypesConfig` argument: + +```bash +wca analyze src --format webtypes --outFile web-types-custom.json --webtypesConfig='{"name": "your-project-name", "version": "0.0.1", "framework": "lit", "description-markup": "markdown"}' +``` + +If you run `wca` from project npm `scripts` section of `package.json`, you can omit `name` and `version` property +in `webtypesConfig`, this will take package `name` and `version` by default. + +## Project setup + +### For Lit + +Generated web-types are working with the generic `@web-types/lit` library. You need to add it on your project. + + +```bash +npm i @web-types/lit -D +``` + +Add `wca` section in your `package.json`: + +```json +"wca": { + "webtypesConfig": { + "framework": "lit", + "description-markup": "markdown" + } +} +``` + +Working with lit, `framework` must have `lit` value. See [Web types config parameters](#web-types-config-parameters) documentation section for more info. + +Add `scripts` section in your `package.json`: + +```json +"scripts": { + "web-types": "wca analyze src --format webtypes --outFile web-types.json" +} +``` + +Generate your components descriptions with wca + + +```bash +npm run web-types +``` + +If your web-types is not named `web-types.json` and placed at root of your project, you need to declare it in your `package.json` + +```json +"web-types": [ + "..../web-types-custom.json" +] +``` + +After the first setup on Intellij, IDE restart might be needed to enable components completion. + +### For Polymer + +Generated web-types are working with the generic `polymer-web-types` library. You need to add it on your project. + + +```bash +npm i polymer-web-types -D +``` + +Add `wca` section in your `package.json`: + +```json +"wca": { + "webtypesConfig": { + "framework": "@polymer/polymer", + "description-markup": "markdown" + } +} +``` + +Working with lit, `framework` must have `@polymer/polymer` value. See [Web types config parameters](#web-types-config-parameters) documentation section for more info. + +Add `scripts` section in your `package.json`: + +```json +"scripts": { + "web-types": "wca analyze src --format webtypes --outFile web-types.json" +} +``` + +Generate your components descriptions with wca + + +```bash +npm run web-types +``` + +If your web-types is not named `web-types.json` and placed at root of your project, you need to declare it in your `package.json` + +```json +"web-types": [ + "..../web-types-custom.json" +] +``` + +After the first setup on Intellij, IDE restart might be needed to enable components completion. + +## Web types config parameters + +`webtypesConfig` parameter is a json object containing web-types file root parameter. + +Available parameters: + +| Name | Description | +| ------------------ | ------------------------------------------------------------------------------------------------------------- | +| name | Name of library | +| version | Version of the library, for which Web-Types are provided | +| framework | See [http://json.schemastore.org/web-types](http://json.schemastore.org/web-types) framework section | +| js-types-syntax | See [http://json.schemastore.org/web-types](http://json.schemastore.org/web-types) js-types-syntax section | +| description-markup | See [http://json.schemastore.org/web-types](http://json.schemastore.org/web-types) description-markup section | +| framework-config | See [http://json.schemastore.org/web-types](http://json.schemastore.org/web-types) framework-config section | +| default-icon | See [http://json.schemastore.org/web-types](http://json.schemastore.org/web-types) default-icon section | +| path-as-absolute | Consider paths as absolute: don't add './' in front of paths | + +See [web-types project:](https://github.com/JetBrains/web-types) for more info. diff --git a/package.json b/package.json index fab85b93..9cb6f698 100644 --- a/package.json +++ b/package.json @@ -34,7 +34,8 @@ "keywords": [ "web components", "web", - "components" + "components", + "web-types" ], "contributors": [ { diff --git a/src/cli/analyze/analyze-cli-command.ts b/src/cli/analyze/analyze-cli-command.ts index 419861a2..0fdd5e06 100644 --- a/src/cli/analyze/analyze-cli-command.ts +++ b/src/cli/analyze/analyze-cli-command.ts @@ -23,7 +23,7 @@ export const analyzeCliCommand: CliCommand = async (config: AnalyzerCliConfig): const inputGlobs = config.glob || []; // Log warning for experimental json format - if (config.format === "json" || config.format === "json2" || config.outFile?.endsWith(".json")) { + if (config.format === "json" || config.format === "json2" || (config.outFile?.endsWith(".json") && config.format !== "webtypes")) { log( ` !!!!!!!!!!!!! WARNING !!!!!!!!!!!!! @@ -37,6 +37,43 @@ Please follow and contribute to the discussion at: ); } + if (config.format === "webtypes") { + if (!config.webtypesConfig) throw makeCliError("Missing webtypes-config configuration"); + + // Allow object being passed as JSON from command line + let cleanedConfiguration; + if (typeof config.webtypesConfig === "string") { + try { + cleanedConfiguration = JSON.parse(config.webtypesConfig); + } catch (e) { + const message = e instanceof Error ? e.message : e; + throw makeCliError("webtypes-config JSON format issue: " + message + "\nReceived value: " + config.webtypesConfig); + } + } else { + cleanedConfiguration = config.webtypesConfig; + } + + if (!cleanedConfiguration.name) { + // Take package name if ran from npm script + if (process.env.npm_package_name) { + cleanedConfiguration.name = process.env.npm_package_name; + } else { + throw makeCliError('Missing webtypes-config "name" property'); + } + } + + if (!cleanedConfiguration.version) { + // Take package version if ran from npm script + if (process.env.npm_package_version) { + cleanedConfiguration.version = process.env.npm_package_version; + } else { + throw makeCliError('Missing webtypes-config "version" property'); + } + } + + config.parsedWebtypesConfig = cleanedConfiguration; + } + // If no "out" is specified, output to console const outStrategy: OutStrategy = (() => { if (config.outDir == null && config.outFile == null && config.outFiles == null) { @@ -124,6 +161,9 @@ function transformResults(results: AnalyzerResult[] | AnalyzerResult, program: P markdown: config.markdown, cwd: config.cwd }; + if (format == "webtypes") { + transformerConfig.webTypes = config.parsedWebtypesConfig; + } return transformAnalyzerResult(format, results, program, transformerConfig); } @@ -227,6 +267,7 @@ function formatToExtension(kind: TransformerKind): string { switch (kind) { case "json": case "vscode": + case "webtypes": return ".json"; case "md": case "markdown": diff --git a/src/cli/analyzer-cli-config.ts b/src/cli/analyzer-cli-config.ts index 9b8012da..f0a2770d 100644 --- a/src/cli/analyzer-cli-config.ts +++ b/src/cli/analyzer-cli-config.ts @@ -2,6 +2,7 @@ import * as tsModule from "typescript"; import { ComponentFeature } from "../analyze/types/features/component-feature"; import { VisibilityKind } from "../analyze/types/visibility-kind"; import { TransformerKind } from "../transformers/transformer-kind"; +import { WebTypesTransformerConfig } from "../transformers"; export interface AnalyzerCliConfig { glob?: string[]; @@ -30,4 +31,7 @@ export interface AnalyzerCliConfig { ts?: typeof tsModule; cwd?: string; + + webtypesConfig?: string | WebTypesTransformerConfig; + parsedWebtypesConfig?: WebTypesTransformerConfig; } diff --git a/src/cli/cli.ts b/src/cli/cli.ts index 97719d65..ebf8fe8f 100644 --- a/src/cli/cli.ts +++ b/src/cli/cli.ts @@ -9,6 +9,7 @@ import { log } from "./util/log"; */ export function cli(): void { const argv = yargs + .pkgConf("wca") .usage("Usage: $0 [glob..] [options]") .command({ command: ["analyze [glob..]", "$0"], @@ -50,7 +51,7 @@ o {tagname}: The element's tag name`, }) .option("format", { describe: `Specify output format`, - choices: ["md", "markdown", "json", "json2", "vscode"], + choices: ["md", "markdown", "json", "json2", "vscode", "webtypes"], nargs: 1, alias: "f" }) @@ -103,6 +104,10 @@ o {tagname}: The element's tag name`, string: true, hidden: true }) + .option("webtypesConfig", { + describe: "WebTypes header configuration", + string: true + }) .alias("v", "version") .help("h") diff --git a/src/transformers/transform-analyzer-result.ts b/src/transformers/transform-analyzer-result.ts index d2b06b9d..c5a13c91 100644 --- a/src/transformers/transform-analyzer-result.ts +++ b/src/transformers/transform-analyzer-result.ts @@ -8,6 +8,7 @@ import { TransformerConfig } from "./transformer-config"; import { TransformerFunction } from "./transformer-function"; import { TransformerKind } from "./transformer-kind"; import { vscodeTransformer } from "./vscode/vscode-transformer"; +import { webtypesTransformer } from "./webtypes/webtypes-transformer"; const transformerFunctionMap: Record = { debug: debugJsonTransformer, @@ -15,7 +16,8 @@ const transformerFunctionMap: Record = { json2: json2Transformer, markdown: markdownTransformer, md: markdownTransformer, - vscode: vscodeTransformer + vscode: vscodeTransformer, + webtypes: webtypesTransformer }; /** diff --git a/src/transformers/transformer-config.ts b/src/transformers/transformer-config.ts index 00c67e9b..caa23fe4 100644 --- a/src/transformers/transformer-config.ts +++ b/src/transformers/transformer-config.ts @@ -1,4 +1,5 @@ import { VisibilityKind } from "../analyze/types/visibility-kind"; +import { GenericContributions } from "./webtypes/webtypes-schema"; export interface TransformerConfig { cwd?: string; @@ -8,4 +9,20 @@ export interface TransformerConfig { headerLevel?: number; }; inlineTypes?: boolean; + webTypes?: WebTypesTransformerConfig; +} + +export interface WebTypesTransformerConfig { + name: string; + version: string; + "default-icon"?: string; + "js-types-syntax"?: "typescript"; + framework?: string; + "framework-config"?: WebTypesFrameworkConfig; + "description-markup"?: "html" | "markdown" | "none"; + pathAsAbsolute?: boolean; +} + +export interface WebTypesFrameworkConfig { + [k: string]: GenericContributions; } diff --git a/src/transformers/transformer-kind.ts b/src/transformers/transformer-kind.ts index c87a2f3a..22e5a190 100644 --- a/src/transformers/transformer-kind.ts +++ b/src/transformers/transformer-kind.ts @@ -1 +1 @@ -export type TransformerKind = "md" | "markdown" | "json" | "vscode" | "debug" | "json2"; +export type TransformerKind = "md" | "markdown" | "json" | "vscode" | "debug" | "json2" | "webtypes"; diff --git a/src/transformers/webtypes/webtypes-schema.ts b/src/transformers/webtypes/webtypes-schema.ts new file mode 100644 index 00000000..3e8adc18 --- /dev/null +++ b/src/transformers/webtypes/webtypes-schema.ts @@ -0,0 +1,224 @@ +// converted from JSON schema with https://transform.tools/json-schema-to-typescript +// just renamed JSONSchemaForWebTypesPreviewOfVersion20OfTheStandard to WebtypesSchema + +/** + * Language in which JavaScript objects types are specified. + */ +export type JsTypesSyntax = "typescript"; +/** + * Markup language in which descriptions are formatted + */ +export type DescriptionMarkup = "html" | "markdown" | "none"; +/** + * A RegEx pattern to match whole content. Syntax should work with at least ECMA, Java and Python implementations. + */ +export type Pattern = + | string + | { + regex?: string; + "case-sensitive"?: boolean; + [k: string]: unknown; + }; +export type NameConverter = "as-is" | "PascalCase" | "camelCase" | "lowercase" | "UPPERCASE" | "kebab-case" | "snake_case"; +export type NameConverters = NameConverter[]; + +export type Priority = "lowest" | "low" | "normal" | "high" | "highest"; +/** + * Relative path to icon + */ +export type Icon = string; +export type GenericContributions = GenericContributionOrProperty[] | GenericContributionOrProperty; +export type GenericContributionOrProperty = string | number | boolean | object | GenericContribution | Source; +export type GenericContribution = TypedContribution; + +export interface TypedContribution extends BaseContribution { + type?: Type | Type[]; +} + +export interface WebtypesSchema { + $schema?: string; + /** + * Framework, for which the components are provided by the library + */ + framework?: string; + /** + * Name of the library + */ + name: string; + /** + * Version of the library, for which web-types are provided + */ + version: string; + "js-types-syntax"?: JsTypesSyntax; + "description-markup"?: DescriptionMarkup; + "framework-config"?: FrameworkConfig; + "default-icon"?: Icon; + contributions?: { + html?: Html; + css?: Css; + js?: Js; + }; +} +export interface FrameworkConfig { + /** + * Specify rules for enabling web framework support. + */ + "enable-when"?: { + /** + * Node.js package names, which enable framework support within the folder containing the package.json. + */ + "node-packages"?: string[]; + /** + * RegExps to match script URLs, which enable framework support within a particular HTML. + */ + "script-url-patterns"?: Pattern[]; + /** + * Extensions of files, which should have the framework support enabled + */ + "file-extensions"?: string[]; + /** + * RegExp patterns to match file names, which should have the framework support enabled + */ + "file-name-patterns"?: Pattern[]; + /** + * Global JavaScript libraries names enabled within the IDE, which enable framework support in the whole project + */ + "ide-libraries"?: string[]; + }; + /** + * Specify rules for disabling web framework support. These rules take precedence over enable-when rules. + */ + "disable-when"?: { + /** + * Extensions of files, which should have the framework support disabled + */ + "file-extensions"?: string[]; + /** + * RegExp patterns to match file names, which should have the framework support disabled + */ + "file-name-patterns"?: Pattern[]; + }; + "canonical-names"?: { + [k: string]: NameConverter; + }; + "name-variants"?: { + [k: string]: NameConverters; + }; +} + +export interface SourceFile { + file: string; + offset: number; +} + +export interface SourceModule { + module: string; + symbol: string; +} + +export type Source = SourceFile | SourceModule; + +export type Html = HtmlContributionHost; + +export interface HtmlElement extends BaseContribution, HtmlContributionHost {} + +export interface BaseContribution { + name?: string; + description?: string; + // "description-sections"?: ; + "doc-url"?: string; + icon?: Icon; + source?: Source; + deprecated?: boolean; + experimental?: boolean; + priority?: Priority; + proximity?: number; + virtual?: boolean; + abstract?: boolean; + extension?: boolean; + extends?: Reference; + // pattern?: NamePatternRoot; + html?: Html; + css?: Css; + js?: Js; + // "exclusive-contributions"?: ; +} + +export interface HtmlContributionHost { + elements?: HtmlElement[]; + attributes?: HtmlAttribute[]; + slots?: SlotAttribute[]; +} + +export interface HtmlAttribute extends BaseContribution, HtmlContributionHost { + value?: HtmlAttributeValue; + default?: string; + required?: boolean; +} + +export interface HtmlAttributeValue { + type?: Type | Type[]; + required?: boolean; + default?: string; + kind?: HtmlAttributeType; +} + +export interface SlotAttribute extends BaseContribution {} + +export interface CssPartAttribute extends BaseContribution {} + +export interface TypeReference { + module?: string; + name: string; +} + +export type Type = string | TypeReference; + +export type HtmlAttributeType = "no-value" | "plain" | "expression"; + +export type Reference = string | ReferenceWithProps; + +export interface ReferenceWithProps { + path: string; + includeVirtual?: boolean; + includeAbstract?: boolean; + filter?: string; +} + +export type Js = JsContributionsHost; + +export interface JsContributionsHost { + events?: GenericJsContribution[]; + properties?: GenericJsContribution[]; +} + +export interface GenericJsContribution extends GenericContribution, JsContributionsHost { + value?: HtmlAttributeValue; + default?: string; + required?: boolean; +} + +export type Css = CssContributionsHost; + +export interface CssContributionsHost { + properties?: CssProperty[]; + "pseudo-elements"?: CssPseudoElement[]; + "pseudo-classes"?: CssPseudoClass[]; + functions?: CssGenericItem[]; + classes?: CssGenericItem[]; + parts?: CssPartAttribute[]; +} + +export interface CssProperty extends BaseContribution, CssContributionsHost { + values?: string[]; +} + +export interface CssPseudoElement extends BaseContribution, CssContributionsHost { + arguments?: boolean; +} + +export interface CssPseudoClass extends BaseContribution, CssContributionsHost { + arguments?: boolean; +} + +export interface CssGenericItem extends BaseContribution, CssContributionsHost {} diff --git a/src/transformers/webtypes/webtypes-transformer.ts b/src/transformers/webtypes/webtypes-transformer.ts new file mode 100644 index 00000000..3e57a561 --- /dev/null +++ b/src/transformers/webtypes/webtypes-transformer.ts @@ -0,0 +1,237 @@ +import { getTypeHintFromType } from "../../util/get-type-hint-from-type"; +import { Program, TypeChecker } from "typescript"; +import { AnalyzerResult } from "../../analyze/types/analyzer-result"; +import { ComponentDefinition } from "../../analyze/types/component-definition"; +import { ComponentEvent } from "../../analyze/types/features/component-event"; +import { ComponentCssProperty } from "../../analyze/types/features/component-css-property"; +import { ComponentMember } from "../../analyze/types/features/component-member"; +import { arrayDefined } from "../../util/array-util"; +import { TransformerConfig } from "../transformer-config"; +import { TransformerFunction } from "../transformer-function"; +import { + HtmlAttribute, + WebtypesSchema, + HtmlElement, + BaseContribution, + Js, + GenericJsContribution, + CssProperty, + HtmlAttributeValue, + SlotAttribute, + CssContributionsHost +} from "./webtypes-schema"; +import { getFirst } from "../../util/set-util"; +import { relative } from "path"; + +interface SourceDescription { + module: string; + className: string; +} + +/** + * Transforms results to json. + * @param results + * @param program + * @param config + */ +export const webtypesTransformer: TransformerFunction = (results: AnalyzerResult[], program: Program, config: TransformerConfig): string => { + const checker = program.getTypeChecker(); + + // Grab all definitions + const definitions = results.map(res => res.componentDefinitions).reduce((acc, cur) => [...acc, ...cur], []); + + // Transform all definitions into "tags" + const elements = definitions.map(d => definitionToHTMLElement(d, checker, config)); + const cssProperties = definitions.map(d => definitionToCssProperties(d)).reduce((acc, cur) => [...acc, ...cur], []); + + const webTypeConfig = config.webTypes; + + const webtypesJson: WebtypesSchema = { + $schema: "http://json.schemastore.org/web-types", + name: webTypeConfig?.name ?? "", + version: webTypeConfig?.version ?? "", + ...(webTypeConfig?.framework ? { framework: webTypeConfig?.framework } : {}), + ...(webTypeConfig?.["js-types-syntax"] ? { "js-types-syntax": webTypeConfig?.["js-types-syntax"] } : {}), + ...(webTypeConfig?.["default-icon"] ? { "default-icon": webTypeConfig?.["default-icon"] } : {}), + ...(webTypeConfig?.["framework-config"] ? { "framework-config": webTypeConfig?.["framework-config"] } : {}), + ...(webTypeConfig?.["description-markup"] ? { "description-markup": webTypeConfig?.["description-markup"] } : {}), + contributions: { + html: { + elements: elements + }, + css: { + properties: cssProperties + } + } + }; + + return JSON.stringify(webtypesJson, null, 4); +}; + +function definitionToCssProperties(definition: ComponentDefinition): CssProperty[] { + if (!definition.declaration) return []; + + return arrayDefined(definition.declaration.cssProperties.map(e => componentCssPropertiesToAttr(e))); +} + +function definitionToHTMLElement(definition: ComponentDefinition, checker: TypeChecker, config: TransformerConfig): HtmlElement { + const declaration = definition.declaration; + + if (declaration == null) { + return { + name: definition.tagName, + attributes: [] + }; + } + + const build: HtmlElement = { + name: definition.tagName, + ...(declaration.deprecated !== undefined ? { deprecated: true } : {}) + }; + + // Build description + if (declaration?.jsDoc?.description) build.description = declaration.jsDoc.description; + + // Build source section + const node = getFirst(definition.identifierNodes); + const path = getRelativePath(node?.getSourceFile().fileName, config); + + let sourceDescription: SourceDescription | null = null; + if (node?.getText() && path) { + build.source = { + module: path, + symbol: node.getText() + }; + sourceDescription = { + module: path, + className: node.getText() + }; + } + + // Build attributes + const customElementAttributes = arrayDefined( + declaration.members.map(d => componentMemberToAttr(d.attrName, d, sourceDescription, checker, config)) + ); + if (customElementAttributes.length > 0) build.attributes = customElementAttributes; + + const js: Js = {}; + + // Build properties + const customElementProperties = arrayDefined( + declaration.members.map(d => componentMemberToAttr(d.propName, d, sourceDescription, checker, config)) + ); + if (customElementProperties.length > 0) js.properties = customElementProperties; + + // Build events + const eventAttributes = arrayDefined(declaration.events.map(e => componentEventToAttr(e))); + if (eventAttributes.length > 0) js.events = eventAttributes; + + if (js.properties || js.events) build.js = js; + + // Build slots + const slots: SlotAttribute[] = declaration.slots?.map(slot => ({ + name: slot.name || "", + description: slot.jsDoc?.description || "" + })); + if (slots && slots.length > 0) { + slots.forEach(slot => { + if (slot.name == "") slot.priority = "low"; + }); + build.slots = slots; + } + + // Build component CSS + const css: CssContributionsHost = {}; + if (declaration.cssParts && declaration.cssParts.length > 0) { + css.parts = declaration.cssParts?.map(part => ({ + name: part.name, + description: part.jsDoc?.description || "" + })); + } + + if (css.parts) build.css = css; + + return build; +} + +function getRelativePath(fileName: string | undefined, config: TransformerConfig): string | undefined { + return fileName != null && config.cwd != null + ? `${config.webTypes?.pathAsAbsolute ? "" : "./"}${relative(config.cwd, fileName)}`.replaceAll("\\", "/") + : undefined; +} + +function componentEventToAttr(event: ComponentEvent): GenericJsContribution { + const builtEvent: GenericJsContribution = { + name: event.name + }; + + if (event?.jsDoc?.description) builtEvent.description = event.jsDoc.description; + + return builtEvent; +} + +function componentCssPropertiesToAttr(cssProperty: ComponentCssProperty): CssProperty { + const builtCssProp: CssProperty = { + name: cssProperty.name + }; + + if (cssProperty?.jsDoc?.description) builtCssProp.description = cssProperty.jsDoc.description; + if (cssProperty.default) { + if (builtCssProp.description) builtCssProp.description += "\n\n**Default:** " + cssProperty.default; + else builtCssProp.description = "**Default:** " + cssProperty.default; + } + + return builtCssProp; +} + +function componentMemberToAttr( + propName: string | undefined, + member: ComponentMember, + sourceDescription: SourceDescription | null, + checker: TypeChecker, + config: TransformerConfig +): BaseContribution | undefined { + if (propName == null) { + return undefined; + } + + const types: string[] | string = getTypeHintFromType(member.typeHint ?? member.type?.(), checker, config)?.split(" | ") ?? []; + const isPlainEnum = types.every(t => t == "null" || t == "undefined" || t.trim().match(/^["'].*["']$/)); + const typeValues: Partial = isPlainEnum + ? { + kind: "plain", + type: types.join(" | ") + } + : { + type: types && Array.isArray(types) && types.length == 1 ? types[0] : types + }; + + const attr: HtmlAttribute = { + name: propName, + required: !!member.required, + priority: member.visibility == "private" || member.visibility == "protected" ? "lowest" : "normal", + value: { + ...typeValues, + required: !isBoolean(types), + ...(member.default !== undefined ? { default: JSON.stringify(member.default) } : {}) + }, + ...(member.deprecated !== undefined ? { deprecated: true } : {}) + }; + + if (member?.jsDoc?.description) attr.description = member.jsDoc.description; + + if (sourceDescription !== null) { + attr.source = { + module: sourceDescription.module, + symbol: sourceDescription.className + "." + member.propName + }; + } + + return attr; +} + +function isBoolean(type: string | string[]): boolean { + if (Array.isArray(type)) return type.some(t => t && t.toLowerCase().includes("boolean")); + + return type ? type.toLowerCase().includes("boolean") : false; +} diff --git a/test/helpers/webtypes-test-utils.ts b/test/helpers/webtypes-test-utils.ts new file mode 100644 index 00000000..e383b34d --- /dev/null +++ b/test/helpers/webtypes-test-utils.ts @@ -0,0 +1,43 @@ +import { analyzeTextWithCurrentTsModule } from "./analyze-text-with-current-ts-module"; +import { VirtualSourceFile } from "../../src/analyze"; +import { System } from "typescript"; +import { getCurrentTsModule } from "./ts-test"; +import { transformAnalyzerResult, TransformerConfig, WebTypesTransformerConfig } from "../../src/transformers"; +import { GenericJsContribution, HtmlAttribute, HtmlElement, WebtypesSchema } from "../../src/transformers/webtypes/webtypes-schema"; + +export function runWebtypesBuild(source: VirtualSourceFile[] | VirtualSourceFile, config: Partial = {}): string { + const parseResult = analyzeTextWithCurrentTsModule(source); + + const system: System = getCurrentTsModule().sys; + const transformerConfig: TransformerConfig = { + inlineTypes: false, + visibility: "public", + cwd: system.getCurrentDirectory(), + webTypes: { + name: "test", + version: "1.0.0", + ...config + } + }; + + return transformAnalyzerResult("webtypes", parseResult.results, parseResult.program, transformerConfig); +} + +export function runAndParseWebtypesBuild( + source: VirtualSourceFile[] | VirtualSourceFile, + config: Partial = {} +): WebtypesSchema { + return JSON.parse(runWebtypesBuild(source, config)); +} + +export function findHtmlElementOfName(schema: WebtypesSchema, name: string): HtmlElement | undefined { + return schema.contributions?.html?.elements?.find(el => el.name == name); +} + +export function findAttributeOfName(element: HtmlElement | undefined, name: string): HtmlAttribute | undefined { + return element?.attributes?.find(el => el.name == name); +} + +export function findPropertyOfName(element: HtmlElement | undefined, name: string): GenericJsContribution | undefined { + return element?.js?.properties?.find(el => el.name == name); +} diff --git a/test/transformers/webtypes/attributes-and-properties-test.ts b/test/transformers/webtypes/attributes-and-properties-test.ts new file mode 100644 index 00000000..5c266ce5 --- /dev/null +++ b/test/transformers/webtypes/attributes-and-properties-test.ts @@ -0,0 +1,177 @@ +import { tsTest } from "../../helpers/ts-test"; +import { findAttributeOfName, findHtmlElementOfName, findPropertyOfName, runAndParseWebtypesBuild } from "../../helpers/webtypes-test-utils"; +import { Type } from "../../../src/transformers/webtypes/webtypes-schema"; + +tsTest("Transformer: Webtypes: Attributes and properties only present if needed", t => { + const res = runAndParseWebtypesBuild(` + @customElement('my-element') + class MyElement extends HTMLElement { + @property({type: String, attribute: "my-prop"}) myProp: string; + @property({type: String, attribute: false}) myProp2: string; + } + `); + + const myElement = findHtmlElementOfName(res, "my-element"); + t.truthy(myElement); + t.is(myElement?.attributes?.length, 1); + t.is(myElement?.js?.properties?.length, 2); + t.truthy(findAttributeOfName(myElement, "my-prop")); + t.truthy(findPropertyOfName(myElement, "myProp")); + t.truthy(findPropertyOfName(myElement, "myProp2")); +}); + +tsTest("Transformer: Webtypes: Attributes and properties default value", t => { + const res = runAndParseWebtypesBuild(` + @customElement('my-element') + class MyElement extends HTMLElement { + @property({type: String, attribute: "my-prop"}) myProp: string; + @property({type: String, attribute: "my-prop2"}) myProp2: string = "testValue"; + } + `); + + const myElement = findHtmlElementOfName(res, "my-element"); + t.truthy(myElement); + t.is(myElement?.attributes?.length, 2); + t.is(myElement?.js?.properties?.length, 2); + const att1 = findAttributeOfName(myElement, "my-prop"); + const att2 = findAttributeOfName(myElement, "my-prop2"); + const prop1 = findPropertyOfName(myElement, "myProp"); + const prop2 = findPropertyOfName(myElement, "myProp2"); + + t.is(att1?.value?.type, "string"); + t.false(att1?.required); + t.true(att1?.value?.required); + t.is(att1?.value?.default, undefined); + + t.is(att2?.value?.type, "string"); + t.false(att2?.required); + t.true(att2?.value?.required); + t.is(att2?.value?.default, '"testValue"'); + + t.is(prop1?.value?.type, "string"); + t.false(prop1?.required); + t.true(prop1?.value?.required); + t.is(prop1?.value?.default, undefined); + + t.is(prop2?.value?.type, "string"); + t.false(prop2?.required); + t.true(prop2?.value?.required); + t.is(prop2?.value?.default, '"testValue"'); +}); + +tsTest("Transformer: Webtypes: Boolean value not required", t => { + const res = runAndParseWebtypesBuild(` + @customElement('my-element') + class MyElement extends HTMLElement { + @property({type: Boolean, attribute: "my-prop"}) myProp: boolean; + @property({type: Boolean, attribute: "my-prop2"}) myProp2: boolean | undefined = false; + } + `); + + const myElement = findHtmlElementOfName(res, "my-element"); + t.truthy(myElement); + t.is(myElement?.attributes?.length, 2); + t.is(myElement?.js?.properties?.length, 2); + const att1 = findAttributeOfName(myElement, "my-prop"); + const att2 = findAttributeOfName(myElement, "my-prop2"); + const prop1 = findPropertyOfName(myElement, "myProp"); + const prop2 = findPropertyOfName(myElement, "myProp2"); + + t.is(att1?.value?.type, "boolean"); + t.false(att1?.required); + t.false(att1?.value?.required); + t.is(att1?.value?.default, undefined); + + t.is((att2?.value?.type as Array)?.length, 2); + t.true((att2?.value?.type as Array)?.includes("boolean")); + t.true((att2?.value?.type as Array)?.includes("undefined")); + t.false(att2?.required); + t.false(att2?.value?.required); + t.is(att2?.value?.default, "false"); + + t.is(prop1?.value?.type, "boolean"); + t.false(prop1?.required); + t.false(prop1?.value?.required); + t.is(prop1?.value?.default, undefined); + + t.is((prop2?.value?.type as Array)?.length, 2); + t.true((prop2?.value?.type as Array)?.includes("boolean")); + t.true((prop2?.value?.type as Array)?.includes("undefined")); + t.false(prop2?.required); + t.false(prop2?.value?.required); + t.is(prop2?.value?.default, "false"); +}); + +tsTest("Transformer: Webtypes: Enum values", t => { + const res = runAndParseWebtypesBuild(` + @customElement('my-element') + class MyElement extends HTMLElement { + /** + * @type {'foo' | 'bar'} + */ + @property({type: String, attribute: "my-prop"}) myProp = "foo"; + } + `); + + const myElement = findHtmlElementOfName(res, "my-element"); + t.truthy(myElement); + + t.is(myElement?.attributes?.length, 1); + t.is(myElement?.js?.properties?.length, 1); + const att1 = findAttributeOfName(myElement, "my-prop"); + const prop1 = findPropertyOfName(myElement, "myProp"); + + t.is(att1?.value?.type, "'foo' | 'bar'"); + t.false(att1?.required); + t.is(att1?.value?.kind, "plain"); + t.true(att1?.value?.required); + t.is(att1?.value?.default, '"foo"'); + + t.is(prop1?.value?.type, "'foo' | 'bar'"); + t.false(prop1?.required); + t.is(prop1?.value?.kind, "plain"); + t.true(prop1?.value?.required); + t.is(prop1?.value?.default, '"foo"'); +}); + +tsTest("Transformer: Webtypes: Slots values", t => { + const res = runAndParseWebtypesBuild(` + /** + * @slot - Default slot desc + * @slot named-slot - Named slot desc + */ + @customElement('my-element') + class MyElement extends HTMLElement {} + `); + + const myElement = findHtmlElementOfName(res, "my-element"); + t.truthy(myElement); + + t.is(myElement?.slots?.length, 2); + const defaultSlot = myElement?.slots?.find(slot => slot.name == ""); + const namedSlot = myElement?.slots?.find(slot => slot.name == "named-slot"); + + t.is(defaultSlot?.description, "Default slot desc"); + t.is(namedSlot?.description, "Named slot desc"); +}); + +tsTest("Transformer: Webtypes: CssParts values", t => { + const res = runAndParseWebtypesBuild(` + /** + * @csspart part1 - Part 1 desc + * @csspart part-2 - Part 2 desc + */ + @customElement('my-element') + class MyElement extends HTMLElement {} + `); + + const myElement = findHtmlElementOfName(res, "my-element"); + t.truthy(myElement); + + t.is(myElement?.css?.parts?.length, 2); + const defaultSlot = myElement?.css?.parts?.find(slot => slot.name == "part1"); + const namedSlot = myElement?.css?.parts?.find(slot => slot.name == "part-2"); + + t.is(defaultSlot?.description, "Part 1 desc"); + t.is(namedSlot?.description, "Part 2 desc"); +}); diff --git a/test/transformers/webtypes/basic-test.ts b/test/transformers/webtypes/basic-test.ts new file mode 100644 index 00000000..d3cf5d62 --- /dev/null +++ b/test/transformers/webtypes/basic-test.ts @@ -0,0 +1,74 @@ +import { tsTest } from "../../helpers/ts-test"; +import { findAttributeOfName, findHtmlElementOfName, findPropertyOfName, runAndParseWebtypesBuild } from "../../helpers/webtypes-test-utils"; +import { WebTypesTransformerConfig } from "../../../src/transformers"; +import { SourceModule } from "../../../src/transformers/webtypes/webtypes-schema"; + +tsTest("Transformer: Webtypes: File header test", t => { + const config: WebTypesTransformerConfig = { + name: "PkgTest", + version: "1.2.3-test", + "default-icon": "path/foo.png", + "framework-config": { + "enable-when": { + "node-packages": ["lit", "lit-html"] + } + }, + framework: "test-fw", + "description-markup": "html" + }; + const res = runAndParseWebtypesBuild( + ` + @customElement('my-element') + class MyElement extends HTMLElement {} + `, + config + ); + + t.is(res.name, config.name); + t.is(res.version, config.version); + t.is(res.framework, config.framework); + t.is(res["default-icon"], config["default-icon"]); + t.is(res["description-markup"], config["description-markup"]); + t.deepEqual(res["framework-config"], config["framework-config"]); + t.is(res.contributions?.html?.elements?.length, 1); +}); + +tsTest("Transformer: Webtypes: Basic content test", t => { + const res = runAndParseWebtypesBuild([ + ` + @customElement('my-element') + class MyElement extends HTMLElement { + @property({type: String, attribute: "my-prop"}) myProp: string; + @property({type: String, attribute: "my-prop2"}) myProp2: string; + } + `, + ` + @customElement('my-element2') + class MyElement2 extends HTMLElement { + @property({type: String, attribute: "my-prop"}) myProp: string; + } + ` + ]); + + t.is(res.contributions?.html?.elements?.length, 2); + + const myElement = findHtmlElementOfName(res, "my-element"); + t.truthy(myElement); + t.is((myElement?.source as SourceModule)?.symbol, "MyElement"); + // Test element contains expected attributes + t.is(myElement?.attributes?.length, 2); + t.truthy(findAttributeOfName(myElement, "my-prop")); + t.truthy(findAttributeOfName(myElement, "my-prop2")); + // Test element contains expected properties + t.is(myElement?.js?.properties?.length, 2); + t.truthy(findPropertyOfName(myElement, "myProp")); + t.truthy(findPropertyOfName(myElement, "myProp2")); + + const myElement2 = findHtmlElementOfName(res, "my-element2"); + t.truthy(myElement2); + t.is((myElement2?.source as SourceModule)?.symbol, "MyElement2"); + t.is(myElement2?.attributes?.length, 1); + t.truthy(findAttributeOfName(myElement2, "my-prop")); + t.is(myElement2?.js?.properties?.length, 1); + t.truthy(findPropertyOfName(myElement2, "myProp")); +}); diff --git a/test/transformers/webtypes/css-test.ts b/test/transformers/webtypes/css-test.ts new file mode 100644 index 00000000..de13afcc --- /dev/null +++ b/test/transformers/webtypes/css-test.ts @@ -0,0 +1,26 @@ +import { tsTest } from "../../helpers/ts-test"; +import { runAndParseWebtypesBuild } from "../../helpers/webtypes-test-utils"; + +tsTest("Transformer: Webtypes: CSS properties test", t => { + const res = runAndParseWebtypesBuild(` + /** + * @cssprop [--var-test] - Var test desc + * @cssprop [--var-test2=48px] - Var test desc with default value + */ + @customElement('my-element') + class MyElement extends HTMLElement {} + `); + + const cssProps = res?.contributions?.css?.properties; + t.truthy(cssProps); + t.is(cssProps?.length, 2); + + const varTest = cssProps?.find(cp => cp.name == "--var-test"); + t.truthy(varTest); + t.is(varTest?.description, "Var test desc"); + + const varTest2 = cssProps?.find(cp => cp.name == "--var-test2"); + t.truthy(varTest2); + t.is(varTest2?.description, "Var test desc with default value\n\n**Default:** 48px"); + // default value not put in CSS +}); diff --git a/test/transformers/webtypes/event-test.ts b/test/transformers/webtypes/event-test.ts new file mode 100644 index 00000000..040e8f51 --- /dev/null +++ b/test/transformers/webtypes/event-test.ts @@ -0,0 +1,29 @@ +import { tsTest } from "../../helpers/ts-test"; +import { findHtmlElementOfName, runAndParseWebtypesBuild } from "../../helpers/webtypes-test-utils"; + +tsTest("Transformer: Webtypes: Element events test", t => { + const res = runAndParseWebtypesBuild(` + /** + * @fires test-event - Test event desc + * @fires test-event2 {CustomEvent<{index: int, name: string}>} - Test event desc with typing + */ + @customElement('my-element') + class MyElement extends HTMLElement {} + `); + + const myElement = findHtmlElementOfName(res, "my-element"); + t.truthy(myElement); + + const events = myElement?.js?.events; + t.truthy(events); + t.is(events?.length, 2); + + const testEvent = events?.find(cp => cp.name == "test-event"); + t.truthy(testEvent); + t.is(testEvent?.description, "Test event desc"); + + const testEvent2 = events?.find(cp => cp.name == "test-event2"); + t.truthy(testEvent2); + t.is(testEvent2?.description, "Test event desc with typing"); + // typing value not put in event +});