/** * Which plays a source file exports, esbuild-free. * * A `.play.ts` file may export more than one play — the shipped prebuilts pair * `export const scalar` with `export const batch` and end `export default * scalar`. Two callers need that list and neither can reach esbuild: * `plays check` (which must check every exported play, not only the default) * and the docflow parser (which must decide which `@mermaid` block belongs to * which export). The bundler owns the same knowledge behind its esbuild * fallback parse; this is the acorn-only subset both can share. * * Abstains (returns `null`) rather than guessing: source acorn cannot parse, or * a file with no recognizable `definePlay` export, leaves the caller on its * existing default-export behaviour. */ import { astArray, isAstNode, parsePlaySourceForAnalysis, type AstNode, } from './ts-ast'; /** The export name the runtime, the registry and the CLI all use for a file's * default play. Aliases resolve to it, never the other way round. */ export const PLAY_DEFAULT_EXPORT = 'default'; export type PlayFileExport = { /** * The canonical name this play is addressed by. `default` wins whenever the * play is the file's default export, even when the same value also has a * named export — `export const scalar = definePlay(…); export default scalar` * is ONE play, canonically `default`. */ name: string; /** Other export names that resolve to the same `definePlay` call. */ aliases: string[]; /** * The play's own name — `definePlay('', …)` — when it is a literal. * * An export name is a module detail (`scalar`, `batch`); the play name is what * the product, the registry and the customer call this thing. So it is a legal * way to address the play, which is why an `@mermaid ` header may write * either. Null when the first argument is not a string literal, which is only * possible for a play the registry could not name either. */ playName: string | null; }; function getIdentifierName(node: unknown): string | null { return isAstNode(node) && node.type === 'Identifier' ? typeof node.name === 'string' ? node.name : null : null; } function unwrapStaticExpression(node: AstNode | null): AstNode | null { let current = node; while ( current && (current.type === 'TSAsExpression' || current.type === 'TSSatisfiesExpression' || current.type === 'TSTypeAssertion' || current.type === 'TSNonNullExpression' || current.type === 'ParenthesizedExpression') ) { current = isAstNode(current.expression) ? current.expression : null; } return current; } /** `definePlay(…)` / `defineWorkflow(…)`, bare or as a member call. Mirrors the * bundler's recognizer so both agree on what counts as a play. */ export function isDefinePlayCall(node: AstNode | null): boolean { const expression = unwrapStaticExpression(node); if (!expression || expression.type !== 'CallExpression') return false; const callee = isAstNode(expression.callee) ? expression.callee : null; if (!callee) return false; if (callee.type === 'Identifier') { return callee.name === 'definePlay' || callee.name === 'defineWorkflow'; } if ( callee.type === 'MemberExpression' && !callee.computed && isAstNode(callee.property) && callee.property.type === 'Identifier' ) { return ( callee.property.name === 'definePlay' || callee.property.name === 'defineWorkflow' ); } return false; } /** The literal name a `definePlay('…', …)` call declares, when it is one. */ function definePlayName(node: AstNode | null): string | null { const expression = unwrapStaticExpression(node); if (!expression || expression.type !== 'CallExpression') return null; const first = astArray(expression.arguments)[0] ?? null; return first?.type === 'Literal' && typeof first.value === 'string' ? first.value : null; } /** * The plays `sourceCode` exports, default first, then named exports in source * order. `null` means acorn could not parse the file — the caller abstains and * the TypeScript diagnostics own the syntax error. An EMPTY array is a real * answer: the file parses and exports no `definePlay`. */ export function listPlayFileExports( sourceCode: string, ): PlayFileExport[] | null { const ast = parsePlaySourceForAnalysis(sourceCode); if (!ast) return null; /** Local const/let/var name -> its initializer, for resolving `export { x }` * and `export default x` back to the `definePlay` call they name. */ const declarations = new Map(); /** Exported name -> local name, in source order. */ const namedExports = new Map(); let defaultExpression: AstNode | null = null; const recordDeclarations = (declaration: AstNode, exported: boolean) => { for (const declarator of astArray(declaration.declarations)) { const name = getIdentifierName(declarator.id); if (!name) continue; declarations.set( name, isAstNode(declarator.init) ? declarator.init : null, ); if (exported) namedExports.set(name, name); } }; for (const statement of astArray(ast.body)) { if (statement.type === 'VariableDeclaration') { recordDeclarations(statement, false); continue; } if (statement.type === 'ExportDefaultDeclaration') { defaultExpression = isAstNode(statement.declaration) ? statement.declaration : null; continue; } if (statement.type === 'TSExportAssignment') { defaultExpression = isAstNode(statement.expression) ? statement.expression : null; continue; } if (statement.type !== 'ExportNamedDeclaration') continue; if ( isAstNode(statement.declaration) && statement.declaration.type === 'VariableDeclaration' ) { recordDeclarations(statement.declaration, true); } for (const specifier of astArray(statement.specifiers)) { const localName = getIdentifierName(specifier.local); const exportedName = getIdentifierName(specifier.exported); if (localName && exportedName) namedExports.set(exportedName, localName); } } // The default export's local name, when it is `export default `. // That identifier is an ALIAS of `default`, not a second play. const defaultLocalName = getIdentifierName( unwrapStaticExpression(defaultExpression), ); const defaultIsPlay = defaultLocalName ? isDefinePlayCall(declarations.get(defaultLocalName) ?? null) : isDefinePlayCall(defaultExpression); const exports: PlayFileExport[] = []; const aliasedLocals = new Set(); if (defaultIsPlay) { const aliases = defaultLocalName ? [...namedExports.entries()] .filter(([, local]) => local === defaultLocalName) .map(([exported]) => exported) : []; if (defaultLocalName) aliasedLocals.add(defaultLocalName); exports.push({ name: PLAY_DEFAULT_EXPORT, aliases, playName: definePlayName( defaultLocalName ? (declarations.get(defaultLocalName) ?? null) : defaultExpression, ), }); } for (const [exportedName, localName] of namedExports) { if (exportedName === PLAY_DEFAULT_EXPORT) continue; if (aliasedLocals.has(localName)) continue; const declaration = declarations.get(localName) ?? null; if (!isDefinePlayCall(declaration)) continue; exports.push({ name: exportedName, aliases: [], playName: definePlayName(declaration), }); } return exports; } /** * Resolves an export name a caller or an `@mermaid ` header wrote to its * canonical name. `scalar` in `export default scalar` resolves to `default`; * an unknown name resolves to `null` so the caller can fail loudly with the * available set rather than silently binding nothing. * * The play's own name resolves too, and is the better thing to write: a diagram * headed `@mermaid scalar` names a module-local binding, while one headed * `@mermaid name-and-domain-to-email-waterfall` names the play the reader came * here for. Export names keep working because every diagram authored before this * used one. */ export function canonicalPlayExportName( exportName: string | null | undefined, exports: readonly PlayFileExport[], ): string | null { const requested = exportName?.trim() || PLAY_DEFAULT_EXPORT; for (const entry of exports) { if (entry.name === requested || entry.aliases.includes(requested)) { return entry.name; } } // Second pass, so an export literally named after another play's play name // can never lose to it. Ambiguity between two plays' names is impossible: // the registry rejects a duplicate play name before this ever runs. for (const entry of exports) { if (entry.playName === requested) return entry.name; } return null; } /** Every name an author may legally write, for a "valid names are …" message. */ export function playExportNamesForMessage( exports: readonly PlayFileExport[], ): string[] { return exports.flatMap((entry) => [ ...(entry.playName ? [entry.playName] : []), entry.name, ...entry.aliases, ]); }