tpl-dot-ts
v0.12.0
Published
An utility belt to generate files from typeScript templates
Readme
tpl-dot-ts
[!Warning] This project is a work in progress. The API is mostly stable but expect some minor breaking changes until v1.
tpl-dot-ts is a TypeScript scaffolding library that simplifies the generation of complex projects from templates. It offers a flexible and type-safe approach to creating dynamic file structures, using contexts to manage data and printers to format the output.
It is ideal for use cases such as:
- Generating boilerplate code for new projects
- Creating API clients from OpenAPI specifications
- Building custom project structures with dynamic configurations
By offering a flexible and type-safe approach to creating dynamic file structures, using contexts to manage data, and printers to format the output, tpl-dot-ts helps developers save time and ensure consistency across their projects.
Core Concepts
tpl-dot-ts generates projects from templates using three core concepts:
- Templates: Directories or
.tpl.tsfiles that define the project structure..tpl.tsfiles are executed to generate dynamic content. - Contexts: Provide type-safe data to templates, enabling reusability across different environments.
- Materialization & Printers: Transforms the template description into actual files and content, using printers to format the output for different file types (e.g., JSON, YAML). You can customize the printers used by providing a
PrinterContextto a Subtree.
Installation
npm install --save-dev tpl-dot-tsyarn add --dev tpl-dot-tsQuick Start Guide
Note: A complete, runnable version is in
examples/01-quick-start.
This guide generates personalized greetings with a static file.
1. Set up the Project Structure
Create the following directory structure:
.
├── templates/
│ ├── greeting.tpl.ts
│ └── static.txt
├── config.ts
└── run.ts2. Create the Template Files
templates/static.txt:
This file is static and will be copied directly.templates/greeting.tpl.ts:
import { defineFile } from 'tpl-dot-ts'
import { Config } from '../config.ts'
export default defineFile(() => {
const config = Config.getContextValue()
return `Hello, ${config.name}!`
})3. Define the Context
config.ts:
import { createContext } from 'tpl-dot-ts'
type ConfigShape = { name: string }
export class Config extends createContext<ConfigShape>('config', () => ({ name: 'World' })) {
static init(data: ConfigShape) {
return new Config(data)
}
}4. Create the Runner Script
run.ts:
#!/usr/bin/env -S npx tsx
import { Tpl, defineDir } from 'tpl-dot-ts'
import { Config } from './config.ts'
async function main() {
const template = await Tpl.fromPath(import.meta, './templates')
const output = defineDir({
english: template.withContext(Config.init({ name: 'World' })),
french: template.withContext(Config.init({ name: 'Monde' })),
})
await output.write('./generated')
console.log('Done! Check the "generated" directory.')
}
main()5. Run it!
chmod +x ./run.ts
./run.tsOutput:
generated/
├── english/
│ ├── greeting (Hello, World!)
│ └── static.txt (copied)
└── french/
├── greeting (Hello, Monde!)
└── static.txt (copied)API Reference
Tpl.fromPath(importMeta, path)
Loads a template from the file system.
importMeta:import.metafor relative path resolution.path: Template file or directory path.
Returns a Template.
defineDir(entries)
Defines a directory.
entries: File/directory names and theirTemplateobjects.
Returns a TemplateDir.
defineFile(content)
Defines a file.
content: File content (string, JSON/YAML serializable, or a function returning the content).
Returns a TemplateFile.
createContext<T>(name, ?defaultValue)
Creates a Context class.
<T>: Data type for the context.name: Context name (for debugging).defaultValue: An optional function() => Tthat returns a default value if no context is provided.
Returns a Context class with static methods getContextValue() and an instance constructor.
template.withContext(context)
Attaches a context to a Template.
context: AContextinstance.
Returns a new Template.
template.write(path)
Writes the materialized template to disk.
path: Output directory.
Recipes
Composing Templates with import
Because templates are just ES modules, you can compose them using standard import/export.
user-profile.tpl.ts
import { defineFile } from 'tpl-dot-ts'
import { UserContext } from './user-context.ts'
export default defineFile(() => {
const { name, email } = UserContext.getContextValue()
return `Name: ${name}\nEmail: ${email}`
})main-template.tpl.ts
import { defineDir } from 'tpl-dot-ts'
import userProfile from './user-profile.tpl.ts'
export default defineDir({
'user/': defineDir({
'profile.txt': userProfile,
})
})Using Multiple Contexts
You can easily combine multiple contexts. The .withContext() method can be chained, and the contexts will be available to all children.
// run.ts
import { Tpl, defineDir } from 'tpl-dot-ts'
import { ThemeContext } from './theme-context.ts'
import { UserContext } from './user-context.ts'
import myTemplate from './my-template.tpl.ts'
const templateWithContexts = myTemplate
.withContext(new ThemeContext({ color: 'blue' }))
.withContext(new UserContext({ name: 'Jane' }))
// Inside my-template.tpl.ts and its children, you can now access
// both ThemeContext.getContextValue() and UserContext.getContextValue().Creating a Custom Printer
While tpl-dot-ts handles JSON and YAML serialization automatically based on file extensions, you can customize the printers used by a subtree by providing a PrinterContext.
To use a custom printer, create a PrinterContext and add it to the template:
import { defineFile, PrinterContext } from 'tpl-dot-ts'
export function toIni(data: Record<string, string>): string {
return Object.entries(data)
.map(([key, value]) => `${key}=${value}`)
.join('\n')
}
const printerContext = PrinterContext.appendedBy(
{
name: 'ini',
async print(fileName, getData) {
if (fileName.endsWith('.ini')) {
const data = await getData(x => typeof x === 'object')
return toIni(data)
}
}
}
)
export default defineFile(() => {
const dbConfig = {
host: 'localhost',
port: '5432',
}
return dbConfig
}).withContext(printerContext)
.write('config.ini')
// If this file is named 'config.ini', the output will be:
// host=localhost
// port=5432Off course, if only one file is concerned, you can just return a string directly from your file template.
Creating an advanced Custom Printer with Metadata
You may have noticed that the printer accepts a getData function. This function is a generator. It accepts a predicate and returns the data that matches the predicate. If the predicate is not provided, it returns the entire data.
Why so complicated you may ask ? So you can decorate the function call.
#!/usr/bin/env -S npx tsx
import {
createContext,
defineFile,
PrinterContext,
runWithContexts,
isPlainObject,
defineDir,
} from 'tpl-dot-ts'
const HostsRegistryContext = createContext<Set<string>>('hosts registry')
export function toIni(data: Record<string, unknown>): string {
return Object.entries(data)
.map(([key, value]) => `${key}=${value}`)
.join('\n')
}
const printerContext = PrinterContext.appendedBy({
name: 'context',
async print(fileName, getData) {
const registry = new Set<string>()
const data = await runWithContexts(
[new HostsRegistryContext(registry)],
() => getData(isPlainObject),
)
return `
# Hosts listed in this file: ${Array.from(registry.values()).join(', ')}
${toIni(data)}
`
},
})
defineDir(() => {
return {
'./config.ini': defineFile(() => {
const dbConfig = {
host: 'localhost',
port: '5432',
}
// append metadata
HostsRegistryContext.getContextValue().add(dbConfig.host)
return dbConfig
}),
}
})
.withContext(printerContext)
.write('./generated', import.meta.dirname)
// The file output will be:
//
// # Hosts listed in this file: localhost
// host=localhost
// port=5432
//
License
MIT
