diff --git a/packages/core-generators/package.json b/packages/core-generators/package.json index eb34dc6a2..851683bc6 100644 --- a/packages/core-generators/package.json +++ b/packages/core-generators/package.json @@ -23,6 +23,10 @@ "./renderers": { "types": "./dist/renderers/types.d.ts", "default": "./dist/renderers/index.js" + }, + "./extractor-v2": { + "types": "./dist/renderers/typescript/extractor-v2/index.d.ts", + "default": "./dist/renderers/typescript/extractor-v2/index.js" } }, "main": "dist/index.js", diff --git a/packages/core-generators/src/generators/metadata/path-roots/path-roots.generator.ts b/packages/core-generators/src/generators/metadata/path-roots/path-roots.generator.ts index 97632dd1d..c13010e65 100644 --- a/packages/core-generators/src/generators/metadata/path-roots/path-roots.generator.ts +++ b/packages/core-generators/src/generators/metadata/path-roots/path-roots.generator.ts @@ -6,10 +6,10 @@ import { import { stringifyPrettyCompact } from '@baseplate-dev/utils'; import { z } from 'zod'; -import type { TemplatePathRoot } from '#src/renderers/templates/plugins/template-paths/template-paths.plugin.js'; +import type { TemplatePathRoot } from '#src/renderers/extractor/plugins/template-paths/template-paths.plugin.js'; import { projectScope } from '#src/providers/scopes.js'; -import { TEMPLATE_PATHS_METADATA_FILE } from '#src/renderers/templates/plugins/template-paths/template-paths.plugin.js'; +import { TEMPLATE_PATHS_METADATA_FILE } from '#src/renderers/extractor/plugins/template-paths/template-paths.plugin.js'; const descriptorSchema = z.object({}); diff --git a/packages/core-generators/src/generators/node/index.ts b/packages/core-generators/src/generators/node/index.ts index 8191b641f..db804404c 100644 --- a/packages/core-generators/src/generators/node/index.ts +++ b/packages/core-generators/src/generators/node/index.ts @@ -3,6 +3,6 @@ export * from './eslint/eslint.generator.js'; export * from './node-git-ignore/node-git-ignore.generator.js'; export * from './node/node.generator.js'; export * from './prettier/prettier.generator.js'; -export * from './ts-utils/ts-utils.generator.js'; +export * from './ts-utils/index.js'; export * from './typescript/typescript.generator.js'; export * from './vitest/vitest.generator.js'; diff --git a/packages/core-generators/src/generators/node/ts-utils/extractor.json b/packages/core-generators/src/generators/node/ts-utils/extractor.json new file mode 100644 index 000000000..c0a8907ef --- /dev/null +++ b/packages/core-generators/src/generators/node/ts-utils/extractor.json @@ -0,0 +1,49 @@ +{ + "name": "node/ts-utils", + "templates": { + "src/utils/arrays.ts": { + "name": "arrays", + "type": "ts", + "generator": "@baseplate-dev/core-generators#node/ts-utils", + "template": "", + "fileOptions": { "kind": "singleton" }, + "projectExports": { "notEmpty": {} }, + "pathRootRelativePath": "{src-root}/utils/arrays.ts", + "variables": {}, + "importMapProviders": {} + }, + "src/utils/normalize-types.ts": { + "name": "normalize-types", + "type": "ts", + "generator": "@baseplate-dev/core-generators#node/ts-utils", + "template": "", + "fileOptions": { "kind": "singleton" }, + "projectExports": { "NormalizeTypes": { "isTypeOnly": true } }, + "pathRootRelativePath": "{src-root}/utils/normalize-types.ts", + "variables": {}, + "importMapProviders": {} + }, + "src/utils/nulls.ts": { + "name": "nulls", + "type": "ts", + "generator": "@baseplate-dev/core-generators#node/ts-utils", + "template": "", + "fileOptions": { "kind": "singleton" }, + "projectExports": { "restrictObjectNulls": {} }, + "pathRootRelativePath": "{src-root}/utils/nulls.ts", + "variables": {}, + "importMapProviders": {} + }, + "src/utils/string.ts": { + "name": "string", + "type": "ts", + "generator": "@baseplate-dev/core-generators#node/ts-utils", + "template": "", + "fileOptions": { "kind": "singleton" }, + "projectExports": { "capitalizeString": {} }, + "pathRootRelativePath": "{src-root}/utils/string.ts", + "variables": {}, + "importMapProviders": {} + } + } +} diff --git a/packages/core-generators/src/generators/node/ts-utils/generated/index.ts b/packages/core-generators/src/generators/node/ts-utils/generated/index.ts new file mode 100644 index 000000000..c002805ea --- /dev/null +++ b/packages/core-generators/src/generators/node/ts-utils/generated/index.ts @@ -0,0 +1,9 @@ +import { NODE_TS_UTILS_PATHS } from './template-paths.js'; +import { NODE_TS_UTILS_IMPORTS } from './ts-import-providers.js'; +import { NODE_TS_UTILS_TEMPLATES } from './typed-templates.js'; + +export const NODE_TS_UTILS_GENERATED = { + imports: NODE_TS_UTILS_IMPORTS, + paths: NODE_TS_UTILS_PATHS, + templates: NODE_TS_UTILS_TEMPLATES, +}; diff --git a/packages/core-generators/src/generators/node/ts-utils/generated/template-paths.ts b/packages/core-generators/src/generators/node/ts-utils/generated/template-paths.ts new file mode 100644 index 000000000..4f8220481 --- /dev/null +++ b/packages/core-generators/src/generators/node/ts-utils/generated/template-paths.ts @@ -0,0 +1,38 @@ +import { createGeneratorTask, createProviderType } from '@baseplate-dev/sync'; + +import { packageInfoProvider } from '#src/providers/project.js'; + +export interface NodeTsUtilsPaths { + arrays: string; + normalizeTypes: string; + nulls: string; + string: string; +} + +const nodeTsUtilsPaths = createProviderType( + 'node-ts-utils-paths', +); + +const nodeTsUtilsPathsTask = createGeneratorTask({ + dependencies: { packageInfo: packageInfoProvider }, + exports: { nodeTsUtilsPaths: nodeTsUtilsPaths.export() }, + run({ packageInfo }) { + const srcRoot = packageInfo.getPackageSrcPath(); + + return { + providers: { + nodeTsUtilsPaths: { + arrays: `${srcRoot}/utils/arrays.ts`, + normalizeTypes: `${srcRoot}/utils/normalize-types.ts`, + nulls: `${srcRoot}/utils/nulls.ts`, + string: `${srcRoot}/utils/string.ts`, + }, + }, + }; + }, +}); + +export const NODE_TS_UTILS_PATHS = { + provider: nodeTsUtilsPaths, + task: nodeTsUtilsPathsTask, +}; diff --git a/packages/core-generators/src/generators/node/ts-utils/generated/ts-import-providers.ts b/packages/core-generators/src/generators/node/ts-utils/generated/ts-import-providers.ts new file mode 100644 index 000000000..292126246 --- /dev/null +++ b/packages/core-generators/src/generators/node/ts-utils/generated/ts-import-providers.ts @@ -0,0 +1,53 @@ +import { + createGeneratorTask, + createReadOnlyProviderType, +} from '@baseplate-dev/sync'; + +import type { TsImportMapProviderFromSchema } from '#src/renderers/typescript/index.js'; + +import { projectScope } from '#src/providers/index.js'; +import { + createTsImportMap, + createTsImportMapSchema, +} from '#src/renderers/typescript/index.js'; + +import { NODE_TS_UTILS_PATHS } from './template-paths.js'; + +const tsUtilsImportsSchema = createTsImportMapSchema({ + capitalizeString: {}, + NormalizeTypes: { isTypeOnly: true }, + notEmpty: {}, + restrictObjectNulls: {}, +}); + +export type TsUtilsImportsProvider = TsImportMapProviderFromSchema< + typeof tsUtilsImportsSchema +>; + +export const tsUtilsImportsProvider = + createReadOnlyProviderType('ts-utils-imports'); + +const nodeTsUtilsImportsTask = createGeneratorTask({ + dependencies: { + paths: NODE_TS_UTILS_PATHS.provider, + }, + exports: { + imports: tsUtilsImportsProvider.export(projectScope), + }, + run({ paths }) { + return { + providers: { + imports: createTsImportMap(tsUtilsImportsSchema, { + capitalizeString: paths.string, + NormalizeTypes: paths.normalizeTypes, + notEmpty: paths.arrays, + restrictObjectNulls: paths.nulls, + }), + }, + }; + }, +}); + +export const NODE_TS_UTILS_IMPORTS = { + task: nodeTsUtilsImportsTask, +}; diff --git a/packages/core-generators/src/generators/node/ts-utils/generated/ts-templates.ts b/packages/core-generators/src/generators/node/ts-utils/generated/ts-templates.ts deleted file mode 100644 index c5f5fe61a..000000000 --- a/packages/core-generators/src/generators/node/ts-utils/generated/ts-templates.ts +++ /dev/null @@ -1,36 +0,0 @@ -import { createTsTemplateFile } from '#src/renderers/typescript/index.js'; - -const arrays = createTsTemplateFile({ - name: 'arrays', - projectExports: { notEmpty: {} }, - source: { path: 'arrays.ts' }, - variables: {}, -}); - -const normalizeTypes = createTsTemplateFile({ - name: 'normalize-types', - projectExports: { NormalizeTypes: { isTypeOnly: true } }, - source: { path: 'normalize-types.ts' }, - variables: {}, -}); - -const nulls = createTsTemplateFile({ - name: 'nulls', - projectExports: { restrictObjectNulls: {} }, - source: { path: 'nulls.ts' }, - variables: {}, -}); - -const string = createTsTemplateFile({ - name: 'string', - projectExports: { capitalizeString: {} }, - source: { path: 'string.ts' }, - variables: {}, -}); - -export const NODE_TS_UTILS_TS_TEMPLATES = { - arrays, - normalizeTypes, - nulls, - string, -}; diff --git a/packages/core-generators/src/generators/node/ts-utils/generated/typed-templates.ts b/packages/core-generators/src/generators/node/ts-utils/generated/typed-templates.ts new file mode 100644 index 000000000..17f1298c0 --- /dev/null +++ b/packages/core-generators/src/generators/node/ts-utils/generated/typed-templates.ts @@ -0,0 +1,57 @@ +import path from 'node:path'; + +import { createTsTemplateFile } from '#src/renderers/typescript/templates/types.js'; + +const arrays = createTsTemplateFile({ + fileOptions: { kind: 'singleton' }, + importMapProviders: {}, + name: 'arrays', + projectExports: { notEmpty: {} }, + source: { + path: path.join(import.meta.dirname, '../templates/src/utils/arrays.ts'), + }, + variables: {}, +}); + +const normalizeTypes = createTsTemplateFile({ + fileOptions: { kind: 'singleton' }, + importMapProviders: {}, + name: 'normalize-types', + projectExports: { NormalizeTypes: { isTypeOnly: true } }, + source: { + path: path.join( + import.meta.dirname, + '../templates/src/utils/normalize-types.ts', + ), + }, + variables: {}, +}); + +const nulls = createTsTemplateFile({ + fileOptions: { kind: 'singleton' }, + importMapProviders: {}, + name: 'nulls', + projectExports: { restrictObjectNulls: {} }, + source: { + path: path.join(import.meta.dirname, '../templates/src/utils/nulls.ts'), + }, + variables: {}, +}); + +const string = createTsTemplateFile({ + fileOptions: { kind: 'singleton' }, + importMapProviders: {}, + name: 'string', + projectExports: { capitalizeString: {} }, + source: { + path: path.join(import.meta.dirname, '../templates/src/utils/string.ts'), + }, + variables: {}, +}); + +export const NODE_TS_UTILS_TEMPLATES = { + arrays, + normalizeTypes, + nulls, + string, +}; diff --git a/packages/core-generators/src/generators/node/ts-utils/index.ts b/packages/core-generators/src/generators/node/ts-utils/index.ts new file mode 100644 index 000000000..48611d5c2 --- /dev/null +++ b/packages/core-generators/src/generators/node/ts-utils/index.ts @@ -0,0 +1,3 @@ +export type { TsUtilsImportsProvider } from './generated/ts-import-providers.js'; +export { tsUtilsImportsProvider } from './generated/ts-import-providers.js'; +export * from './ts-utils.generator.js'; diff --git a/packages/core-generators/src/generators/node/ts-utils/templates/arrays.ts b/packages/core-generators/src/generators/node/ts-utils/templates/src/utils/arrays.ts similarity index 100% rename from packages/core-generators/src/generators/node/ts-utils/templates/arrays.ts rename to packages/core-generators/src/generators/node/ts-utils/templates/src/utils/arrays.ts diff --git a/packages/core-generators/src/generators/node/ts-utils/templates/normalize-types.ts b/packages/core-generators/src/generators/node/ts-utils/templates/src/utils/normalize-types.ts similarity index 100% rename from packages/core-generators/src/generators/node/ts-utils/templates/normalize-types.ts rename to packages/core-generators/src/generators/node/ts-utils/templates/src/utils/normalize-types.ts diff --git a/packages/core-generators/src/generators/node/ts-utils/templates/nulls.ts b/packages/core-generators/src/generators/node/ts-utils/templates/src/utils/nulls.ts similarity index 100% rename from packages/core-generators/src/generators/node/ts-utils/templates/nulls.ts rename to packages/core-generators/src/generators/node/ts-utils/templates/src/utils/nulls.ts diff --git a/packages/core-generators/src/generators/node/ts-utils/templates/string.ts b/packages/core-generators/src/generators/node/ts-utils/templates/src/utils/string.ts similarity index 100% rename from packages/core-generators/src/generators/node/ts-utils/templates/string.ts rename to packages/core-generators/src/generators/node/ts-utils/templates/src/utils/string.ts diff --git a/packages/core-generators/src/generators/node/ts-utils/ts-extractor.json b/packages/core-generators/src/generators/node/ts-utils/ts-extractor.json deleted file mode 100644 index f979601a4..000000000 --- a/packages/core-generators/src/generators/node/ts-utils/ts-extractor.json +++ /dev/null @@ -1,7 +0,0 @@ -{ - "exportGroups": { - "": { - "exportProviderType": true - } - } -} diff --git a/packages/core-generators/src/generators/node/ts-utils/ts-utils.generator.ts b/packages/core-generators/src/generators/node/ts-utils/ts-utils.generator.ts index 02a79b66f..80055d60a 100644 --- a/packages/core-generators/src/generators/node/ts-utils/ts-utils.generator.ts +++ b/packages/core-generators/src/generators/node/ts-utils/ts-utils.generator.ts @@ -1,52 +1,34 @@ -import type { TemplateFileSource } from '@baseplate-dev/sync'; - import { createGenerator, createGeneratorTask } from '@baseplate-dev/sync'; -import path from 'node:path'; import { z } from 'zod'; -import { projectScope } from '#src/providers/scopes.js'; - import { typescriptFileProvider } from '../typescript/typescript.generator.js'; -import { - createTsUtilsImports, - tsUtilsImportsProvider, -} from './generated/ts-import-maps.js'; -import { NODE_TS_UTILS_TS_TEMPLATES } from './generated/ts-templates.js'; +import { NODE_TS_UTILS_GENERATED } from './generated/index.js'; const descriptorSchema = z.object({}); -function getUtilsPath(source: TemplateFileSource): string { - if (!('path' in source)) { - throw new Error('Template path is required'); - } - return path.join('@/src/utils', source.path); -} - -type TsUtilKey = keyof typeof NODE_TS_UTILS_TS_TEMPLATES; +type TsUtilKey = keyof typeof NODE_TS_UTILS_GENERATED.templates; export const tsUtilsGenerator = createGenerator({ name: 'node/ts-utils', generatorFileUrl: import.meta.url, descriptorSchema, buildTasks: () => ({ + paths: NODE_TS_UTILS_GENERATED.paths.task, + imports: NODE_TS_UTILS_GENERATED.imports.task, main: createGeneratorTask({ dependencies: { typescriptFile: typescriptFileProvider, + paths: NODE_TS_UTILS_GENERATED.paths.provider, }, - exports: { - tsUtilsImports: tsUtilsImportsProvider.export(projectScope), - }, - run({ typescriptFile }) { + run({ typescriptFile, paths }) { return { - providers: { - tsUtilsImports: createTsUtilsImports('@/src/utils'), - }, build: (builder) => { - for (const key of Object.keys(NODE_TS_UTILS_TS_TEMPLATES)) { - const template = NODE_TS_UTILS_TS_TEMPLATES[key as TsUtilKey]; + for (const key of Object.keys(NODE_TS_UTILS_GENERATED.templates)) { + const template = + NODE_TS_UTILS_GENERATED.templates[key as TsUtilKey]; typescriptFile.addLazyTemplateFile({ template, - destination: getUtilsPath(template.source), + destination: paths[key as TsUtilKey], generatorInfo: builder.generatorInfo, }); } @@ -56,6 +38,3 @@ export const tsUtilsGenerator = createGenerator({ }), }), }); - -export { tsUtilsImportsProvider } from './generated/ts-import-maps.js'; -export type { TsUtilsImportsProvider } from './generated/ts-import-maps.js'; diff --git a/packages/core-generators/src/generators/node/typescript/typescript.generator.ts b/packages/core-generators/src/generators/node/typescript/typescript.generator.ts index 33da11411..7d16b2d88 100644 --- a/packages/core-generators/src/generators/node/typescript/typescript.generator.ts +++ b/packages/core-generators/src/generators/node/typescript/typescript.generator.ts @@ -15,6 +15,7 @@ import { safeMergeAll } from '@baseplate-dev/utils'; import path from 'node:path'; import { z } from 'zod'; +import type { RenderTsTemplateGroupActionInput as RenderTsTemplateGroupActionInputV2 } from '#src/renderers/typescript/extractor-v2/render-ts-template-group-action.js'; import type { RenderTsCodeFileTemplateOptions, RenderTsFragmentActionInput, @@ -27,6 +28,7 @@ import type { import { CORE_PACKAGES } from '#src/constants/core-packages.js'; import { projectScope } from '#src/providers/scopes.js'; import { renderTsTemplateFileAction } from '#src/renderers/typescript/actions/render-ts-template-file-action.js'; +import { renderTsTemplateGroupAction as renderTsTemplateGroupActionV2 } from '#src/renderers/typescript/extractor-v2/render-ts-template-group-action.js'; import { extractTsTemplateFileInputsFromTemplateGroup, generatePathMapEntries, @@ -115,6 +117,18 @@ export interface TypescriptFileProvider { renderTemplateGroup( payload: RenderTsTemplateGroupActionInput, ): BuilderAction; + /** + * Renders a template group to an action using the new v2 implementation + * with Record signature + * + * @param payload - The payload for the template group + * @returns The action for the template group + */ + renderTemplateGroupV2< + T extends Record = Record, + >( + payload: RenderTsTemplateGroupActionInputV2, + ): BuilderAction; /** * Marks an import as used * @@ -337,6 +351,19 @@ export const typescriptGenerator = createGenerator({ ...sharedRenderOptions, }, }), + renderTemplateGroupV2: (payload) => + renderTsTemplateGroupActionV2({ + ...payload, + renderOptions: { + resolveModule(moduleSpecifier, sourceDirectory) { + return resolveModuleSpecifier( + moduleSpecifier, + sourceDirectory, + ); + }, + ...sharedRenderOptions, + }, + }), markImportAsUsed: (projectRelativePath) => { usedProjectRelativePaths.add( projectRelativePath.replace(/\.(j|t)sx?$/, ''), diff --git a/packages/core-generators/src/renderers/templates/index.ts b/packages/core-generators/src/renderers/extractor/index.ts similarity index 100% rename from packages/core-generators/src/renderers/templates/index.ts rename to packages/core-generators/src/renderers/extractor/index.ts diff --git a/packages/core-generators/src/renderers/extractor/plugins/barrel-export.ts b/packages/core-generators/src/renderers/extractor/plugins/barrel-export.ts new file mode 100644 index 000000000..cac0b94ec --- /dev/null +++ b/packages/core-generators/src/renderers/extractor/plugins/barrel-export.ts @@ -0,0 +1,315 @@ +import { parseGeneratorName } from '@baseplate-dev/sync'; +import { createTemplateExtractorPlugin } from '@baseplate-dev/sync/extractor-v2'; +import { handleFileNotFoundError } from '@baseplate-dev/utils/node'; +import fs from 'node:fs/promises'; +import path from 'node:path'; +import { Project, QuoteKind, VariableDeclarationKind } from 'ts-morph'; + +import { getGeneratedTemplateConstantName } from '../utils/generated-template-file-names.js'; + +export interface TemplateExtractorBarrelExport { + moduleSpecifier: string; + namedExports: string[]; + isTypeOnly?: boolean; +} + +export interface TemplateExtractorGeneratedBarrelExport { + moduleSpecifier: string; // relative to generated folder + namedExport: string; // the import name + name: string; // the property name in the export object +} + +export function mergeBarrelExports( + indexFileContents: string | undefined, + barrelExports: TemplateExtractorBarrelExport[], +): string { + const project = new Project({ + useInMemoryFileSystem: true, + manipulationSettings: { + quoteKind: QuoteKind.Single, + }, + }); + const sourceFile = project.createSourceFile( + 'index.ts', + indexFileContents ?? '', + ); + + // Remove all existing export statements + for (const decl of sourceFile.getExportDeclarations()) { + decl.remove(); + } + + // Group exports by module specifier and type + const exportsByModuleAndType = new Map< + string, + { + starExport?: { isTypeOnly: boolean }; + namedExports: Map; // name -> isTypeOnly + } + >(); + + for (const barrelExport of barrelExports) { + const key = barrelExport.moduleSpecifier; + if (!exportsByModuleAndType.has(key)) { + exportsByModuleAndType.set(key, { + namedExports: new Map(), + }); + } + + // eslint-disable-next-line @typescript-eslint/no-non-null-assertion + const moduleData = exportsByModuleAndType.get(key)!; + const isTypeOnly = barrelExport.isTypeOnly ?? false; + + for (const namedExport of barrelExport.namedExports) { + if (namedExport === '*') { + // Star export takes precedence + moduleData.starExport = { isTypeOnly }; + } else { + moduleData.namedExports.set(namedExport, isTypeOnly); + } + } + } + + // Sort module specifiers + const sortedModuleSpecifiers = [...exportsByModuleAndType.keys()].sort(); + + // Add exports back in sorted order + for (const moduleSpecifier of sortedModuleSpecifiers) { + const moduleData = exportsByModuleAndType.get(moduleSpecifier); + if (!moduleData) continue; + + // Add star export first if it exists + if (moduleData.starExport) { + sourceFile.addExportDeclaration({ + moduleSpecifier, + isTypeOnly: moduleData.starExport.isTypeOnly, + }); + } else if (moduleData.namedExports.size > 0) { + // Group named exports by isTypeOnly + const typeOnlyExports: string[] = []; + const regularExports: string[] = []; + + for (const [name, isTypeOnly] of moduleData.namedExports) { + if (isTypeOnly) { + typeOnlyExports.push(name); + } else { + regularExports.push(name); + } + } + + // Add type-only exports + if (typeOnlyExports.length > 0) { + sourceFile.addExportDeclaration({ + moduleSpecifier, + isTypeOnly: true, + namedExports: typeOnlyExports.sort().map((name) => ({ name })), + }); + } + + // Add regular exports + if (regularExports.length > 0) { + sourceFile.addExportDeclaration({ + moduleSpecifier, + isTypeOnly: false, + namedExports: regularExports.sort().map((name) => ({ name })), + }); + } + } + } + + return sourceFile.getFullText(); +} + +export function mergeGeneratedBarrelExports( + generatorName: string, + indexFileContents: string | undefined, + generatedBarrelExports: TemplateExtractorGeneratedBarrelExport[], +): string { + const project = new Project({ + useInMemoryFileSystem: true, + manipulationSettings: { + quoteKind: QuoteKind.Single, + }, + }); + const sourceFile = project.createSourceFile( + 'index.ts', + indexFileContents ?? '', + ); + + // Remove all existing import and export statements + for (const decl of sourceFile.getImportDeclarations()) decl.remove(); + for (const decl of sourceFile.getExportDeclarations()) decl.remove(); + for (const decl of sourceFile.getVariableStatements()) decl.remove(); + + if (generatedBarrelExports.length === 0) { + return sourceFile.getFullText(); + } + + // Group exports by module specifier + const importsByModuleSpecifier = new Map>(); + + for (const barrelExport of generatedBarrelExports) { + let imports = importsByModuleSpecifier.get(barrelExport.moduleSpecifier); + if (!imports) { + imports = new Set(); + importsByModuleSpecifier.set(barrelExport.moduleSpecifier, imports); + } + imports.add(barrelExport.namedExport); + } + + // Add import statements (sorted by module specifier) + const sortedModuleSpecifiers = [...importsByModuleSpecifier.keys()].sort(); + for (const moduleSpecifier of sortedModuleSpecifiers) { + const imports = importsByModuleSpecifier.get(moduleSpecifier); + if (!imports) continue; + const namedImports = [...imports].sort(); + sourceFile.addImportDeclaration({ + moduleSpecifier, + namedImports, + }); + } + + // Create the export constant + const constantName = getGeneratedTemplateConstantName( + generatorName, + 'GENERATED', + ); + + // Group exports by their property names + const exportProperties = new Map(); + for (const barrelExport of generatedBarrelExports) { + exportProperties.set(barrelExport.name, barrelExport.namedExport); + } + + // Sort properties by name for consistent output + const sortedPropertyNames = [...exportProperties.keys()].sort(); + const properties = sortedPropertyNames.map( + (propName) => `${propName}: ${exportProperties.get(propName)}`, + ); + + sourceFile.addVariableStatement({ + isExported: true, + declarationKind: VariableDeclarationKind.Const, + declarations: [ + { + name: constantName, + initializer: `{\n ${properties.join(',\n ')},\n}`, + }, + ], + }); + + return sourceFile.getFullText(); +} + +export const templateExtractorBarrelExportPlugin = + createTemplateExtractorPlugin({ + name: 'barrel-export', + getInstance: ({ context, api }) => { + const { fileContainer } = context; + const barrelExportMap = new Map< + string, + { + moduleSpecifier: string; + namedExports: string[]; + isTypeOnly?: boolean; + }[] + >(); + + const generatedBarrelExportMap = new Map< + string, + TemplateExtractorGeneratedBarrelExport[] + >(); + + function addBarrelExport( + generatorName: string, + barrelExport: TemplateExtractorBarrelExport, + ): void { + const barrelExports = barrelExportMap.get(generatorName) ?? []; + barrelExports.push(barrelExport); + barrelExportMap.set(generatorName, barrelExports); + } + + function addGeneratedBarrelExport( + generatorName: string, + generatedBarrelExport: TemplateExtractorGeneratedBarrelExport, + ): void { + const generatedBarrelExports = + generatedBarrelExportMap.get(generatorName) ?? []; + generatedBarrelExports.push(generatedBarrelExport); + generatedBarrelExportMap.set(generatorName, generatedBarrelExports); + } + + // Merge the barrel exports into the barrel file + api.registerHook('afterWrite', async () => { + // Process regular barrel exports + for (const [generatorName, barrelExports] of barrelExportMap) { + const parsedGeneratorName = parseGeneratorName(generatorName); + const extractorConfig = + context.configLookup.getExtractorConfig(generatorName); + if (!extractorConfig) { + throw new Error(`Extractor config not found: ${generatorName}`); + } + if (barrelExports.length === 0) { + continue; + } + const indexFileContents = await fs + .readFile( + path.join(extractorConfig.generatorDirectory, 'index.ts'), + 'utf8', + ) + .catch(handleFileNotFoundError); + const updatedContents = mergeBarrelExports(indexFileContents, [ + ...barrelExports, + // always export the generator file + { + moduleSpecifier: `./${parsedGeneratorName.generatorBasename}.generator.js`, + namedExports: ['*'], + }, + ]); + fileContainer.writeFile( + path.join(extractorConfig.generatorDirectory, 'index.ts'), + updatedContents, + ); + } + + // Process generated barrel exports + for (const [ + generatorName, + generatedBarrelExports, + ] of generatedBarrelExportMap) { + const extractorConfig = + context.configLookup.getExtractorConfig(generatorName); + if (!extractorConfig) { + throw new Error(`Extractor config not found: ${generatorName}`); + } + if (generatedBarrelExports.length === 0) { + continue; + } + + // Ensure generated directory exists + const generatedDir = path.join( + extractorConfig.generatorDirectory, + 'generated', + ); + const generatedIndexPath = path.join(generatedDir, 'index.ts'); + + const generatedIndexFileContents = await fs + .readFile(generatedIndexPath, 'utf8') + .catch(handleFileNotFoundError); + + const updatedGeneratedContents = mergeGeneratedBarrelExports( + generatorName, + generatedIndexFileContents, + generatedBarrelExports, + ); + + fileContainer.writeFile(generatedIndexPath, updatedGeneratedContents); + } + }); + + return { + addBarrelExport, + addGeneratedBarrelExport, + }; + }, + }); diff --git a/packages/core-generators/src/renderers/extractor/plugins/barrel-export.unit.test.ts b/packages/core-generators/src/renderers/extractor/plugins/barrel-export.unit.test.ts new file mode 100644 index 000000000..4f3a74db6 --- /dev/null +++ b/packages/core-generators/src/renderers/extractor/plugins/barrel-export.unit.test.ts @@ -0,0 +1,316 @@ +import { + addMockExtractorConfig, + createMockContext, + createPluginInstance, +} from '@baseplate-dev/sync/extractor-v2/test-utils'; +import { vol } from 'memfs'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +import type { TemplateExtractorGeneratedBarrelExport } from './barrel-export.js'; + +import { + mergeBarrelExports, + mergeGeneratedBarrelExports, + templateExtractorBarrelExportPlugin, +} from './barrel-export.js'; + +vi.mock('node:fs'); +vi.mock('node:fs/promises'); + +beforeEach(() => { + vol.reset(); +}); + +describe('mergeBarrelExports', () => { + it('should merge and sort exports', () => { + // Arrange + const indexFileContents = ` +export { foo } from './foo'; +export { bar } from './bar'; +export * from './baz'; +`; + + const barrelExports = [ + { moduleSpecifier: './bar', namedExports: ['qux', 'quux'] }, + { moduleSpecifier: './foo', namedExports: ['baz'] }, + { moduleSpecifier: './baz', namedExports: ['*'] }, + ]; + + // Act + const result = mergeBarrelExports(indexFileContents, barrelExports); + + // Assert + expect(result).toMatchInlineSnapshot(` + "export { quux, qux } from './bar'; + export * from './baz'; + export { baz } from './foo'; + " + `); + }); + + it('should handle empty exports', () => { + // Arrange + const indexFileContents = ''; + const barrelExports: { moduleSpecifier: string; namedExports: string[] }[] = + []; + + // Act + const result = mergeBarrelExports(indexFileContents, barrelExports); + + // Assert + expect(result).toBe(''); + }); +}); + +describe('mergeGeneratedBarrelExports', () => { + it('should create generated barrel export with constant name', () => { + // Arrange + const generatorName = 'test-package#error-handler-service'; + const indexFileContents = ''; + const generatedBarrelExports = [ + { + moduleSpecifier: './services/error-handler.service', + namedExport: 'ErrorHandlerService', + name: 'errorHandlerService', + }, + { + moduleSpecifier: './constants/error-codes', + namedExport: 'ERROR_CODES', + name: 'errorCodes', + }, + ]; + + // Act + const result = mergeGeneratedBarrelExports( + generatorName, + indexFileContents, + generatedBarrelExports, + ); + + // Assert + expect(result).toContain( + "import { ErrorHandlerService } from './services/error-handler.service';", + ); + expect(result).toContain( + "import { ERROR_CODES } from './constants/error-codes';", + ); + expect(result).toContain( + 'export const ERROR_HANDLER_SERVICE_GENERATED = {', + ); + expect(result).toContain('errorCodes: ERROR_CODES,'); + expect(result).toContain('errorHandlerService: ErrorHandlerService,'); + }); + + it('should handle empty generated barrel exports', () => { + // Arrange + const generatorName = 'test-package#test-generator'; + const indexFileContents = ''; + const generatedBarrelExports: TemplateExtractorGeneratedBarrelExport[] = []; + + // Act + const result = mergeGeneratedBarrelExports( + generatorName, + indexFileContents, + generatedBarrelExports, + ); + + // Assert + expect(result).toBe(''); + }); + + it('should group imports by module specifier', () => { + // Arrange + const generatorName = 'test-package#test-generator'; + const indexFileContents = ''; + const generatedBarrelExports = [ + { + moduleSpecifier: './services/user.service', + namedExport: 'UserService', + name: 'userService', + }, + { + moduleSpecifier: './services/user.service', + namedExport: 'UserConstants', + name: 'userConstants', + }, + { + moduleSpecifier: './utils/helpers', + namedExport: 'formatUser', + name: 'formatUser', + }, + ]; + + // Act + const result = mergeGeneratedBarrelExports( + generatorName, + indexFileContents, + generatedBarrelExports, + ); + + // Assert + expect(result).toContain( + "import { UserConstants, UserService } from './services/user.service';", + ); + expect(result).toContain("import { formatUser } from './utils/helpers';"); + expect(result).toContain('formatUser: formatUser,'); + expect(result).toContain('userConstants: UserConstants,'); + expect(result).toContain('userService: UserService,'); + }); +}); + +describe('templateExtractorBarrelExportPlugin', () => { + it('should create plugin instance and add barrel exports', async () => { + // Arrange + const context = await createMockContext({ + outputDirectory: '/test-output', + packageMap: new Map([['test-package', '/test-generator']]), + }); + + addMockExtractorConfig(context, 'test-package#test-generator', { + name: 'test-generator', + generatorDirectory: '/test-generator', + }); + + // Act + const { instance, executeHooks } = await createPluginInstance( + templateExtractorBarrelExportPlugin, + context, + ); + + // Add some barrel exports + instance.addBarrelExport('test-package#test-generator', { + moduleSpecifier: './utils', + namedExports: ['helper1', 'helper2'], + }); + + instance.addBarrelExport('test-package#test-generator', { + moduleSpecifier: './components', + namedExports: ['*'], + }); + + // Execute the afterWrite hook + await executeHooks('afterWrite'); + + // Assert - check the file container instead of memfs + const files = context.fileContainer.getFiles(); + const indexPath = '/test-generator/index.ts'; + expect(files.has(indexPath)).toBe(true); + + const indexContent = files.get(indexPath) as string; + expect(indexContent).toContain("export * from './components'"); + expect(indexContent).toContain( + "export { helper1, helper2 } from './utils'", + ); + expect(indexContent).toContain( + "export * from './test-generator.generator.js'", + ); + }); + + it('should create plugin instance and add generated barrel exports', async () => { + // Arrange + const context = await createMockContext({ + outputDirectory: '/test-output', + packageMap: new Map([['test-package', '/test-generator']]), + }); + + addMockExtractorConfig(context, 'test-package#error-handler-service', { + name: 'error-handler-service', + generatorDirectory: '/test-generator', + }); + + // Act + const { instance, executeHooks } = await createPluginInstance( + templateExtractorBarrelExportPlugin, + context, + ); + + // Add some generated barrel exports + instance.addGeneratedBarrelExport('test-package#error-handler-service', { + moduleSpecifier: './services/error-handler.service', + namedExport: 'ErrorHandlerService', + name: 'errorHandlerService', + }); + + instance.addGeneratedBarrelExport('test-package#error-handler-service', { + moduleSpecifier: './constants/error-codes', + namedExport: 'ERROR_CODES', + name: 'errorCodes', + }); + + // Execute the afterWrite hook + await executeHooks('afterWrite'); + + // Assert - check the file container for generated/index.ts + const files = context.fileContainer.getFiles(); + const generatedIndexPath = '/test-generator/generated/index.ts'; + expect(files.has(generatedIndexPath)).toBe(true); + + const generatedIndexContent = files.get(generatedIndexPath) as string; + expect(generatedIndexContent).toContain( + "import { ErrorHandlerService } from './services/error-handler.service';", + ); + expect(generatedIndexContent).toContain( + "import { ERROR_CODES } from './constants/error-codes';", + ); + expect(generatedIndexContent).toContain( + 'export const ERROR_HANDLER_SERVICE_GENERATED = {', + ); + expect(generatedIndexContent).toContain('errorCodes: ERROR_CODES,'); + expect(generatedIndexContent).toContain( + 'errorHandlerService: ErrorHandlerService,', + ); + }); + + it('should handle both regular and generated barrel exports', async () => { + // Arrange + const context = await createMockContext({ + outputDirectory: '/test-output', + packageMap: new Map([['test-package', '/test-generator']]), + }); + + addMockExtractorConfig(context, 'test-package#test-generator', { + name: 'test-generator', + generatorDirectory: '/test-generator', + }); + + // Act + const { instance, executeHooks } = await createPluginInstance( + templateExtractorBarrelExportPlugin, + context, + ); + + // Add regular barrel export + instance.addBarrelExport('test-package#test-generator', { + moduleSpecifier: './utils', + namedExports: ['helper1'], + }); + + // Add generated barrel export + instance.addGeneratedBarrelExport('test-package#test-generator', { + moduleSpecifier: './services/test.service', + namedExport: 'TestService', + name: 'testService', + }); + + // Execute the afterWrite hook + await executeHooks('afterWrite'); + + // Assert - both files should exist + const files = context.fileContainer.getFiles(); + + // Regular barrel export should be in index.ts + const indexPath = '/test-generator/index.ts'; + expect(files.has(indexPath)).toBe(true); + const indexContent = files.get(indexPath) as string; + expect(indexContent).toContain("export { helper1 } from './utils'"); + + // Generated barrel export should be in generated/index.ts + const generatedIndexPath = '/test-generator/generated/index.ts'; + expect(files.has(generatedIndexPath)).toBe(true); + const generatedIndexContent = files.get(generatedIndexPath) as string; + expect(generatedIndexContent).toContain( + 'export const TEST_GENERATOR_GENERATED = {', + ); + expect(generatedIndexContent).toContain('testService: TestService,'); + }); +}); diff --git a/packages/core-generators/src/renderers/extractor/plugins/index.ts b/packages/core-generators/src/renderers/extractor/plugins/index.ts new file mode 100644 index 000000000..4c4aefcb1 --- /dev/null +++ b/packages/core-generators/src/renderers/extractor/plugins/index.ts @@ -0,0 +1,5 @@ +export * from './barrel-export.js'; +export { + TEMPLATE_PATHS_METADATA_FILE, + templatePathsPlugin, +} from './template-paths/index.js'; diff --git a/packages/core-generators/src/renderers/templates/plugins/template-paths/index.ts b/packages/core-generators/src/renderers/extractor/plugins/template-paths/index.ts similarity index 100% rename from packages/core-generators/src/renderers/templates/plugins/template-paths/index.ts rename to packages/core-generators/src/renderers/extractor/plugins/template-paths/index.ts diff --git a/packages/core-generators/src/renderers/templates/plugins/template-paths/paths-file.ts b/packages/core-generators/src/renderers/extractor/plugins/template-paths/paths-file.ts similarity index 90% rename from packages/core-generators/src/renderers/templates/plugins/template-paths/paths-file.ts rename to packages/core-generators/src/renderers/extractor/plugins/template-paths/paths-file.ts index bdb425428..27fa59f7a 100644 --- a/packages/core-generators/src/renderers/templates/plugins/template-paths/paths-file.ts +++ b/packages/core-generators/src/renderers/extractor/plugins/template-paths/paths-file.ts @@ -1,5 +1,6 @@ import type { TemplateExtractorContext } from '@baseplate-dev/sync/extractor-v2'; +import { TEMPLATE_EXTRACTOR_GENERATED_DIRECTORY } from '@baseplate-dev/sync/extractor-v2'; import { mapValuesOfMap } from '@baseplate-dev/utils'; import { posixJoin } from '@baseplate-dev/utils/node'; import { camelCase } from 'change-case'; @@ -7,20 +8,25 @@ import { z } from 'zod'; import type { TsCodeFragment } from '#src/renderers/typescript/index.js'; -import { - renderTsCodeFileTemplate, - TsCodeUtils, - tsTemplate, -} from '#src/renderers/typescript/index.js'; import { getGeneratedTemplateConstantName, getGeneratedTemplateExportName, getGeneratedTemplateInterfaceName, getGeneratedTemplateProviderName, resolvePackagePathSpecifier, -} from '#src/renderers/templates/utils/index.js'; +} from '#src/renderers/extractor/utils/index.js'; +import { + renderTsCodeFileTemplate, + TsCodeUtils, + tsTemplate, +} from '#src/renderers/typescript/index.js'; + +export const GENERATED_PATHS_FILE_NAME = 'template-paths.ts'; -const GENERATED_PATHS_FILE_NAME = 'generated/template-paths.ts'; +const GENERATED_PATHS_FILE_PATH = posixJoin( + TEMPLATE_EXTRACTOR_GENERATED_DIRECTORY, + GENERATED_PATHS_FILE_NAME, +); const GENERATED_PATHS_TEMPLATE = ` import { createProviderType, createGeneratorTask } from '@baseplate-dev/sync'; @@ -64,6 +70,7 @@ interface PathsFileExportNames { interfaceName: string; providerExportName: string; taskName: string; + rootExportName: string; } export function getPathsFileExportNames( @@ -73,6 +80,7 @@ export function getPathsFileExportNames( interfaceName: getGeneratedTemplateInterfaceName(generatorName, 'paths'), providerExportName: getGeneratedTemplateExportName(generatorName, 'paths'), taskName: getGeneratedTemplateExportName(generatorName, 'paths-task'), + rootExportName: getGeneratedTemplateConstantName(generatorName, 'paths'), }; } @@ -200,12 +208,12 @@ export function writePathMapFile( generatorName: string, pathMap: Map, context: TemplateExtractorContext, -): void { +): { exportName: string } { const extractorConfig = context.configLookup.getExtractorConfigOrThrow(generatorName); const pathMapPath = posixJoin( extractorConfig.generatorDirectory, - GENERATED_PATHS_FILE_NAME, + GENERATED_PATHS_FILE_PATH, ); const fileExportNames = getPathsFileExportNames(generatorName); @@ -232,12 +240,18 @@ export function writePathMapFile( ), TPL_PATHS_TASK_NAME: taskName, TPL_PATHS_GENERATOR_TASK: taskFragment, - TPL_PATHS_EXPORT_NAME: getGeneratedTemplateConstantName( - generatorName, - 'paths', - ), + TPL_PATHS_EXPORT_NAME: fileExportNames.rootExportName, + }, + options: { + importSortOptions: { + internalPatterns: [/^#src/], + }, }, }); context.fileContainer.writeFile(pathMapPath, pathMapContents); + + return { + exportName: fileExportNames.rootExportName, + }; } diff --git a/packages/core-generators/src/renderers/templates/plugins/template-paths/template-paths.plugin.ts b/packages/core-generators/src/renderers/extractor/plugins/template-paths/template-paths.plugin.ts similarity index 62% rename from packages/core-generators/src/renderers/templates/plugins/template-paths/template-paths.plugin.ts rename to packages/core-generators/src/renderers/extractor/plugins/template-paths/template-paths.plugin.ts index 86ba0f693..44d5a4ff7 100644 --- a/packages/core-generators/src/renderers/templates/plugins/template-paths/template-paths.plugin.ts +++ b/packages/core-generators/src/renderers/extractor/plugins/template-paths/template-paths.plugin.ts @@ -9,8 +9,16 @@ import { camelCase } from 'change-case'; import path from 'node:path'; import { z } from 'zod'; -import { templateExtractorBarrelImportPlugin } from '../barrel-import.js'; -import { writePathMapFile } from './paths-file.js'; +import type { TemplateFileOptions } from '#src/renderers/schemas/template-file-options.js'; + +import { normalizeTsPathToJsPath } from '#src/utils/ts-paths.js'; + +import { templateExtractorBarrelExportPlugin } from '../barrel-export.js'; +import { + GENERATED_PATHS_FILE_NAME, + getPathsFileExportNames, + writePathMapFile, +} from './paths-file.js'; export interface TemplatePathRoot { canonicalPath: string; @@ -23,6 +31,11 @@ const templatePathRootSchema = z.object({ pathRootName: z.string(), }); +function getPathsRootExportName(generatorName: string): string { + const fileExportNames = getPathsFileExportNames(generatorName); + return fileExportNames.rootExportName; +} + export const TEMPLATE_PATHS_METADATA_FILE = '.paths-metadata.json'; /** @@ -34,8 +47,11 @@ export const TEMPLATE_PATHS_METADATA_FILE = '.paths-metadata.json'; */ export const templatePathsPlugin = createTemplateExtractorPlugin({ name: 'template-paths', - pluginDependencies: [templateExtractorBarrelImportPlugin], + pluginDependencies: [templateExtractorBarrelExportPlugin], getInstance: async ({ context, api }) => { + const barrelExportPlugin = context.getPlugin( + templateExtractorBarrelExportPlugin.name, + ); const templatePathRoots = await discoverTemplatePathRoots( context.outputDirectory, ); @@ -70,9 +86,56 @@ export const templatePathsPlugin = createTemplateExtractorPlugin({ ); } + /** + * Resolves template paths for a given file based on its file options. + * + * @param fileOptions - The file options containing template path configuration. + * @param absolutePath - The absolute path of the template file. + * @param templateName - The name of the template (for error messages). + * @param generatorName - The name of the generator (for error messages). + * @returns An object containing the pathRootRelativePath and generatorTemplatePath. + */ + function resolveTemplatePaths( + fileOptions: TemplateFileOptions, + absolutePath: string, + templateName: string, + generatorName: string, + ): { + pathRootRelativePath: string | undefined; + generatorTemplatePath: string; + } { + const pathRootRelativePath = + fileOptions.kind === 'singleton' + ? getPathRootRelativePath(absolutePath) + : undefined; + + // By default, singleton templates have the path like `feature-root/services/[file].ts` + const generatorTemplatePath = + fileOptions.generatorTemplatePath ?? + (pathRootRelativePath && + getTemplatePathFromPathRootRelativePath(pathRootRelativePath)); + + if (!generatorTemplatePath) { + throw new Error( + `Template path is required for ${templateName} in ${generatorName}`, + ); + } + + return { pathRootRelativePath, generatorTemplatePath }; + } + api.registerHook('afterWrite', () => { for (const [generatorName, pathMap] of pathMapByGenerator) { - writePathMapFile(generatorName, pathMap, context); + const { exportName } = writePathMapFile( + generatorName, + pathMap, + context, + ); + barrelExportPlugin.addGeneratedBarrelExport(generatorName, { + moduleSpecifier: `./${normalizeTsPathToJsPath(GENERATED_PATHS_FILE_NAME)}`, + namedExport: exportName, + name: 'paths', + }); } }); @@ -80,6 +143,8 @@ export const templatePathsPlugin = createTemplateExtractorPlugin({ getPathRootRelativePath, getTemplatePathFromPathRootRelativePath, registerTemplatePathEntry, + resolveTemplatePaths, + getPathsRootExportName, }; }, }); @@ -104,7 +169,7 @@ function getTemplatePathFromPathRootRelativePath( * @param outputDirectory - The output directory of the project. * @returns The template path roots from longest to shortest. */ -export async function discoverTemplatePathRoots( +async function discoverTemplatePathRoots( outputDirectory: string, ): Promise { const pathsMetadataFile = path.join( @@ -128,7 +193,7 @@ export async function discoverTemplatePathRoots( * @param templatePathRoots - The template path roots. * @returns The template path. */ -export function getTemplatePathFromRelativePath( +function getTemplatePathFromRelativePath( outputRelativePath: string, templatePathRoots: TemplatePathRoot[], ): string { @@ -137,7 +202,7 @@ export function getTemplatePathFromRelativePath( `${canonicalPath}/`.startsWith(`${templatePathRoot.canonicalPath}/`), ); if (!templatePathRoot) { - return canonicalPath; + throw new Error(`Could not find template path root for ${canonicalPath}`); } return posixJoin( `{${templatePathRoot.pathRootName}}`, diff --git a/packages/core-generators/src/renderers/templates/plugins/typed-templates-file.ts b/packages/core-generators/src/renderers/extractor/plugins/typed-templates-file.ts similarity index 65% rename from packages/core-generators/src/renderers/templates/plugins/typed-templates-file.ts rename to packages/core-generators/src/renderers/extractor/plugins/typed-templates-file.ts index ff76b184a..65e4606e0 100644 --- a/packages/core-generators/src/renderers/templates/plugins/typed-templates-file.ts +++ b/packages/core-generators/src/renderers/extractor/plugins/typed-templates-file.ts @@ -1,6 +1,11 @@ -import { createTemplateExtractorPlugin } from '@baseplate-dev/sync/extractor-v2'; +import { + createTemplateExtractorPlugin, + TEMPLATE_EXTRACTOR_GENERATED_DIRECTORY, +} from '@baseplate-dev/sync/extractor-v2'; import { posixJoin } from '@baseplate-dev/utils/node'; +import { normalizeTsPathToJsPath } from '#src/utils/ts-paths.js'; + import type { TsCodeFragment } from '../../typescript/index.js'; import { @@ -8,9 +13,15 @@ import { TsCodeUtils, } from '../../typescript/index.js'; import { getGeneratedTemplateConstantName } from '../utils/index.js'; +import { templateExtractorBarrelExportPlugin } from './barrel-export.js'; import { templatePathsPlugin } from './template-paths/template-paths.plugin.js'; -export const TYPED_TEMPLATES_FILE_PATH = 'generated/typed-templates.ts'; +const TYPED_TEMPLATES_FILE_NAME = 'typed-templates.ts'; + +const TYPED_TEMPLATES_FILE_PATH = posixJoin( + TEMPLATE_EXTRACTOR_GENERATED_DIRECTORY, + TYPED_TEMPLATES_FILE_NAME, +); export interface TemplateExtractorTypedTemplate { fragment: TsCodeFragment; @@ -29,8 +40,14 @@ export const TPL_EXPORT_NAME = TPL_TEMPLATE_EXPORTS; */ export const typedTemplatesFilePlugin = createTemplateExtractorPlugin({ name: 'typed-templates-file', - pluginDependencies: [templatePathsPlugin], + pluginDependencies: [ + templatePathsPlugin, + templateExtractorBarrelExportPlugin, + ], getInstance: ({ context, api }) => { + const barrelExportPlugin = context.getPlugin( + templateExtractorBarrelExportPlugin.name, + ); const generatorTemplates = new Map< string, TemplateExtractorTypedTemplate[] @@ -50,16 +67,23 @@ export const typedTemplatesFilePlugin = createTemplateExtractorPlugin({ const templateExports = templates.map((t) => t.exportName); const templatesFragment = TsCodeUtils.mergeFragments( new Map(templates.map((t) => [t.exportName, t.fragment])), + '\n\n', + ); + const exportName = getGeneratedTemplateConstantName( + generatorName, + 'TEMPLATES', ); const templateFileContents = renderTsCodeFileTemplate({ templateContents: TS_TEMPLATE, variables: { TPL_TEMPLATE_FRAGMENTS: templatesFragment, TPL_TEMPLATE_EXPORTS: `{ ${templateExports.join(', ')} }`, - TPL_EXPORT_NAME: getGeneratedTemplateConstantName( - generatorName, - 'TEMPLATES', - ), + TPL_EXPORT_NAME: exportName, + }, + options: { + importSortOptions: { + internalPatterns: [/^#src/], + }, }, }); const generatorPath = @@ -74,6 +98,12 @@ export const typedTemplatesFilePlugin = createTemplateExtractorPlugin({ typedTemplatesPath, templateFileContents, ); + + barrelExportPlugin.addGeneratedBarrelExport(generatorName, { + moduleSpecifier: `./${normalizeTsPathToJsPath(TYPED_TEMPLATES_FILE_NAME)}`, + namedExport: exportName, + name: 'templates', + }); } }); diff --git a/packages/core-generators/src/renderers/templates/utils/generated-template-file-names.ts b/packages/core-generators/src/renderers/extractor/utils/generated-template-file-names.ts similarity index 100% rename from packages/core-generators/src/renderers/templates/utils/generated-template-file-names.ts rename to packages/core-generators/src/renderers/extractor/utils/generated-template-file-names.ts diff --git a/packages/core-generators/src/renderers/templates/utils/index.ts b/packages/core-generators/src/renderers/extractor/utils/index.ts similarity index 100% rename from packages/core-generators/src/renderers/templates/utils/index.ts rename to packages/core-generators/src/renderers/extractor/utils/index.ts diff --git a/packages/core-generators/src/renderers/templates/utils/package-path-specifier.ts b/packages/core-generators/src/renderers/extractor/utils/package-path-specifier.ts similarity index 96% rename from packages/core-generators/src/renderers/templates/utils/package-path-specifier.ts rename to packages/core-generators/src/renderers/extractor/utils/package-path-specifier.ts index d1457897d..5c0614539 100644 --- a/packages/core-generators/src/renderers/templates/utils/package-path-specifier.ts +++ b/packages/core-generators/src/renderers/extractor/utils/package-path-specifier.ts @@ -39,7 +39,7 @@ export function resolvePackagePathSpecifier( ): string { const parsed = parsePackagePathSpecifier(packagePathSpecifier); if (parsed.packageName === importingPackage) { - return `#${parsed.filePath}`; + return `#${parsed.filePath.replace(/\.(t|j)sx?$/, '.js')}`; } // Return the package name directly if importing from another package. return parsed.packageName; diff --git a/packages/core-generators/src/renderers/index.ts b/packages/core-generators/src/renderers/index.ts index 62428b13c..8c655582e 100644 --- a/packages/core-generators/src/renderers/index.ts +++ b/packages/core-generators/src/renderers/index.ts @@ -1,4 +1,4 @@ +export * from './extractor/index.js'; export * from './raw/index.js'; -export * from './templates/index.js'; export * from './text/index.js'; export * from './typescript/index.js'; diff --git a/packages/core-generators/src/renderers/raw/raw-template-file-extractor.ts b/packages/core-generators/src/renderers/raw/raw-template-file-extractor.ts index fae2fc8bb..de4c02408 100644 --- a/packages/core-generators/src/renderers/raw/raw-template-file-extractor.ts +++ b/packages/core-generators/src/renderers/raw/raw-template-file-extractor.ts @@ -3,9 +3,9 @@ import { createTemplateFileExtractor } from '@baseplate-dev/sync/extractor-v2'; import { camelCase } from 'change-case'; import pLimit from 'p-limit'; -import { templatePathsPlugin } from '../templates/plugins/template-paths/template-paths.plugin.js'; -import { typedTemplatesFilePlugin } from '../templates/plugins/typed-templates-file.js'; -import { resolvePackagePathSpecifier } from '../templates/utils/package-path-specifier.js'; +import { templatePathsPlugin } from '../extractor/plugins/template-paths/template-paths.plugin.js'; +import { typedTemplatesFilePlugin } from '../extractor/plugins/typed-templates-file.js'; +import { resolvePackagePathSpecifier } from '../extractor/utils/package-path-specifier.js'; import { TsCodeUtils, tsImportBuilder } from '../typescript/index.js'; import { rawTemplateGeneratorTemplateMetadataSchema, @@ -22,48 +22,51 @@ export const RawTemplateFileExtractor = createTemplateFileExtractor({ extractTemplateMetadataEntries: (files, context) => { const templatePathPlugin = context.getPlugin('template-paths'); return files.map(({ metadata, absolutePath }) => { - const pathRootRelativePath = - metadata.fileOptions.kind === 'singleton' - ? templatePathPlugin.getPathRootRelativePath(absolutePath) - : undefined; + try { + const { pathRootRelativePath, generatorTemplatePath } = + templatePathPlugin.resolveTemplatePaths( + metadata.fileOptions, + absolutePath, + metadata.name, + metadata.generator, + ); - // By default, singleton templates have the path like `feature-root/services/[file].ts` - const generatorTemplatePath = - metadata.fileOptions.generatorTemplatePath ?? - (pathRootRelativePath && - templatePathPlugin.getTemplatePathFromPathRootRelativePath( + return { + generator: metadata.generator, + generatorTemplatePath, + sourceAbsolutePath: absolutePath, + metadata: { + name: metadata.name, + type: metadata.type, + fileOptions: metadata.fileOptions, pathRootRelativePath, - )); - - if (!generatorTemplatePath) { + }, + }; + } catch (error) { throw new Error( - `Template path is required for ${metadata.name} in ${metadata.generator}`, + `Error extracting template metadata for ${absolutePath}: ${error instanceof Error ? error.message : String(error)}`, + { cause: error }, ); } - - return { - generator: metadata.generator, - generatorTemplatePath, - sourceAbsolutePath: absolutePath, - metadata: { - name: metadata.name, - type: metadata.type, - fileOptions: metadata.fileOptions, - pathRootRelativePath, - }, - }; }); }, - writeTemplateFiles: async (files, context, api) => { + writeTemplateFiles: async (files, _context, api) => { await Promise.all( files.map((file) => limit(async () => { - const contents = await api.readOutputFile(file.sourceAbsolutePath); - api.writeTemplateFile( - file.generator, - file.generatorTemplatePath, - contents, - ); + try { + const contents = await api.readOutputFile(file.sourceAbsolutePath); + api.writeTemplateFile( + file.generator, + file.generatorTemplatePath, + contents, + ); + } catch (error) { + throw new Error( + `Error writing template file for ${file.sourceAbsolutePath}: ${error instanceof Error ? error.message : String(error)}`, + { cause: error }, + ); + } }), ), ); diff --git a/packages/core-generators/src/renderers/raw/render-raw-template-action.ts b/packages/core-generators/src/renderers/raw/render-raw-template-action.ts index 69e5e61c5..25e5fe996 100644 --- a/packages/core-generators/src/renderers/raw/render-raw-template-action.ts +++ b/packages/core-generators/src/renderers/raw/render-raw-template-action.ts @@ -18,7 +18,7 @@ interface RenderRawTemplateFileActionInputBase< } type RenderRawTemplateFileActionInput = - RenderRawTemplateFileActionInputBase & + RenderRawTemplateFileActionInputBase & (TTemplateFile['fileOptions']['kind'] extends 'singleton' ? { id?: undefined } : { id: string }); diff --git a/packages/core-generators/src/renderers/raw/types.ts b/packages/core-generators/src/renderers/raw/types.ts index ea54015a5..19b2e00cd 100644 --- a/packages/core-generators/src/renderers/raw/types.ts +++ b/packages/core-generators/src/renderers/raw/types.ts @@ -44,15 +44,17 @@ export type RawTemplateOutputTemplateMetadata = z.infer< /** * A template for a raw file with no replacements. */ -export interface RawTemplateFile extends TemplateFileBase { - fileOptions: TemplateFileOptions; +export interface RawTemplateFile< + TFileOptions extends TemplateFileOptions = TemplateFileOptions, +> extends TemplateFileBase { + fileOptions: TFileOptions; } /** * Create a raw template file */ -export function createRawTemplateFile( - templateFile: RawTemplateFile, -): RawTemplateFile { +export function createRawTemplateFile( + templateFile: RawTemplateFile, +): RawTemplateFile { return templateFile; } diff --git a/packages/core-generators/src/renderers/schemas/template-file-options.ts b/packages/core-generators/src/renderers/schemas/template-file-options.ts index d2f354d91..87e9e63f6 100644 --- a/packages/core-generators/src/renderers/schemas/template-file-options.ts +++ b/packages/core-generators/src/renderers/schemas/template-file-options.ts @@ -3,7 +3,7 @@ import { z } from 'zod'; export const templateFileOptionsSchema = z .discriminatedUnion('kind', [ z.object({ - kind: z.literal('singleton'), + kind: z.literal('singleton').optional().default('singleton'), /** * The path of the template in the generator's `templates/` directory. * diff --git a/packages/core-generators/src/renderers/templates/plugins/barrel-import.ts b/packages/core-generators/src/renderers/templates/plugins/barrel-import.ts deleted file mode 100644 index 11da7a5ad..000000000 --- a/packages/core-generators/src/renderers/templates/plugins/barrel-import.ts +++ /dev/null @@ -1,178 +0,0 @@ -import { createTemplateExtractorPlugin } from '@baseplate-dev/sync/extractor-v2'; -import fs from 'node:fs/promises'; -import path from 'node:path'; -import { Project, QuoteKind } from 'ts-morph'; - -interface BarrelExport { - moduleSpecifier: string; - namedExports: string[]; - isTypeOnly?: boolean; -} - -export function mergeBarrelExports( - indexFileContents: string, - barrelExports: BarrelExport[], -): string { - const project = new Project({ - useInMemoryFileSystem: true, - manipulationSettings: { - quoteKind: QuoteKind.Single, - }, - }); - const sourceFile = project.createSourceFile('index.ts', indexFileContents); - - // Remove all existing export statements - for (const decl of sourceFile.getExportDeclarations()) { - decl.remove(); - } - - // Group exports by module specifier and type - const exportsByModuleAndType = new Map< - string, - { - starExport?: { isTypeOnly: boolean }; - namedExports: Map; // name -> isTypeOnly - } - >(); - - for (const barrelExport of barrelExports) { - const key = barrelExport.moduleSpecifier; - if (!exportsByModuleAndType.has(key)) { - exportsByModuleAndType.set(key, { - namedExports: new Map(), - }); - } - - // eslint-disable-next-line @typescript-eslint/no-non-null-assertion - const moduleData = exportsByModuleAndType.get(key)!; - const isTypeOnly = barrelExport.isTypeOnly ?? false; - - for (const namedExport of barrelExport.namedExports) { - if (namedExport === '*') { - // Star export takes precedence - moduleData.starExport = { isTypeOnly }; - } else { - moduleData.namedExports.set(namedExport, isTypeOnly); - } - } - } - - // Sort module specifiers, putting * exports first - const sortedModuleSpecifiers = [...exportsByModuleAndType.keys()].sort( - (a, b) => { - const aHasStar = exportsByModuleAndType.get(a)?.starExport !== undefined; - const bHasStar = exportsByModuleAndType.get(b)?.starExport !== undefined; - - // If one has star export and the other doesn't, prioritize the one with star - if (aHasStar && !bHasStar) return -1; - if (!aHasStar && bHasStar) return 1; - - // Otherwise, sort alphabetically - return a.localeCompare(b); - }, - ); - - // Add exports back in sorted order - for (const moduleSpecifier of sortedModuleSpecifiers) { - const moduleData = exportsByModuleAndType.get(moduleSpecifier); - if (!moduleData) continue; - - // Add star export first if it exists - if (moduleData.starExport) { - sourceFile.addExportDeclaration({ - moduleSpecifier, - isTypeOnly: moduleData.starExport.isTypeOnly, - }); - } else if (moduleData.namedExports.size > 0) { - // Group named exports by isTypeOnly - const typeOnlyExports: string[] = []; - const regularExports: string[] = []; - - for (const [name, isTypeOnly] of moduleData.namedExports) { - if (isTypeOnly) { - typeOnlyExports.push(name); - } else { - regularExports.push(name); - } - } - - // Add type-only exports - if (typeOnlyExports.length > 0) { - sourceFile.addExportDeclaration({ - moduleSpecifier, - isTypeOnly: true, - namedExports: typeOnlyExports.sort().map((name) => ({ name })), - }); - } - - // Add regular exports - if (regularExports.length > 0) { - sourceFile.addExportDeclaration({ - moduleSpecifier, - isTypeOnly: false, - namedExports: regularExports.sort().map((name) => ({ name })), - }); - } - } - } - - return sourceFile.getFullText(); -} - -export const templateExtractorBarrelImportPlugin = - createTemplateExtractorPlugin({ - name: 'barrel-import', - getInstance: ({ context, api }) => { - const { fileContainer } = context; - const barrelExportMap = new Map< - string, - { - moduleSpecifier: string; - namedExports: string[]; - isTypeOnly?: boolean; - }[] - >(); - - function addBarrelExport( - generatorName: string, - moduleSpecifier: string, - namedExports: string[], - isTypeOnly?: boolean, - ): void { - const barrelExports = barrelExportMap.get(generatorName) ?? []; - barrelExports.push({ - moduleSpecifier, - namedExports, - isTypeOnly, - }); - barrelExportMap.set(generatorName, barrelExports); - } - - // Merge the barrel exports into the barrel file - api.registerHook('afterWrite', async () => { - for (const [generatorName, barrelExports] of barrelExportMap) { - const extractorConfig = - context.configLookup.getExtractorConfig(generatorName); - if (!extractorConfig) { - throw new Error(`Extractor config not found: ${generatorName}`); - } - const indexFileContents = await fs.readFile( - path.join(extractorConfig.generatorDirectory, 'index.ts'), - 'utf8', - ); - const updatedContents = mergeBarrelExports( - indexFileContents, - barrelExports, - ); - fileContainer.writeFile( - path.join(extractorConfig.generatorDirectory, 'index.ts'), - updatedContents, - ); - } - }); - - return { - addBarrelExport, - }; - }, - }); diff --git a/packages/core-generators/src/renderers/templates/plugins/barrel-import.unit.test.ts b/packages/core-generators/src/renderers/templates/plugins/barrel-import.unit.test.ts deleted file mode 100644 index 4afa65bf0..000000000 --- a/packages/core-generators/src/renderers/templates/plugins/barrel-import.unit.test.ts +++ /dev/null @@ -1,106 +0,0 @@ -import { describe, expect, it } from 'vitest'; - -import { mergeBarrelExports } from './barrel-import.js'; - -describe('mergeBarrelExports', () => { - it('should merge and sort exports', () => { - // Arrange - const indexFileContents = ` -export { foo } from './foo'; -export { bar } from './bar'; -export * from './baz'; -`; - - const barrelExports = [ - { moduleSpecifier: './bar', namedExports: ['qux', 'quux'] }, - { moduleSpecifier: './foo', namedExports: ['baz'] }, - { moduleSpecifier: './baz', namedExports: ['*'] }, - ]; - - // Act - const result = mergeBarrelExports(indexFileContents, barrelExports); - - // Assert - expect(result).toMatchInlineSnapshot(` - "export * from './baz'; - export { quux, qux } from './bar'; - export { baz } from './foo'; - " - `); - }); - - it('should handle empty exports', () => { - // Arrange - const indexFileContents = ''; - const barrelExports: { moduleSpecifier: string; namedExports: string[] }[] = - []; - - // Act - const result = mergeBarrelExports(indexFileContents, barrelExports); - - // Assert - expect(result).toBe(''); - }); - - it('should handle duplicate exports', () => { - // Arrange - const indexFileContents = ''; - const barrelExports = [ - { moduleSpecifier: './foo', namedExports: ['bar', 'baz'] }, - { moduleSpecifier: './foo', namedExports: ['qux', 'bar'] }, - ]; - - // Act - const result = mergeBarrelExports(indexFileContents, barrelExports); - - // Assert - expect(result).toMatchInlineSnapshot(` - "export { bar, baz, qux } from './foo'; - " - `); - }); - - it('should handle type-only exports', () => { - // Arrange - const indexFileContents = ''; - const barrelExports = [ - { - moduleSpecifier: './types', - namedExports: ['TypeA', 'TypeB'], - isTypeOnly: true, - }, - { moduleSpecifier: './utils', namedExports: ['funcA', 'funcB'] }, - { moduleSpecifier: './types', namedExports: ['funcC'] }, - ]; - - // Act - const result = mergeBarrelExports(indexFileContents, barrelExports); - - // Assert - expect(result).toMatchInlineSnapshot(` - "export type { TypeA, TypeB } from './types'; - export { funcC } from './types'; - export { funcA, funcB } from './utils'; - " - `); - }); - - it('should handle type-only star exports', () => { - // Arrange - const indexFileContents = ''; - const barrelExports = [ - { moduleSpecifier: './types', namedExports: ['*'], isTypeOnly: true }, - { moduleSpecifier: './utils', namedExports: ['funcA'] }, - ]; - - // Act - const result = mergeBarrelExports(indexFileContents, barrelExports); - - // Assert - expect(result).toMatchInlineSnapshot(` - "export type * from './types'; - export { funcA } from './utils'; - " - `); - }); -}); diff --git a/packages/core-generators/src/renderers/templates/plugins/index.ts b/packages/core-generators/src/renderers/templates/plugins/index.ts deleted file mode 100644 index 470d1931b..000000000 --- a/packages/core-generators/src/renderers/templates/plugins/index.ts +++ /dev/null @@ -1,5 +0,0 @@ -export * from './barrel-import.js'; -export { - TEMPLATE_PATHS_METADATA_FILE, - templatePathsPlugin, -} from './template-paths/template-paths.plugin.js'; diff --git a/packages/core-generators/src/renderers/text/text-template-file-extractor.ts b/packages/core-generators/src/renderers/text/text-template-file-extractor.ts index 5e7c333d4..7072f97a5 100644 --- a/packages/core-generators/src/renderers/text/text-template-file-extractor.ts +++ b/packages/core-generators/src/renderers/text/text-template-file-extractor.ts @@ -1,23 +1,20 @@ import { getGenerationConcurrencyLimit } from '@baseplate-dev/sync'; import { createTemplateFileExtractor } from '@baseplate-dev/sync/extractor-v2'; import { camelCase } from 'change-case'; -import { mapValues } from 'es-toolkit'; +import { mapValues, omit } from 'es-toolkit'; import pLimit from 'p-limit'; import type { TextTemplateFileVariableWithValue } from './types.js'; -import { templatePathsPlugin } from '../templates/plugins/template-paths/template-paths.plugin.js'; -import { typedTemplatesFilePlugin } from '../templates/plugins/typed-templates-file.js'; -import { resolvePackagePathSpecifier } from '../templates/utils/package-path-specifier.js'; +import { templatePathsPlugin } from '../extractor/plugins/template-paths/template-paths.plugin.js'; +import { typedTemplatesFilePlugin } from '../extractor/plugins/typed-templates-file.js'; +import { resolvePackagePathSpecifier } from '../extractor/utils/package-path-specifier.js'; import { TsCodeUtils, tsImportBuilder } from '../typescript/index.js'; import { textTemplateGeneratorTemplateMetadataSchema, textTemplateOutputTemplateMetadataSchema, } from './types.js'; -import { - getTextTemplateDelimiters, - getTextTemplateVariableRegExp, -} from './utils.js'; +import { extractTemplateVariables } from './utils.js'; const limit = pLimit(getGenerationConcurrencyLimit()); @@ -29,83 +26,64 @@ export const TextTemplateFileExtractor = createTemplateFileExtractor({ extractTemplateMetadataEntries: (files, context) => { const templatePathPlugin = context.getPlugin('template-paths'); return files.map(({ metadata, absolutePath }) => { - const pathRootRelativePath = - metadata.fileOptions.kind === 'singleton' - ? templatePathPlugin.getPathRootRelativePath(absolutePath) - : undefined; + try { + const { pathRootRelativePath, generatorTemplatePath } = + templatePathPlugin.resolveTemplatePaths( + metadata.fileOptions, + absolutePath, + metadata.name, + metadata.generator, + ); - // By default, singleton templates have the path like `feature-root/services/[file].ts` - const generatorTemplatePath = - metadata.fileOptions.generatorTemplatePath ?? - (pathRootRelativePath && - templatePathPlugin.getTemplatePathFromPathRootRelativePath( + return { + generator: metadata.generator, + generatorTemplatePath, + sourceAbsolutePath: absolutePath, + metadata: { + name: metadata.name, + type: metadata.type, + fileOptions: metadata.fileOptions, pathRootRelativePath, - )); - - if (!generatorTemplatePath) { + variables: mapValues(metadata.variables ?? {}, (variable) => + omit(variable, ['value']), + ), + }, + extractionContext: { + variables: metadata.variables ?? {}, + }, + }; + } catch (error) { throw new Error( - `Template path is required for ${metadata.name} in ${metadata.generator}`, + `Error extracting template metadata for ${absolutePath}: ${error instanceof Error ? error.message : String(error)}`, + { cause: error }, ); } - - return { - generator: metadata.generator, - generatorTemplatePath, - sourceAbsolutePath: absolutePath, - metadata: { - name: metadata.name, - type: metadata.type, - fileOptions: metadata.fileOptions, - pathRootRelativePath, - variables: metadata.variables, - }, - }; }); }, writeTemplateFiles: async (files, _context, api) => { await Promise.all( files.map((file) => limit(async () => { - const contents = await api.readOutputFile(file.sourceAbsolutePath); - const { metadata } = file; - const { start, end } = getTextTemplateDelimiters( - file.sourceAbsolutePath, - ); - - // replace variable values with template string - let templateContents = contents; + try { + const contents = await api.readOutputFile(file.sourceAbsolutePath); - // Sort variables by descending length of their values to prevent overlapping replacements - const sortedVariables = Object.entries(metadata.variables ?? {}).sort( - ([, a], [, b]) => { - const aValue = (a as TextTemplateFileVariableWithValue).value; - const bValue = (b as TextTemplateFileVariableWithValue).value; - return bValue.length - aValue.length; - }, - ); + const templateContents = extractTemplateVariables( + contents, + file.extractionContext?.variables ?? {}, + file.sourceAbsolutePath, + ); - for (const [key, variableWithValue] of sortedVariables) { - // variableWithValue has the 'value' property, we need to remove it for the variable definition - const { value } = - variableWithValue as TextTemplateFileVariableWithValue; - const variableRegex = getTextTemplateVariableRegExp(value); - const newTemplateContents = templateContents.replaceAll( - variableRegex, - `${start}${key}${end}`, + api.writeTemplateFile( + file.generator, + file.generatorTemplatePath, + templateContents, + ); + } catch (error) { + throw new Error( + `Error writing template file for ${file.sourceAbsolutePath}: ${error instanceof Error ? error.message : String(error)}`, + { cause: error }, ); - if (newTemplateContents === templateContents) { - throw new Error( - `Variable ${key} with value ${value} not found in template ${file.sourceAbsolutePath}`, - ); - } - templateContents = newTemplateContents; } - - api.writeTemplateFile( - file.generator, - file.generatorTemplatePath, - templateContents, - ); }), ), ); diff --git a/packages/core-generators/src/renderers/text/utils.ts b/packages/core-generators/src/renderers/text/utils.ts index 699bf2bc2..96e76dfde 100644 --- a/packages/core-generators/src/renderers/text/utils.ts +++ b/packages/core-generators/src/renderers/text/utils.ts @@ -1,5 +1,7 @@ import { escapeRegExp } from 'es-toolkit'; +import type { TextTemplateFileVariableWithValue } from './types.js'; + /** * Get the delimiters for a text template file. * @param filename The filename of the text template file. @@ -33,10 +35,52 @@ export function getTextTemplateDelimiters(filename: string): { /** * Get the regex for a text template variable. We check for non-alphanumeric characters around the variable name. * - * @param variable The variable to get the regex for. * @param value The value of the variable. * @returns The regex for the text template variable. */ export function getTextTemplateVariableRegExp(value: string): RegExp { return new RegExp(`(? | undefined, + filename: string, +): string { + if (!variables) { + return contents; + } + + let templateContents = contents; + const { start, end } = getTextTemplateDelimiters(filename); + + // Sort variables by descending length of their values to prevent overlapping replacements + const sortedVariables = Object.entries(variables).sort(([, a], [, b]) => { + const aValue = a.value; + const bValue = b.value; + return bValue.length - aValue.length; + }); + + for (const [key, variableWithValue] of sortedVariables) { + const { value } = variableWithValue; + const variableRegex = getTextTemplateVariableRegExp(value); + const newTemplateContents = templateContents.replaceAll( + variableRegex, + `${start}${key}${end}`, + ); + if (newTemplateContents === templateContents) { + throw new Error(`Variable ${key} with value ${value} not found`); + } + templateContents = newTemplateContents; + } + + return templateContents; +} diff --git a/packages/core-generators/src/renderers/text/utils.unit.test.ts b/packages/core-generators/src/renderers/text/utils.unit.test.ts index 695735fe2..a40deb130 100644 --- a/packages/core-generators/src/renderers/text/utils.unit.test.ts +++ b/packages/core-generators/src/renderers/text/utils.unit.test.ts @@ -1,6 +1,12 @@ import { describe, expect, it } from 'vitest'; -import { getTextTemplateVariableRegExp } from './utils.js'; +import type { TextTemplateFileVariableWithValue } from './types.js'; + +import { + extractTemplateVariables, + getTextTemplateDelimiters, + getTextTemplateVariableRegExp, +} from './utils.js'; describe('getTextTemplateVariableRegExp', () => { describe('when variable is an identifier', () => { @@ -21,3 +27,137 @@ describe('getTextTemplateVariableRegExp', () => { }); }); }); + +describe('getTextTemplateDelimiters', () => { + it('should return CSS delimiters for .css files', () => { + const result = getTextTemplateDelimiters('styles.css'); + expect(result).toEqual({ + start: '/* ', + end: ' */', + }); + }); + + it('should return empty delimiters for .gql files', () => { + const result = getTextTemplateDelimiters('query.gql'); + expect(result).toEqual({ + start: '', + end: '', + }); + }); + + it('should return default delimiters for other files', () => { + const result = getTextTemplateDelimiters('component.tsx'); + expect(result).toEqual({ + start: '{{', + end: '}}', + }); + }); +}); + +describe('extractTemplateVariables', () => { + it('should return original contents when empty variables provided', () => { + const contents = 'const name = "test";'; + const result = extractTemplateVariables(contents, {}, 'test.ts'); + expect(result).toBe(contents); + }); + + it('should replace variable values with template placeholders', () => { + const contents = + 'const MyComponent = () => { return
Hello World
; };'; + const variables: Record = { + componentName: { + value: 'MyComponent', + }, + message: { + value: 'Hello World', + }, + }; + + const result = extractTemplateVariables( + contents, + variables, + 'component.tsx', + ); + expect(result).toBe( + 'const {{componentName}} = () => { return
{{message}}
; };', + ); + }); + + it('should use CSS delimiters for .css files', () => { + const contents = '.my-class { color: red; }'; + const variables: Record = { + className: { + value: 'my-class', + }, + }; + + const result = extractTemplateVariables(contents, variables, 'styles.css'); + expect(result).toBe('./* className */ { color: red; }'); + }); + + it('should use empty delimiters for .gql files', () => { + const contents = 'query GetUser { user { name } }'; + const variables: Record = { + queryName: { + value: 'GetUser', + }, + }; + + const result = extractTemplateVariables(contents, variables, 'query.gql'); + expect(result).toBe('query queryName { user { name } }'); + }); + + it('should handle overlapping variable values by processing longer values first', () => { + const contents = + 'const MyComponentProps = {}; const MyComponent = () => {};'; + const variables: Record = { + componentName: { + value: 'MyComponent', + }, + propsName: { + value: 'MyComponentProps', + }, + }; + + const result = extractTemplateVariables( + contents, + variables, + 'component.tsx', + ); + expect(result).toBe( + 'const {{propsName}} = {}; const {{componentName}} = () => {};', + ); + }); + + it('should throw error when variable value is not found', () => { + const contents = 'const SomeOtherComponent = () => {};'; + const variables: Record = { + componentName: { + value: 'MyComponent', + }, + }; + + expect(() => { + extractTemplateVariables(contents, variables, 'component.tsx'); + }).toThrow('Variable componentName with value MyComponent not found'); + }); + + it('should respect word boundaries when replacing variables', () => { + const contents = + 'const MyComponentWrapper = () => { return ; };'; + const variables: Record = { + componentName: { + value: 'MyComponent', + }, + }; + + const result = extractTemplateVariables( + contents, + variables, + 'component.tsx', + ); + expect(result).toBe( + 'const MyComponentWrapper = () => { return <{{componentName}} />; };', + ); + }); +}); diff --git a/packages/core-generators/src/renderers/typescript/actions/render-ts-template-file-action.ts b/packages/core-generators/src/renderers/typescript/actions/render-ts-template-file-action.ts index 678ea311e..25ddbf70b 100644 --- a/packages/core-generators/src/renderers/typescript/actions/render-ts-template-file-action.ts +++ b/packages/core-generators/src/renderers/typescript/actions/render-ts-template-file-action.ts @@ -10,6 +10,7 @@ import { readTemplateFileSource, } from '@baseplate-dev/sync'; import { differenceSet } from '@baseplate-dev/utils'; +import path from 'node:path'; import type { TsPositionedHoistedFragment } from '../fragments/types.js'; import type { RenderTsCodeFileTemplateOptions } from '../renderers/file.js'; @@ -17,8 +18,8 @@ import type { InferImportMapProvidersFromProviderTypeMap, InferTsTemplateVariablesFromMap, TsTemplateFile, - TsTemplateFileMetadata, - TsTemplateVariable, + TsTemplateFileVariable, + TsTemplateOutputTemplateMetadata, } from '../templates/types.js'; import { renderTsCodeFileTemplate } from '../renderers/file.js'; @@ -92,7 +93,7 @@ export function renderTsTemplateFileAction< // make sure variables and template variables match keys const templateVariables = template.variables as Record< string, - TsTemplateVariable + TsTemplateFileVariable >; const templateKeySet = new Set(Object.keys(templateVariables)); const providedKeySet = new Set(Object.keys(variableValues)); @@ -109,11 +110,14 @@ export function renderTsTemplateFileAction< ); } - const templateMetadata: TsTemplateFileMetadata | undefined = { + const templateMetadata: TsTemplateOutputTemplateMetadata | undefined = { name: template.name, template: 'path' in template.source - ? template.source.path + ? // TODO[2025-06-11]: Remove this once we've migrated all TS templates. + path.isAbsolute(template.source.path) + ? '' + : template.source.path : 'content-only-template', generator: generatorInfo.name, group: template.group, @@ -122,14 +126,24 @@ export function renderTsTemplateFileAction< Object.keys(template.projectExports ?? {}).length > 0 ? template.projectExports : undefined, + // TODO[2025-06-11]: Remove casting once we've migrated all TS templates. + // eslint-disable-next-line @typescript-eslint/no-non-null-assertion + fileOptions: template.fileOptions!, }; + if (template.fileOptions?.kind === 'instance' && !id) { + throw new Error('Instance template must have an id'); + } + + const fileId = id ?? template.name; + const shouldIncludeMetadata = builder.metadataOptions.includeTemplateMetadata && builder.metadataOptions.shouldGenerateMetadata({ - fileId: id ?? template.name, + fileId, filePath: normalizePathToProjectPath(destination), generatorName: generatorInfo.name, + // TODO[2025-06-11]: Turn this into a file options === 'kind' isInstance: !!id, }); diff --git a/packages/core-generators/src/renderers/typescript/extractor-v2/build-ts-project-export-map.ts b/packages/core-generators/src/renderers/typescript/extractor-v2/build-ts-project-export-map.ts new file mode 100644 index 000000000..c48beba29 --- /dev/null +++ b/packages/core-generators/src/renderers/typescript/extractor-v2/build-ts-project-export-map.ts @@ -0,0 +1,124 @@ +import type { TemplateExtractorContext } from '@baseplate-dev/sync/extractor-v2'; + +import path from 'node:path'; + +import { + TS_TEMPLATE_TYPE, + tsTemplateGeneratorTemplateMetadataSchema, +} from '../templates/types.js'; +import { getDefaultImportProviderNames } from './default-import-providers.js'; +import { GENERATED_IMPORT_PROVIDERS_FILE_NAME } from './render-ts-import-providers.js'; + +/** + * A project export that represents a single export from a generator. + */ +export interface TsProjectExport { + /** + * The name of the export used in the import provider. + */ + name: string; + /** + * The exported name of the export within the file, e.g. 'default' for default exports. + * + * If not provided, the name will be the same as the export name. + */ + exportedName?: string; + /** + * The output relative path of the file that contains the export. + */ + outputRelativePath: string; + /** + * Whether the export is a type only export. + */ + isTypeOnly?: boolean; + /** + * The placeholder module specifier to import from the import provider, e.g. %configServiceImports + */ + placeholderModuleSpecifier: string; + /** + * The package path specifier of the import provider, e.g. `@baseplate-dev/core-generators:src/renderers/plugins/typed-templates-file.ts` + */ + providerPackagePathSpecifier: string; + /** + * The name of the import provider, e.g. configServiceImportsProvider + */ + providerImportName: string; +} + +/** + * A map of output relative paths to a map of export names to project exports. + */ +export type TsProjectExportMap = Map>; + +/** + * Builds a map of output relative paths to a map of export names to project exports. + * + * @param context - The template extractor context. + * @returns A map of output relative paths to a map of export names to project exports. + */ +export function buildTsProjectExportMap( + context: TemplateExtractorContext, +): TsProjectExportMap { + const generatorConfigs = + context.configLookup.getGeneratorConfigsForExtractorType( + TS_TEMPLATE_TYPE, + tsTemplateGeneratorTemplateMetadataSchema, + ); + + const projectExportMap: TsProjectExportMap = new Map(); + + for (const generatorConfig of generatorConfigs) { + const { + generatorName, + generatorDirectory, + packageName, + templates, + packagePath, + } = generatorConfig; + + // Figure out the default import provider + const importProviderNames = getDefaultImportProviderNames(generatorName); + + const relativeGeneratorDirectory = path.relative( + packagePath, + generatorDirectory, + ); + const defaultImportsProviderPackagePathSpecifier = `${packageName}:${relativeGeneratorDirectory}/generated/${GENERATED_IMPORT_PROVIDERS_FILE_NAME}`; + + for (const [, template] of Object.entries(templates)) { + // skip non-singleton templates + if (template.fileOptions.kind !== 'singleton') continue; + + const outputRelativePath = + context.configLookup.getOutputRelativePathForTemplate( + generatorName, + template.name, + ); + if (!outputRelativePath) { + // if the template file was not written, skip it + continue; + } + + const templateProjectExportsMap = new Map(); + + for (const [name, projectExport] of Object.entries( + template.projectExports ?? {}, + )) { + templateProjectExportsMap.set(projectExport.exportName ?? name, { + name, + exportedName: projectExport.exportName, + outputRelativePath, + placeholderModuleSpecifier: + importProviderNames.placeholderModuleSpecifier, + providerPackagePathSpecifier: + defaultImportsProviderPackagePathSpecifier, + providerImportName: importProviderNames.providerExportName, + }); + } + + projectExportMap.set(outputRelativePath, templateProjectExportsMap); + } + } + + return projectExportMap; +} diff --git a/packages/core-generators/src/renderers/typescript/extractor-v2/default-import-providers.ts b/packages/core-generators/src/renderers/typescript/extractor-v2/default-import-providers.ts new file mode 100644 index 000000000..f540dfce8 --- /dev/null +++ b/packages/core-generators/src/renderers/typescript/extractor-v2/default-import-providers.ts @@ -0,0 +1,51 @@ +import { parseGeneratorName } from '@baseplate-dev/sync'; +import { camelCase, kebabCase, pascalCase } from 'change-case'; + +export interface TsImportProviderNames { + /** + * The name of the import provider type e.g. NodeImportsProvider + */ + providerTypeName: string; + /** + * The name of the import provider export, e.g. nodeImportsProvider + */ + providerExportName: string; + /** + * The name of the import provider schema, e.g. `nodeImportsSchema` + */ + providerSchemaName: string; + /** + * The name of the import provider, e.g. `node-imports` + */ + providerName: string; + /** + * The placeholder module specifier for the import provider, e.g. `%node-imports` + */ + placeholderModuleSpecifier: string; +} + +/** + * Gets the names of the default import provider. + * + * @param generatorName - The name of the generator. + * @returns The names of the import provider. + */ +export function getDefaultImportProviderNames( + generatorName: string, +): TsImportProviderNames { + const parsedGeneratorName = parseGeneratorName(generatorName); + const { generatorBasename } = parsedGeneratorName; + const providerTypeName = `${pascalCase(generatorBasename)}ImportsProvider`; + const providerExportName = `${camelCase(generatorBasename)}ImportsProvider`; + const providerSchemaName = `${camelCase(generatorBasename)}ImportsSchema`; + const providerName = `${kebabCase(generatorBasename)}-imports`; + const placeholderModuleSpecifier = `%${camelCase(generatorBasename)}Imports`; + + return { + providerTypeName, + providerExportName, + providerSchemaName, + providerName, + placeholderModuleSpecifier, + }; +} diff --git a/packages/core-generators/src/renderers/typescript/extractor-v2/extract-ts-template-variables.ts b/packages/core-generators/src/renderers/typescript/extractor-v2/extract-ts-template-variables.ts new file mode 100644 index 000000000..f138d80df --- /dev/null +++ b/packages/core-generators/src/renderers/typescript/extractor-v2/extract-ts-template-variables.ts @@ -0,0 +1,73 @@ +import { sortObjectKeys } from '@baseplate-dev/utils'; + +import type { TsTemplateFileVariable } from '../templates/types.js'; + +import { preprocessCodeForExtractionHack } from './preprocess-code-for-extraction-hack.js'; + +const VARIABLE_REGEX = + /\/\* TPL_([A-Z0-9_]+):START \*\/([\s\S]*?)\/\* TPL_\1:END \*\//g; +const TSX_VARIABLE_REGEX = + /\{\/\* TPL_([A-Z0-9_]+):START \*\/\}([\s\S]*?)\{\/\* TPL_\1:END \*\/\}/g; +const COMMENT_VARIABLE_REGEX = + /\/\* TPL_([A-Z0-9_]+):COMMENT:START \*\/([\s\S]*?)\/\* TPL_\1:COMMENT:END \*\//g; +const HOISTED_REGEX = + /\/\* HOISTED:([A-Za-z0-9_-]+):START \*\/([\s\S]*?)\/\* HOISTED:\1:END \*\/\n?/g; + +interface ExtractedTemplateVariables { + content: string; + variables: Record; +} + +/** + * Extracts template variables from a Typescript template file and returns both the processed content + * and the discovered variables. + * - Replaces TPL variable blocks with placeholders `TPL_VAR`. + * - Removes HOISTED blocks. + * - Discovers all template variables used in the content. + * + * @param content - The raw file content. + * @returns An object containing the processed content and discovered variables. + */ +export function extractTsTemplateVariables( + content: string, +): ExtractedTemplateVariables { + let processedContent = content; + const discoveredVariables: Record = {}; + + // Preprocess the content to handle the extraction hack + processedContent = preprocessCodeForExtractionHack(processedContent); + + // Extract and process TPL variable blocks + const processVariableBlock = ( + varName: string, + formatName: (name: string) => string, + ): string => { + const fullName = `TPL_${varName}`; + discoveredVariables[fullName] = {}; + return formatName(fullName); + }; + + processedContent = processedContent.replaceAll( + TSX_VARIABLE_REGEX, + (match: string, varName: string) => + processVariableBlock(varName, (name) => `<${name} />`), + ); + processedContent = processedContent.replaceAll( + VARIABLE_REGEX, + (match: string, varName: string) => + processVariableBlock(varName, (name) => name), + ); + processedContent = processedContent.replaceAll( + COMMENT_VARIABLE_REGEX, + (match: string, varName: string) => + processVariableBlock(varName, (name) => `/* ${name} */`), + ); + + // Remove HOISTED blocks + processedContent = processedContent.replaceAll(HOISTED_REGEX, ''); + + return { + content: processedContent, + variables: sortObjectKeys(discoveredVariables), + }; +} diff --git a/packages/core-generators/src/renderers/typescript/extractor-v2/extract-ts-template-variables.unit.test.ts b/packages/core-generators/src/renderers/typescript/extractor-v2/extract-ts-template-variables.unit.test.ts new file mode 100644 index 000000000..285512861 --- /dev/null +++ b/packages/core-generators/src/renderers/typescript/extractor-v2/extract-ts-template-variables.unit.test.ts @@ -0,0 +1,146 @@ +import { describe, expect, it } from 'vitest'; + +import { extractTsTemplateVariables } from './extract-ts-template-variables.js'; + +describe('extractTsTemplateVariables', () => { + it('should extract template variables correctly', () => { + const content = ` + /* TPL_NAME:START */ + const name = "John"; + /* TPL_NAME:END */ + + /* TPL_AGE:START */ + const age = 30; + /* TPL_AGE:END */ + + /* TPL_NAME:START */ + const name = "John"; + /* TPL_NAME:END */ + `; + + const result = extractTsTemplateVariables(content); + expect(result.content).toMatchInlineSnapshot(` + " + TPL_NAME + + TPL_AGE + + TPL_NAME + " + `); + expect(result.variables).toEqual({ + TPL_NAME: {}, + TPL_AGE: {}, + }); + }); + + it('should remove HOISTED blocks', () => { + const content = ` + /* HOISTED:IMPORTS:START */ + import { something } from 'somewhere'; + /* HOISTED:IMPORTS:END */ + + const test = 123; + `; + + const result = extractTsTemplateVariables(content); + expect(result.content).toMatchInlineSnapshot(` + " + + const test = 123; + " + `); + expect(result.variables).toEqual({}); + }); + + it('should handle TSX-style template variables in a React component', () => { + const content = ` + import React from 'react'; + import { Layout } from '@components/Layout'; + + export const PageTemplate = () => { + const title = /* TPL_TITLE:START */ "Welcome" /* TPL_TITLE:END */; + return ( + + {/* TPL_HEADER:START */} +
+ {/* TPL_HEADER:END */} + +
+ {/* TPL_CONTENT:START */} + +

Hello World

+

This is some content

+
+ {/* TPL_CONTENT:END */} +
+ + {/* TPL_FOOTER:START */} +