diff --git a/packages/components/package.json b/packages/components/package.json index faa8215949f..6373aab0f66 100644 --- a/packages/components/package.json +++ b/packages/components/package.json @@ -17,7 +17,7 @@ "license": "MPL-2.0", "author": "HashiCorp Design Systems ", "scripts": { - "build": "rollup --config", + "build": "rollup --config && node scripts/parse-docs.mjs", "format": "prettier . --cache --write", "lint": "concurrently \"pnpm:lint:*(!fix)\" --names \"lint:\" --prefixColors auto", "lint:fix": "concurrently \"pnpm:lint:*:fix\" --names \"fix:\" --prefixColors auto && pnpm run format", @@ -114,6 +114,7 @@ "stylelint": "^16.17.0", "stylelint-config-rational-order": "^0.1.2", "stylelint-config-standard-scss": "^14.0.0", + "ts-morph": "^28.0.0", "typescript": "^5.8.2", "typescript-eslint": "^8.29.0", "webpack": "^5.104.1" diff --git a/packages/components/scripts/parse-docs.mjs b/packages/components/scripts/parse-docs.mjs new file mode 100644 index 00000000000..0670624f37c --- /dev/null +++ b/packages/components/scripts/parse-docs.mjs @@ -0,0 +1,82 @@ +/** + * Copyright IBM Corp. 2021, 2026 + * SPDX-License-Identifier: MPL-2.0 + */ + +/** + * Parse component docs metadata from signature types. + * + * Input: src/components.ts central public exports. Output: + * dist/manifest/components.json consumed by docs tooling. Invoked during `pnpm + * build` in this package. + */ + +import { Project } from 'ts-morph'; +import { existsSync, mkdirSync } from 'node:fs'; +import { dirname } from 'node:path'; + +import { + ENTRY_FILE_PATH, + OUTPUT_FILE_PATH, + TSCONFIG_PATH, + TYPE_TRACE_LIMITS, +} from './parse-docs/config.mjs'; +import { createSourceFileResolver } from './parse-docs/source-files.mjs'; +import { createTypeResolver } from './parse-docs/type-resolver.mjs'; +import { extractDocData } from './parse-docs/doc-text.mjs'; +import { parseComponentsFromEntry } from './parse-docs/component-parser.mjs'; +import { + printSkippedComponentsSummary, + printSuccess, + sortDocPayloads, + writeManifest, +} from './parse-docs/output.mjs'; + +if (!existsSync(ENTRY_FILE_PATH)) { + console.error(`āŒ Central entry file not found at: ${ENTRY_FILE_PATH}`); + + process.exit(1); +} + +const outputDir = dirname(OUTPUT_FILE_PATH); + +if (!existsSync(outputDir)) { + mkdirSync(outputDir, { recursive: true }); +} + +const project = new Project({ tsConfigFilePath: TSCONFIG_PATH }); +const entryFile = project.addSourceFileAtPath(ENTRY_FILE_PATH); +const missingFamilyTypes = []; +const missingSignatures = []; + +const sourceFileResolver = createSourceFileResolver({ project, entryFile }); +const typeResolver = createTypeResolver({ + limits: TYPE_TRACE_LIMITS, + resolveImportSourceFile: sourceFileResolver.resolveImportSourceFile, +}); + +// walk only from the central components entrypoint so output order and coverage stay deterministic +console.log(`šŸ” Crawling entry point via AST: ${ENTRY_FILE_PATH}\n`); + +const allDocPayloads = parseComponentsFromEntry({ + entryFile, + sourceFileResolver, + typeResolver, + extractDocData, + onMissingFamilyTypes: (moduleSpecifier, componentName) => { + missingFamilyTypes.push({ moduleSpecifier, componentName }); + }, + onMissingSignature: (moduleSpecifier, componentName, signatureName) => { + missingSignatures.push({ moduleSpecifier, componentName, signatureName }); + }, +}); + +const sortedDocPayloads = sortDocPayloads(allDocPayloads); + +writeManifest(OUTPUT_FILE_PATH, sortedDocPayloads); + +printSuccess(OUTPUT_FILE_PATH); +printSkippedComponentsSummary({ + missingFamilyTypes, + missingSignatures, +}); diff --git a/packages/components/scripts/parse-docs/ast-helpers.mjs b/packages/components/scripts/parse-docs/ast-helpers.mjs new file mode 100644 index 00000000000..51bf662e524 --- /dev/null +++ b/packages/components/scripts/parse-docs/ast-helpers.mjs @@ -0,0 +1,64 @@ +/** + * Copyright IBM Corp. 2021, 2026 + * SPDX-License-Identifier: MPL-2.0 + */ + +import { Node } from 'ts-morph'; + +export function findImportForLocalName(sourceFile, localName) { + for (const importDecl of sourceFile.getImportDeclarations()) { + const moduleSpecifier = importDecl.getModuleSpecifierValue(); + + const defaultImport = importDecl.getDefaultImport(); + + if (defaultImport && defaultImport.getText() === localName) { + return { + importDecl, + moduleSpecifier, + importedName: 'default', + isDefault: true, + }; + } + + for (const namedImport of importDecl.getNamedImports()) { + const candidateLocalName = + namedImport.getAliasNode()?.getText() || namedImport.getName(); + + if (candidateLocalName !== localName) { + continue; + } + + return { + importDecl, + moduleSpecifier, + importedName: namedImport.getName(), + isDefault: false, + }; + } + } + + return null; +} + +export function getContextualComponentTypeQuery(typeNode) { + if (!typeNode) { + return null; + } + + if (Node.isTypeQuery(typeNode)) { + return typeNode; + } + + if ( + Node.isTypeReference(typeNode) && + typeNode.getTypeName().getText() === 'WithBoundArgs' + ) { + const firstTypeArg = typeNode.getTypeArguments()[0]; + + if (firstTypeArg && Node.isTypeQuery(firstTypeArg)) { + return firstTypeArg; + } + } + + return null; +} diff --git a/packages/components/scripts/parse-docs/component-parser.mjs b/packages/components/scripts/parse-docs/component-parser.mjs new file mode 100644 index 00000000000..9028a4cd8ab --- /dev/null +++ b/packages/components/scripts/parse-docs/component-parser.mjs @@ -0,0 +1,264 @@ +/** + * Copyright IBM Corp. 2021, 2026 + * SPDX-License-Identifier: MPL-2.0 + */ + +import { + PROP_ARGS, + PROP_BLOCKS, + PROP_ELEMENT, + SIGNATURE_SUFFIX, + TYPE_ENUM, + TYPE_UNKNOWN, +} from './constants.mjs'; +import { + findImportForLocalName, + getContextualComponentTypeQuery, +} from './ast-helpers.mjs'; + +export function parseComponentsFromEntry({ + entryFile, + sourceFileResolver, + typeResolver, + extractDocData, + onMissingFamilyTypes, + onMissingSignature, +}) { + function getExportedComponentNames(exportDeclaration) { + return exportDeclaration + .getNamedExports() + .map( + (specifier) => + specifier.getAliasNode()?.getText() || specifier.getName() + ) + .filter((name) => name !== 'default'); + } + + function getYieldedComponentSourcePath(yieldDeclaration) { + const typeNode = yieldDeclaration.getTypeNode?.(); + const typeQueryNode = getContextualComponentTypeQuery(typeNode); + + if (!typeQueryNode) { + return undefined; + } + + const symbolName = typeQueryNode.getExprName().getText(); + const sourceFile = yieldDeclaration.getSourceFile(); + const importMatch = findImportForLocalName(sourceFile, symbolName); + + if (!importMatch) { + return undefined; + } + + return importMatch.moduleSpecifier; + } + + function parseArgs(signatureInterface) { + const args = []; + const argsProperty = signatureInterface.getProperty(PROP_ARGS); + + if (!argsProperty) { + return args; + } + + const argsType = argsProperty.getType().getApparentType(); + + argsType.getProperties().forEach((prop) => { + const declaration = prop.getValueDeclaration(); + + if (!declaration) { + return; + } + + const docData = extractDocData(declaration); + const resolvedType = typeResolver.resolveDeclarationType(declaration); + + const parsedArg = { + name: prop.getName(), + type: + resolvedType.enumValues === undefined ? resolvedType.text : TYPE_ENUM, + required: !declaration.hasQuestionToken(), + description: docData.description, + remarks: docData.remarks, + defaultValue: docData.defaultValue, + dependsOn: docData.dependsOn, + }; + + if (resolvedType.enumValues !== undefined) { + parsedArg.values = resolvedType.enumValues; + } + + args.push(parsedArg); + }); + + return args; + } + + function parseBlockYields(declaration) { + const yields = []; + const tupleElements = declaration.getType().getTupleElements(); + + if (tupleElements.length === 0) { + return yields; + } + + tupleElements.forEach((tupleElement, index) => { + const yieldedProps = tupleElement.getProperties(); + + if (yieldedProps.length > 0) { + // object-like tuple members represent named yield values + yieldedProps.forEach((yieldedProp) => { + const yieldDecl = yieldedProp.getValueDeclaration(); + const yieldDocData = yieldDecl + ? extractDocData(yieldDecl) + : { description: '' }; + + if (!yieldDecl) { + // continue with unknown type when no declaration is available + } + + yields.push({ + name: yieldedProp.getName(), + type: yieldDecl + ? typeResolver.resolveYieldTypeText(yieldDecl) + : TYPE_UNKNOWN, + description: yieldDocData.description, + remarks: yieldDocData.remarks, + sourcePath: yieldDecl + ? getYieldedComponentSourcePath(yieldDecl) + : undefined, + }); + }); + + return; + } + + yields.push({ + name: `item${index + 1}`, + type: tupleElement.getText(declaration), + description: '', + }); + }); + + return yields; + } + + function parseBlocks(signatureInterface) { + const blocks = []; + const blocksProperty = signatureInterface.getProperty(PROP_BLOCKS); + + if (!blocksProperty) { + return blocks; + } + + const blocksType = blocksProperty.getType().getApparentType(); + + blocksType.getProperties().forEach((prop) => { + const declaration = prop.getValueDeclaration(); + + if (!declaration) { + return; + } + + const docData = extractDocData(declaration); + const yields = parseBlockYields(declaration); + + blocks.push({ + name: prop.getName(), + description: docData.description, + yields, + }); + }); + + return blocks; + } + + function parseElement(signatureInterface) { + const elementProperty = signatureInterface.getProperty(PROP_ELEMENT); + + if (!elementProperty) { + return { + element: null, + splattributes: false, + }; + } + + const docData = extractDocData(elementProperty); + + return { + element: elementProperty.getType().getText(), + splattributes: docData.hasSplattributesTag, + }; + } + + const allDocPayloads = {}; + const exportDeclarations = entryFile.getExportDeclarations(); + + for (const exportDecl of exportDeclarations) { + const moduleSpecifier = exportDecl.getModuleSpecifierValue(); + + if (!moduleSpecifier) { + continue; + } + + const componentNames = getExportedComponentNames(exportDecl); + + if (componentNames.length === 0) { + continue; + } + + const familyTypesFile = + sourceFileResolver.resolveFamilyTypesSourceFile(moduleSpecifier); + + if (!familyTypesFile) { + componentNames.forEach((componentName) => { + onMissingFamilyTypes(moduleSpecifier, componentName); + }); + + continue; + } + + componentNames.forEach((componentName) => { + if (allDocPayloads[componentName]) { + return; + } + + const signatureName = `${componentName}${SIGNATURE_SUFFIX}`; + const signatureInterface = familyTypesFile.getInterface(signatureName); + + if (!signatureInterface) { + onMissingSignature(moduleSpecifier, componentName, signatureName); + + return; + } + + console.log(`šŸ“¦ Generating docs for: ${componentName}`); + + const componentDocs = { + name: componentName, + args: [], + blocks: [], + element: null, + splattributes: false, + }; + + componentDocs.args = parseArgs(signatureInterface); + componentDocs.blocks = parseBlocks(signatureInterface); + + const parsedElement = parseElement(signatureInterface); + componentDocs.element = parsedElement.element; + componentDocs.splattributes = parsedElement.splattributes; + + componentDocs.args.sort((a, b) => a.name.localeCompare(b.name)); + // keep stable ordering in manifest diffs + componentDocs.blocks.forEach((block) => { + block.yields.sort((a, b) => a.name.localeCompare(b.name)); + }); + componentDocs.blocks.sort((a, b) => a.name.localeCompare(b.name)); + + allDocPayloads[componentName] = componentDocs; + }); + } + + return allDocPayloads; +} diff --git a/packages/components/scripts/parse-docs/config.mjs b/packages/components/scripts/parse-docs/config.mjs new file mode 100644 index 00000000000..c45a3061673 --- /dev/null +++ b/packages/components/scripts/parse-docs/config.mjs @@ -0,0 +1,25 @@ +/** + * Copyright IBM Corp. 2021, 2026 + * SPDX-License-Identifier: MPL-2.0 + */ + +import { dirname, resolve } from 'node:path'; +import { fileURLToPath } from 'node:url'; + +export const SCRIPT_DIR = dirname(fileURLToPath(import.meta.url)); +export const ENTRY_FILE_PATH = resolve(SCRIPT_DIR, '../../src/components.ts'); +export const OUTPUT_FILE_PATH = resolve( + SCRIPT_DIR, + '../../dist/manifest/components.json' +); +export const TSCONFIG_PATH = resolve(SCRIPT_DIR, '../../tsconfig.json'); + +/** + * Keep tracing bounded so a single highly-expanded type from local or external + * declarations cannot explode parse time or output size. + */ +export const TYPE_TRACE_LIMITS = { + maxDepth: 24, + maxUnionMembers: 80, + maxTemplateExpansions: 120, +}; diff --git a/packages/components/scripts/parse-docs/constants.mjs b/packages/components/scripts/parse-docs/constants.mjs new file mode 100644 index 00000000000..b76207a6ccd --- /dev/null +++ b/packages/components/scripts/parse-docs/constants.mjs @@ -0,0 +1,16 @@ +/** + * Copyright IBM Corp. 2021, 2026 + * SPDX-License-Identifier: MPL-2.0 + */ + +export const TYPE_COMPONENT = 'component'; +export const TYPE_ENUM = 'enum'; +export const TYPE_FUNCTION = 'function'; +export const TYPE_STRING = 'string'; +export const TYPE_UNKNOWN = 'unknown'; +export const TYPE_YIELDED_COMPONENT = TYPE_COMPONENT; + +export const SIGNATURE_SUFFIX = 'Signature'; +export const PROP_ARGS = 'Args'; +export const PROP_BLOCKS = 'Blocks'; +export const PROP_ELEMENT = 'Element'; diff --git a/packages/components/scripts/parse-docs/doc-text.mjs b/packages/components/scripts/parse-docs/doc-text.mjs new file mode 100644 index 00000000000..3bb9427aa3c --- /dev/null +++ b/packages/components/scripts/parse-docs/doc-text.mjs @@ -0,0 +1,167 @@ +/** + * Copyright IBM Corp. 2021, 2026 + * SPDX-License-Identifier: MPL-2.0 + */ + +export function normalizeTagText(tag) { + const tagComment = tag.getCommentText(); + + if (typeof tagComment === 'string') { + // ts-morph can return structured tag comment text so normalize line endings here + return tagComment + .split('\n') + .map((line) => line.trimEnd()) + .join('\n'); + } + + return ''; +} + +export function toSingleLineText(value) { + if (typeof value !== 'string') { + return ''; + } + + return value + .replace(/\r\n?/g, '\n') + .replace(/\s*\n\s*/g, ' ') + .replace(/\s{2,}/g, ' ') + .trim(); +} + +export function normalizeMarkdownText(value) { + if (typeof value !== 'string') { + return ''; + } + + const lines = value + .replace(/\r\n?/g, '\n') + .split('\n') + .map((line) => line.trimEnd()); + + while (lines.length > 0 && lines[0].trim() === '') { + lines.shift(); + } + + while (lines.length > 0 && lines[lines.length - 1].trim() === '') { + lines.pop(); + } + + const indents = lines + .filter((line) => line.trim() !== '') + .map((line) => { + const match = line.match(/^\s*/); + return match ? match[0].length : 0; + }); + + const commonIndent = indents.length > 0 ? Math.min(...indents) : 0; + + const deindentedLines = lines.map((line) => line.slice(commonIndent)); + const outputLines = []; + let currentLine = ''; + let currentMode = null; + + function flushCurrentLine() { + if (currentLine) { + outputLines.push(currentLine.trim()); + currentLine = ''; + currentMode = null; + } + } + + function pushBlankLine() { + if (outputLines.length === 0) { + return; + } + + if (outputLines[outputLines.length - 1] !== '') { + outputLines.push(''); + } + } + + for (const rawLine of deindentedLines) { + const line = rawLine.trim(); + + if (line === '') { + flushCurrentLine(); + pushBlankLine(); + continue; + } + + const listMatch = line.match(/^([-*+]|\d+[.)])\s+(.*)$/); + + if (listMatch) { + flushCurrentLine(); + // keep list item boundaries while still collapsing wrapped lines + currentMode = 'list'; + currentLine = `${listMatch[1]} ${listMatch[2]}`; + continue; + } + + if (currentMode === 'list' || currentMode === 'paragraph') { + currentLine = `${currentLine} ${line}`; + continue; + } + + currentMode = 'paragraph'; + currentLine = line; + } + + flushCurrentLine(); + + while (outputLines.length > 0 && outputLines[0] === '') { + outputLines.shift(); + } + + while (outputLines.length > 0 && outputLines[outputLines.length - 1] === '') { + outputLines.pop(); + } + + return outputLines.join('\n'); +} + +export function extractDocData(declarationNode) { + const result = { + description: '', + remarks: '', + defaultValue: null, + dependsOn: null, + splattributes: null, + hasSplattributesTag: false, + }; + + const jsDocs = declarationNode.getJsDocs(); + + if (jsDocs.length === 0) { + return result; + } + + const doc = jsDocs[0]; + // intentionally prefer the first jsdoc block to match current authoring conventions + + result.description = toSingleLineText(doc.getComment()); + + doc.getTags().forEach((tag) => { + const tagName = tag.getTagName(); + const tagText = normalizeTagText(tag); + + if (tagName === 'remarks') { + result.remarks = normalizeMarkdownText(tagText); + } + + if (tagName === 'defaultValue') { + result.defaultValue = toSingleLineText(tagText) || null; + } + + if (tagName === 'dependsOn') { + result.dependsOn = toSingleLineText(tagText) || null; + } + + if (tagName === 'splattributes') { + result.hasSplattributesTag = true; + result.splattributes = toSingleLineText(tagText) || null; + } + }); + + return result; +} diff --git a/packages/components/scripts/parse-docs/literal.mjs b/packages/components/scripts/parse-docs/literal.mjs new file mode 100644 index 00000000000..a943af051db --- /dev/null +++ b/packages/components/scripts/parse-docs/literal.mjs @@ -0,0 +1,49 @@ +/** + * Copyright IBM Corp. 2021, 2026 + * SPDX-License-Identifier: MPL-2.0 + */ + +export function splitUnion(typeText) { + if (typeof typeText !== 'string') { + return []; + } + + return typeText + .split('|') + .map((part) => part.trim()) + .filter(Boolean); +} + +export function isQuotedLiteral(value) { + return typeof value === 'string' && /^(['"]).*\1$/u.test(value); +} + +export function unquoteLiteral(value) { + if (typeof value !== 'string') { + return value; + } + + const match = /^(['"])(.*)\1$/u.exec(value); + + if (match === null) { + return value; + } + + return match[2]; +} + +export function parseStringEnumValues(typeText) { + const values = splitUnion(typeText); + + if (values.length < 2) { + return undefined; + } + + const areAllStringLiterals = values.every((value) => isQuotedLiteral(value)); + + if (areAllStringLiterals === false) { + return undefined; + } + + return values.map((value) => unquoteLiteral(value)); +} diff --git a/packages/components/scripts/parse-docs/output.mjs b/packages/components/scripts/parse-docs/output.mjs new file mode 100644 index 00000000000..c729d235288 --- /dev/null +++ b/packages/components/scripts/parse-docs/output.mjs @@ -0,0 +1,35 @@ +/** + * Copyright IBM Corp. 2021, 2026 + * SPDX-License-Identifier: MPL-2.0 + */ + +import { writeFileSync } from 'node:fs'; + +export function sortDocPayloads(allDocPayloads) { + return Object.fromEntries( + Object.entries(allDocPayloads).sort(([a], [b]) => a.localeCompare(b)) + ); +} + +export function writeManifest(outputFilePath, payload) { + writeFileSync(outputFilePath, JSON.stringify(payload, null, 2)); +} + +export function printSuccess(outputFilePath) { + console.log( + `\nšŸŽ‰ Successfully compiled component documentation to: ${outputFilePath}` + ); +} + +export function printSkippedComponentsSummary({ + missingFamilyTypes, + missingSignatures, +}) { + const totalSkipped = missingFamilyTypes.length + missingSignatures.length; + + if (totalSkipped === 0) { + return; + } + + console.log(`\nāš ļø Skipped ${totalSkipped} component docs entries.`); +} diff --git a/packages/components/scripts/parse-docs/source-files.mjs b/packages/components/scripts/parse-docs/source-files.mjs new file mode 100644 index 00000000000..97c3a840d54 --- /dev/null +++ b/packages/components/scripts/parse-docs/source-files.mjs @@ -0,0 +1,74 @@ +/** + * Copyright IBM Corp. 2021, 2026 + * SPDX-License-Identifier: MPL-2.0 + */ + +import { dirname, resolve } from 'node:path'; + +export function createSourceFileResolver({ project, entryFile }) { + const componentsDirectoryPath = resolve( + entryFile.getDirectoryPath(), + 'components' + ); + + function resolveComponentSourceFile(moduleSpecifier) { + if (!moduleSpecifier.endsWith('.gts')) { + return null; + } + + const componentFilePath = resolve( + entryFile.getDirectoryPath(), + moduleSpecifier + ); + + return project.addSourceFileAtPathIfExists(componentFilePath); + } + + function resolveFamilyTypesSourceFile(moduleSpecifier) { + const componentSourceFile = resolveComponentSourceFile(moduleSpecifier); + + if (!componentSourceFile) { + return null; + } + + let currentDirectory = dirname(componentSourceFile.getFilePath()); + + while (currentDirectory.startsWith(componentsDirectoryPath)) { + const typesFilePath = resolve(currentDirectory, 'types.ts'); + const typesFile = project.addSourceFileAtPathIfExists(typesFilePath); + + if (typesFile) { + return typesFile; + } + + if (currentDirectory === componentsDirectoryPath) { + break; + } + + currentDirectory = dirname(currentDirectory); + } + + return null; + } + + function resolveImportSourceFile(fromSourceFile, moduleSpecifier) { + const resolvedByTs = fromSourceFile + .getImportDeclarations() + .find( + (importDecl) => importDecl.getModuleSpecifierValue() === moduleSpecifier + ) + ?.getModuleSpecifierSourceFile(); + + if (resolvedByTs) { + return resolvedByTs; + } + + return null; + } + + return { + resolveComponentSourceFile, + resolveFamilyTypesSourceFile, + resolveImportSourceFile, + }; +} diff --git a/packages/components/scripts/parse-docs/type-resolver.mjs b/packages/components/scripts/parse-docs/type-resolver.mjs new file mode 100644 index 00000000000..d775187ab24 --- /dev/null +++ b/packages/components/scripts/parse-docs/type-resolver.mjs @@ -0,0 +1,596 @@ +/** + * Copyright IBM Corp. 2021, 2026 + * SPDX-License-Identifier: MPL-2.0 + */ + +import { Node, SyntaxKind } from 'ts-morph'; + +import { + TYPE_FUNCTION, + TYPE_STRING, + TYPE_UNKNOWN, + TYPE_YIELDED_COMPONENT, +} from './constants.mjs'; +import { + findImportForLocalName, + getContextualComponentTypeQuery, +} from './ast-helpers.mjs'; +import { + isQuotedLiteral, + parseStringEnumValues, + splitUnion, + unquoteLiteral, +} from './literal.mjs'; + +export function createTypeResolver({ limits, resolveImportSourceFile }) { + function isKeywordTypeNode(node) { + return ( + Node.isAnyKeyword(node) || + Node.isBooleanKeyword(node) || + Node.isNumberKeyword(node) || + Node.isStringKeyword(node) || + Node.isSymbolKeyword(node) || + Node.isObjectKeyword(node) || + Node.isUndefinedKeyword(node) || + Node.isNeverKeyword(node) + ); + } + + function findDeclaredTypeByName(sourceFile, typeName) { + return ( + sourceFile.getTypeAlias(typeName) || + sourceFile.getInterface(typeName) || + sourceFile.getEnum(typeName) || + sourceFile.getClass(typeName) + ); + } + + function findImportedTypeDeclaration(sourceFile, localTypeName) { + const importMatch = findImportForLocalName(sourceFile, localTypeName); + + if (!importMatch) { + return null; + } + + const importedFile = resolveImportSourceFile( + sourceFile, + importMatch.moduleSpecifier + ); + + if (!importedFile) { + return null; + } + + if (importMatch.isDefault) { + const defaultExportSymbol = importedFile.getDefaultExportSymbol(); + const declaration = defaultExportSymbol?.getDeclarations()?.[0]; + + if (declaration) { + return declaration; + } + + return null; + } + + const declaration = findDeclaredTypeByName( + importedFile, + importMatch.importedName + ); + + if (declaration) { + return declaration; + } + + return null; + } + + function findTypeDeclarationFromReference(sourceFile, typeName) { + return ( + findDeclaredTypeByName(sourceFile, typeName) || + findImportedTypeDeclaration(sourceFile, typeName) + ); + } + + function createDeclarationKey(declaration, typeName) { + return `${declaration.getSourceFile().getFilePath()}:${typeName}`; + } + + function withDeclarationCycleGuard(seen, declaration, typeName, onResolve) { + const declarationKey = createDeclarationKey(declaration, typeName); + + if (seen.has(declarationKey)) { + return null; + } + + seen.add(declarationKey); + const resolved = onResolve(); + seen.delete(declarationKey); + + return resolved; + } + + function resolveTypeFromDeclaration(declaration, seen, depth) { + if (!declaration || depth > limits.maxDepth) { + return null; + } + + if (Node.isTypeAliasDeclaration(declaration)) { + return resolveTypeNodeToText( + declaration.getTypeNode(), + declaration.getSourceFile(), + seen, + depth + 1 + ); + } + + if (Node.isEnumDeclaration(declaration)) { + const members = declaration + .getMembers() + .map((member) => member.getInitializer()?.getText() || member.getName()) + .filter(Boolean); + + if (members.length > 0) { + if (members.length > limits.maxUnionMembers) { + return declaration.getName(); + } + + return members.join(' | '); + } + + return declaration.getName(); + } + + if ( + Node.isClassDeclaration(declaration) || + Node.isInterfaceDeclaration(declaration) + ) { + return declaration.getName() || declaration.getText(); + } + + return declaration.getText(); + } + + function resolveTypeReferenceNodeToText(typeNode, sourceFile, seen, depth) { + const typeNameNode = typeNode.getTypeName(); + const typeNameText = typeNameNode.getText(); + + if (typeNameText.includes('.')) { + return typeNode.getText(); + } + + const typeArgs = typeNode.getTypeArguments(); + const resolvedTypeArgs = typeArgs.map((arg) => + resolveTypeNodeToText(arg, sourceFile, seen, depth + 1) + ); + + const declaration = findTypeDeclarationFromReference( + sourceFile, + typeNameText + ); + + if (!declaration) { + if (resolvedTypeArgs.length === 0) { + return typeNameText; + } + + return `${typeNameText}<${resolvedTypeArgs.join(', ')}>`; + } + + const resolved = withDeclarationCycleGuard( + seen, + declaration, + typeNameText, + () => resolveTypeFromDeclaration(declaration, seen, depth + 1) + ); + + const baseType = resolved || typeNameText; + + if (resolvedTypeArgs.length === 0) { + return baseType; + } + + return `${baseType}<${resolvedTypeArgs.join(', ')}>`; + } + + function findPropertyTypeNodeFromContainer(containerNode, propertyName) { + if (!containerNode) { + return null; + } + + if (Node.isTypeLiteral(containerNode)) { + const property = containerNode.getProperty(propertyName); + + return property?.getTypeNode() || null; + } + + if (Node.isInterfaceDeclaration(containerNode)) { + const property = containerNode.getProperty(propertyName); + + return property?.getTypeNode() || null; + } + + if (typeof containerNode.getType === 'function') { + const containerType = containerNode.getType(); + const propertySymbol = containerType.getProperty(propertyName); + const propertyDeclaration = + propertySymbol?.getValueDeclaration() || + propertySymbol?.getDeclarations()?.[0]; + + if ( + propertyDeclaration && + typeof propertyDeclaration.getTypeNode === 'function' + ) { + return propertyDeclaration.getTypeNode() || null; + } + } + + return null; + } + + function expandTemplateLiteralTypeNode(typeNode, sourceFile, seen, depth) { + const head = typeNode.getHead().getLiteralText(); + const spans = typeNode.getTemplateSpans(); + let combinations = [head]; + + for (const span of spans) { + const spanTypeNode = + span.getFirstChildIfKind(SyntaxKind.TypeReference) || + span.getFirstChildIfKind(SyntaxKind.TemplateLiteralType) || + span.getFirstChildIfKind(SyntaxKind.UnionType) || + span.getFirstChildIfKind(SyntaxKind.LiteralType) || + span.getFirstChild(); + + if (!spanTypeNode || !Node.isTypeNode(spanTypeNode)) { + return TYPE_STRING; + } + + const spanTypeText = resolveTypeNodeToText( + spanTypeNode, + sourceFile, + seen, + depth + 1 + ); + + const spanOptions = splitUnion(spanTypeText) + .filter((option) => isQuotedLiteral(option)) + .map((option) => unquoteLiteral(option)); + + if (spanOptions.length === 0) { + return TYPE_STRING; + } + + const tailNode = span.getLastChild(); + const tailText = + tailNode && typeof tailNode.getLiteralText === 'function' + ? tailNode.getLiteralText() + : ''; + + const nextCombinations = []; + + for (const prefix of combinations) { + for (const option of spanOptions) { + nextCombinations.push(`${prefix}${option}${tailText}`); + + if (nextCombinations.length > limits.maxTemplateExpansions) { + return TYPE_STRING; + } + } + } + + combinations = nextCombinations; + } + + const literalUnion = combinations.map((value) => `'${value}'`); + + if (literalUnion.length > limits.maxUnionMembers) { + return TYPE_STRING; + } + + return literalUnion.join(' | '); + } + + function resolveIndexedAccessTypeNodeToText( + typeNode, + sourceFile, + seen, + depth + ) { + const keys = []; + let currentNode = typeNode; + + while (Node.isIndexedAccessTypeNode(currentNode)) { + const indexTypeNode = currentNode.getIndexTypeNode(); + + if (!Node.isLiteralTypeNode(indexTypeNode)) { + return typeNode.getText(); + } + + const literalNode = indexTypeNode.getLiteral(); + if (!Node.isStringLiteral(literalNode)) { + return typeNode.getText(); + } + + keys.unshift(literalNode.getLiteralText()); + currentNode = currentNode.getObjectTypeNode(); + } + + let rootTypeNode = currentNode; + let rootSourceFile = sourceFile; + + if (Node.isTypeReference(rootTypeNode)) { + const rootName = rootTypeNode.getTypeName().getText(); + const declaration = findTypeDeclarationFromReference( + sourceFile, + rootName + ); + + if (!declaration) { + return typeNode.getText(); + } + + rootSourceFile = declaration.getSourceFile(); + + if (Node.isTypeAliasDeclaration(declaration)) { + rootTypeNode = declaration.getTypeNode(); + } else { + rootTypeNode = declaration; + } + } + + let container = rootTypeNode; + + for (const key of keys) { + let nextTypeNode = findPropertyTypeNodeFromContainer(container, key); + + if (!nextTypeNode && Node.isTypeReference(container)) { + const referencedName = container.getTypeName().getText(); + const declaration = findTypeDeclarationFromReference( + rootSourceFile, + referencedName + ); + + if (!declaration) { + return typeNode.getText(); + } + + rootSourceFile = declaration.getSourceFile(); + container = declaration; + nextTypeNode = findPropertyTypeNodeFromContainer(container, key); + } + + if (!nextTypeNode) { + return typeNode.getText(); + } + + container = nextTypeNode; + } + + if (Node.isTypeNode(container) || isKeywordTypeNode(container)) { + return resolveTypeNodeToText(container, rootSourceFile, seen, depth + 1); + } + + return typeNode.getText(); + } + + function resolveTypeNodeToText( + typeNode, + sourceFile, + seen = new Set(), + depth = 0 + ) { + if (!typeNode || depth > limits.maxDepth) { + return TYPE_UNKNOWN; + } + + if (Node.isParenthesizedTypeNode(typeNode)) { + return `(${resolveTypeNodeToText( + typeNode.getTypeNode(), + sourceFile, + seen, + depth + 1 + )})`; + } + + if (Node.isUnionTypeNode(typeNode)) { + const nodes = typeNode.getTypeNodes(); + + if (nodes.length > limits.maxUnionMembers) { + return typeNode.getText(); + } + + return nodes + .map((node) => resolveTypeNodeToText(node, sourceFile, seen, depth + 1)) + .join(' | '); + } + + if (Node.isIntersectionTypeNode(typeNode)) { + return typeNode + .getTypeNodes() + .map((node) => resolveTypeNodeToText(node, sourceFile, seen, depth + 1)) + .join(' & '); + } + + if (Node.isArrayTypeNode(typeNode)) { + return `${resolveTypeNodeToText( + typeNode.getElementTypeNode(), + sourceFile, + seen, + depth + 1 + )}[]`; + } + + if (Node.isTupleTypeNode(typeNode)) { + return `[${typeNode + .getElements() + .map((node) => resolveTypeNodeToText(node, sourceFile, seen, depth + 1)) + .join(', ')}]`; + } + + if (Node.isLiteralTypeNode(typeNode)) { + return typeNode.getText(); + } + + if (isKeywordTypeNode(typeNode)) { + return typeNode.getText(); + } + + if (Node.isFunctionTypeNode(typeNode)) { + return TYPE_FUNCTION; + } + + if (Node.isTemplateLiteralTypeNode(typeNode)) { + return expandTemplateLiteralTypeNode( + typeNode, + sourceFile, + seen, + depth + 1 + ); + } + + if (Node.isTypeReference(typeNode)) { + return resolveTypeReferenceNodeToText( + typeNode, + sourceFile, + seen, + depth + 1 + ); + } + + if (Node.isIndexedAccessTypeNode(typeNode)) { + return resolveIndexedAccessTypeNodeToText( + typeNode, + sourceFile, + seen, + depth + 1 + ); + } + + return typeNode.getText(); + } + + function tryResolveEnumValues(typeNode, sourceFile, seen, depth) { + if (!typeNode || depth > limits.maxDepth) { + return undefined; + } + + if (Node.isTypeReference(typeNode)) { + const typeNameText = typeNode.getTypeName().getText(); + const declaration = findTypeDeclarationFromReference( + sourceFile, + typeNameText + ); + + if (!declaration) { + return undefined; + } + + const resolved = withDeclarationCycleGuard( + seen, + declaration, + typeNameText, + () => { + if (!Node.isTypeAliasDeclaration(declaration)) { + return undefined; + } + + return tryResolveEnumValues( + declaration.getTypeNode(), + declaration.getSourceFile(), + seen, + depth + 1 + ); + } + ); + + return resolved === null ? undefined : resolved; + } + + if (!Node.isUnionTypeNode(typeNode)) { + return undefined; + } + + const values = []; + + for (const node of typeNode.getTypeNodes()) { + if (!Node.isLiteralTypeNode(node)) { + return undefined; + } + + const literalNode = node.getLiteral(); + + if (!Node.isStringLiteral(literalNode)) { + return undefined; + } + + values.push(literalNode.getLiteralText()); + } + + if (values.length < 2) { + return undefined; + } + + return values; + } + + function resolveDeclarationType(declaration) { + const typeNode = declaration.getTypeNode?.(); + + if (typeNode) { + const tracedText = resolveTypeNodeToText( + typeNode, + declaration.getSourceFile() + ); + + if (tracedText && tracedText !== TYPE_UNKNOWN) { + const enumValuesFromAst = tryResolveEnumValues( + typeNode, + declaration.getSourceFile(), + new Set(), + 0 + ); + + return { + text: tracedText, + enumValues: + enumValuesFromAst === undefined + ? parseStringEnumValues(tracedText) + : enumValuesFromAst, + }; + } + } + + const fallbackTypeText = declaration.getType().getText(declaration); + const normalizedText = fallbackTypeText.includes('=>') + ? TYPE_FUNCTION + : fallbackTypeText; + + return { + text: normalizedText, + enumValues: parseStringEnumValues(normalizedText), + }; + } + + function resolveDeclarationTypeText(declaration) { + return resolveDeclarationType(declaration).text; + } + + function resolveYieldTypeText(declaration) { + const typeNode = declaration.getTypeNode?.(); + + if (!typeNode) { + return resolveDeclarationTypeText(declaration); + } + + if (getContextualComponentTypeQuery(typeNode)) { + return TYPE_YIELDED_COMPONENT; + } + + return resolveDeclarationTypeText(declaration); + } + + return { + resolveDeclarationType, + resolveDeclarationTypeText, + resolveYieldTypeText, + }; +} diff --git a/packages/components/src/components/hds/accordion/index.gts b/packages/components/src/components/hds/accordion/index.gts index d9a4996de92..435c4980528 100644 --- a/packages/components/src/components/hds/accordion/index.gts +++ b/packages/components/src/components/hds/accordion/index.gts @@ -7,8 +7,6 @@ import Component from '@glimmer/component'; import { assert } from '@ember/debug'; import { hash } from '@ember/helper'; -import type { WithBoundArgs } from '@glint/template'; - import HdsAccordionItem, { SIZES, DEFAULT_SIZE, @@ -18,32 +16,12 @@ import HdsAccordionItem, { import { HdsAccordionItemTitleTagValues } from './types.ts'; import type { - HdsAccordionForceStates, HdsAccordionSizes, HdsAccordionTypes, HdsAccordionItemTitleTags, + HdsAccordionSignature, } from './types.ts'; -export interface HdsAccordionSignature { - Args: { - size?: HdsAccordionSizes; - type?: HdsAccordionTypes; - forceState?: HdsAccordionForceStates; - titleTag?: HdsAccordionItemTitleTags; - }; - Blocks: { - default: [ - { - Item?: WithBoundArgs< - typeof HdsAccordionItem, - 'titleTag' | 'size' | 'type' | 'forceState' - >; - }, - ]; - }; - Element: HTMLDivElement; -} - export default class HdsAccordion extends Component { get size(): HdsAccordionSizes { const { size = DEFAULT_SIZE } = this.args; diff --git a/packages/components/src/components/hds/accordion/types.ts b/packages/components/src/components/hds/accordion/types.ts index 677d4b25d2c..d04b1671be2 100644 --- a/packages/components/src/components/hds/accordion/types.ts +++ b/packages/components/src/components/hds/accordion/types.ts @@ -3,6 +3,10 @@ * SPDX-License-Identifier: MPL-2.0 */ +import HdsAccordionItem from './item/index.gts'; + +import type { WithBoundArgs } from '@glint/template'; + export enum HdsAccordionTypeValues { Card = 'card', Flush = 'flush', @@ -33,3 +37,40 @@ export enum HdsAccordionItemTitleTagValues { } export type HdsAccordionItemTitleTags = `${HdsAccordionItemTitleTagValues}`; + +export interface HdsAccordionSignature { + Args: { + /** + * @defaultValue 'medium' + */ + size?: HdsAccordionSizes; + + /** + * @defaultValue 'card' + */ + type?: HdsAccordionTypes; + + /** + * Controls the state of all items within a group. Can be used to expand or collapse all items at once. + * @defaultValue 'close' + */ + forceState?: HdsAccordionForceStates; + + /** + * The HTML tag that wraps the content of each Accordion Item "toggle" block. + * @defaultValue 'div' + */ + titleTag?: HdsAccordionItemTitleTags; + }; + Blocks: { + default: [ + { + Item?: WithBoundArgs< + typeof HdsAccordionItem, + 'titleTag' | 'size' | 'type' | 'forceState' + >; + }, + ]; + }; + Element: HTMLDivElement; +} diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 15b24305b4b..473402ea673 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -336,6 +336,9 @@ importers: stylelint-config-standard-scss: specifier: ^14.0.0 version: 14.0.0(postcss@8.5.15)(stylelint@16.23.0(typescript@5.9.2)) + ts-morph: + specifier: ^28.0.0 + version: 28.0.0 typescript: specifier: ^5.8.2 version: 5.9.2 @@ -3590,6 +3593,9 @@ packages: '@tootallnate/quickjs-emscripten@0.23.0': resolution: {integrity: sha512-C5Mc6rdnsaJDjO3UpGW/CQTHtCKaYlScZTly4JIu97Jxo/odCiH0ITnDXSJPTOrEKk/ycSZ0AOgTmkDtkOsvIA==} + '@ts-morph/common@0.29.0': + resolution: {integrity: sha512-35oUmphHbJvQ/+UTwFNme/t2p3FoKiGJ5auTjjpNTop2dyREspirjMy82PLSC1pnDJ8ah1GU98hwpVt64YXQsg==} + '@tsconfig/ember@3.0.11': resolution: {integrity: sha512-c9uaKBN4KxtdT/UNPPxoWghyoDsTRHzYb53Z5Fv9eum+4cok0U+hYwyKNjvCxkBjEPECS8QAyp8nGAhRifZnBQ==} deprecated: Please use @ember/app-tsconfig or @ember/library-tsconfig instead. These live at https://github.com/ember-cli/tsconfigs @@ -5351,6 +5357,9 @@ packages: resolution: {integrity: sha512-QVb0dM5HvG+uaxitm8wONl7jltx8dqhfU33DcqtOZcLSVIKSDDLDi7+0LbAKiyI8hD9u42m2YxXSkMGWThaecQ==} engines: {iojs: '>= 1.0.0', node: '>= 0.12.0'} + code-block-writer@13.0.3: + resolution: {integrity: sha512-Oofo0pq3IKnsFtuHqSF7TqBfr71aeyZDVJ0HpmqB7FBM2qEigL0iPONSCZSO9pE9dZTAxANe5XHG9Uy0YMv8cg==} + codemirror-lang-hcl@0.0.0-beta.2: resolution: {integrity: sha512-R3ew7Z2EYTdHTMXsWKBW9zxnLoLPYO+CrAa3dPZjXLrIR96Q3GR4cwJKF7zkSsujsnWgwRQZonyWpXYXfhQYuQ==} @@ -11477,6 +11486,9 @@ packages: peerDependencies: typescript: '>=4.0.0' + ts-morph@28.0.0: + resolution: {integrity: sha512-Wp3tnZ2bzwxyTZMtgWVzXDfm7lB1Drz+y9DmmYH/L702PQhPyVrp3pkou3yIz4qjS14GY9kcpmLiOOMvl8oG1g==} + ts-node@10.9.2: resolution: {integrity: sha512-f0FFpIdcHgn8zcPSbf1dRevwt047YMnaiJM3u2w2RewrB+fob/zePZcrOyQoLMMO7aBIddLcQIEK5dYjkLnGrQ==} hasBin: true @@ -15449,6 +15461,12 @@ snapshots: '@tootallnate/quickjs-emscripten@0.23.0': {} + '@ts-morph/common@0.29.0': + dependencies: + minimatch: 10.2.3 + path-browserify: 1.0.1 + tinyglobby: 0.2.14 + '@tsconfig/ember@3.0.11': {} '@tsconfig/node10@1.0.11': {} @@ -18043,6 +18061,8 @@ snapshots: co@4.6.0: {} + code-block-writer@13.0.3: {} + codemirror-lang-hcl@0.0.0-beta.2: dependencies: '@codemirror/language': 6.11.2 @@ -26314,6 +26334,11 @@ snapshots: picomatch: 4.0.4 typescript: 5.9.2 + ts-morph@28.0.0: + dependencies: + '@ts-morph/common': 0.29.0 + code-block-writer: 13.0.3 + ts-node@10.9.2(@types/node@22.17.0)(typescript@5.9.2): dependencies: '@cspotcode/source-map-support': 0.8.1 diff --git a/showcase/app/components/page-components/accordion/code-fragments/with-placeholder-content.gts b/showcase/app/components/page-components/accordion/code-fragments/with-placeholder-content.gts index 48b8c066474..5e8417b3f10 100644 --- a/showcase/app/components/page-components/accordion/code-fragments/with-placeholder-content.gts +++ b/showcase/app/components/page-components/accordion/code-fragments/with-placeholder-content.gts @@ -11,7 +11,7 @@ import ShwPlaceholder from 'showcase/components/shw/placeholder'; import { HdsAccordion } from '@hashicorp/design-system-components/components'; -import type { HdsAccordionSignature } from '@hashicorp/design-system-components/components/hds/accordion/index'; +import type { HdsAccordionSignature } from '@hashicorp/design-system-components/components'; export interface CodeFragmentWithPlaceholderContentSignature { Args: { diff --git a/showcase/app/components/page-components/accordion/code-fragments/with-toggle-variants.gts b/showcase/app/components/page-components/accordion/code-fragments/with-toggle-variants.gts index fa975ca215c..7391b00f28e 100644 --- a/showcase/app/components/page-components/accordion/code-fragments/with-toggle-variants.gts +++ b/showcase/app/components/page-components/accordion/code-fragments/with-toggle-variants.gts @@ -8,7 +8,7 @@ import ShwPlaceholder from 'showcase/components/shw/placeholder'; import { HdsAccordion } from '@hashicorp/design-system-components/components'; -import type { HdsAccordionSignature } from '@hashicorp/design-system-components/components/hds/accordion/index'; +import type { HdsAccordionSignature } from '@hashicorp/design-system-components/components'; export interface CodeFragmentWithToggleVariantsSignature { Args: {