UNPKG

@raven-js/glean

Version:

Glean documentation gold from your codebase - JSDoc parsing, validation, and beautiful doc generation

450 lines (405 loc) 15.1 kB
/** * @author Anonyfox <max@anonyfox.com> * @license MIT * @see {@link https://github.com/Anonyfox/ravenjs} * @see {@link https://ravenjs.dev} * @see {@link https://anonyfox.com} */ /** * @file Attribution context for authorship and reference composition. * * Composes JSDoc @author and @see tags with package metadata for * documentation attribution. Optional rendering - no data, no output. */ /** * Attribution context composer for entities and modules with optional rendering. * * Extracts authorship from JSDoc tags and package metadata. Primary author = first @author tag, * contributors = subsequent @author tags. Optional rendering - no authors, no output. * * @example * // Basic entity attribution * const context = createEntityAttribution(entity, packageMeta); * const primary = context.getPrimaryAuthor(); * const links = context.getSeeLinks(); * * @example * // Module attribution aggregation * const context = createModuleAttribution(entities, packageMeta); * console.log(context.hasAttribution); */ export class AttributionContext { /** * Create attribution context instance * @param {Array<{tagType: string, authorInfo?: string, name?: string, email?: string}>} authorTags - Array of author tag instances from entity * @param {Array<{tagType: string, referenceType?: string, reference?: string, description?: string, url?: string}>} seeTags - Array of see tag instances from entity * @param {Object} [packageMetadata] - Optional package.json metadata * @param {string} [packageMetadata.author] - Package author info * @param {string} [packageMetadata.homepage] - Package homepage URL * @param {Object} [packageMetadata.repository] - Repository info * @param {string} [packageMetadata.repository.url] - Repository URL * @param {Object} [packageMetadata.bugs] - Bug tracker URL * @param {string} [packageMetadata.bugs.url] - Bug tracker URL * @param {Object} [packageMetadata.funding] - Funding info * @param {string} [packageMetadata.funding.url] - Funding URL */ constructor(authorTags, seeTags, packageMetadata) { // Extract author intelligence - primary vs contributors this.authorTags = Array.isArray(authorTags) ? authorTags : []; this.primaryAuthor = this.authorTags[0] || null; this.contributors = this.authorTags.slice(1) || []; // Extract see tag intelligence this.seeTags = Array.isArray(seeTags) ? seeTags : []; // Package metadata enrichment this.packageMeta = packageMetadata || null; // Computed attribution properties this.hasAttribution = this.authorTags.length > 0 || this.seeTags.length > 0 || Boolean(this.packageMeta); this.hasAuthors = this.authorTags.length > 0 || Boolean(this.packageMeta?.author); this.hasLinks = this.seeTags.length > 0; this.hasPackageMeta = Boolean(this.packageMeta); } /** * Get primary author with proper attribution hierarchy * * Returns the primary author following the hierarchy: * 1. Entity-specific author (from JSDoc tags) - highest priority * 2. Package-level author (from package.json) - fallback when no entity authors * * @returns {{name: string, email: string, authorInfo: string, hasEmail: boolean}|null} Primary author or null */ getPrimaryAuthor() { // If we have entity-level authors, use the primary one if (this.primaryAuthor) { return { name: this.primaryAuthor.name || "", email: this.primaryAuthor.email || "", authorInfo: this.primaryAuthor.authorInfo || "", hasEmail: Boolean(this.primaryAuthor.email), }; } // Fallback to package-level author if no entity authors if (this.packageMeta?.author) { const packageAuthor = this.packageMeta.author; // Handle both string and object formats from package.json if (typeof packageAuthor === "string") { // Parse "Name <email>" format const match = packageAuthor.match(/^(.+?)\s*<([^>]+)>$/); if (match) { return { name: match[1].trim(), email: match[2].trim(), authorInfo: packageAuthor, hasEmail: true, }; } return { name: packageAuthor, email: "", authorInfo: packageAuthor, hasEmail: false, }; } else if ( packageAuthor && typeof packageAuthor === "object" && /** @type {any} */ (packageAuthor).name ) { // Handle object format { name: "...", email: "...", url: "..." } /** @type {any} */ const author = packageAuthor; return { name: author.name, email: author.email || "", authorInfo: author.email ? `${author.name} <${author.email}>` : author.name, hasEmail: Boolean(author.email), }; } } return null; } /** * Get all contributors with parsed name/email * @returns {Array<Object>} Contributor info array */ getContributors() { return this.contributors.map((contributor) => ({ name: contributor.name || "", email: contributor.email || "", authorInfo: contributor.authorInfo || "", hasEmail: Boolean(contributor.email), })); } /** * Get see links grouped by type * @returns {{links: Array<Object>, urls: Array<Object>, symbols: Array<Object>, modules: Array<Object>, text: Array<Object>}} See links grouped by reference type */ getSeeLinks() { const grouped = { /** @type {Array<{reference: string, description: string, url: string, type: string}>} */ links: [], /** @type {Array<{reference: string, description: string, url: string, type: string}>} */ urls: [], /** @type {Array<{reference: string, description: string, url: string, type: string}>} */ symbols: [], /** @type {Array<{reference: string, description: string, url: string, type: string}>} */ modules: [], /** @type {Array<{reference: string, description: string, url: string, type: string}>} */ text: [], }; for (const seeTag of this.seeTags) { const linkData = { reference: seeTag.reference || "", description: seeTag.description || "", url: seeTag.url || "", type: seeTag.referenceType || "text", }; switch (seeTag.referenceType) { case "link": grouped.links.push(linkData); break; case "url": grouped.urls.push(linkData); break; case "symbol": grouped.symbols.push(linkData); break; case "module": grouped.modules.push(linkData); break; default: grouped.text.push(linkData); } } return grouped; } /** * Get package metadata for footer rendering * @returns {Object|null} Package metadata or null */ getPackageMetadata() { if (!this.packageMeta) return null; return { homepage: this.packageMeta.homepage || "", repository: this.packageMeta.repository?.url || "", bugs: this.packageMeta.bugs?.url || "", funding: this.packageMeta.funding?.url || "", hasHomepage: Boolean(this.packageMeta.homepage), hasRepository: Boolean(this.packageMeta.repository?.url), hasBugs: Boolean(this.packageMeta.bugs?.url), hasFunding: Boolean(this.packageMeta.funding?.url), hasAnyLink: Boolean( this.packageMeta.homepage || this.packageMeta.repository?.url || this.packageMeta.bugs?.url || this.packageMeta.funding?.url, ), }; } } /** * Create attribution context from entity JSDoc tags * @param {import('./entities/base.js').EntityBase} entity - Entity with JSDoc tags * @param {Object} [packageMetadata] - Optional package metadata * @param {string} [packageMetadata.homepage] - Package homepage URL * @param {Object} [packageMetadata.repository] - Repository info * @param {string} [packageMetadata.repository.url] - Repository URL * @param {Object} [packageMetadata.bugs] - Bug tracker info * @param {string} [packageMetadata.bugs.url] - Bug tracker URL * @param {Object} [packageMetadata.funding] - Funding info * @param {string} [packageMetadata.funding.url] - Funding URL * @param {Array<import('./module.js').Module>} [allModules] - All modules for re-export tracing * @returns {AttributionContext} Attribution context instance */ export function createEntityAttribution(entity, packageMetadata, allModules) { // Check if this entity is a re-export and trace to original source const originalEntity = allModules ? traceReexportToSource(entity, allModules) : null; // Use original entity for attribution if found, otherwise use current entity const sourceEntity = originalEntity || entity; const authorTags = sourceEntity.getJSDocTagsByType("author") || []; const seeTags = sourceEntity.getJSDocTagsByType("see") || []; return new AttributionContext(authorTags, seeTags, packageMetadata); } /** * Aggregate unique authors from multiple entities for module attribution * @param {Array<import('./entities/base.js').EntityBase>} entities - Array of entities with JSDoc tags * @param {Object} [packageMetadata] - Optional package metadata * @param {string} [packageMetadata.homepage] - Package homepage URL * @param {Object} [packageMetadata.repository] - Repository info * @param {string} [packageMetadata.repository.url] - Repository URL * @param {Object} [packageMetadata.bugs] - Bug tracker info * @param {string} [packageMetadata.bugs.url] - Bug tracker URL * @param {Object} [packageMetadata.funding] - Funding info * @param {string} [packageMetadata.funding.url] - Funding URL * @param {Array<import('./module.js').Module>} [allModules] - All modules for re-export tracing * @returns {AttributionContext} Aggregated attribution context */ export function createModuleAttribution(entities, packageMetadata, allModules) { const authorMap = new Map(); const seeTagMap = new Map(); // Aggregate unique authors by contribution count and unique see tags for (const entity of entities) { // Trace re-exports to original source for attribution const originalEntity = allModules ? traceReexportToSource(entity, allModules) : null; const sourceEntity = originalEntity || entity; const authorTags = sourceEntity.getJSDocTagsByType("author") || []; const seeTags = sourceEntity.getJSDocTagsByType("see") || []; // Track author contributions for (const authorTag of authorTags) { /** @type {{authorInfo?: string}} */ const tag = /** @type {any} */ (authorTag); const key = tag.authorInfo || ""; if (key) { if (!authorMap.has(key)) { authorMap.set(key, { tag: authorTag, count: 0 }); } authorMap.get(key).count++; } } // Collect unique see tags by reference for (const seeTag of seeTags) { /** @type {{reference?: string}} */ const tag = /** @type {any} */ (seeTag); const key = tag.reference || ""; if (key && !seeTagMap.has(key)) { seeTagMap.set(key, seeTag); } } } // Sort authors by contribution count (descending) const sortedAuthors = Array.from(authorMap.values()) .sort((a, b) => b.count - a.count) .map((entry) => entry.tag); // Get unique see tags const uniqueSeeTags = Array.from(seeTagMap.values()); return new AttributionContext(sortedAuthors, uniqueSeeTags, packageMetadata); } /** * Trace a re-exported entity back to its original source definition * * When an entity is a re-export (export { something } from './other-file'), * this function finds the original entity definition to get proper attribution * from where it was actually defined, not where it was re-exported from. * * @param {import('./entities/base.js').EntityBase} entity - Entity to trace * @param {Array<import('./module.js').Module>} allModules - All modules in the package * @returns {import('./entities/base.js').EntityBase|null} Original entity or null if not found */ function traceReexportToSource(entity, allModules) { if (!entity || !allModules) { return null; } // Check if this entity has re-export metadata indicating it came from another module /** @type {any} */ const ent = entity; // Look for re-export indicators in the entity // This could be stored differently depending on how re-exports are processed if (ent.sourceModule || ent.reexportFrom || ent.originalModule) { const sourceModulePath = ent.sourceModule || ent.reexportFrom || ent.originalModule; const entityName = ent.originalName || ent.name; // Find the source module const sourceModule = findModuleByPath( sourceModulePath, allModules, ent.moduleId, ); if (sourceModule) { // Find the original entity in the source module const originalEntity = sourceModule.entities.find( (e) => e.name === entityName, ); if (originalEntity) { // Recursively trace in case of chained re-exports return ( traceReexportToSource(originalEntity, allModules) || originalEntity ); } } } return null; } /** * Find a module by resolving a relative path from the current module * * @param {string} targetPath - Target module path (e.g., './other-file', '../utils') * @param {Array<import('./module.js').Module>} allModules - All modules in the package * @param {string} currentModulePath - Current module's path for relative resolution * @returns {import('./module.js').Module|null} Found module or null */ function findModuleByPath(targetPath, allModules, currentModulePath) { if (!targetPath || !allModules || !currentModulePath) { return null; } // Handle different path formats if (targetPath.startsWith("./") || targetPath.startsWith("../")) { // Relative path - need to resolve against current module path const resolvedPath = resolveRelativePath(currentModulePath, targetPath); // Find module by resolved path return allModules.find((module) => { // Check various possible matches const modulePath = module.importPath || ""; const fileBaseName = modulePath .split("/") .pop() ?.replace(/\.(js|ts)$/, "") || ""; const resolvedBaseName = resolvedPath .split("/") .pop() ?.replace(/\.(js|ts)$/, "") || ""; return ( modulePath.includes(resolvedBaseName) || fileBaseName === resolvedBaseName || modulePath.endsWith(resolvedPath) || modulePath.endsWith(`${resolvedPath}.js`) ); }); } else { // Absolute import path return allModules.find( (module) => module.importPath === targetPath || module.importPath.endsWith(`/${targetPath}`), ); } } /** * Resolve a relative path against a base path * * @param {string} basePath - Base path (e.g., '@package/module/submodule') * @param {string} relativePath - Relative path (e.g., './other', '../utils') * @returns {string} Resolved path */ function resolveRelativePath(basePath, relativePath) { if (!basePath || !relativePath) { return relativePath; } // Split paths into segments const baseSegments = basePath.split("/"); const relativeSegments = relativePath.split("/"); // Remove empty segments and current directory references const cleanedRelativeSegments = relativeSegments.filter( (segment) => segment && segment !== ".", ); const result = [...baseSegments]; for (const segment of cleanedRelativeSegments) { if (segment === "..") { // Go up one directory result.pop(); } else { // Add directory or file result.push(segment); } } return result.join("/"); }