diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..fc3ee64 --- /dev/null +++ b/.gitignore @@ -0,0 +1,15 @@ +* +!.gitignore +!etc +!examples +!examples/** +!templates +!scripts +!**/*.ts +!**/*.md +!**/*.svg +!**/*.png +!**/*.json +!**/*.jsonc +!**/*.sh +!**/*.vto \ No newline at end of file diff --git a/README.md b/README.md new file mode 100644 index 0000000..fd41904 --- /dev/null +++ b/README.md @@ -0,0 +1,55 @@ +

+

+

Turn data into generated code and text files.

+ +--- + +`dgen` takes a template file, plus some data or executable code, and then generates a new file from it. + +It simplifies going from structured data to code – and can be used to generate and format TypeScript, HTML, Markdown, JSON, blog posts, or **any** other type of text file. + +This can be used as a command line utility, or as a module in your own codebase +via its exported `codegen` function. + +## Examples + +

+

+ +Generate Markdown, Typescript, and much more. Check out the full [examples](examples/). + +## Setup + +[Install Deno](https://docs.deno.com/runtime/manual) and ensure it is in your +path. + +Then, run: `deno install -frA --name=dgen mod.ts` + +This will install `dgen` as a command line utility. + +## Usage + +A template is required, and ideally a data file or some TypeScript code to +return data. + +Templates must be [vento (.vto) files](https://github.com/oscarotero/vento). + + +```sh +# Use with input file, data, and output. +dgen --in=myCodegenTemplate.vto --data=myCodegenData.json --out=myCodegenFile.ts + +# Use with input file, data, output, plus additional processor step. +dgen --in=myCodegenTemplate.vto --data=myCodegenData.json --processor=myTransformationStep.ts --out=myCodegenFile.ts +``` + +### Command Line Arguments + +| Option | Description | Example Usage | +| ------------- | -------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------ | +| `--in` | Path to the template file (vento .vto template), required | `--in ` | +| `--out` | Path to the output file, optional, will print to stdout if not provided | `--out ` | +| `--data` | Path to the data file (JSON or JSONC), optional | `--data ` | +| `--processor` | Path to the JS/TS processor file, optional | `--processor ` | +| `--flags` | Additional flags to run alongside the codegen process, optional. Accepts 'fmt', 'check', 'print_info'. Set to 'none' to skip defaults. | `--flags fmt,check,print_info` | +| `--help` | Print the help message | `--help` | \ No newline at end of file diff --git a/etc/dgen.svg b/etc/dgen.svg new file mode 100644 index 0000000..cb7a478 --- /dev/null +++ b/etc/dgen.svg @@ -0,0 +1,20 @@ + + + + + + + + + + + + + + + + + + + + diff --git a/etc/md.png b/etc/md.png new file mode 100644 index 0000000..0680197 Binary files /dev/null and b/etc/md.png differ diff --git a/etc/ts.png b/etc/ts.png new file mode 100644 index 0000000..b52573e Binary files /dev/null and b/etc/ts.png differ diff --git a/examples/README.md b/examples/README.md new file mode 100644 index 0000000..b2dea0a --- /dev/null +++ b/examples/README.md @@ -0,0 +1,10 @@ +## Examples + +A few examples are provided in this folder. See the [input](input/) and corresponding [output](output/) folders. + +Try them out by running **`./scripts/run-example-.sh` from the parent folder**. + +- `basic` - A basic example of using `dgen` with just a template files and the default data. It creates a TypeScript file. +- `data` - An example of using `dgen` with a data file. It creates a TypeScript file and then executes it. +- `markdown` - An example of using `dgen` with a data file and outputting Markdown. +- `processor` - An example of using `dgen` with a processor file. \ No newline at end of file diff --git a/examples/input/basic.vto b/examples/input/basic.vto new file mode 100644 index 0000000..defb46f --- /dev/null +++ b/examples/input/basic.vto @@ -0,0 +1,4 @@ +// Example TypeScript template + +const str = "{{ hello() }}, my name is {{ name }}!" +console.log(str); diff --git a/examples/input/data.jsonc b/examples/input/data.jsonc new file mode 100644 index 0000000..8da1a17 --- /dev/null +++ b/examples/input/data.jsonc @@ -0,0 +1,29 @@ +{ + // This is a JSONC file, which means it's a JSON file with comments. + "players": [ + { + "name": "art3mis", + "level": 10, + "isOnline": true, + "notes": "This ain't no game, kid!" + }, + { + "name": "parzival", + "level": 5, + "isOnline": false, + "notes": "I'm not a gunter, I'm a gamer." + }, + { + "name": "aech", + "level": 7, + "isOnline": true, + "notes": "It is on like Red Dawn!" + }, + { + "name": "gunter", + "level": 2, + "isOnline": false, + "notes": "I support the OASIS." + } + ] +} \ No newline at end of file diff --git a/examples/input/data.vto b/examples/input/data.vto new file mode 100644 index 0000000..7253717 --- /dev/null +++ b/examples/input/data.vto @@ -0,0 +1,30 @@ +// Example Typescript code with a JSONC data file + +class Player { + name: string; + level: number; + isOnline: boolean; + + constructor(name: string, level: number, isOnline: boolean) { + this.name = name; + this.level = level; + this.isOnline = isOnline; + } + + toString() { + return `${this.name} (Lvl. ${this.level})`; + } +} + +const players = [ +{{ for player of players }} + // {{ player.notes}} + new Player('{{ player.name }}', {{ player.level }}, {{ player.isOnline }}), +{{ /for }} +]; + +console.log(`There are ${players.length} players in the game.`); + +const onlinePlayers = players.filter(player => player.isOnline).join(', '); + +console.log(`The following players are online: ${onlinePlayers}`); \ No newline at end of file diff --git a/examples/input/markdown.jsonc b/examples/input/markdown.jsonc new file mode 100644 index 0000000..7aa80a2 --- /dev/null +++ b/examples/input/markdown.jsonc @@ -0,0 +1,14 @@ +{ + "sections": { + "header": { + "title": "dgen markdown template", + "content": "This is the header." + }, + "content": { + "content": "## Hello, World!\n\nThis is a simple example of a markdown template." + }, + "footer": { + "content": "This is the footer." + } + } +} \ No newline at end of file diff --git a/examples/input/markdown.vto b/examples/input/markdown.vto new file mode 100644 index 0000000..ce36436 --- /dev/null +++ b/examples/input/markdown.vto @@ -0,0 +1,5 @@ +{{ for section of sections }} +{{ if section.title }}# {{ section.title }}{{ /if }} +{{ if section.subtitle }}## {{ section.subtitle }}{{ /if }} +{{ if section.content }}{{ section.content }}{{ /if }} +{{ /for }} \ No newline at end of file diff --git a/examples/input/processor.ts b/examples/input/processor.ts new file mode 100644 index 0000000..39062c0 --- /dev/null +++ b/examples/input/processor.ts @@ -0,0 +1,12 @@ +export default async function codegen( + data: any, +): Promise> { + return { + data: { + ...(data ? data : {}), + pi: Math.PI, + e: Math.E, + c: 299792458, + } + } +} \ No newline at end of file diff --git a/examples/input/processor.vto b/examples/input/processor.vto new file mode 100644 index 0000000..e5f0cfa --- /dev/null +++ b/examples/input/processor.vto @@ -0,0 +1,7 @@ +# dgen processor example + +Here are some math constants: + +e: {{ e }} +pi: {{ pi }} +c: {{ c }} m/s \ No newline at end of file diff --git a/examples/output/basic.ts b/examples/output/basic.ts new file mode 100644 index 0000000..2ad0c72 --- /dev/null +++ b/examples/output/basic.ts @@ -0,0 +1,4 @@ +// Example TypeScript template + +const str = "sup dawg, my name is Bobert Paulson!"; +console.log(str); diff --git a/examples/output/data.ts b/examples/output/data.ts new file mode 100644 index 0000000..a83ed98 --- /dev/null +++ b/examples/output/data.ts @@ -0,0 +1,37 @@ +// Example Typescript code with a JSONC data file + +class Player { + name: string; + level: number; + isOnline: boolean; + + constructor(name: string, level: number, isOnline: boolean) { + this.name = name; + this.level = level; + this.isOnline = isOnline; + } + + toString() { + return `${this.name} (Lvl. ${this.level})`; + } +} + +const players = [ + // This ain't no game, kid! + new Player("art3mis", 10, true), + + // I'm not a gunter, I'm a gamer. + new Player("parzival", 5, false), + + // It is on like Red Dawn! + new Player("aech", 7, true), + + // I support the OASIS. + new Player("gunter", 2, false), +]; + +console.log(`There are ${players.length} players in the game.`); + +const onlinePlayers = players.filter((player) => player.isOnline).join(", "); + +console.log(`The following players are online: ${onlinePlayers}`); diff --git a/examples/output/markdown.md b/examples/output/markdown.md new file mode 100644 index 0000000..f02db69 --- /dev/null +++ b/examples/output/markdown.md @@ -0,0 +1,13 @@ +# dgen markdown template + +This is the header. + + + +## Hello, World! + +This is a simple example of a markdown template. + + + +This is the footer. \ No newline at end of file diff --git a/examples/output/processor.md b/examples/output/processor.md new file mode 100644 index 0000000..1fe94a5 --- /dev/null +++ b/examples/output/processor.md @@ -0,0 +1,7 @@ +# dgen processor example + +Here are some math constants: + +e: 2.718281828459045 +pi: 3.141592653589793 +c: 299792458 m/s \ No newline at end of file diff --git a/mod.ts b/mod.ts new file mode 100644 index 0000000..fde280b --- /dev/null +++ b/mod.ts @@ -0,0 +1,305 @@ +import vento from "https://deno.land/x/vento@v0.12.1/mod.ts"; +import { Filter } from "https://deno.land/x/vento@v0.12.1/src/environment.ts"; +import { parse as parseJsonc } from "https://deno.land/std@0.212.0/jsonc/parse.ts"; +import { parseArgs } from "https://deno.land/std@0.212.0/cli/parse_args.ts"; + +type CodegenArgs = { + /** + * Full path to the template file (vento .vto template) + */ + templateVtoPath: string; + + /** + * Full path to processor file [optional], which will pass data and + * additional filters to the template. + * + * This file should export a function (or async function) that + * takes an optional data Json object and returns an object with the final + * data and filters to pass to the template. + * + * Example: + * ```ts + * export default (dataJson: any) => ({ + * data: { + * myVar: "yeet", + * hello() { + * return "hello world"; + * }, + * filters: { + * upper: (str: string) => str.toUpperCase(), + * } + * }); + * ` + */ + processorTsPath?: string; + + /** + * JSON string to pass to the template as data. + * This can be either a JSON or JSONC (JSON with comments) file. + */ + dataJsonPath?: string; + + /** + * Full path to the final output file, can be any file type (.ts, .json, .md, etc.) + */ + outputPath?: string; + + /** + * Filters to pass to the template, which are arbitrary functions that + * can transform any variable. + * + * Read about filters here: + * https://vento.js.org/syntax/pipes/ + */ + filters?: Record; + + /** + * Arbitrary data to pass to the template + * Can be functions, objects, arrays, etc. + * + * Read more about using data in templates here: + * https://vento.js.org/syntax/print/ + */ + data?: Record; + + /** + * Additional flags to run alongside the codegen process + * For example, formatting and checking the output is valid TypeScript + */ + flags?: ('fmt' | 'check' | 'print_info')[] + + /** + * Optional error handler + */ + error?: (err: Error) => void; +} + +const DEFAULT_ARGS: Partial = { + templateVtoPath: "template.vto", + filters: { + upper: (str: string) => str.toUpperCase(), + lower: (str: string) => str.toLowerCase(), + }, + data: { + name: "Bobert Paulson", + hello() { + return "sup dawg"; + } + }, + flags: ['fmt', 'check', 'print_info'], +}; + +export const codegen = async (args: CodegenArgs): Promise => { + const startTime = performance.now(); + const { + templateVtoPath, + processorTsPath, + dataJsonPath, + outputPath, + filters, + data, + flags, + error, + } = { ...DEFAULT_ARGS, ...args }; + + const env = vento(); + const failures: string[] = []; + + let processorData: Record | undefined = undefined; + let processorFilters: Record | undefined = undefined; + + if (dataJsonPath) { + try { + const dataJson = await Deno.readTextFile(dataJsonPath); + const parsedData = parseJsonc(dataJson) as Record; + + if (parsedData) { + processorData = parsedData; + } + } + catch (err) { + if (error) { + error(err); + } + console.error(err); + failures.push(`data (${dataJsonPath})`); + } + } + + if (processorTsPath) { + try { + const processor = (await import(processorTsPath)).default; + const result = await processor(processorData); + + processorData = result.data; + processorFilters = result.filters; + } + catch (err) { + if (error) { + error(err); + } + console.error(err); + failures.push(`processor (${processorTsPath})`) + } + } + + if (processorFilters) { + env.filters = { ...processorFilters } + } + else { + env.filters = { ...filters }; + } + + let output: string = ""; + + try { + const template = await env.load(templateVtoPath); + const finalData = { + ...(processorData ? processorData : data), + } + + const result = await template(finalData); + + output = result.content.trim(); + + if (outputPath) { + await Deno.writeTextFile(outputPath, output); + } + } + catch (err) { + if (error) { + error(err); + } + console.error(err); + failures.push(`vento template (${templateVtoPath})`); + } + + if (flags && flags.length && outputPath) { + if (flags.includes("fmt")) { + const p = new Deno.Command("deno", { + args: ["fmt", outputPath], + stdout: "piped", + stderr: "piped", + }).spawn(); + + const { + success, + stdout, + stderr, + } = await p.output(); + + // Write output to stderr + if (flags.includes('print_info')) { + await Deno.stderr.write(stdout); + await Deno.stderr.write(stderr); + } + + if (!success) { + failures.push("deno fmt"); + } + } + + if (flags.includes("check")) { + const p = new Deno.Command("deno", { + args: ["check", outputPath], + stdout: "piped", + stderr: "piped", + }).spawn(); + + const { + success, + stdout, + stderr, + } = await p.output(); + + // Write output to stderr + if (flags.includes('print_info')) { + await Deno.stderr.write(stdout); + await Deno.stderr.write(stderr); + } + + if (!success) { + failures.push("deno check"); + } + } + } + + if (flags && flags.includes('print_info')) { + const terminalWidth = Deno.consoleSize().columns; + const printDivider = (char: string) => { + const divider = char.repeat(Math.floor(terminalWidth * 0.67)); + return `${divider}\n`; + } + const bold = (str: string) => `\x1b[1m${str}\x1b[22m`; + const colorize = (str: string, color: 'red' | 'green') => { + const colors = { + red: "\x1b[31m", + green: "\x1b[32m", + } + return `${colors[color]}${str}\x1b[0m`; + } + + await Deno.stderr.write(new TextEncoder().encode(printDivider("="))); + const emoji = failures.length ? "⚠️" : "✅"; + await Deno.stdout.write(new TextEncoder().encode(bold(colorize(`${emoji} Codegen finished ${failures.length ? "with errors" : "successfully" + } in ${Math.round(performance.now() - startTime)}ms\n`, failures.length ? 'red' : 'green')))); + + if (failures.length) { + const failStr = `Failed steps: \n\t· ${bold(failures.join("\n\t· "))}\n` + await Deno.stderr.write(new TextEncoder().encode(failStr)); + } + await Deno.stderr.write(new TextEncoder().encode(printDivider("="))); + } + + if (failures.length && error) { + error(new Error(`Failed steps: ${failures + .map((f) => f.replace("deno ", "")) + .join(", ")}\n`)); + } + + return output; +} + +if (import.meta.main) { + const cliArgs = parseArgs(Deno.args); + + if (!cliArgs.in || cliArgs._.length || cliArgs.help) { + console.log(`Usage: + dgen --in --out --data --processor --flags fmt,check,print_info + +Options: + --in Path to the template file (vento .vto template), required + --out Path to the output file, optional, will print to stdout if not provided + --data Path to the data file (JSON or JSONC), optional + --processor Path to the JS/TS processor file, optional + --flags Additional flags to run alongside the codegen process, optional + --help Print this help message + `); + Deno.exit(1); + } + + let errors = false; + + const args: CodegenArgs = { + templateVtoPath: cliArgs.in, + processorTsPath: cliArgs.processor, + dataJsonPath: cliArgs.data, + outputPath: cliArgs.out, + flags: cliArgs.flags ? cliArgs.flags.split(",").map((f: string) => f.trim()).filter(Boolean) as ('fmt' | 'check' | 'print_info')[] || undefined : undefined, + error: (_err: Error) => { + errors = true; + } + } + + // Filter out undefined values + // @ts-ignore Object keys are always strings + Object.keys(args).forEach((key) => args[key] === undefined && delete args[key]); + + const result = await codegen(args); + + if (!args.outputPath) { + console.log(result); + } + + Deno.exit(errors ? 1 : 0); +} diff --git a/scripts/clean-examples.sh b/scripts/clean-examples.sh new file mode 100755 index 0000000..140e6d4 --- /dev/null +++ b/scripts/clean-examples.sh @@ -0,0 +1,3 @@ +#!/bin/sh +# This script cleans the output directory of the examples +rm -r examples/output/* \ No newline at end of file diff --git a/scripts/dev.sh b/scripts/dev.sh new file mode 100755 index 0000000..f35721b --- /dev/null +++ b/scripts/dev.sh @@ -0,0 +1,3 @@ +#!/bin/sh +# Use this to develop the project +deno run -A --watch mod.ts diff --git a/scripts/install.sh b/scripts/install.sh new file mode 100755 index 0000000..99026f3 --- /dev/null +++ b/scripts/install.sh @@ -0,0 +1,3 @@ +#!/bin/sh +# This script installs the project as a deno executable +deno install -frA --name=dgen mod.ts \ No newline at end of file diff --git a/scripts/run-example-basic-alias.sh b/scripts/run-example-basic-alias.sh new file mode 100755 index 0000000..91485de --- /dev/null +++ b/scripts/run-example-basic-alias.sh @@ -0,0 +1,4 @@ +#!/bin/sh +# This script runs the basic example from its default alias `dgen`, requiring the project to be installed +dgen --in=examples/input/basic.vto --out=examples/output/basic.ts +deno run examples/output/basic.ts \ No newline at end of file diff --git a/scripts/run-example-basic.sh b/scripts/run-example-basic.sh new file mode 100755 index 0000000..285d625 --- /dev/null +++ b/scripts/run-example-basic.sh @@ -0,0 +1,4 @@ +#!/bin/sh +# This script runs the basic example and outputs the result of executing the generated file +deno run --allow-read --allow-write --allow-run mod.ts --in=examples/input/basic.vto --out=examples/output/basic.ts +deno run examples/output/basic.ts \ No newline at end of file diff --git a/scripts/run-example-data.sh b/scripts/run-example-data.sh new file mode 100755 index 0000000..0174cbe --- /dev/null +++ b/scripts/run-example-data.sh @@ -0,0 +1,4 @@ +#!/bin/sh +# This script runs the data example and outputs the result of executing the generated file +deno run --allow-read --allow-write --allow-run mod.ts --in=examples/input/data.vto --data=examples/input/data.jsonc --out=examples/output/data.ts +deno run examples/output/data.ts \ No newline at end of file diff --git a/scripts/run-example-markdown.sh b/scripts/run-example-markdown.sh new file mode 100755 index 0000000..494bbba --- /dev/null +++ b/scripts/run-example-markdown.sh @@ -0,0 +1,4 @@ +#!/bin/sh +# This script runs the markdown example and outputs the result +deno run --allow-read --allow-write --allow-run mod.ts --in=examples/input/markdown.vto --data=examples/input/markdown.jsonc --out=examples/output/markdown.md --flags=none +cat examples/output/markdown.md \ No newline at end of file diff --git a/scripts/run-example-processor.sh b/scripts/run-example-processor.sh new file mode 100755 index 0000000..d7298bc --- /dev/null +++ b/scripts/run-example-processor.sh @@ -0,0 +1,4 @@ +#!/bin/sh +# This script runs the processor example and outputs the result +deno run --allow-read --allow-write --allow-run mod.ts --in=examples/input/processor.vto --processor=./examples/input/processor.ts --out=examples/output/processor.md --flags=none +cat examples/output/processor.md \ No newline at end of file