From d6f598abaa615c8709bc0c381c303d24c81a0fb4 Mon Sep 17 00:00:00 2001 From: Jeremy Saenz Date: Thu, 18 Dec 2025 10:49:01 -0800 Subject: [PATCH] feat: add custom elements manifest generator --- libs/core/package.json | 4 +- .../custom-elements-manifest-generator.ts | 990 ++++++++++++++++++ .../scripts/vscode-custom-data-generator.ts | 528 ---------- libs/core/scripts/vscode-settings-patcher.cjs | 89 -- libs/core/stencil.config.ts | 13 +- package.json | 1 - wc.config.js | 7 + 7 files changed, 1003 insertions(+), 629 deletions(-) create mode 100644 libs/core/scripts/custom-elements-manifest-generator.ts delete mode 100644 libs/core/scripts/vscode-custom-data-generator.ts delete mode 100644 libs/core/scripts/vscode-settings-patcher.cjs create mode 100644 wc.config.js diff --git a/libs/core/package.json b/libs/core/package.json index ca407cfa5..aa8d06e58 100644 --- a/libs/core/package.json +++ b/libs/core/package.json @@ -33,6 +33,7 @@ "collection": "dist/collection/collection-manifest.json", "collection:main": "dist/collection/index.js", "unpkg": "dist/pine-core/pine-core.esm.js", + "customElements": "dist/custom-elements.json", "publishConfig": { "access": "public", "directory": "dist" @@ -53,8 +54,7 @@ "scripts": { "build": "npm run build.stencil", "build.all": "run-s build.stencil build.storybook", - "build.stencil": "stencil build --docs && mkdir -p dist/scripts && cp scripts/vscode-settings-patcher.cjs dist/scripts/", - "postinstall": "node ./dist/scripts/vscode-settings-patcher.cjs || true", + "build.stencil": "stencil build --docs", "build.storybook": "storybook build", "build.ts": "tsc -p scripts/tsconfig.json", "deploy": "npm run build.all", diff --git a/libs/core/scripts/custom-elements-manifest-generator.ts b/libs/core/scripts/custom-elements-manifest-generator.ts new file mode 100644 index 000000000..3d159f178 --- /dev/null +++ b/libs/core/scripts/custom-elements-manifest-generator.ts @@ -0,0 +1,990 @@ +import { + JsonDocs, + JsonDocsComponent, + JsonDocsProp, + JsonDocsEvent, + JsonDocsMethod, + JsonDocMethodParameter, + JsonDocsSlot, + JsonDocsPart, + JsonDocsStyle, +} from '@stencil/core/internal'; +import * as fs from 'fs'; +import * as path from 'path'; + +/** + * Custom Elements Manifest Generator + * + * Generates a standard Custom Elements Manifest (CEM) file following the + * schema defined at: https://github.com/webcomponents/custom-elements-manifest + * + * This format is supported by: + * - VS Code web components extension + * - Storybook (web-components) + * - JetBrains IDEs + * - Other tooling that follows the CEM spec + */ + +// ============================================================================ +// MDX Documentation Parsing +// ============================================================================ + +interface MdxParsedData { + description: string; + summary: string; + examples: MdxExample[]; +} + +interface MdxExample { + name: string; + description: string; + code: string; +} + +// Cache for parsed MDX data +let mdxCache: Map | null = null; + +// Cache for component tag -> storybook title mapping +let storyTitleCache: Map | null = null; + +// Configuration +const DOCS_BASE_URL = 'https://pine-design-system.netlify.app'; +const GITHUB_REPO_URL = 'https://github.com/AmerisourceBergen/pine/blob/main/libs/core'; + +/** + * Find the MDX documentation file for a component + */ +function findMdxFile(componentTag: string): string | null { + const componentsDir = path.resolve(__dirname, '..', 'src', 'components'); + + // Try direct path first: pds-button -> pds-button/docs/pds-button.mdx + const directPath = path.join(componentsDir, componentTag, 'docs', `${componentTag}.mdx`); + if (fs.existsSync(directPath)) { + return directPath; + } + + // Try nested component path: pds-table-cell -> pds-table/pds-table-cell/docs/pds-table-cell.mdx + const searchForMdx = (dir: string): string | null => { + if (!fs.existsSync(dir)) return null; + + const entries = fs.readdirSync(dir, { withFileTypes: true }); + for (const entry of entries) { + if (entry.isDirectory()) { + if (entry.name === componentTag) { + const mdxPath = path.join(dir, entry.name, 'docs', `${componentTag}.mdx`); + if (fs.existsSync(mdxPath)) { + return mdxPath; + } + } + const found = searchForMdx(path.join(dir, entry.name)); + if (found !== null) return found; + } + } + return null; + }; + + return searchForMdx(componentsDir); +} + +/** + * Parse MDX content and extract the first paragraph after the component name header. + */ +function parseMdxDescription(mdxContent: string): string { + const lines = mdxContent.split('\n'); + let foundHeader = false; + const paragraphLines: string[] = []; + + for (const line of lines) { + const trimmedLine = line.trim(); + + // Skip import statements and JSX at the top + if (trimmedLine.startsWith('import ') || trimmedLine.startsWith(' 0 && trimmedLine === '')) { + break; + } + paragraphLines.push(trimmedLine); + } + } + + return paragraphLines.join(' ').trim(); +} + +/** + * Parse MDX content and extract examples with code snippets + */ +function parseMdxExamples(mdxContent: string): MdxExample[] { + const lines = mdxContent.split('\n'); + const examples: MdxExample[] = []; + let inExamplesSection = false; + let currentExample: MdxExample | null = null; + let inCodeBlock = false; + let codeLines: string[] = []; + + for (let i = 0; i < lines.length; i++) { + const line = lines[i]; + const trimmedLine = line.trim(); + + // Look for ## Examples heading + if (trimmedLine.toLowerCase() === '## examples') { + inExamplesSection = true; + continue; + } + + // Stop at the next h2 heading + if (inExamplesSection && trimmedLine.startsWith('## ') && trimmedLine.toLowerCase() !== '## examples') { + if (currentExample) { + examples.push(currentExample); + } + break; + } + + if (!inExamplesSection) continue; + + // New example heading + if (trimmedLine.startsWith('### ')) { + if (currentExample) { + examples.push(currentExample); + } + currentExample = { + name: trimmedLine.replace(/^### /, '').trim(), + description: '', + code: '', + }; + continue; + } + + if (!currentExample) continue; + + // Code block handling + if (trimmedLine.startsWith('```html') || trimmedLine.startsWith('```jsx')) { + inCodeBlock = true; + codeLines = []; + continue; + } + + if (inCodeBlock && trimmedLine === '```') { + inCodeBlock = false; + if (codeLines.length > 0 && !currentExample.code) { + currentExample.code = codeLines.join('\n').trim(); + } + continue; + } + + if (inCodeBlock) { + codeLines.push(line); + continue; + } + + // Description text (before code blocks, skip JSX) + if (!currentExample.code && !trimmedLine.startsWith('<') && trimmedLine !== '') { + if (currentExample.description) { + currentExample.description += ' ' + trimmedLine; + } else { + currentExample.description = trimmedLine; + } + } + } + + // Don't forget the last example + if (currentExample && inExamplesSection) { + examples.push(currentExample); + } + + return examples; +} + +/** + * Recursively find all story files in a directory + */ +function findStoryFiles(dir: string): string[] { + const results: string[] = []; + + if (!fs.existsSync(dir)) { + return results; + } + + const entries = fs.readdirSync(dir, { withFileTypes: true }); + + for (const entry of entries) { + const fullPath = path.join(dir, entry.name); + + if (entry.isDirectory()) { + results.push(...findStoryFiles(fullPath)); + } else if (entry.isFile() && entry.name.endsWith('.stories.js')) { + results.push(fullPath); + } + } + + return results; +} + +/** + * Build cache mapping component tags to their Storybook titles + */ +function buildStoryTitleCache(): void { + if (storyTitleCache) { + return; + } + + storyTitleCache = new Map(); + const componentsDir = path.resolve(__dirname, '..', 'src', 'components'); + const storyFiles = findStoryFiles(componentsDir); + + for (const storyFile of storyFiles) { + try { + const content = fs.readFileSync(storyFile, 'utf-8'); + + // Extract component tag from the story file + const componentMatch = content.match(/component:\s*['"]([^'"]+)['"]/); + // Extract title from the story file + const titleMatch = content.match(/title:\s*['"]([^'"]+)['"]/); + + if (componentMatch && titleMatch) { + storyTitleCache.set(componentMatch[1], titleMatch[1]); + } + } catch { + // Skip files that can't be read + } + } +} + +/** + * Get the Storybook title for a component + */ +function getStoryTitle(tag: string): string | undefined { + buildStoryTitleCache(); + return storyTitleCache?.get(tag); +} + +/** + * Convert a Storybook title to a URL path segment + */ +function titleToUrlPath(title: string): string { + return title.toLowerCase().replace(/\s+/g, '-').replace(/\//g, '-'); +} + +/** + * Get the Storybook documentation URL for a component + */ +function getComponentDocsUrl(tag: string): string { + const title = getStoryTitle(tag); + + if (title !== undefined) { + const urlPath = titleToUrlPath(title); + return `${DOCS_BASE_URL}/?path=/docs/${urlPath}--docs`; + } + + // Fallback for components without a story file + const componentName = tag.replace(/^pds-/, ''); + return `${DOCS_BASE_URL}/?path=/docs/components-${componentName}--docs`; +} + +/** + * Convert an MDX heading to a URL anchor fragment + */ +function headingToAnchor(heading: string): string { + return heading.toLowerCase().replace(/\s+/g, '-'); +} + +/** + * Get a URL to a specific example section in the docs + */ +function getExampleUrl(tag: string, exampleHeading: string): string { + const docsUrl = getComponentDocsUrl(tag); + const anchor = headingToAnchor(exampleHeading); + return `${docsUrl}#${anchor}`; +} + +/** + * Get the GitHub source URL for a component file + */ +function getSourceUrl(filePath: string): string { + // filePath is like "src/components/pds-button/pds-button.tsx" + return `${GITHUB_REPO_URL}/${filePath}`; +} + +/** + * Build a rich component description matching VS Code custom data format + */ +function buildComponentDescription(component: JsonDocsComponent): string { + const parts: string[] = []; + const mdxData = getMdxData(component.tag); + const docsUrl = getComponentDocsUrl(component.tag); + + // Main description from MDX or JSDoc + if (mdxData.description) { + parts.push(mdxData.description); + } else if (component.docs) { + parts.push(component.docs); + } + + // Examples section with links (kept in description for quick access) + if (mdxData.examples.length > 0) { + const exampleLinks = mdxData.examples.map((example) => { + const url = getExampleUrl(component.tag, example.name); + return `[${example.name}](${url})`; + }); + parts.push(`\n\n**Examples:** ${exampleLinks.join(', ')}`); + } + + // Note: Slots, CSS Parts, and Events are omitted from description + // since they're now structured fields in the CEM (slots, cssParts, events) + + // Documentation link + parts.push(`\n\n---\n📖 [View full documentation](${docsUrl})`); + + return parts.join(''); +} + +/** + * Get parsed MDX data for a component (with caching) + */ +function getMdxData(componentTag: string): MdxParsedData { + if (!mdxCache) { + mdxCache = new Map(); + } + + const cached = mdxCache.get(componentTag); + if (cached) { + return cached; + } + + const emptyData: MdxParsedData = { description: '', summary: '', examples: [] }; + + const mdxPath = findMdxFile(componentTag); + if (!mdxPath) { + mdxCache.set(componentTag, emptyData); + return emptyData; + } + + try { + const mdxContent = fs.readFileSync(mdxPath, 'utf-8'); + const description = parseMdxDescription(mdxContent); + const data: MdxParsedData = { + description, + summary: description.split('.')[0] + '.', // First sentence as summary + examples: parseMdxExamples(mdxContent), + }; + mdxCache.set(componentTag, data); + return data; + } catch { + mdxCache.set(componentTag, emptyData); + return emptyData; + } +} + +// ============================================================================ +// Custom Elements Manifest Types (following CEM schema v1.0.0) +// ============================================================================ + +interface CustomElementsManifest { + schemaVersion: string; + readme?: string; + modules: Module[]; +} + +interface Module { + kind: 'javascript-module'; + path: string; + declarations: Declaration[]; + exports: Export[]; +} + +type Declaration = CustomElementDeclaration; + +interface CustomElementDeclaration { + kind: 'class'; + name: string; + tagName: string; + customElement: true; + description?: string; + summary?: string; + deprecated?: string | boolean; + superclass?: Reference; + source?: SourceReference; + attributes?: Attribute[]; + members?: Member[]; + events?: Event[]; + slots?: Slot[]; + cssParts?: CssPart[]; + cssProperties?: CssProperty[]; + demos?: Demo[]; +} + +interface Demo { + description?: string; + url?: string; + source?: { + code: string; + language?: string; + }; +} + +interface Reference { + name: string; + package?: string; + module?: string; +} + +interface SourceReference { + href: string; +} + +interface Attribute { + name: string; + type?: Type; + default?: string; + description?: string; + fieldName?: string; + deprecated?: string | boolean; +} + +interface Type { + text: string; +} + +type Member = Field | Method; + +interface Field { + kind: 'field'; + name: string; + type?: Type; + default?: string; + description?: string; + privacy?: 'public' | 'protected' | 'private'; + static?: boolean; + readonly?: boolean; + attribute?: string; + reflects?: boolean; + deprecated?: string | boolean; +} + +interface Method { + kind: 'method'; + name: string; + description?: string; + privacy?: 'public' | 'protected' | 'private'; + static?: boolean; + return?: ReturnType; + parameters?: Parameter[]; + deprecated?: string | boolean; +} + +interface ReturnType { + type?: Type; + description?: string; +} + +interface Parameter { + name: string; + type?: Type; + default?: string; + description?: string; + optional?: boolean; + rest?: boolean; +} + +interface Event { + name: string; + type?: Type; + description?: string; + deprecated?: string | boolean; +} + +interface Slot { + name: string; + description?: string; +} + +interface CssPart { + name: string; + description?: string; +} + +interface CssProperty { + name: string; + default?: string; + description?: string; + syntax?: string; // CSS syntax like "", "", etc. +} + +interface Export { + kind: 'js' | 'custom-element-definition'; + name: string; + declaration: Reference; +} + +// ============================================================================ +// Conversion Functions +// ============================================================================ + +/** + * Convert a Stencil component tag to a class name + * e.g., "pds-button" -> "PdsButton" + */ +function tagToClassName(tag: string): string { + return tag + .split('-') + .map((part) => part.charAt(0).toUpperCase() + part.slice(1)) + .join(''); +} + +/** + * Clean up type strings for better readability + */ +function cleanTypeString(type: string): string { + // Remove extra quotes around union members for cleaner display + return type.replace(/"/g, "'"); +} + +/** + * Extract default value, handling Stencil's format + */ +function getDefaultValue(prop: JsonDocsProp): string | undefined { + if (prop.default !== undefined) { + return prop.default; + } + + // Check docsTags for @default or @defaultValue + const defaultTag = prop.docsTags?.find((t) => t.name === 'default' || t.name === 'defaultValue'); + if (defaultTag?.text) { + return defaultTag.text; + } + + return undefined; +} + +/** + * Get deprecation info from a prop + */ +function getDeprecation(prop: JsonDocsProp): string | undefined { + if (prop.deprecation) { + return prop.deprecation; + } + + const deprecatedTag = prop.docsTags?.find((t) => t.name === 'deprecated'); + if (deprecatedTag) { + return deprecatedTag.text || 'This property is deprecated.'; + } + + return undefined; +} + +// Note: buildAttributeDescription removed - type, default, and deprecated +// are now structured CEM fields, so we just use the plain docs string + +/** + * Convert a Stencil prop to a CEM attribute + */ +function convertPropToAttribute(prop: JsonDocsProp): Attribute | null { + // Only include props that have an HTML attribute representation + if (!prop.attr) { + return null; + } + + const attribute: Attribute = { + name: prop.attr, + fieldName: prop.name, + description: prop.docs || undefined, + }; + + if (prop.type) { + attribute.type = { text: cleanTypeString(prop.type) }; + } + + const defaultValue = getDefaultValue(prop); + if (defaultValue) { + attribute.default = defaultValue; + } + + const deprecation = getDeprecation(prop); + if (deprecation) { + attribute.deprecated = deprecation; + } + + return attribute; +} + +/** + * Convert a Stencil prop to a CEM field member + */ +function convertPropToField(prop: JsonDocsProp): Field { + const field: Field = { + kind: 'field', + name: prop.name, + privacy: 'public', + description: prop.docs || undefined, + }; + + if (prop.type) { + field.type = { text: cleanTypeString(prop.type) }; + } + + const defaultValue = getDefaultValue(prop); + if (defaultValue) { + field.default = defaultValue; + } + + if (prop.attr) { + field.attribute = prop.attr; + } + + if (prop.reflectToAttr) { + field.reflects = true; + } + + const deprecation = getDeprecation(prop); + if (deprecation) { + field.deprecated = deprecation; + } + + return field; +} + +/** + * Convert Stencil method parameters to CEM parameters + */ +function convertParameters(params: JsonDocMethodParameter[]): Parameter[] { + return params.map((param) => ({ + name: param.name, + type: param.type ? { text: cleanTypeString(param.type) } : undefined, + description: param.docs || undefined, + })); +} + +/** + * Convert a Stencil method to a CEM method member + */ +function convertMethod(method: JsonDocsMethod): Method { + const cemMethod: Method = { + kind: 'method', + name: method.name, + privacy: 'public', + description: method.docs || undefined, + }; + + if (method.returns) { + cemMethod.return = { + type: method.returns.type ? { text: cleanTypeString(method.returns.type) } : undefined, + description: method.returns.docs || undefined, + }; + } + + if (method.parameters && method.parameters.length > 0) { + cemMethod.parameters = convertParameters(method.parameters); + } + + const deprecatedTag = method.docsTags?.find((t) => t.name === 'deprecated'); + if (deprecatedTag) { + cemMethod.deprecated = deprecatedTag.text || 'This method is deprecated.'; + } + + return cemMethod; +} + +/** + * Convert a Stencil event to a CEM event + */ +function convertEvent(event: JsonDocsEvent): Event { + const cemEvent: Event = { + name: event.event, + description: event.docs || undefined, + }; + + // Construct the CustomEvent type + if (event.detail) { + cemEvent.type = { text: `CustomEvent<${event.detail}>` }; + } else { + cemEvent.type = { text: 'CustomEvent' }; + } + + const deprecatedTag = event.docsTags?.find((t) => t.name === 'deprecated'); + if (deprecatedTag) { + cemEvent.deprecated = deprecatedTag.text || 'This event is deprecated.'; + } + + return cemEvent; +} + +/** + * Convert a Stencil slot to a CEM slot + */ +function convertSlot(slot: JsonDocsSlot): Slot { + // CEM uses empty string for default slot, Stencil uses "(default)" + const name = slot.name === '(default)' ? '' : slot.name; + + return { + name, + description: slot.docs || undefined, + }; +} + +/** + * Convert a Stencil CSS part to a CEM CSS part + */ +function convertCssPart(part: JsonDocsPart): CssPart { + return { + name: part.name, + description: part.docs || undefined, + }; +} + +/** + * Convert a Stencil CSS custom property to a CEM CSS property + */ +function convertCssProperty(style: JsonDocsStyle): CssProperty { + return { + name: style.name, + description: style.docs || undefined, + }; +} + +/** + * Convert a Stencil component to a CEM module + */ +function convertComponent(component: JsonDocsComponent): Module | null { + // Skip test/mock components + if (component.tag.startsWith('mock-') || component.filePath.includes('/test/')) { + return null; + } + + const className = tagToClassName(component.tag); + + // Get rich documentation from MDX files + const mdxData = getMdxData(component.tag); + + // Build the class declaration + const declaration: CustomElementDeclaration = { + kind: 'class', + name: className, + tagName: component.tag, + customElement: true, + }; + + // Build rich description matching VS Code custom data format + declaration.description = buildComponentDescription(component); + + // Add summary for quick display + if (mdxData.summary) { + declaration.summary = mdxData.summary; + } + + // Add source link to GitHub + declaration.source = { + href: getSourceUrl(component.filePath), + }; + + // Add demos/examples with URLs + if (mdxData.examples.length > 0) { + declaration.demos = mdxData.examples.map((example) => ({ + description: example.name, + url: getExampleUrl(component.tag, example.name), + source: example.code + ? { + code: example.code, + language: 'html', + } + : undefined, + })); + } + + // Superclass (all Stencil components extend HTMLElement conceptually) + declaration.superclass = { + name: 'HTMLElement', + package: 'global', + }; + + // Convert attributes + const attributes: Attribute[] = []; + if (component.props) { + for (const prop of component.props) { + const attr = convertPropToAttribute(prop); + if (attr) { + attributes.push(attr); + } + } + } + if (attributes.length > 0) { + declaration.attributes = attributes; + } + + // Convert members (fields and methods) + const members: Member[] = []; + + // Add fields from props + if (component.props) { + for (const prop of component.props) { + members.push(convertPropToField(prop)); + } + } + + // Add methods + if (component.methods) { + for (const method of component.methods) { + members.push(convertMethod(method)); + } + } + + if (members.length > 0) { + declaration.members = members; + } + + // Convert events + if (component.events && component.events.length > 0) { + declaration.events = component.events.map(convertEvent); + } + + // Convert slots + if (component.slots && component.slots.length > 0) { + declaration.slots = component.slots.map(convertSlot); + } + + // Convert CSS parts + if (component.parts && component.parts.length > 0) { + declaration.cssParts = component.parts.map(convertCssPart); + } + + // Convert CSS custom properties + if (component.styles && component.styles.length > 0) { + declaration.cssProperties = component.styles.map(convertCssProperty); + } + + // Create the module + const module: Module = { + kind: 'javascript-module', + path: component.filePath, + declarations: [declaration], + exports: [ + { + kind: 'js', + name: className, + declaration: { + name: className, + module: component.filePath, + }, + }, + { + kind: 'custom-element-definition', + name: component.tag, + declaration: { + name: className, + module: component.filePath, + }, + }, + ], + }; + + return module; +} + +// ============================================================================ +// Main Generator Function +// ============================================================================ + +/** + * Generate a Custom Elements Manifest from Stencil docs + */ +export function generateCustomElementsManifest(docs: JsonDocs, outputPath: string): void { + // Reset caches for fresh generation + mdxCache = null; + storyTitleCache = null; + + const manifest: CustomElementsManifest = { + schemaVersion: '1.0.0', + readme: 'Pine Design System Web Components', + modules: [], + }; + + for (const component of docs.components) { + const module = convertComponent(component); + if (module) { + manifest.modules.push(module); + } + } + + // Sort modules by tag name for consistent output + manifest.modules.sort((a, b) => { + const tagA = (a.declarations[0] as CustomElementDeclaration)?.tagName || ''; + const tagB = (b.declarations[0] as CustomElementDeclaration)?.tagName || ''; + return tagA.localeCompare(tagB); + }); + + // Ensure output directory exists + const outputDir = path.dirname(outputPath); + if (!fs.existsSync(outputDir)) { + fs.mkdirSync(outputDir, { recursive: true }); + } + + const newContent = JSON.stringify(manifest, null, 2); + + // Only write if content has changed (prevents watch loop) + let existingContent = ''; + try { + existingContent = fs.readFileSync(outputPath, 'utf-8'); + } catch { + // File doesn't exist yet, that's fine + } + + if (newContent !== existingContent) { + fs.writeFileSync(outputPath, newContent); + + // Count components with enhanced docs + const withMdxDocs = manifest.modules.filter((m) => { + const decl = m.declarations[0] as CustomElementDeclaration; + return decl?.summary !== undefined; + }).length; + const withExamples = manifest.modules.filter((m) => { + const decl = m.declarations[0] as CustomElementDeclaration; + return decl?.demos && decl.demos.length > 0; + }).length; + + console.log(`✅ Generated Custom Elements Manifest: ${outputPath}`); + console.log(` ${manifest.modules.length} components documented`); + console.log(` ${withMdxDocs} with rich MDX descriptions`); + console.log(` ${withExamples} with code examples`); + console.log(` Schema version: ${manifest.schemaVersion}`); + } else { + console.log(`ℹ️ Custom Elements Manifest unchanged: ${outputPath}`); + } +} + +// ============================================================================ +// Stencil Output Target +// ============================================================================ + +/** + * Custom Stencil output target for generating Custom Elements Manifest + * + * Usage in stencil.config.ts: + * ```ts + * import customElementsManifestOutputTarget from './scripts/custom-elements-manifest-generator'; + * + * export const config: Config = { + * outputTargets: [ + * customElementsManifestOutputTarget('./dist/custom-elements.json'), + * ], + * }; + * ``` + */ +export default function customElementsManifestOutputTarget(outputPath: string = './custom-elements-manifest.json') { + return { + type: 'docs-custom' as const, + generator: (docs: JsonDocs) => { + generateCustomElementsManifest(docs, outputPath); + }, + }; +} diff --git a/libs/core/scripts/vscode-custom-data-generator.ts b/libs/core/scripts/vscode-custom-data-generator.ts deleted file mode 100644 index c3941bd71..000000000 --- a/libs/core/scripts/vscode-custom-data-generator.ts +++ /dev/null @@ -1,528 +0,0 @@ -import { JsonDocs, JsonDocsComponent, JsonDocsProp } from '@stencil/core/internal'; -import * as fs from 'fs'; -import * as path from 'path'; - -/** - * VS Code HTML Custom Data Generator - * - * Generates a VS Code custom data file that maximizes the spec: - * - Tag descriptions with markdown - * - Attribute descriptions with types and defaults - * - Attribute value descriptions for enums - * - References to documentation - * - Slot documentation as attributes - * - CSS Parts documentation - * - Event documentation - */ - -interface VSCodeCustomData { - version: 1.1; - tags: VSCodeTag[]; - globalAttributes?: VSCodeAttribute[]; -} - -interface VSCodeTag { - name: string; - description: string | { kind: 'markdown' | 'plaintext'; value: string }; - attributes: VSCodeAttribute[]; - references?: VSCodeReference[]; -} - -interface VSCodeAttribute { - name: string; - description?: string | { kind: 'markdown' | 'plaintext'; value: string }; - values?: VSCodeAttributeValue[]; - references?: VSCodeReference[]; -} - -interface VSCodeAttributeValue { - name: string; - description?: string; - references?: VSCodeReference[]; -} - -interface VSCodeReference { - name: string; - url: string; -} - -// Configuration -const DOCS_BASE_URL = 'https://pine-design-system.netlify.app'; - -// Cache for component tag -> storybook title mapping -let storyTitleCache: Map | null = null; - -// Cache for component tag -> parsed MDX data (docs and examples) -interface MdxParsedData { - docs: string; - examples: string[]; -} -let mdxCache: Map | null = null; - -/** - * Find the MDX documentation file for a component - */ -function findMdxFile(componentTag: string): string | null { - const componentsDir = path.resolve(__dirname, '..', 'src', 'components'); - - // Try direct path first: pds-button -> pds-button/docs/pds-button.mdx - const directPath = path.join(componentsDir, componentTag, 'docs', `${componentTag}.mdx`); - if (fs.existsSync(directPath)) { - return directPath; - } - - // Try nested component path: pds-table-cell -> pds-table/pds-table-cell/docs/pds-table-cell.mdx - // Search recursively for the docs folder - const searchForMdx = (dir: string): string | null => { - if (!fs.existsSync(dir)) return null; - - const entries = fs.readdirSync(dir, { withFileTypes: true }); - for (const entry of entries) { - if (entry.isDirectory()) { - if (entry.name === componentTag) { - const mdxPath = path.join(dir, entry.name, 'docs', `${componentTag}.mdx`); - if (fs.existsSync(mdxPath)) { - return mdxPath; - } - } - // Recurse into subdirectories - const found = searchForMdx(path.join(dir, entry.name)); - if (found !== null) return found; - } - } - return null; - }; - - return searchForMdx(componentsDir); -} - -/** - * Parse MDX content and extract h3 headings under the ## Examples section. - * Returns an array of example names (e.g., ["Primary", "Secondary", "Tertiary"]). - */ -function parseMdxExamples(mdxContent: string): string[] { - const lines = mdxContent.split('\n'); - const examples: string[] = []; - - let inExamplesSection = false; - - for (const line of lines) { - const trimmedLine = line.trim(); - - // Look for ## Examples heading - if (trimmedLine.toLowerCase() === '## examples') { - inExamplesSection = true; - continue; - } - - // Stop at the next h2 heading (but not h3/h4) - if (inExamplesSection && trimmedLine.startsWith('## ') && trimmedLine.toLowerCase() !== '## examples') { - break; - } - - // Collect h3 headings within the Examples section - if (inExamplesSection && trimmedLine.startsWith('### ')) { - const heading = trimmedLine.replace(/^### /, '').trim(); - examples.push(heading); - } - } - - return examples; -} - -/** - * Parse MDX content and extract the first paragraph after the component name header. - * This provides a concise description for VS Code hover tooltips. - */ -function parseMdxToMarkdown(mdxContent: string): string { - const lines = mdxContent.split('\n'); - - let foundHeader = false; - const paragraphLines: string[] = []; - - for (const line of lines) { - const trimmedLine = line.trim(); - - // Skip import statements and JSX at the top - if (trimmedLine.startsWith('import ') || trimmedLine.startsWith(' 0 && trimmedLine === '')) { - break; - } - - paragraphLines.push(trimmedLine); - } - } - - return paragraphLines.join(' ').trim(); -} - -/** - * Get parsed MDX data for a component (with caching). - * Reads and parses the MDX file once, extracting both docs and examples. - */ -function getMdxData(componentTag: string): MdxParsedData { - if (!mdxCache) { - mdxCache = new Map(); - } - - const cached = mdxCache.get(componentTag); - if (cached) { - return cached; - } - - const emptyData: MdxParsedData = { docs: '', examples: [] }; - - const mdxPath = findMdxFile(componentTag); - if (!mdxPath) { - mdxCache.set(componentTag, emptyData); - return emptyData; - } - - try { - const mdxContent = fs.readFileSync(mdxPath, 'utf-8'); - const data: MdxParsedData = { - docs: parseMdxToMarkdown(mdxContent), - examples: parseMdxExamples(mdxContent), - }; - mdxCache.set(componentTag, data); - return data; - } catch { - mdxCache.set(componentTag, emptyData); - return emptyData; - } -} - -/** - * Recursively find all story files in a directory - */ -function findStoryFiles(dir: string): string[] { - const results: string[] = []; - - if (!fs.existsSync(dir)) { - return results; - } - - const entries = fs.readdirSync(dir, { withFileTypes: true }); - - for (const entry of entries) { - const fullPath = path.join(dir, entry.name); - - if (entry.isDirectory()) { - results.push(...findStoryFiles(fullPath)); - } else if (entry.isFile() && entry.name.endsWith('.stories.js')) { - results.push(fullPath); - } - } - - return results; -} - -/** - * Build cache mapping component tags to their Storybook titles - * by reading story files - */ -function buildStoryTitleCache(): void { - if (storyTitleCache) { - return; - } - - storyTitleCache = new Map(); - const componentsDir = path.resolve(__dirname, '..', 'src', 'components'); - const storyFiles = findStoryFiles(componentsDir); - - for (const storyFile of storyFiles) { - try { - const content = fs.readFileSync(storyFile, 'utf-8'); - - // Extract component tag from the story file - const componentMatch = content.match(/component:\s*['"]([^'"]+)['"]/); - // Extract title from the story file - const titleMatch = content.match(/title:\s*['"]([^'"]+)['"]/); - - if (componentMatch && titleMatch) { - storyTitleCache.set(componentMatch[1], titleMatch[1]); - } - } catch { - // Skip files that can't be read - } - } -} - -/** - * Get the Storybook title for a component - */ -function getStoryTitle(tag: string): string | undefined { - buildStoryTitleCache(); - return storyTitleCache?.get(tag); -} - -/** - * Convert a Storybook title to a URL path segment - * e.g., "components/Table" -> "components-table" - * e.g., "components/Copy Text" -> "components-copy-text" - * e.g., "components/Radio Group/Radio" -> "components-radio-group-radio" - */ -function titleToUrlPath(title: string): string { - return title - .toLowerCase() - .replace(/\s+/g, '-') // spaces to dashes - .replace(/\//g, '-'); // slashes to dashes -} - -/** - * Get the Storybook documentation URL for a component - */ -function getComponentDocsUrl(tag: string): string { - const title = getStoryTitle(tag); - - if (title !== undefined) { - // Generate proper Storybook docs URL - const urlPath = titleToUrlPath(title); - return `${DOCS_BASE_URL}/?path=/docs/${urlPath}--docs`; - } - - // Fallback for components without a story file - // Convert pds-button -> components-button - const componentName = tag.replace(/^pds-/, ''); - return `${DOCS_BASE_URL}/?path=/docs/components-${componentName}--docs`; -} - -/** - * Convert an MDX heading to a URL anchor fragment - * e.g., "Primary" -> "primary", "Icon Only" -> "icon-only", "Full Width" -> "full-width" - */ -function headingToAnchor(heading: string): string { - return heading.toLowerCase().replace(/\s+/g, '-'); // spaces to dashes -} - -/** - * Get a URL to a specific example section in the docs (using anchor) - */ -function getExampleUrl(tag: string, exampleHeading: string): string { - const docsUrl = getComponentDocsUrl(tag); - const anchor = headingToAnchor(exampleHeading); - return `${docsUrl}#${anchor}`; -} - -function formatPropDescription(prop: JsonDocsProp): string { - const parts: string[] = []; - - // Main description - if (prop.docs) { - parts.push(prop.docs); - } - - // Type info (skip common primitive types) - if (prop.type && prop.type !== 'string' && prop.type !== 'boolean') { - parts.push(`\n\n**Type:** \`${prop.type}\``); - } - - // Default value - const defaultTag = prop.docsTags?.find((t) => t.name === 'default'); - const defaultValue = defaultTag?.text || prop.default; - if (defaultValue) { - parts.push(`\n\n**Default:** \`${defaultValue}\``); - } - - // Deprecation warning - const deprecatedTag = prop.docsTags?.find((t) => t.name === 'deprecated'); - if (deprecatedTag) { - parts.push(`\n\n⚠️ **Deprecated:** ${deprecatedTag.text || 'This property is deprecated.'}`); - } - - return parts.join(''); -} - -function formatComponentDescription(component: JsonDocsComponent): string { - const parts: string[] = []; - const mdxData = getMdxData(component.tag); - - // Try to get rich MDX documentation first, fallback to JSDoc description - if (mdxData.docs) { - parts.push(mdxData.docs); - } else if (component.docs) { - parts.push(component.docs); - } - - // Add Examples section with links to MDX example headings - if (mdxData.examples.length > 0) { - const exampleLinks = mdxData.examples.map((heading) => { - const url = getExampleUrl(component.tag, heading); - return `[${heading}](${url})`; - }); - parts.push(`\n\n**Examples:**\n${exampleLinks.join(', ')}`); - } - - // Slots - if (component.slots && component.slots.length > 0) { - parts.push('\n\n**Slots:**'); - for (const slot of component.slots) { - const slotName = slot.name === '' || slot.name === '(default)' ? '(default)' : slot.name; - parts.push(`\n- \`${slotName}\` - ${slot.docs || 'No description'}`); - } - } - - // CSS Parts - if (component.parts && component.parts.length > 0) { - parts.push('\n\n**CSS Parts:**'); - for (const part of component.parts) { - parts.push(`\n- \`${part.name}\` - ${part.docs || 'No description'}`); - } - } - - // Events - if (component.events && component.events.length > 0) { - parts.push('\n\n**Events:**'); - for (const event of component.events) { - parts.push(`\n- \`${event.event}\` - ${event.docs || 'No description'}`); - } - } - - return parts.join(''); -} - -function extractEnumValues(prop: JsonDocsProp): VSCodeAttributeValue[] | undefined { - // Check if prop.values has string literal values - if (prop.values === undefined || prop.values.length === 0) { - return undefined; - } - - const enumValues = prop.values.filter((v) => v.type === 'string' && v.value); - - if (enumValues.length === 0) { - return undefined; - } - - return enumValues.map((v) => ({ - name: v.value!, - description: undefined, // Could be enhanced with JSDoc @value tags if we add them - })); -} - -function convertPropToAttribute(prop: JsonDocsProp): VSCodeAttribute | null { - // Skip props that don't have an HTML attribute equivalent - if (prop.attr === undefined || prop.attr === '') { - return null; - } - - const attribute: VSCodeAttribute = { - name: prop.attr, - description: { - kind: 'markdown', - value: formatPropDescription(prop), - }, - }; - - // Add enum values if applicable - const enumValues = extractEnumValues(prop); - if (enumValues) { - attribute.values = enumValues; - } - - return attribute; -} - -function convertComponent(component: JsonDocsComponent): VSCodeTag | null { - // Skip test/mock components - if (component.tag.startsWith('mock-') || component.filePath.includes('/test/')) { - return null; - } - - const tag: VSCodeTag = { - name: component.tag, - description: { - kind: 'markdown', - value: formatComponentDescription(component), - }, - attributes: [], - references: [ - { - name: 'Documentation', - url: getComponentDocsUrl(component.tag), - }, - ], - }; - - // Convert props to attributes - if (component.props !== undefined && component.props.length > 0) { - for (const prop of component.props) { - const attr = convertPropToAttribute(prop); - if (attr) { - tag.attributes.push(attr); - } - } - } - - return tag; -} - -export function generateVSCodeCustomData(docs: JsonDocs, outputPath: string): void { - // Reset caches for fresh generation - storyTitleCache = null; - mdxCache = null; - - const customData: VSCodeCustomData = { - version: 1.1, - tags: [], - }; - - for (const component of docs.components) { - const tag = convertComponent(component); - if (tag) { - customData.tags.push(tag); - } - } - - // Sort tags alphabetically - customData.tags.sort((a, b) => a.name.localeCompare(b.name)); - - const outputDir = path.dirname(outputPath); - if (!fs.existsSync(outputDir)) { - fs.mkdirSync(outputDir, { recursive: true }); - } - - const newContent = JSON.stringify(customData, null, 2); - - // Only write if content has changed (prevents watch loop) - let existingContent = ''; - try { - existingContent = fs.readFileSync(outputPath, 'utf-8'); - } catch { - // File doesn't exist yet, that's fine - } - - if (newContent !== existingContent) { - fs.writeFileSync(outputPath, newContent); - console.log(`✅ Generated VS Code custom data: ${outputPath}`); - console.log(` ${customData.tags.length} components documented`); - } else { - console.log(`ℹ️ VS Code custom data unchanged: ${outputPath}`); - } -} - -// For use as a Stencil docs-custom generator -export default function vscodeCustomDataOutputTarget(outputPath: string = 'vscode.html-data.json') { - return { - type: 'docs-custom' as const, - generator: (docs: JsonDocs) => { - generateVSCodeCustomData(docs, outputPath); - }, - }; -} diff --git a/libs/core/scripts/vscode-settings-patcher.cjs b/libs/core/scripts/vscode-settings-patcher.cjs deleted file mode 100644 index dcff51e83..000000000 --- a/libs/core/scripts/vscode-settings-patcher.cjs +++ /dev/null @@ -1,89 +0,0 @@ -#!/usr/bin/env node - -/** - * VSCode Settings Patcher for Pine Design System - * - * This script automatically configures VSCode to use Pine's HTML custom data - * for web component autocomplete. It runs as a postinstall hook. - * - * Features: - * - Detects monorepo vs external package context - * - Creates .vscode directory if needed - * - Backs up existing settings.json before modifying - * - Merges html.customData without duplicates - * - Fails gracefully (won't break npm install) - */ - -const fs = require('fs'); -const path = require('path'); - -function patchVSCodeSettings() { - try { - // Find project root (where npm install was run) - // When running as postinstall, process.cwd() is the consuming project's root - // But when run from node_modules, we need to traverse up - let projectRoot = process.cwd(); - - // If we're inside node_modules, traverse up to find project root - if (projectRoot.includes('node_modules')) { - projectRoot = projectRoot.split('node_modules')[0].replace(/[/\\]$/, ''); - } - - // Detect context: monorepo vs external package - const isMonorepo = fs.existsSync(path.join(projectRoot, 'libs/core/package.json')); - - // Determine the correct path to the HTML custom data file - const customDataPath = isMonorepo - ? './libs/core/dist/vscode.html-data.json' - : './node_modules/@pine-ds/core/dist/vscode.html-data.json'; - - const vscodeDir = path.join(projectRoot, '.vscode'); - const settingsPath = path.join(vscodeDir, 'settings.json'); - const backupPath = path.join(vscodeDir, 'settings.json.backup'); - - // Create .vscode directory if it doesn't exist - if (!fs.existsSync(vscodeDir)) { - fs.mkdirSync(vscodeDir, { recursive: true }); - console.log('[pine-ds] Created .vscode directory'); - } - - // Read existing settings or start fresh - let settings = {}; - let existingContent = null; - if (fs.existsSync(settingsPath)) { - existingContent = fs.readFileSync(settingsPath, 'utf8'); - try { - settings = JSON.parse(existingContent); - } catch (e) { - console.warn('[pine-ds] Warning: Could not parse existing settings.json, starting fresh'); - settings = {}; - } - } - - // Check if we need to add the custom data path - const existingCustomData = settings['html.customData'] || []; - if (!existingCustomData.includes(customDataPath)) { - // Backup only if we're about to make changes - if (existingContent !== null) { - fs.writeFileSync(backupPath, existingContent); - console.log(`[pine-ds] Backed up existing settings to ${path.relative(projectRoot, backupPath)}`); - } - - // Merge html.customData - settings['html.customData'] = [...existingCustomData, customDataPath]; - - // Write updated settings - fs.writeFileSync(settingsPath, JSON.stringify(settings, null, 2) + '\n'); - console.log(`[pine-ds] VSCode settings updated with Pine HTML custom data: ${customDataPath}`); - console.log('[pine-ds] Restart VSCode for autocomplete to take effect'); - } else { - console.log('[pine-ds] Pine HTML custom data already configured in VSCode settings'); - } - } catch (error) { - // Don't fail the install if VSCode setup fails - console.warn('[pine-ds] Warning: Could not set up VSCode HTML custom data:', error.message); - console.warn('[pine-ds] You can manually add html.customData to your .vscode/settings.json'); - } -} - -patchVSCodeSettings(); diff --git a/libs/core/stencil.config.ts b/libs/core/stencil.config.ts index 52d528bc7..c8ac6b603 100644 --- a/libs/core/stencil.config.ts +++ b/libs/core/stencil.config.ts @@ -5,7 +5,7 @@ import { reactOutputTarget } from '@stencil/react-output-target'; import { sass } from '@stencil/sass'; // Custom output targets -import vscodeCustomDataOutputTarget from './scripts/vscode-custom-data-generator'; +import customElementsManifestOutputTarget from './scripts/custom-elements-manifest-generator'; export const config: Config = { namespace: 'pine-core', @@ -42,14 +42,9 @@ export const config: Config = { type: 'docs-readme', footer: '', }, - // Built-in docs-vscode (basic) - // { - // type: 'docs-vscode', - // file: 'vscode-data.json', - // }, - // Custom VS Code data generator (enhanced with full spec support) - // Output to dist/ so it's included in the npm package - vscodeCustomDataOutputTarget('./dist/vscode.html-data.json'), + // Custom Elements Manifest generator (CEM v1.0.0 spec) + // Used by Storybook, VS Code, JetBrains IDEs, and other tooling + customElementsManifestOutputTarget('./dist/custom-elements.json'), { type: 'dist-hydrate-script', }, diff --git a/package.json b/package.json index 825a27241..a70b5d835 100644 --- a/package.json +++ b/package.json @@ -10,7 +10,6 @@ "scripts": { "build.all": "npx nx run-many --target=build", "commit": "npx cz", - "postinstall": "node libs/core/scripts/vscode-settings-patcher.cjs || true", "coverage": "open libs/core/coverage/lcov-report/index.html", "deploy": "npx nx run-many -t deploy -p core", "lint.all": "npx nx run-many --target=lint", diff --git a/wc.config.js b/wc.config.js new file mode 100644 index 000000000..d742f220e --- /dev/null +++ b/wc.config.js @@ -0,0 +1,7 @@ +// Web Components Language Server Configuration +// See: https://wc-toolkit.com/integrations/vscode/ + +export default { + // Point to the standard CEM format file (not the Stencil docs-json) + manifestSrc: './libs/core/dist/custom-elements.json', +};